PackagesSolid

@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

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).
createSubscriptionprimitiveRaw live stream as { data, error } accessors. "skip" tears down.
createPaginatedQueryprimitiveCursor pagination: { results, status, isLoading, loadMore }.
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, 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