PackagesClient

@lunora/client

Framework-agnostic browser/edge client.

@lunora/client is the framework-agnostic SDK. React, Vue, Solid and Svelte adapters wrap it. You only depend on it directly when writing a custom adapter or driving Lunora from a Node/Bun script.

import { LunoraClient } from "lunorash/client";

import { api } from "@/lunora/_generated/api";

const client = new LunoraClient({ url: "https://app.example.workers.dev" });

const messages = await client.query(api.messages.list, { channelId: "general" });
await client.mutation(api.messages.send, { channelId: "general", text: "hi" });

// Live subscription — returns an unsubscribe function.
const unsubscribe = client.subscribe(api.messages.list, { channelId: "general" }, (next) => {
    console.log("new value", next);
});

Wire protocol

  • Queries / mutations / actionsPOST /_lunora/rpc, JSON body { functionPath, args, shardKey? }. Response: { result } or { error }.
  • Subscriptions — single multiplexed WebSocket at /_lunora/ws. The client sends subscribe / unsubscribe / connect / ack / stream frames; the server pushes delta (and resume) frames back, each tagged with the originating subscription id.

Reconnect & bookmarks

Reconnect uses decorrelated-jitter backoff (reconnect option). The client keeps a per-session monotonic bookmark in BookmarkStorage (in-memory by default), sent on the x-d1-bookmark request header of each HTTP RPC and refreshed from the response, giving D1 read-your-writes consistency across replicas. When a durable queryCache is configured, a re-subscribe also carries the last sinceSeq / sinceEpoch cursor so the server can resume from where the cache left off instead of re-sending a full snapshot.

Offline queue

Mutations issued while disconnected land in OfflineQueue — a bounded FIFO (default maxItems: 1000, oldest rejected with OFFLINE_QUEUE_OVERFLOW on overflow). They replay in submission order once the socket reconnects. Each mutation carries a stable idempotency id, so a write that committed before the client lost the ack is deduplicated by the server on replay rather than applied twice. The queue lives in memory by default; for durability across a reload pass a persistence adapter:

import { LunoraClient, createIndexedDbPersistence, createAsyncStoragePersistence } from "lunorash/client";

// Browser:
new LunoraClient({ url, persistence: createIndexedDbPersistence() });

// React Native / Expo (any async key/value store):
import AsyncStorage from "@react-native-async-storage/async-storage";
new LunoraClient({ url, persistence: createAsyncStoragePersistence({ storage: AsyncStorage }) });

A queued write that is replayed after a reload — or evicted on overflow, or discarded on an identity change — has no live mutation() Promise left to reject, so its rolled-back optimistic row would vanish silently. Subscribe to onMutationSettled for a durable, per-write terminal verdict (committed / rejected, with hadAwaiter: false for those orphaned replays) and surface the rejection in your UI:

client.onMutationSettled((event) => {
    if (event.status === "rejected") {
        toast.error(`Couldn't save your change (${event.code ?? "error"}).`);
    }
});

See Offline-first for the full reads + writes story.

Optimistic updates

client.mutation(fn, args, { optimistic: (current) => next }) patches the matching subscription's cache immediately, then reconciles it gaplessly: the patch is recorded as a rebaseable layer, so an unrelated server delta that lands while the write is still pending is re-folded under it (your change never flickers away and back), and the layer is dropped the moment a server frame whose cursor reaches the write's committed cursor arrives — the server echoes that cursor on the mutation response, so the drop is keyed on confirmed server state, not RPC-response timing (which races the WebSocket broadcast). A coded rejection rolls the layer back.

To patch several subscribed queries from one mutation, pass optimisticUpdate instead — it receives an OptimisticLocalStore over the live cache, and each setQuery registers a constant-value layer on the same engine, so the whole batch rebases and settles together (confirmed on the mutation's commit cursor, or rolled back atomically on failure). See Offline-first for the full reconciliation model.

Shape subscriptions

subscribeShape is the local-first sync engine's parallel to subscribe — it replicates a partial view of a table (a shape) over the poke diff protocol instead of re-running a query:

const unsubscribe = client.subscribeShape({ name: "messagesByChannel", args: { channelId: "general" } }, (rows) => console.log("current shape rows", rows), {
    onError: (e) => console.error(e),
});

You send the shape name + validated args (never a where the client could forge); the server seeds the current membership as an insert-poke and streams live membership diffs, materializing the rowset into callback on each applied poke. Unlike subscribe, shape subscriptions are not deduped by (name, args) — the server resolves each under the socket's verified identity, so every call gets its own view. Pass { shardKey } to route a sharded shape to its DO.

Most apps don't call this directly — @lunora/db's lunoraCollectionOptions({ shape }) wires it into a TanStack DB collection.

Auth

client.setAuthToken(jwt) stamps an Authorization: Bearer <jwt> header on every HTTP RPC and clears the in-memory offline queue's writes from the previous identity. It does not touch the WebSocket — that token is fixed at upgrade time and lives in the URL. To rotate live WS auth call client.setWsToken(token), which closes the open shard sockets so they reconnect with the new credential.

getCurrentUser() resolves the signed-in user from better-auth's get-session route (returns null when signed out), and onAuthTokenChange fires whenever the bearer changes. The @lunora/client/auth subpath wraps these into a shared per-client identity store (single in-flight fetch, fan-out to every mounted hook) that the framework adapters consume.

SSR preloading

@lunora/client/ssr runs a query once on the server and captures the result in a serializable Preloaded token. Embed the token in the rendered HTML and hand it to usePreloadedQuery on the client: the first render shows the server value with no loading flash, then a live subscription takes over. The SSR client only needs a fetch that can reach the worker — no in-process Durable Object access.

import { createServerClient, preloadQuery, serializePreloaded } from "lunorash/client/ssr";

import { api } from "@/lunora/_generated/api";

const client = createServerClient({ url: "https://app.example.workers.dev" });
const preloaded = await preloadQuery(client, api.messages.list, { channelId: "general" });

// Embed `serializePreloaded(preloaded)` in HTML; rehydrate with usePreloadedQuery on the client.

See also