@lunora/solid is the SolidJS 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.
Solid's fine-grained signals map directly onto Lunora's per-subscription deltas,
so a live query is a signal the socket writes to: only the components that read
the accessor re-render.
Preview. The Solid 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. Solid lands first after React because its signals map most directly onto Lunora deltas. See Bring your framework.
import { LunoraClient } from "@lunora/client";
import { LunoraProvider } from "@lunora/solid";
import { render } from "solid-js/web";
import { api } from "./lunora/_generated/api";
import App from "./App";
const client = new LunoraClient({ url: window.location.origin });
render(
() => (
<LunoraProvider client={client}>
<App />
</LunoraProvider>
),
document.getElementById("root")!,
);Exports
| Symbol | Kind | Role |
|---|---|---|
LunoraProvider | component | Provides the shared LunoraClient to the tree via context. Required at the root. |
useLunora | function | Read the LunoraClient from the nearest <LunoraProvider>. Throws if none is mounted. |
LunoraContext | context | The underlying Solid context, for advanced manual useContext. |
createQuery | primitive | Live query as a reactive accessor. Accessor args re-subscribe; "skip" short-circuits. |
createMutation | primitive | Optimistic mutation handle: { data, error, pending, mutate, reset } accessors. |
createMutationForClient | primitive | Build a mutation handle bound to an explicit client (test/internal seam). |
createAction | primitive | Action handle: { data, error, pending, call, reset } accessors. No optimistic options. |
createActionForClient | primitive | Build an action handle bound to an explicit client (test/internal seam). |
createSubscription | primitive | Raw live stream as { data, error } accessors. "skip" tears down. |
createPaginatedQuery | primitive | Cursor pagination: { results, status, isLoading, loadMore, error }. |
createInfiniteQuery | primitive | Infinite-scroll variant: { pages, status, hasNextPage, fetchNextPage, ... }. |
createAuth | primitive | Identity plumbing: { token, user } signals + setToken. |
Authenticated / AuthLoading / Unauthenticated | component | Render children per identity state (signed-in / resolving / signed-out). |
createPresence | primitive | Collaborative awareness: heartbeat + live present member list. |
createRateLimit | primitive | Client-side rate-limit mirror: { ok, disabled, retryAfter, check, consume, reset }. |
createConnectionStatus | primitive | Reactive accessor of the live-socket ConnectionStatus. |
hydratePreloaded | primitive | Seed a query accessor synchronously from an SSR Preloaded token, then attach the subscription. |
Re-exported types: ArgsOf, FunctionReference, OptimisticUpdate,
Preloaded, ReturnOf, Unsubscribe, plus LunoraProviderProps,
CreateQueryOptions, MutationClient, ActionClient, and the
MutationHandle / ActionHandle interfaces.
<LunoraProvider client={...}>
Provides a LunoraClient to the Solid tree. Unlike the React provider there is
no QueryClient to detect or lazily create: the adapter's reactive primitives
own their own signals and read the client straight from context. The provider
does not own the client's lifecycle, so the same instance survives across route
navigations.
import { LunoraProvider, useLunora } from "@lunora/solid";
// Anywhere below the provider:
const client = useLunora(); // throws if used outside a <LunoraProvider>createQuery(fn, args, options?)
Subscribes to a query and returns a reactive accessor of its value. The
accessor reads undefined until the first server frame lands, then updates on
every delta the WebSocket pushes.
args may be a plain value or an accessor. Passing an accessor makes the
subscription reactive: when the args change the previous subscription is torn
down (via onCleanup), the accessor resets to undefined, and a fresh one opens
for the new args. Pass "skip" (or
an accessor returning "skip") to short-circuit: no network call, no socket.
import { createQuery } from "@lunora/solid";
import { For, createSignal } from "solid-js";
import { api } from "./lunora/_generated/api";
function Messages() {
const [channelId, setChannelId] = createSignal("channel:demo");
// Accessor args: changing `channelId()` re-subscribes.
const messages = createQuery(api.messages.list, () => ({ channelId: channelId() }));
return <For each={messages()?.messages ?? []}>{(m) => <li>{m.text}</li>}</For>;
}Pass { shardKey } in options to route to a specific shard when the target
function is .shardBy(...)-partitioned. The subscription tears down when the
owning reactive scope is disposed.
createMutation(fn)
Returns a reactive MutationHandle bound to the client from the nearest
<LunoraProvider>:
interface MutationHandle<F> {
data: Accessor<ReturnOf<F> | undefined>; // latest resolved value
error: Accessor<Error | undefined>; // latest error
pending: Accessor<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
reset: () => void; // clear data/error back to idle
}pending is ref-counted across overlapping invocations of the same handle. The
mutation also engages @lunora/client's offline queue when the socket is down,
so mutate stays durable across reconnects.
import { createMutation } from "@lunora/solid";
import { api } from "./lunora/_generated/api";
function Composer(props: { channelId: string }) {
const send = createMutation(api.messages.send);
return (
<button disabled={send.pending()} onClick={() => send.mutate({ channelId: props.channelId, text: "hi" })}>
Send
</button>
);
}Optimistic updates
Optimistic updates stay client-owned and are passed per call via mutate's
options: the optimistic / optimisticUpdate options pass straight through to
client.mutation, which applies and rolls them back against the live Lunora
subscription cache (the same machinery createQuery / hydratePreloaded
subscribe to), so an optimistic write reflects in those accessors immediately and
reverts on failure.
await send.mutate(
{ channelId, text },
{
optimisticUpdate: (store) => {
const current = store.getQuery(api.messages.list, { channelId }) ?? [];
store.setQuery(api.messages.list, { channelId }, [...current, draft]);
},
},
);createMutationForClient(client, fn) builds the same handle bound to an explicit
client object (only { mutation } is required). It's the internal seam behind
createMutation, exported for tests that inject a stub.
createAction(fn)
Returns a reactive ActionHandle — the same shape as MutationHandle, with
call in place of mutate:
interface ActionHandle<F> {
data: Accessor<ReturnOf<F> | undefined>; // latest resolved value
error: Accessor<Error | undefined>; // latest error
pending: Accessor<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
reset: () => void; // clear data/error back to idle
}import { createAction } from "@lunora/solid";
import { api } from "./lunora/_generated/api";
function VerifyButton() {
const verify = createAction(api.commands.run);
return (
<button disabled={verify.pending()} onClick={() => verify.call({ command: "lunora", args: ["verify"] })}>
Verify
</button>
);
}Per-call options are { shardKey } only. Unlike createMutation 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.
createActionForClient(client, fn) builds the same handle bound to an explicit
client object (only { action } is required), the seam behind createAction.
Reactive loaders — hydratePreloaded
The client half of "your loaders are live". Run the query on the server with
preloadQuery (from @lunora/solid/server) inside a SolidStart route loader,
hand the serializable Preloaded token to the client, and hydratePreloaded
seeds the accessor synchronously from preloaded.value, so the first read
during hydration returns the server-rendered value with no loading flash and no
Suspense fallback (unlike createResource, which always starts pending). After
mount, a WebSocket subscription attaches in an effect and every subsequent delta
flows into the same signal, so the UI goes live with zero refetch.
Pass { onError } as the second argument 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 accessor keeps rendering the SSR snapshot as if
it were live.
Internally hydratePreloaded is a default export, but the package barrel
re-publishes it as a named binding, so import it from @lunora/solid directly:
import { hydratePreloaded } from "@lunora/solid";
import type { Preloaded } from "@lunora/solid";
function Posts(props: { preloaded: Preloaded<Array<{ _id: string; title: string }>> }) {
// Seeded from SSR on the first read, then live.
const posts = hydratePreloaded(props.preloaded);
return <For each={posts()}>{(p) => <li>{p.title}</li>}</For>;
}Effects do not run during SSR (Solid runs them only after hydration), so the subscription is strictly client-side; the seed is the only value the server render ever sees.
@lunora/solid/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
SolidStart "use server" route loader.
import { createServerClient, preloadQuery } from "@lunora/solid/server";
import { api } from "./lunora/_generated/api";
// Per request — never reuse a client across requests (token leakage).
const client = createServerClient({ url: process.env.LUNORA_URL!, token });
const preloaded = await preloadQuery(client, api.posts.list, {});
// → hand `preloaded` to hydratePreloaded on the client.Exports: createServerClient, preloadQuery, serializePreloaded,
deserializePreloaded, preloadedQueryResult, getServerSession, plus the
Preloaded / ServerClientOptions / ServerSession types.
Agent tool events — createAgentToolEvents(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 createAgentChat takes. Returns { events }, an Accessor.
threadKey may be a plain string or an accessor — an accessor re-subscribes
when it changes.
Each event discriminates on type:
type | Fields | Source |
|---|---|---|
call | toolCallId, toolName, input, seq | durable |
result | toolCallId?, toolName?, output, status?, seq | durable |
awaiting-approval | toolCallId?, toolName?, seq | durable |
progress | toolCallId, data | live 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.
import { For } from "solid-js";
import { createAgentToolEvents } from "@lunora/solid";
import { api } from "~/lunora/_generated/api";
const ToolTimeline = (props: { threadKey: string }) => {
const { events } = createAgentToolEvents({ api, stream: api.chat.liveEvents, threadKey: () => props.threadKey });
return (
<ol>
<For each={events()}>{(event) => <li>{event.type}</li>}</For>
</ol>
);
};Voice agents — createVoiceAgent(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? }. threadKey is shared with the agent's text turns, so a voice
call continues the very same conversation createAgentChat renders.
Returns { status, connected, transcript, interimTranscript, audioLevel, isMuted, error, startCall, endCall, sendText, toggleMute }, where every value
is an Accessor. status is
"idle" | "listening" | "thinking" | "speaking".
import { createVoiceAgent } from "@lunora/solid";
import { api } from "~/lunora/_generated/api";
const CallButton = (props: { threadKey: string }) => {
const call = createVoiceAgent({ threadKey: () => props.threadKey, voice: api.agents.supportVoice });
return (
<>
<button onClick={() => (call.status() === "idle" ? void call.startCall() : call.endCall())}>{call.status() === "idle" ? "Call" : "Hang up"}</button>
<meter max={1} value={call.audioLevel()} />
<p>{call.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.startCallis what callsgetUserMedia, so it has to run from a user gesture — browsers block both the permission prompt andAudioContextresumption outside one. It is idempotent while a call is active, and a denied permission lands inerrorrather than throwing at the call site. endCall()releases everything — the socket, the microphone tracks, and the Web Audio graph — and is idempotent.onCleanupcalls it for you, so disposing the owning root ends the call.audioLevelis the live input RMS (0–1). It drives a mic meter, and it is also what the heuristics read:silenceThreshold+silenceDurationMsdecide when an utterance auto-commits (defaults0.01/1200ms), andinterruptThreshold+interruptChunksdecide when the user barges in on the agent mid-sentence (defaults0.15/3consecutive chunks). These are room-dependent; tune them against real hardware rather than trusting the defaults.createMicrophone/createSpeaker/createSocketare injection seams for tests and non-DOM hosts. The defaults aregetUserMedia+ Web Audio +new WebSocket(url), so the primitive is inert (and mockable) anywhere those are missing.