HTTP endpoints
Public HTTP routes with httpAction and the Hono-based httpRouter.
Last updated:
Queries, mutations, and actions are reached through Lunora's typed client over
the RPC/WebSocket transport. Sometimes you need a plain HTTP endpoint instead:
an inbound webhook, an OAuth callback, a REST surface for a non-Lunora client, a
file download. That's what httpAction and httpRouter are for.
httpRouter() returns a Hono app, so the full Hono surface
(middleware, path params, .route() composition) is available. You mount it on
the worker via createWorker({ httpRouter: app }), and the runtime injects a
per-request HttpActionCtx that your handlers read.
// lunora/http.ts
import { httpAction, httpRouter } from "lunorash/server";
import { cors } from "hono/cors";
import { api } from "@/lunora/_generated/api";
const app = httpRouter();
app.use("*", cors());
app.post(
"/webhook/stripe",
httpAction(async (ctx, request) => {
const event = await request.json();
await ctx.runMutation(api.billing.applyStripeEvent, { event });
return new Response("ok", { status: 200 });
}),
);
app.get(
"/users/:id",
httpAction(async (ctx, request) => {
const id = new URL(request.url).pathname.split("/").pop()!;
const user = await ctx.runQuery(api.users.get, { id });
return user ? Response.json(user) : new Response("Not Found", { status: 404 });
}),
);
export default app;httpAction
httpAction(handler) wraps a (ctx, request) => Response function into a Hono
handler. You get the underlying Request and return a raw Response, so you
own status codes, headers, and body. Mount it with any Hono verb:
app.post(path, httpAction(fn)), app.all(path, httpAction(fn)).
HttpActionCtx
HTTP actions run in the worker (the "action runtime"), separate from the
transactional store. The context is therefore a narrower view of ActionCtx. It
carries:
ctx.auth— the resolved caller identity.ctx.fetch— outbound HTTP.ctx.runQuery/ctx.runMutation/ctx.runAction— reach the data layer; these forward to the owning shard.
An HTTP action has no direct ctx.db, ctx.scheduler, ctx.storage, or
ctx.vectors. Go through runQuery / runMutation / runAction to read or
write your tables. (To stream a stored R2 object straight from a handler,
serveStorageObject(ctx, key, request) is the purpose-built helper.)
Typed routes with httpRoute
For a JSON REST surface, the httpRoute builder gives you validated query
params, body, and path params plus an optional .output() schema. It compiles
down to the same handler type as httpAction, so the two are interchangeable
when mounted:
import { httpRoute, httpRouter, v } from "lunorash/server";
import { api } from "@/lunora/_generated/api";
export const listTodos = httpRoute
.get("/api/todos")
.searchParams({ limit: v.number(), q: v.optional(v.string()) })
.output(v.array(v.object({ id: v.string(), text: v.string() })))
.handler(async ({ ctx, searchParams }) => ctx.runQuery(api.todos.list, searchParams));
export const getTodo = httpRoute
.get("/api/todos/:id")
.params({ id: v.string() })
.handler(async ({ ctx, params }) => ctx.runQuery(api.todos.get, params));
const app = httpRouter();
app.get("/api/todos", listTodos);
app.get("/api/todos/:id", getTodo);.searchParams(), .body(), and .params() accumulate validator maps that
decode the URL query, JSON body, and Hono path params into the handler's typed
searchParams / body / params. Query-string values arrive as strings and are
coerced to the declared scalar kind (?limit=5 satisfies v.number()). A
decode failure surfaces as a 400, a result that violates .output() is a
500, and a handler that returns undefined produces a 204.
For Server-Sent Events, swap the terminal .handler() for .stream(). The
handler is then an async generator whose yielded chunks become SSE data:
frames, with the client's disconnect wired through to an AbortSignal.
How routes mount in the worker
httpRouter() pre-wires a middleware that lifts the runtime-injected request
context onto c.var.lunora, where both httpAction and httpRoute read it.
Pass the app to createWorker, which supplies that context on every request:
import { createWorker } from "@lunora/runtime";
import app from "./http";
export default createWorker({ httpRouter: app /* …other options */ });The lifting middleware throws if the context is absent, so this only trips when
the router is run outside the runtime. The misconfiguration surfaces loudly
rather than leaving c.var.lunora silently undefined.