@lunora/runtime
The Worker entrypoint — createWorker, RPC envelope, shard routing.
@lunora/runtime is the low-level Worker runtime. In a normal Lunora app you
don't call it directly — codegen emits src/server/index.ts as a fluent
builder that wires createWorker for you. Drop down to this package when you
hand-write a Worker entry, add a custom HTTP route, or build your own transport.
Bindings only exist per request, so build the worker lazily off env and reuse
the instance for the isolate's lifetime:
import type { LunoraWorker } from "lunorash/runtime";
import { createWorker } from "lunorash/runtime";
import { ShardDO } from "./shard";
interface Env {
SHARD: DurableObjectNamespace;
}
let worker: LunoraWorker | null = null;
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
worker ??= createWorker({ shardDO: env.SHARD });
return worker.fetch(request, env, ctx);
},
};
export { ShardDO };createWorker(options)
Returns a { fetch, scheduled, serverQuery } object — a valid Cloudflare module
Worker. fetch handles HTTP/WebSocket traffic, scheduled handles Cron
Triggers (see Scheduled backups), and serverQuery is an
in-process query/mutation dispatch for SSR loaders co-located in the same Worker
(no network self-fetch; identity / RLS / auth semantics identical to the HTTP
path). Internally fetch:
- Decodes the RPC envelope (
POST /_lunora/rpc). - Resolves the calling identity (via
resolveIdentity, if configured). - Routes to the right
ShardDOviaresolveShard, or fans out across shards through thequeryCoordinatorwhen the envelope setsfanOut. - Forwards the request to the shard, which validates args and runs the
registered
query/mutation/action. - Decorates the response with the security edge and returns it.
shardDO is the only required option. The rest are opt-in:
| Option | Purpose |
|---|---|
shardDO (required) | The shard DurableObjectNamespace (typically env.SHARD) |
queryCoordinator | Enables cross-shard fan-out (createQueryCoordinator({ registry })); without it fanOut 400s |
resolveIdentity | (request, env) => identity | null — sets ctx.auth on the shard |
authorizeShard / authorizeFanOut | Per-shard / fan-out authorization gates (return false to reject) |
routes | Record<string, Route> of custom HTTP handlers ("GET /healthz" or "/healthz") |
httpRouter | A meta-framework SSR handler / httpRouter() app, dispatched after the reserved endpoints |
security | The secure-by-default HTTP edge — headers, CORS, CSRF (see SecurityOptions) |
crons / cronJobs | Cron-trigger handlers keyed by their exact expression |
backupCron / backupStore / adminToken | The built-in scheduled NDJSON backup (see below) |
functions, *Introspector, storage*, openApiSpec, … | Back the admin-gated /_lunora/admin/* endpoints the Studio reads |
composeWorker / withFrameworkWorker
composeWorker(options) is createWorker under a name that reads better in
framework templates — same options, same return. Use it when a meta-framework's
SSR handler (TanStack Start, React Router, SolidStart, …) is passed as
httpRouter: the reserved realtime endpoints (/_lunora/rpc, /_lunora/ws,
/_lunora/admin/*), auth, and explicit routes go to Lunora first, then
everything else falls through to the SSR handler.
withFrameworkWorker(host, options) composes the other direction: a framework's
own emitted Cloudflare handler (@sveltejs/adapter-cloudflare, Nitro
cloudflare-module, @astrojs/cloudflare) plus Lunora into one
{ fetch, scheduled }. host may be a bare fetch function or a { fetch }
object, and options may be a plain object or an (env) => options factory
(for per-request bindings). It is WorkerOptions minus httpRouter (the host
supplies that). When Lunora configures no cron surface, the host's own
scheduled is preserved rather than dropped.
createLunoraHandler(options?)
The framework-neutral mount seam for web-standard frameworks that own the
worker entry (Hono, Nitro/h3, Elysia, any WinterCG host on Workers). Returns a
(request, env, ctx?) => Response that serves Lunora's realtime plane
(/_lunora/rpc, /_lunora/ws, /_lunora/admin/*); mount it under /_lunora/*
in your router and everything else stays your framework's. shardDO defaults to
env.SHARD, so the common case needs no options; pass a partial options object
or an (env) => options factory for auth, crons, or a custom namespace.
import { Hono } from "hono";
import { createLunoraHandler } from "lunorash/runtime";
const lunora = createLunoraHandler();
const app = new Hono<{ Bindings: Env }>();
app.use("/_lunora/*", (c) => lunora(c.req.raw, c.env, c.executionCtx));This is the one shared helper that replaces per-framework adapter packages —
see Bring your framework
for Nitro/Elysia examples and the host contract. resolveLunoraOptions and
NOOP_EXECUTION_CONTEXT are exported too, for adapters that need finer control.
defineRpcEnvelope
Helper for building a custom transport (e.g. a CLI driver). Wraps a
JSON-serializable payload in the same envelope @lunora/client speaks. An
RpcEnvelope is { functionPath, args?, shardKey?, fanOut? }:
const envelope = defineRpcEnvelope({ functionPath: "messages:send", args: { body: "hi" } });RpcEnvelope / Route / WorkerOptions
Type-only exports. WorkerOptions is the options bag of createWorker. Route
is a custom HTTP handler — (request, env, context) => Response | Promise<Response>
— stored in the routes map.
resolveShard(namespace, shardKey)
Returns a ResolvedShard ({ fetch }) — the shard stub for the given shard key,
preferring namespace.getByName and falling back to idFromName + get. The
runtime calls this for you, but it's exported so add-ons can route through the
same logic.
const shard = resolveShard(env.SHARD, channelId);
const response = await shard.fetch(request);ShardNamespaceLike / ResolvedShard / ExecutionContextLike
Structural type aliases over the corresponding @cloudflare/workers-types
shapes — they exist so unit tests can pass plain mock objects without
pulling in the full Workers types.
Scheduled backups
createWorker returns a scheduled handler alongside fetch. Wire both into
your Worker so Wrangler can deliver Cron Triggers:
let worker: LunoraWorker | null = null;
const build = (env: Env): LunoraWorker =>
createWorker({
adminToken: env.LUNORA_ADMIN_TOKEN,
queryCoordinator, // required — the backup fans the export out across shards
shardDO: env.SHARD,
// Built-in backup: on this cron, export every table to NDJSON and write
// it (plus a manifest sidecar) to the bound R2 bucket.
backupStore: env.BACKUPS, // any R2 bucket binding
backupCron: "0 3 * * *", // must match a wrangler triggers.crons entry
backupRetain: 14, // keep the newest 14 snapshots; prune older ones
// backupPrefix: "backups/", // key prefix (default)
// backupTables: ["users"], // omit to back up every table
});
export default {
fetch: (request: Request, env: Env, ctx: ExecutionContext) => (worker ??= build(env)).fetch(request, env, ctx),
scheduled: (controller: ScheduledController, env: Env, ctx: ExecutionContext) => (worker ??= build(env)).scheduled(controller, env, ctx),
};Each run writes two objects under backupPrefix:
lunora-backup-<id>.ndjson— the snapshot, one{ table, doc }per line (the same NDJSON shape the/_lunora/admin/exportendpoint andlunora backup createproduce).lunora-backup-<id>.ndjson.manifest.json— aBackupManifest({ id, createdAt, cron, file, rows, bytes, scheduledTime, tables? }).
The id is the trigger's scheduledTime as an ISO timestamp, so a snapshot
is named after the moment it represents. The backup requires adminToken
(it authenticates the per-shard export gate), queryCoordinator, and
backupStore; a misconfigured run throws so the failed Cron invocation is
recorded rather than silently skipped.
Register your own cron handlers via crons (keyed by the exact expression);
they run independently of — and, on a shared expression, alongside — the
built-in backup.
Wrangler bindings
{
"r2_buckets": [{ "binding": "BACKUPS", "bucket_name": "my-app-backups" }],
"triggers": { "crons": ["0 3 * * *"] },
}The crons array must contain the exact backupCron string; Wrangler only
delivers triggers it declares. To restore a scheduled snapshot, download the
NDJSON object and feed it to lunora backup restore <file> (optionally with
--to <time> for point-in-time replay).
LunoraError / toErrorResponse / LunoraErrorBody
The runtime's typed error class. Throwing a LunoraError(code, message)
inside a handler causes a { error: { code, message, ... } } body to come
back over RPC instead of an HTTP 500. toErrorResponse(err) is the same
mapping exposed for add-ons.