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.

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