Skip to content
DocspackagesDocumentation

@lunora/svelte

Svelte 5 bindings built on @lunora/client — live query stores, optimistic mutations, and an SSR hydration handoff.

PackagesSvelte

@lunora/svelte is the Svelte adapter for Lunora. It's a thin layer over the framework-neutral @lunora/client, which owns the WebSocket transport, subscription registry, offline queue, and delta-merge. The adapter 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 and 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.
actionfunctionAction handle: { data, error, pending, call, reset } stores. No optimistic options.
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, MutationHandle, and ActionHandle.

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. This is 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]);
        },
    },
);

action(fn)

Creates an ActionHandle for an action reference — the same shape as MutationHandle, with call in place of mutate:

interface ActionHandle<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)
    call: (args, options?) => Promise<ReturnOf<F>>; // awaitable; resolves with the server value, rejects on failure
    reset: () => void; // clear data/error back to idle
}

action is overloaded the same way as query and mutation: pass client explicitly or omit it to use the ambient client.

<script lang="ts">
    import { action } from "@lunora/svelte";

    import { api } from "$lib/_generated/api";

    const verify = action(api.commands.run);
    const { call, pending, error } = verify;
</script>

<button disabled={$pending} on:click={() => call({ command: "lunora", args: ["verify"] })}>Verify</button>
{#if $error}<p>{$error.message}</p>{/if}

Per-call options are { shardKey } only. Unlike mutation there are no optimistic / optimisticUpdate options: an optimistic update patches the subscription cache on the assumption a write will land, and an action is not a write — it runs in the Worker, may call a third party, and has no declared effect on any query.

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.

The preloaded value was read for whoever was signed in when the page loaded. After a sign-out or user switch retires that identity, every hydratePreloaded on the client — mounted then or later — stops using it and holds undefined until the live value arrives, so the previous user's rows are never shown to the next one. A same-user token refresh does not count as a switch. The store is typed Readable<T | undefined> for that reason, like query.

Pass { onError } as the third argument (after the optional client) to observe a subscription-scoped error the server pushes after hydration (a session expiry, an RLS denial). Without it such an error is dropped and the store keeps rendering the SSR snapshot as if it were live.

<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.

Single-worker composition

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, via buildFrameworkWorker on the generated app (withFrameworkWorker from @lunora/runtime under the hood): the reserved /_lunora/* endpoints hit Lunora and everything else delegates to SvelteKit. One worker, one deploy. See Bring your framework for the full composition and a working example.

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 (a LunoraError carrying the server code when one was sent). 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, shardKey?, onError? })

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, error }: 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"); error holds the last page subscription error (undefined when healthy) — a page that fails before its first frame is dropped, status returns to "CanLoadMore", and loadMore(n) retries it. onError is called with the same error.

<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, error }, 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? }, where 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" },
    });
    // `room` is a plain handle, not a store — `present` is the readable on it.
    const { present } = room;
    onDestroy(room.teardown);
</script>

{#each $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 }, where 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 { status, user, token, setToken }: status, user and token are readable stores, 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.

$status is the auth gate — "loading", "authenticated", "unauthenticated" or "unreachable" (a credential is held but the identity endpoint could not be reached; gates as authenticated). Branch on it, not on $user: $user is null both when signed out and when a held credential's identity has not resolved, so a $user gate shows the sign-in prompt to a signed-in user on an offline reload. See the @lunora/client reference for the table. authGate() returns { isAuthenticated, isLoading } derived from the same status, when a boolean is all a template needs.

<script lang="ts">
    import { auth, connectionStatus } from "@lunora/svelte";

    const { setToken, status: authStatus, user } = auth();
    const connection = connectionStatus();
</script>

{#if $authStatus === "loading"}Checking…{:else if $authStatus === "unauthenticated"}Signed out{:else}Signed in as {$user?.name}{/if}
<span data-status={$connection}>{$connection}</span>

Agent tool events — agentToolEvents(options)

Observes one agent thread's tool activity, separate from its chat transcript: which tools the model called, what they returned, which are parked on a human approval, and any in-flight ctx.reportProgress(...) updates.

options: { api, threadKey, stream?, limit? } — api is the generated api (it reads api.agents.agentMessages), and stream is the same app stream reference agentChat takes. Returns { events }, a readable store read with $events. Like every store here it also accepts an explicit leading client (agentToolEvents(client, options)) when there is no ambient one.

Each event discriminates on type:

typeFieldsSource
calltoolCallId, toolName, input, seqdurable
resulttoolCallId?, toolName?, output, status?, seqdurable
awaiting-approvaltoolCallId?, toolName?, seqdurable
progresstoolCallId, datalive stream

Durable events come first, oldest first by seq, followed by the ephemeral progress events for the in-flight turn. With no stream reference only the durable lifecycle is surfaced. The array is rebuilt on every update — treat it as derived, not identity-stable, and key rendered rows on toolCallId/seq.

<script lang="ts">
    import { agentToolEvents } from "@lunora/svelte";
    import { api } from "$lib/_generated/api";

    export let threadKey: string;

    const { events } = agentToolEvents({ api, stream: api.chat.liveEvents, threadKey });
</script>

<ol>
    {#each $events as event, index (event.type === "progress" ? `p-${index}` : event.seq)}
        <li>{event.type}</li>
    {/each}
</ol>

Voice agents — voiceAgent(options)

Opens a full-duplex voice call against a voice-enabled agent — the api.agents.<name>Voice reference codegen emits. Microphone capture goes up the agent's WebSocket, synthesized speech comes back down, and transcripts plus barge-in are surfaced along the way.

options: { voice, threadKey, silenceThreshold?, silenceDurationMs?, interruptThreshold?, interruptChunks?, createMicrophone?, createSocket?, createSpeaker? }, with the usual optional leading client. threadKey is shared with the agent's text turns, so a voice call continues the very same conversation agentChat renders.

Returns { status, connected, transcript, interimTranscript, audioLevel, isMuted, error, startCall, endCall, sendText, toggleMute }, where every value is a readable store read with $. status is "idle" | "listening" | "thinking" | "speaking".

<script lang="ts">
    import { onDestroy } from "svelte";
    import { voiceAgent } from "@lunora/svelte";
    import { api } from "$lib/_generated/api";

    const call = voiceAgent({ threadKey: "thread-1", voice: api.agents.supportVoice });
    const { audioLevel, status, transcript } = call;

    onDestroy(call.endCall);
</script>

<button on:click={() => ($status === "idle" ? call.startCall() : call.endCall())}>
    {$status === "idle" ? "Call" : "Hang up"}
</button>
<meter max={1} value={$audioLevel} />
<p>{$transcript}</p>

Microphone and audio lifecycle

The part the type signature does not tell you:

  • Nothing opens until startCall(). Creating the handle touches neither the microphone nor the socket. startCall is what calls getUserMedia, so it has to run from a user gesture — browsers block both the permission prompt and AudioContext resumption outside one. It is idempotent while a call is active, and a denied permission lands in error rather than throwing at the call site.
  • endCall() releases everything — the socket, the microphone tracks, and the Web Audio graph — and is idempotent. Svelte has no auto-dispose here. Unlike the other adapters nothing tears the call down for you, so call it yourself: onDestroy(call.endCall).
  • audioLevel is the live input RMS (0–1). It drives a mic meter, and it is also what the heuristics read: silenceThreshold + silenceDurationMs decide when an utterance auto-commits (defaults 0.01 / 1200ms), and interruptThreshold + interruptChunks decide when the user barges in on the agent mid-sentence (defaults 0.15 / 3 consecutive chunks). These are room-dependent; tune them against real hardware rather than trusting the defaults.
  • createMicrophone / createSpeaker / createSocket are injection seams for tests and non-DOM hosts. The defaults are getUserMedia + Web Audio + new WebSocket(url), so the primitive is inert (and mockable) anywhere those are missing.