PackagesSvelte

@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

SymbolKindRole
setLunoraClientfunctionPublish the shared LunoraClient on Svelte context. Call once high in the tree.
getLunoraClientfunctionRead the LunoraClient from context. Throws if none was set.
queryfunctionLive query as a readable store ($store). Sharing one store shares one subscription.
mutationfunctionOptimistic mutation handle: { data, error, pending, mutate, reset } stores.
hydratePreloadedfunctionSeed 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 (idleconnectingconnectedoffline), 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