Skip to content
DocspackagesDocumentation

@lunora/solid

SolidJS bindings built on @lunora/client — live query signals, optimistic mutations, and an SSR hydration handoff.

PackagesSolid

@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

SymbolKindRole
LunoraProvidercomponentProvides the shared LunoraClient to the tree via context. Required at the root.
useLunorafunctionRead the LunoraClient from the nearest <LunoraProvider>. Throws if none is mounted.
LunoraContextcontextThe underlying Solid context, for advanced manual useContext.
createQueryprimitiveLive query as a reactive accessor. Accessor args re-subscribe; "skip" short-circuits.
createMutationprimitiveOptimistic mutation handle: { data, error, pending, mutate, reset } accessors.
createMutationForClientprimitiveBuild a mutation handle bound to an explicit client (test/internal seam).
createActionprimitiveAction handle: { data, error, pending, call, reset } accessors. No optimistic options.
createActionForClientprimitiveBuild an action handle bound to an explicit client (test/internal seam).
createSubscriptionprimitiveRaw live stream as { data, error } accessors. "skip" tears down.
createPaginatedQueryprimitiveCursor pagination: { results, status, isLoading, loadMore, error }.
createInfiniteQueryprimitiveInfinite-scroll variant: { pages, status, hasNextPage, fetchNextPage, ... }.
createAuthprimitiveIdentity plumbing: { token, user } signals + setToken.
Authenticated / AuthLoading / UnauthenticatedcomponentRender children per identity state (signed-in / resolving / signed-out).
createPresenceprimitiveCollaborative awareness: heartbeat + live present member list.
createRateLimitprimitiveClient-side rate-limit mirror: { ok, disabled, retryAfter, check, consume, reset }.
createConnectionStatusprimitiveReactive accessor of the live-socket ConnectionStatus.
hydratePreloadedprimitiveSeed 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:

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.

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. 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. onCleanup calls it for you, so disposing the owning root ends the call.
  • 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.