@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) |
allowUnauthenticatedShardAccess | Opt into open shard/fan-out access (only safe when every table is per-row RLS-protected) |
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 |
Cross-shard access is default-denied. A request that names a non-default
shard (shardKey !== defaultShardKey, default __root__) or a fan-out is
rejected with 403 — FORBIDDEN_SHARD / FORBIDDEN_FANOUT — unless the
worker sets authorizeShard / authorizeFanOut, or
allowUnauthenticatedShardAccess: true. A single-DO app never addresses a
non-default shard, so it is unaffected; the gate only bites once you
.shardBy(). allowUnauthenticatedShardAccess is
only safe when every table is protected by per-row RLS (the runtime logs a
one-time warning while it is on with no authorizeShard/authorizeFanOut).
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.
Health / readiness probe
createWorker serves two probe endpoints an uptime monitor, a load balancer,
or a Cloudflare Health Check can hit — no configuration required:
GET /_lunora/health— the aggregate probe.200when every critical dependency resolves,503when a critical one is down. A non-critical failure reportsstatus: "degraded"but keeps the200.GET /_lunora/health/ready— the readiness gate (only readiness checks).
The runtime auto-registers probes structurally from env: the shard Durable
Object (reachability, critical), any D1 binding (SELECT 1, critical), and R2 /
queue / Hyperdrive bindings (presence, non-critical). The body carries only
per-check up/down and static appName / appVersion — never a secret,
connection string, or env value. Configure via health:
createWorker({
shardDO: env.SHARD,
health: {
appName: "my-app",
appVersion: env.CF_VERSION_METADATA?.id,
auth: "public", // or "admin" — bearer-gated + per-check messages included
probes: [
// add a bespoke downstream check
{ name: "billing-api", critical: false, check: async () => ({ healthy: await pingBilling() }) },
],
},
});Point a Cloudflare Health Check (or any uptime monitor) at
https://<your-worker>/_lunora/health and alert on a non-200.
Opt-in public REST surface
Publish a procedure over REST by tagging it with .expose({ rest: true }) in
server/:
export const list = query
.input({ channelId: v.id("channels") })
.expose({ rest: true })
.query(async ({ ctx, args }) => ctx.db.query("messages")./* … */);The runtime then mints GET /_lunora/rest/messages/list (queries; also POST)
or POST /_lunora/rest/messages/send (mutations / actions). A REST call is
routed through the procedure via the same dispatch as typed RPC, so
ctx.auth, RLS, and the v.* validators are all enforced at the shard — REST
is a transport adapter, never a weaker entry point. Default-closed: a
procedure without the tag has no REST route (it 404s), and there is no
table-level auto-CRUD (that would bypass RLS). Query args ride the query string
(?limit=10, JSON-encoded for non-scalars); mutation/action args come from the
JSON body. The generated OpenAPI (_generated/openapi.json) describes exactly
this surface. Rate-limit it over @lunora/ratelimit:
import { createRestRateLimit } from "lunorash/runtime";
import { RateLimiter } from "@lunora/ratelimit";
createWorker({
shardDO: env.SHARD,
functions: LUNORA_FUNCTIONS,
restRateLimit: createRestRateLimit(new RateLimiter(/* … */), { name: "rest" }),
});Security guidance: keep the surface small, prefer .output() on exposed
procedures so the response shape is contractual, and rely on the procedure's own
RLS/auth for authorization — never expose an unauthenticated mutation that
writes state you wouldn't accept from an anonymous caller.
Continuous CDC export tap
Stream the shard op-log to an external warehouse/ETL sink continuously (the
outbound counterpart to CDC-in). Wire named sinks + a durable cursor store, and
drive one drain pass per POST /_lunora/admin/export-tap/run (cron or external
scheduler):
import { createKvCursorStore, r2Sink, webhookExportSink } from "lunorash/runtime";
createWorker({
shardDO: env.SHARD,
queryCoordinator,
exportCursorStore: createKvCursorStore(env.CDC_CURSORS),
exportSinks: {
warehouse: webhookExportSink({ name: "warehouse", url: env.WAREHOUSE_URL }),
archive: r2Sink({ name: "archive", bucket: env.CDC_BUCKET, prefix: "cdc" }),
},
});Delivery is at-least-once, ordered per shard, and resumable: a
shard's cursor advances only after the sink acknowledges its batch, so a crash
replays rather than drops. A failing sink applies backpressure (the shard's
cursor stays put and the next pass retries with exponential backoff) without
stalling the shard — the tap is a reader, so writes proceed. POST body:
{ "sink": "warehouse", "limit"?: number }; the response reports delivered,
cursors, failures, and hasMore (poll again while true). Managed
Snowflake / BigQuery / Airbyte / Fivetran connectors are a Lunora Cloud concern
— the framework ships the tap.