@lunora/svelte
Svelte 5 bindings built on @lunora/client — live query stores, optimistic mutations, and an SSR hydration handoff.
@lunora/svelte is the Svelte adapter for Lunora. It's a thin, idiomatic layer
over the framework-neutral @lunora/client — which owns the
WebSocket transport, subscription registry, offline queue, and delta-merge — and
re-expresses that contract as Svelte stores you read with the $store idiom. A
live query is a readable store that re-emits on every server delta; an optimistic
mutation is a small bundle of stores plus an awaitable mutate.
The package is plain .ts over Svelte stores — no .svelte component compiler is
required to build or use it.
Preview. The Svelte adapter exists and exposes the same hydrate-then-subscribe handoff as React, but it is preview maturity — not yet proven end-to-end against a running app. The React adapter is the only one verified through the full live-loader path. See Bring your framework.
<!-- +layout.svelte — provide the client once at the root. -->
<script lang="ts">
import { LunoraClient } from "@lunora/client";
import { setLunoraClient } from "@lunora/svelte";
setLunoraClient(new LunoraClient({ url: import.meta.env.VITE_LUNORA_URL }));
</script>
<slot />Exports
| Symbol | Kind | Role |
|---|---|---|
setLunoraClient | function | Publish the shared LunoraClient on Svelte context. Call once high in the tree. |
getLunoraClient | function | Read the LunoraClient from context. Throws if none was set. |
query | function | Live query as a readable store ($store). Sharing one store shares one subscription. |
mutation | function | Optimistic mutation handle: { data, error, pending, mutate, reset } stores. |
hydratePreloaded | function | Seed a readable store synchronously from an SSR Preloaded token, then attach the socket. |
The table above lists the core handful. The package also exports the live
stores covered under Additional stores: subscription,
paginatedQuery, infiniteQuery, presence, rateLimit, auth, and
connectionStatus.
Re-exported types from @lunora/client: ArgsOf, ConnectionStatus,
FunctionReference, LunoraClient, MutationCallOptions, Preloaded,
ReturnOf — plus the adapter's own QueryStore, QueryStoreOptions, and
MutationHandle.
Unlike React/Solid/Vue (which use useLunora), the Svelte provider accessor is
the idiomatic getLunoraClient. There is no <LunoraProvider> component — you
publish the client with setLunoraClient during component init.
Provider — setLunoraClient / getLunoraClient
setLunoraClient(client) publishes the LunoraClient on the Svelte component
context. Call it once, high in the tree (typically your root +layout.svelte or
App.svelte), during component initialisation — setContext must run while the
component is being constructed. This is the Svelte analogue of mounting React's
LunoraProvider.
getLunoraClient() reads it from the nearest ancestor and throws if no provider
was set, so a missing client fails loudly rather than as a later undefined
dereference. Most code never calls it directly — query / mutation /
hydratePreloaded resolve the ambient client for you.
query(fn, args, options?)
Opens a live query as a Svelte readable store. Read it with the $store
idiom and it stays current: a WS subscription attaches the moment the store
gains its first subscriber, and the value re-emits on every server delta. The
store holds undefined until the first response lands — the Svelte equivalent of
React's useQuery.
The subscription is opened lazily (inside readable's start callback, on the
first $-read) and torn down when the last subscriber goes away, so a store
that's never read opens no socket and an unmounting component releases its
subscription. Sharing one store across several components shares a single
underlying subscription (the client de-dupes by (fn, args, shardKey)).
<script lang="ts">
import { query } from "@lunora/svelte";
import { api } from "$lib/_generated/api";
const messages = query(api.messages.list, { channelId: "general" });
</script>
<ul>
{#each $messages ?? [] as m (m._id)}
<li>{m.text}</li>
{/each}
</ul>query is overloaded: pass client explicitly as the first argument, or omit it
to resolve the ambient client published by setLunoraClient.
// Ambient client (default):
query(api.messages.list, { channelId });
// Explicit client:
query(client, api.messages.list, { channelId });options accepts { shardKey } to route to a specific shard when the target
function is .shardBy(...)-partitioned, and { onError } — a callback invoked
when the underlying subscription reports an error.
mutation(fn)
Creates an optimistic MutationHandle for a mutation reference, expressed as
readable stores plus an awaitable mutate:
interface MutationHandle<F> {
data: Readable<ReturnOf<F> | undefined>; // latest resolved value
error: Readable<Error | undefined>; // latest error
pending: Readable<boolean>; // true while any call from this handle is in flight (ref-counted)
mutate: (args, options?) => Promise<ReturnOf<F>>; // awaitable; resolves with the server value, rejects on failure
reset: () => void; // clear data/error back to idle
}pending is ref-counted across overlapping invocations of the same handle, so
read $pending to disable a button until the last concurrent call settles.
mutation is overloaded the same way as query — pass client explicitly or
omit it to use the ambient client.
<script lang="ts">
import { mutation } from "@lunora/svelte";
import { api } from "$lib/_generated/api";
const send = mutation(api.messages.send);
const { mutate, pending, error } = send;
</script>
<button disabled={$pending} on:click={() => mutate({ channelId: "general", text: "hi" })}>Send</button>
{#if $error}<p>{$error.message}</p>{/if}Optimistic updates
Optimistic updates stay client-owned and are passed per call in mutate's
options — the optimistic / optimisticUpdate options are applied and rolled
back by the client against the live query subscriptions, exactly as in the React
adapter. Any query / hydratePreloaded store reading the same data reflects
the change immediately and reverts on failure.
await mutate(
{ channelId, text },
{
optimisticUpdate: (store) => {
const current = store.getQuery(api.messages.list, { channelId }) ?? [];
store.setQuery(api.messages.list, { channelId }, [...current, draft]);
},
},
);Reactive loaders — hydratePreloaded
The SSR-seed-to-live-store handoff — the Svelte equivalent of React's
usePreloadedQuery. Run the query on the server with preloadQuery (from
@lunora/svelte/server) in a SvelteKit +page.ts / +layout.ts load, hand the
serializable Preloaded token to the client, and hydratePreloaded seeds the
readable store synchronously with preloaded.value — so the first $store
read during hydration returns the server value with no loading flash and no
hydration mismatch. When the store gains its first subscriber on the client, a
live WS subscription attaches and every subsequent delta re-emits, exactly like a
plain query store.
<script lang="ts">
import { hydratePreloaded } from "@lunora/svelte";
// `data.preloaded` came from a +page.ts load → preloadQuery
export let data;
const posts = hydratePreloaded(data.preloaded);
</script>
<ul>
{#each $posts as p (p._id)}
<li>{p.title}</li>
{/each}
</ul>Pass client as a second argument to override the ambient client. On the server
readable's start callback never runs (no subscriber), so the store only holds
the seeded value and opens no socket — the token's value is the single source
of truth for the first paint.
@lunora/svelte/server
Server-side preload helpers, re-exported from @lunora/client/ssr — the
framework-neutral server contract shared by every adapter. This entry opens no
WebSocket and touches no browser globals, so it is safe to import from a
SvelteKit +page.ts / +layout.ts load.
// src/routes/posts/+page.ts
import { createServerClient, preloadQuery } from "@lunora/svelte/server";
import { api } from "$lib/_generated/api";
export async function load({ fetch }) {
// Forward SvelteKit's fetch so the session cookie rides along.
const client = createServerClient({ url: import.meta.env.VITE_LUNORA_URL, fetch });
const preloaded = await preloadQuery(client, api.posts.list, {});
return { preloaded };
}Exports: createServerClient, preloadQuery, serializePreloaded,
deserializePreloaded, preloadedQueryResult, getServerSession, plus the
Preloaded / ServerClientOptions / ServerSession types.
@lunora/svelte/worker
Single-worker composition for SvelteKit (class-B — "own CF adapter,
hook-injection"). SvelteKit owns its Cloudflare build via
@sveltejs/adapter-cloudflare, so Lunora cannot own the worker entry; instead it
injects its realtime plane into the worker SvelteKit emits. Re-exports
withLunora (withFrameworkWorker from @lunora/runtime),
which wraps the SvelteKit handler as Lunora's fallback httpRouter — the reserved
/_lunora/* endpoints hit Lunora and everything else delegates to SvelteKit. One
worker, one deploy.
This entry is socket-free and runs on the worker — keep it out of client code.
// src/worker.ts (your CF Worker entry)
import { withLunora } from "@lunora/svelte/worker";
import svelteKitWorker from "../.svelte-kit/cloudflare/_worker.js"; // adapter output
import { auth } from "./lunora/auth";
// `shardDO` lives on `env` (per request), so pass a factory:
export default withLunora(svelteKitWorker, (env) => ({ shardDO: env.SHARD, auth }));Additional stores
Beyond query / mutation, the package exports the same store-shaped surface
the React adapter exposes as hooks. Each resolves the ambient client by default
or takes an explicit client as its first argument. Stores that own a timer or
listener (presence, rateLimit) return a teardown() — call it from
onDestroy.
subscription(fn, args, options?)
A raw live subscription as a pair of readable stores, { data, error }. data
holds the latest server push (undefined until the first), error holds the
last subscription error. Pass "skip" as args to keep the handle but leave the
subscription dormant. Options: { shardKey, onError }.
<script lang="ts">
import { subscription } from "@lunora/svelte";
import { api } from "$lib/_generated/api";
const { data, error } = subscription(api.messages.live, { room: "general" });
</script>
{#if $error}<p>{$error.message}</p>{/if}
{#each $data ?? [] as m (m._id)}<li>{m.text}</li>{/each}paginatedQuery(fn, args, { initialNumItems })
A cursor-paginated live query. args is the function's args minus
paginationOpts (the framework supplies the cursor), or "skip". The handle is
{ results, status, isLoading, loadMore }: results is the flattened items
across every loaded page; status is "LoadingFirstPage" | "LoadingMore" | "CanLoadMore" | "Exhausted"; loadMore(n) appends the next page (a no-op unless
status === "CanLoadMore").
<script lang="ts">
import { paginatedQuery } from "@lunora/svelte";
import { api } from "$lib/_generated/api";
const { results, status, loadMore } = paginatedQuery(api.messages.page, { room: "general" }, { initialNumItems: 20 });
</script>
{#each $results as m (m._id)}<li>{m.text}</li>{/each}
<button disabled={$status !== "CanLoadMore"} on:click={() => loadMore(20)}>Load more</button>infiniteQuery is the TanStack-Query-shaped variant: { pages, status, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage }, where pages keeps
each loaded page as its own inner array.
presence(roomId, options)
Collaborative-awareness — the client half of the server definePresence preset.
Drives a heartbeat mutation (on call, on an interval, and on tab re-focus) and
subscribes to the room's listPresent query. Options: { heartbeat, listPresent, sessionId?, data?, intervalMs?, shardKey? } — heartbeat and listPresent are
api.* references. The handle is { present, sessionId, setData, teardown }.
<script lang="ts">
import { onDestroy } from "svelte";
import { presence } from "@lunora/svelte";
import { api } from "$lib/_generated/api";
const room = presence("room-42", {
heartbeat: api.presence.heartbeat,
listPresent: api.presence.listPresent,
data: { name: "Ada" },
});
onDestroy(room.teardown);
</script>
{#each $room.present ?? [] as p (p.userId)}<span>{p.data?.name}</span>{/each}rateLimit(config, options?)
A client-side mirror of a server rate limit for instant UX — disable a button or
show a countdown without a round-trip. It runs the same token-bucket /
fixed-window math as @lunora/ratelimit, but the server stays authoritative. The
handle is { ok, disabled, retryAfter, check, consume, reset, teardown } —
ok / disabled / retryAfter are readable stores; consume(count?) records a
local hit. Pass a stable config reference (a module constant). Options: { now?, tickMs? }.
<script lang="ts">
import { onDestroy } from "svelte";
import { rateLimit } from "@lunora/svelte";
const { disabled, retryAfter, consume, teardown } = rateLimit({ kind: "token bucket", rate: 5, period: 60_000 });
onDestroy(teardown);
</script>
<button disabled={$disabled} on:click={() => consume()}>Send {$disabled ? `(${$retryAfter}ms)` : ""}</button>auth(client?) and connectionStatus(client?)
auth returns { user, token, setToken }: user and token are readable
stores (null when signed out), and setToken(jwt) sets the token on the
client after sign-in. connectionStatus returns a readable store of the
aggregate live-socket state (idle → connecting → connected → offline),
for driving a connection indicator.
<script lang="ts">
import { auth, connectionStatus } from "@lunora/svelte";
const { user, setToken } = auth();
const status = connectionStatus();
</script>
{#if $user}Signed in as {$user.name}{/if}
<span data-status={$status}>{$status}</span>See also
- @lunora/react — the same contract for React
- @lunora/solid — the SolidJS adapter
- @lunora/vue — the Vue adapter
- @lunora/client — the framework-neutral SDK this wraps
- Bring your framework — composition + adapter maturity
- Reactive loaders — the hydrate-then-subscribe handoff
- Real-time