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.

Bring your framework. Your loaders are live.

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:

  1. /api/auth/*@lunora/auth
  2. explicit routes → webhooks / callbacks
  3. httpRouter.fetch → your meta-framework's SSR handler (everything else)
  4. /_lunora/rpc → query / mutation RPC
  5. /_lunora/ws → subscriptions / deltas
  6. /_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:

  1. a Web Request,
  2. the Cloudflare env (carrying the SHARD Durable Object namespace),
  3. an ExecutionContext when the runtime supplies one (omit it and a no-op is used), and
  4. WebSocket pass-through — the 101 Switching Protocols upgrade Response (with its webSocket) 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.

ClassFrameworksComposition strategyStatus
A — Vite-native, we own the entryTanStack Start, React Router (Vite), SolidStartcreateWorker({ 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-injectionSvelteKit, Nuxt, AstroThe 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-lessstatic / SPANo 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:

  1. Point wrangler main at the adapter's own default output (.svelte-kit/cloudflare/_worker.js), so the adapter writes there and never touches src/worker.ts.
  2. Keep src/worker.ts as the withLunora wrapper: it imports that emitted handler, mounts Lunora realtime under /_lunora/*, and re-exports ShardDO.
  3. lunora deploy passes src/worker.ts as the positional deploy entry, which overrides main, so the single worker Cloudflare runs is the composed one. (The template's deploy script runs vite build first 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.

AdapterIdiomStatus
@lunora/reacthooks + contextshipped
@lunora/solidsignals / resourcespreview
@lunora/sveltestores ($store) / runespreview
@lunora/vuecomposables (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:

  • useMutation handle 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 the optimisticUpdate option on mutate; the Vue adapter no longer ships a bound withOptimisticUpdate(...) handle (React keeps it). Use mutate(args, { optimisticUpdate }) everywhere instead.
  • Provider accessor is useLunora in React/Solid/Vue (Svelte uses the idiomatic getLunoraClient). The Vue adapter's earlier useLunoraClient name is gone; use useLunora.

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 frontend

lunora 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