@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 / actions:
POST /_lunora/rpc, JSON body{ functionPath, args, shardKey? }. Response:{ result }or{ error }. - Subscriptions: single multiplexed WebSocket at
/_lunora/ws. The client sendssubscribe/unsubscribe/connect/ack/streamframes; the server pushesdelta(andresume) frames back, each tagged with the originating subscriptionid.
Supported value types
The wire is JSON, but a tagged codec carries the value kinds JSON can't, so args and results round-trip losslessly across RPC, streams, whispers, and batches:
bigint,ArrayBuffer+ typed-array views (v.bytes()),Date,NaN/±Infinity, andundefinedin array positions.Map,Set,URL, andError(including itscausechain), reachable via av.any()field, whisper data, an action's return, or aLunoraError'sdata.
A pure-JSON payload encodes byte-identically, so this is transparent and
back-compatible. A value a stable form can't represent is rejected with a
TypeError at the send site (never silently corrupted): RegExp, Headers, a
class instance, an over-long bigint, or anything nested deeper than 64 levels.
Cache-keyed args included. A subscription /
useQuery/ shape arg doubles as the reactive cache key: it is keyed on its wire form (stableWireKey), sobigint, bytes,Date,Map,Set, andURLargs subscribe, dedup, and re-execute with their real values, and two subscriptions whose args differ only by such a value get distinct keys. Two caveats:Map/Setargs key by insertion order (two equal Maps built in different orders open separate subscriptions, so prefer plain objects), and aURLarg can't survive the server's hibernation attachment (you'll get a structuredSUBSCRIPTION_PERSIST_FAILEDerror; passurl.hrefinstead). Values the wire refuses (RegExp, class instances, …) still fail loud with aTypeErrorat the call site.
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, so D1 reads are read-your-writes consistent 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. On reconnect the replay coalesces the queued writes for a shard
into batched round trips (one /_lunora/rpc-batch per ≤500-write chunk) instead of
one request per write: a flaky reconnect with a full outbox flushes in a handful
of round trips, not hundreds. 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.
Actions
client.action(fn, args, { shardKey }) invokes an action over the same
POST /_lunora/rpc transport as a query or mutation, and resolves with the
server value (rejects on failure). It takes no optimistic 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.
const result = await client.action(api.commands.run, { command: "lunora", args: ["verify"] });The shared call runner
createCallRunner(invoke, sinks) is the half of an adapter's write primitive
that isn't framework-specific — it backs both the mutate of
useMutation/createMutation/mutation and the call of
useAction/createAction/action, in every adapter. It ref-counts overlapping
invocations into sinks.setPending (so the flag clears only once the last
settles), normalizes a thrown non-Error, and routes success/failure to
setResult / setError before re-throwing the same instance.
data and error track the latest invocation rather than the last to
settle: each call takes a monotonic token and writes the value sinks only while
it is still the most recent one, so a double-click whose first call resolves
after the second cannot leave the UI showing the first one's result — or an
error for a call that succeeded.
invoke is a pre-bound thunk, which is what lets one runner serve both
procedure kinds: the runner forwards options without ever inspecting them, so
the option type is inferred from the closure. What keeps optimisticUpdate off
an action is the adapter's exported handle type, not this runner.
import { createCallRunner } from "@lunora/client";
const call = createCallRunner((args, options) => client.action(api.commands.run, args, options), {
setError: (error) => {
/* your framework's error sink */
},
setPending: (pending) => {
/* your framework's pending sink */
},
setResult: (result) => {
/* your framework's value sink */
},
});The adapters agree on one lifecycle contract on top of it: a success clears
error, a failure leaves the previous data in place (a transient error does
not blank the view), and reset() clears both without cancelling an in-flight
call — a call that settles after a reset repopulates.
You only reach for it when writing a custom adapter; app code uses the adapter primitive for its framework.
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, and 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.