Server-side & non-reactive clients
Calling a Lunora deployment without a live subscription — from SSR loaders, scripts, other languages, or plain HTTP.
Last updated:
Most Lunora clients are reactive: a useQuery opens a WebSocket and re-renders
when data changes. But plenty of callers do not want a subscription — an SSR
loader, a cron script, a service in another language, a webhook handler. This
page covers those.
Pick by what the caller is:
| Caller | Reach for |
|---|---|
| An SSR loader / Server Component | @lunora/client/ssr |
| A Node or Bun script | LunoraClient over HTTP RPC |
| A one-off call from your terminal | lunora run |
| Python | the Python SDK |
| Anything else | the REST/HTTP API |
From an SSR loader
@lunora/client/ssr builds a request-scoped client that talks HTTP RPC only. A
LunoraClient opens its socket lazily on .subscribe() / .stream(), and SSR
loading never calls those, so no live connection is established even when the
server runtime happens to expose a global WebSocket.
import { createServerClient } from "@lunora/client/ssr";
import { api } from "@/lunora/_generated/api";
const client = createServerClient({
url: process.env.LUNORA_URL!,
token: sessionToken, // runs the load as the signed-in user
});
const messages = await client.query(api.messages.list, { channelId });Create the client per request, not once at module scope. The bearer token and any forwarded cookies are per-user — a shared module-level client leaks one request's identity into another request.
getServerSession resolves the session from the incoming request's headers, and
preloadQuery / serializePreloaded hand a result across an SSR boundary so the
client can hydrate it into a live subscription without a second round trip. The
React binding for this is described in Next.js; the
same primitives back every framework adapter's loaders.
From a script
The same client works in any JavaScript runtime with fetch — Node 18+, Bun, or
another Worker. Construct it, call query / mutation / action, and skip the
subscription API entirely:
import { LunoraClient } from "@lunora/client";
import { api } from "@/lunora/_generated/api";
const client = new LunoraClient({ url: process.env.LUNORA_URL! });
await client.mutation(api.messages.send, { channelId: "general", text: "deploy finished" });Both query and mutation take a generated function reference — api.<file>.<function> —
rather than a string path, which is what carries the argument and return types to
the call site. The string form ("messages:send") is what the CLI and the
non-TypeScript SDKs use.
For an admin-shaped script — backfills, one-off repairs — prefer an internal function invoked with an admin token over widening a public function's surface.
From the terminal
lunora run sends a single RPC without any SDK at all:
lunora run messages:send --args '{"channelId":"general","text":"hi"}'
lunora run messages:list --args '{}' --url https://my-app.workers.dev--shard overrides shard routing. This is the quickest way to check a function
end to end without writing a client.
From Python
The Python SDK implements the wire protocol directly — HTTP RPC, live
subscriptions, the poke-based shape protocol, and the full value codec. The core
is standard-library only; only the live WebSocket loop needs the optional
websockets package.
import asyncio
from lunora import LunoraClient, WireBigInt
async def main():
client = LunoraClient(url="https://my-app.example.com", auth_token="…")
messages = await client.query("messages:list", {"channel": "general"})
await client.mutation("ledger:add", {"amount": WireBigInt(1000)})
asyncio.run(main())Because Python has no distinct bigint, Date, Map, or Set, those types are
expressed through small wrappers — WireBigInt, WireDate, WireMap,
WireSet, WireUrl, WireBytes. Plain values map straight to JSON. See
Data types for which types need the codec at all.
The Python SDK is a v1 proof that the protocol ports cleanly. Optimistic updates, the offline queue, and generated typed bindings are follow-ups.
From any language, over HTTP
Every function can be reached as a plain HTTP endpoint, so a language with no SDK still has a first-class path in. The surface is default-closed — a function is only reachable over REST when you expose it — and Lunora emits an OpenAPI 3.1 document describing exactly what you exposed.
See REST API for URLs, argument encoding, shard selection, authentication, and rate limiting, and Wire protocol if you are implementing a client rather than calling one.
Writing a new SDK
The protocol is specified rather than implied: the RPC envelope, the WebSocket
frame types (data / delta / ack / error / resume / settled), the poke
frames for partial replication, and the value codec are all documented in
Wire protocol. The Python SDK exists mainly to
prove that spec is complete — it is the reference to read when porting to another
language.