PackagesRuntime

@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:

  1. Decodes the RPC envelope (POST /_lunora/rpc).
  2. Resolves the calling identity (via resolveIdentity, if configured).
  3. Routes to the right ShardDO via resolveShard, or fans out across shards through the queryCoordinator when the envelope sets fanOut.
  4. Forwards the request to the shard, which validates args and runs the registered query / mutation / action.
  5. Decorates the response with the security edge and returns it.

shardDO is the only required option. The rest are opt-in:

OptionPurpose
shardDO (required)The shard DurableObjectNamespace (typically env.SHARD)
queryCoordinatorEnables cross-shard fan-out (createQueryCoordinator({ registry })); without it fanOut 400s
resolveIdentity(request, env) => identity | null — sets ctx.auth on the shard
authorizeShard / authorizeFanOutPer-shard / fan-out authorization gates (return false to reject)
routesRecord<string, Route> of custom HTTP handlers ("GET /healthz" or "/healthz")
httpRouterA meta-framework SSR handler / httpRouter() app, dispatched after the reserved endpoints
securityThe secure-by-default HTTP edge — headers, CORS, CSRF (see SecurityOptions)
crons / cronJobsCron-trigger handlers keyed by their exact expression
backupCron / backupStore / adminTokenThe 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/export endpoint and lunora backup create produce).
  • lunora-backup-<id>.ndjson.manifest.json — a BackupManifest ({ 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.

See also