@lunora/solid
SolidJS bindings built on @lunora/client — live query signals, optimistic mutations, and an SSR hydration handoff.
@lunora/solid is the SolidJS adapter for Lunora. It's a thin, idiomatic 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 — 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). |
createSubscription | primitive | Raw live stream as { data, error } accessors. "skip" tears down. |
createPaginatedQuery | primitive | Cursor pagination: { results, status, isLoading, loadMore }. |
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, and the MutationHandle interface.
<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) 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.
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.
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.
See also
- @lunora/react — the same contract for React
- @lunora/vue — the Vue adapter
- @lunora/svelte — the Svelte adapter
- @lunora/client — the framework-neutral SDK this wraps
- Bring your framework — composition + adapter maturity
- Reactive loaders — the hydrate-then-subscribe handoff
- Real-time