Bring your framework
Compose any meta-framework's SSR with Lunora realtime in one Cloudflare Worker.
Last updated:
Lunora is not a web framework, and it does not try to be one. It is a reactive backend that any meta-framework plugs into (TanStack Start, React Router, SolidStart, SvelteKit, Nuxt, Astro), composed into a single Cloudflare Worker. You keep your framework's routing, rendering, and developer experience; Lunora adds type-safe data and live loaders.
One worker, two dispatch flows
A Lunora worker is created with createWorker(...), which accepts an optional
httpRouter: any object shaped like { fetch(request, env?, ctx?) }. Every
meta-framework's SSR handler has exactly that shape, so you mount it as the
httpRouter and Lunora dispatches by path:
import { createWorker } from "lunorash/runtime";
import { ssrHandler } from "./framework-entry"; // your framework's SSR fetch handler
export default createWorker({
httpRouter: ssrHandler, // pages / API / SSR loaders ───────┐
shardDO: ShardDO, // │
auth, // /api/auth/* ───────────────────────────────────────┤
// … ▼
});The worker resolves each request in this order:
/api/auth/*→@lunora/auth- explicit routes → webhooks / callbacks
httpRouter.fetch→ your meta-framework's SSR handler (everything else)/_lunora/rpc→ query / mutation RPC/_lunora/ws→ subscriptions / deltas/_lunora/admin/*→ studio / observability
Lunora realtime is mounted under the reserved /_lunora/* namespace, so it
never collides with your framework's routes. Pages and API go to your framework;
queries, mutations, and subscriptions go to /_lunora/*. A 500 from your SSR
handler does not take down the realtime endpoints.
Shipped: a composeWorker({ httpRouter, ...lunoraOptions }) helper, a thin
wrapper over createWorker so templates read cleanly, plus an in-process
serverQuery fast-path so an SSR loader can call Lunora directly inside the
same worker instead of round-tripping /_lunora/rpc. serverQuery still makes
the worker-to-Durable-Object hop (dispatch lives inside the DO, so a worker-side
createCaller is impossible), but it drops the self-fetch loopback while
keeping byte-identical identity and RLS semantics.
Mount Lunora inside your framework
The section above is the Lunora-owns-the-entry direction (httpRouter /
withLunora). The inverse — your framework owns the entry and you mount
Lunora's realtime plane as a middleware — is the common case for web-standard
HTTP frameworks like Hono, Nitro / h3,
and Elysia. For these you do not need a per-framework
adapter package: the entire seam is one shared helper, createLunoraHandler.
import { createLunoraHandler } from "lunorash/runtime";createLunoraHandler(options?) returns a framework-neutral
(request, env, ctx?) => Response that owns the reserved /_lunora/* namespace
(/_lunora/rpc, /_lunora/ws, /_lunora/admin/*). Mount it under /_lunora/*
in your router; everything else stays your framework's. shardDO defaults to the
conventional env.SHARD binding, so the common case needs no options.
// Hono — c.req.raw is a Web Request; c.env carries the bindings.
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));
app.get("/", (c) => c.text("hi")); // your routes, 100% plain Hono
export default app;// Nitro / h3 — env + ExecutionContext live on the Cloudflare runtime accessor.
import { createLunoraHandler } from "lunorash/runtime";
const lunora = createLunoraHandler();
// server/routes/_lunora/[...].ts
export default defineEventHandler((event) => {
const { ctx, env } = event.context.cloudflare;
return lunora(toWebRequest(event), env, ctx);
});// Elysia — ctx.request is a Web Request.
import { createLunoraHandler } from "lunorash/runtime";
const lunora = createLunoraHandler();
app.all("/_lunora/*", ({ request }) => lunora(request, env));Every host satisfies the same four-part contract, which is all the helper needs:
- a Web
Request, - the Cloudflare
env(carrying theSHARDDurable Object namespace), - an
ExecutionContextwhen the runtime supplies one (omit it and a no-op is used), and - WebSocket pass-through — the
101 Switching ProtocolsupgradeResponse(with itswebSocket) is returned verbatim, so the framework streams the socket through unchanged.
For bindings that only exist at request time, pass an (env) => options factory
instead of a fixed object:
const lunora = createLunoraHandler((env) => ({ shardDO: env.MY_SHARD, auth }));Your framework must run on Cloudflare Workers. Durable Object bindings (env.SHARD) exist only inside the Workers runtime, so this seam works for
web-standard frameworks deployed to Workers (Hono natively, Nitro's cloudflare-module preset, Elysia on Workers, …). Node-only servers (Express, Fastify,
Koa) cannot host the realtime plane in-process — point their client at a separately-deployed Lunora worker instead.
Why no @lunora/hono / @lunora/nitro / @lunora/elysia packages? Because there is nothing framework-specific left to package — each integration is
the one-/two-line bridge above over the shared createLunoraHandler. A dedicated package is only warranted when a framework needs real build/module wiring
beyond a fetch bridge (e.g. Nuxt, which registers a server handler, a #lunora/app virtual, and re-exports ShardDO via exports.cloudflare.ts —
hence @lunora/nuxt exists).
The per-framework matrix
How a framework composes with Lunora depends on who owns the worker entry. This mirrors void's class-a/b/c model.
| Class | Frameworks | Composition strategy | Status |
|---|---|---|---|
| A — Vite-native, we own the entry | TanStack Start, React Router (Vite), SolidStart | createWorker({ httpRouter: <framework SSR handler> }) directly in the worker entry. Cleanest tier; in-process serverQuery available here. | TanStack Start proven; React Router + SolidStart templated, in progress |
| B — own CF adapter, hook-injection | SvelteKit, Nuxt, Astro | The framework builds its own server; src/worker.ts wraps that output with withLunora (realtime under /_lunora/*) and lunora deploy bundles it as the deploy entry. One worker. | SvelteKit proven end-to-end; Astro/Nuxt wired on the same mechanism |
| C — non-CF or SSR-less | static / SPA | No SSR loaders. Ship the client adapter plus a standalone Lunora worker (the current default for SPA apps). Data is still live; it just hydrates client-side instead of from the HTML. | shipped |
Class A is the cleanest: we own the Cloudflare worker entry, so we drop the
framework's SSR handler straight into httpRouter. Class B frameworks bundle
their own worker, so Lunora is injected into their server entry rather than
fighting their build (the adapter ships withLunora()-style wrappers). Class
C has no SSR loaders, so there's nothing to make live on the server. The client
adapter still gives you live queries; they just hydrate on the client.
How class-B composition deploys one worker. The wrinkle class-B frameworks
introduce is that their Cloudflare adapter owns the build. @sveltejs/adapter-cloudflare
overwrites whatever the wrangler main field points at with its own generated
worker, so main cannot point at the template's src/worker.ts (the build would
clobber it). Here is the working wiring, proven by a real lunora init -t sveltekit,
then vite build, then wrangler deploy --dry-run:
- Point wrangler
mainat the adapter's own default output (.svelte-kit/cloudflare/_worker.js), so the adapter writes there and never touchessrc/worker.ts. - Keep
src/worker.tsas thewithLunorawrapper: it imports that emitted handler, mounts Lunora realtime under/_lunora/*, and re-exportsShardDO. lunora deploypassessrc/worker.tsas the positional deploy entry, which overridesmain, so the single worker Cloudflare runs is the composed one. (The template'sdeployscript runsvite buildfirst so the adapter output exists.)
The dry-run confirms a single worker exporting ShardDO with both the SHARD
(Durable Object) and ASSETS bindings: no second worker, no class-C fallback.
Astro composes the same way via src/worker.ts plus withLunora. Because
@astrojs/cloudflare writes to dist/_worker.js/ and does not clobber main,
its wiring needs no main redirection. Nuxt uses Nitro's emitted server as main
directly.
Client adapters
The browser SDK, @lunora/client, is framework-neutral: transport,
subscriptions, the offline queue, delta-merge, and reconnect have zero framework
code. Each adapter is a thin idiomatic layer on top, and every adapter exposes the
same handoff: a live useQuery (or equivalent), an optimistic useMutation (or
equivalent), a provider/context, and the preloaded-hydration primitive that seeds
an SSR value into a live subscription.
| Adapter | Idiom | Status |
|---|---|---|
@lunora/react | hooks + context | shipped |
@lunora/solid | signals / resources | preview |
@lunora/svelte | stores ($store) / runes | preview |
@lunora/vue | composables (ref / reactive) | preview |
Solid lands first after React: its fine-grained signals map most directly onto Lunora deltas, so it showcases live loaders with the least glue.
The Solid, Svelte, and Vue adapter packages exist and expose the same hydrate-then-subscribe handoff under their idiom, but they are preview: not yet proven end-to-end against a running app. The React adapter is the only one proven through the full live-loader path (see Manual end-to-end verification).
For React today, the preloaded-hydration primitive is usePreloadedQuery (see
Reactive loaders). The Solid/Svelte/Vue
adapters will expose the same hydrate-then-subscribe handoff under their idiom's
name.
Adapter API notes (alpha)
The adapters share one contract across frameworks. A couple of differences are worth calling out for anyone who tracked the earlier previews:
useMutationhandle is uniform:{ data, error, pending, mutate, reset }in React/Solid/Vue (Svelte exposes the same fields as stores). Multi-query optimistic updates are passed per call via theoptimisticUpdateoption onmutate; the Vue adapter no longer ships a boundwithOptimisticUpdate(...)handle (React keeps it). Usemutate(args, { optimisticUpdate })everywhere instead.- Provider accessor is
useLunorain React/Solid/Vue (Svelte uses the idiomaticgetLunoraClient). The Vue adapter's earlieruseLunoraClientname is gone; useuseLunora.
Scaffolding
Scaffold a new project wired for your framework with lunora init:
lunora init my-app -t tanstack-start # class A — proven, with a live-loader route
lunora init my-app --vite react # React SPA (create-vite overlay) — the default
lunora init my-app -t standalone # worker-only, no frontendlunora init -t tanstack-start is fully wired: it mounts the framework SSR
handler as the worker's httpRouter, ships a sample live loader route, and
sets up the @lunora/react provider. lunora init -t sveltekit is proven
end-to-end; its src/worker.ts, wrangler.jsonc, and lunora deploy compose
into a single worker (verified through vite build then wrangler deploy --dry-run,
exporting ShardDO with the SHARD and ASSETS bindings). Templates for
react-router, solid-start, nuxt, and astro exist alongside their
adapters and are wired on the same composition seam. They are preview:
scaffoldable, not yet each smoke-proven end-to-end.
To add Lunora to an existing meta-framework app, the in-place patcher
lunora init --here detects your framework from package.json, classifies it
(A/B/C), patches your Vite config where applicable, scaffolds lunora/ (schema +
a sample query/mutation) idempotently, and prints per-framework next steps:
the right @lunora/<adapter> to install plus the worker composition to wire
(httpRouter for class A, hook-injection for class B). The lunora/ scaffold and
the config patch are applied automatically; the provider mount and worker
composition live in framework-owned files, so the CLI prints precise steps for
those rather than guessing.
Vite composition
Under Vite, the Lunora plugin stacks alongside your framework's plugin so a single
worker is emitted. Pass cloudflare: false to let the framework supply its own
Cloudflare/SSR build while the Lunora plugins (codegen, studio, wrangler
validate/reconcile) run beside it:
// vite.config.ts
import { lunora } from "@lunora/vite";
export default defineConfig({
plugins: [
frameworkPlugin(), // your framework's Vite plugin
lunora({ cloudflare: false }), // codegen + studio + wrangler reconcile, no CF takeover
],
});Framework auto-detection from package.json and full one-worker emit for class-A
frameworks are in progress.
See also
- Reactive loaders — the differentiator
- Deploy your framework — one worker, one deploy
- Manual end-to-end verification — prove live loaders work
- Real-time
- @lunora/runtime —
createWorker, thehttpRouterseam - @lunora/cli —
lunora init -t <framework> - Deployment