Skip to content
DocsconceptsDocumentation

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:

CallerReach for
An SSR loader / Server Component@lunora/client/ssr
A Node or Bun scriptLunoraClient over HTTP RPC
A one-off call from your terminallunora run
Python, Go, Ruby, Rust, Swift, Java, Kotlinlunora sdk generate --lang <id>
Anything elsethe 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.