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, Go, Ruby, Rust, Swift, Java, Kotlin | lunora sdk generate --lang <id> |
| 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, and 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.
lunora sdk generate --lang python emits typed argument and result models from
your schema, and does the same for six other languages. See
Non-JS SDKs for the full list, what each one needs
installed, and how the generated output is versioned against your CLI.
Optimistic updates and the offline queue are JS-only: the non-JS clients implement the wire protocol, subscriptions and the shape/poke protocol, but not the client-side mutation queue.
From any language, over HTTP
Every function can be reached as a plain HTTP endpoint, so a language with no SDK can still call one. 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, and it is the reference to read when porting to
another language.