@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 stored in the routes map:
(request, env, context) => Response | Promise<Response>.
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, // the window: `lunora backup prune` keeps the newest 14
// 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, sha256, 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 the built-in backup, and alongside it on a shared
expression.
Retention does not delete on its own
backupRetain is the window (how many snapshots this cron keeps) and nothing
more. The scheduled backup never deletes. It writes the snapshot, reports
how many sit past the window, and leaves them there; removing them is
lunora backup prune, which is the only thing in Lunora that deletes a backup.
lunora backup retention --url https://api.example.com # what would go; deletes nothing
lunora backup prune --url https://api.example.com # deletes it, after confirmingThe trade is deliberate. An R2 delete has no undo, the thing deleted is the backup, and a config mistake surfaces when someone reaches for a snapshot that is not there, so the destructive step is one an operator takes, not a side effect of a backup succeeding. The cost is that a bucket grows until somebody prunes it, which is why every run that has snapshots past the window says so and names the command.
Both commands answer from the worker, because the worker is the only party that
knows its own backupCron and backupRetain, and because eligibility depends
on a marker that is not visible in a plain object listing. They share one
selection, so what retention predicts is what prune removes.
prune prints the list and asks before deleting; --yes skips the prompt for
scripts, and without a TTY it refuses rather than assuming. It needs a window:
with no backupRetain there is nothing past it, and a default is deliberately
not invented.
Run the preview on any bucket that predates the marker: those sidecars are never eligible, so retention may own far fewer snapshots than the bucket contains. It says exactly how many.
A prune only ever removes snapshots this cron wrote: each sidecar is stamped with its cron expression, and the prune matches on it. Snapshots taken by hand, and snapshots from a second deployment sharing the bucket, are left alone, so two workers on one bucket each keep the retention they configured. (Sidecars written before this marker existed are never eligible; delete those by hand once.) A failed retention report is warned about and does not fail the backup that already landed; the run itself deletes nothing to fail at.
lunora backup list --bucket <name> reads the same layout, so scheduled
snapshots and ones taken by hand with lunora backup create --bucket <name>
appear as one history, and lunora backup restore <id> --bucket <name> --verify
works on either, because both tiers record the snapshot's SHA-256 in the
manifest. The scheduled run also hands the digest to R2, which verifies it on
write and reports it from head/list afterwards. (Snapshots written by a
release before this one carry no checksum; --verify refuses those rather than
reporting an unverified restore as a verified one.)
Size
The backup is built in the isolate: orchestrateExport resolves every shard's
rows into memory before the first row is encoded, so the snapshot has to fit
alongside them in a Worker's ~128 MB. The run refuses past 24 MiB of NDJSON
(BACKUP_TOO_LARGE): the cron invocation fails loudly and nothing is written.
Read that cap for what it is: a bound on the snapshot, not on peak memory. For
shard-local tables the row set is already resident when the first check runs, so
it only bounds anything incrementally for .global() tables, which stream from
a generator. The number is set well under the isolate's limit precisely because
it cannot bound the larger allocation, and a backup comfortably under it can
still exhaust a Worker if the row set behind it is large.
Narrow it with backupTables, or move the job off-platform with
lunora backup create --bucket (32 MiB, and it runs on a machine). Backing up
more often does not help: every run is a full snapshot, not an increment.
Raising the ceiling means making the export fan-out stream per shard. A bigger object or a multipart upload would not help: the rows are already in memory before the upload starts.
Reading objects back
lunora backup restore --bucket pulls a snapshot down through the admin-gated
GET /_lunora/admin/storage/object?key=…, which streams one object's body
under the same bearer as every other admin route. It needs a storageDownload
function on the worker; wrap the storage call, as the generated app worker
does:
storageDownload: (key: string, opts?: { bucket?: string }) => pick(opts?.bucket).download(key),createStorage(...).download cannot be passed directly: its second parameter is
a byte range, not a bucket. Without it the endpoint
answers STORAGE_DOWNLOAD_NOT_CONFIGURED and a bucket restore is not
available; the snapshot is still readable with wrangler r2 object get.
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, with 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.