Skip to content
DocsconceptsDocumentation

Non-JS SDKs

Generate a typed, self-contained Lunora client for Python, Go, Ruby, Rust, Swift, Java, Kotlin or Dart/Flutter — vendored into your project and pinned to your CLI version.

Last updated:

Lunora's own clients are TypeScript, but the wire protocol is not TypeScript-bound. lunora sdk generate emits a client for eight other languages, built from your deployment's own function surface.

lunora codegen --api-spec openrpc      # writes lunora/_generated/openrpc.json
lunora sdk generate --lang python      # → ./sdk/python

The generated directory is self-contained. It holds the hand-written transport (the wire codec, HTTP RPC, subscriptions, the shape/poke protocol) alongside a typed surface derived from your schema, so there is no Lunora package to install in the consuming project.

Language--langNeeds installing
Pythonpythonnothing; standard library only
Gogonothing; standard library only
Javajavanothing; JDK only
Kotlinkotlinnothing; JDK + Kotlin stdlib
Swiftswiftnothing; Foundation only
Dartdartnothing; dart:convert only, so every Flutter target works
Rustrustserde + serde_json, already declared in the emitted Cargo.toml
Rubyrubydry-struct + dry-types, which the generated models require

The live WebSocket loop is the one exception on Python: query/mutation/action and the codec are stdlib-only, but connect_and_run() needs pip install websockets.

The output is pinned to your CLI version

A vendored transport has to match the protocol vintage of the surface generated beside it. So the transport is fetched from the git tag matching the CLI you ran (@lunora/cli@<version>), and the copy records what it got:

sdk/python/lunora-transport.json
{
    "cliVersion": "1.0.0-alpha.159",
    "ref": "@lunora/cli@1.0.0-alpha.159",
    "source": "gh:anolilab/lunora/sdks/python",
    "versionMatched": true
}

Regenerating with a newer CLI brings a newer transport, which is how you upgrade. If the exact tag has no transport for that language, the CLI falls back to the release branch, says so loudly, and records versionMatched: false, so a copy is never silently a different vintage than the surface next to it.

FlagFor
--out <dir>Output directory (default ./sdk/<lang>)
--spec <path>An OpenRPC document other than lunora/_generated/openrpc.json
--ref <tag>Pin the transport explicitly. Never falls back; a miss is an error
--from <dir>Copy from a local checkout of sdks/ instead of fetching

Wiring it into your project

The layout differs per language because each toolchain resolves differently. Point your build at the generated directory:

LanguageWire it up with
Pythonput sdk/python on sys.path; import lunora_api
Gorequire/replace the emitted module at sdk/go
Ruby$LOAD_PATH.unshift("sdk/ruby"); require "api"
Rusta path dependency on sdk/rust in your Cargo.toml
Swift.package(path: "sdk/swift"), product LunoraApi. SwiftPM identifies a path package by its directory name
Javajavac -sourcepath sdk/java
Kotlinkotlinc sdk/kotlin …
Dartlunora_sdk: {path: sdk/dart} in dependencies; import 'package:lunora_sdk/lunora_api.dart'

What you get, and what you don't

Every language implements the full wire codec, the stable subscription key, RPC, live subscriptions, the shape/poke protocol and resume-across-reconnect, and every client is safe to share across threads. All eight get typed argument and result models generated from your schema.

All eight also implement the two client-side write features, ported from @lunora/client and held to the same golden fixtures:

  • Optimistic updates. Patch any number of subscribed queries before the server answers. A prediction is a LAYER, so an unrelated push re-folds it onto the new value rather than wiping it, and it is released the moment a frame carries the write's own commit cursor — not when the HTTP call returns, which races the socket. A failed write unwinds it.
  • An offline mutation queue. A write issued while disconnected is held and replayed in order on reconnect, under the same idempotency key the call minted, so a write the server already committed is not applied twice. The queue is a bounded FIFO: overflow drops the OLDEST write, a stale precondition drops one before it replays, and a write queued under one identity never replays under another. Give the client a persistence adapter to survive a process restart.

Two deliberate gaps everywhere:

  • No multi-tab leader election. A browser concern; there are no tabs here.
  • No built-in HTTP or socket. Both are injected, which is what lets you keep your own stack — see below.

How you reach the queued write path differs in one place, and it is worth knowing before you look for a method that is not there:

  • Everywhere except Dart, submit is the queued write path and mutation is unchanged. mutation stays one direct round-trip that fails when the deployment is unreachable, because the generated typed wrappers call it. submit returns immediately with a status of committed or queued; the eventual verdict on a queued write arrives through the client's settled callback rather than by blocking a thread. Stamp the queue's identity yourself with client.identity — a stable, non-secret subject such as a user id.
  • Dart uses mutation directly and learns about connectivity from you. It does not own a socket, so setConnected(true|false) is what flushes the queue, and its LunoraPersistence adapter is asynchronous where the other seven's is synchronous.

Two things only Dart gets, because a mobile client is disconnected routinely rather than exceptionally:

  • A live query is a Stream. watchList(args) (and client.watch(path, args)) hands back a Stream a Flutter StreamBuilder consumes directly. It subscribes on first listen and unsubscribes when the last listener cancels, so disposing the widget disposes the subscription. The callback-shaped subscribeList(...) every other language has is there too.
  • Batched replay. A reconnect flushing two or more writes coalesces them into /_lunora/rpc-batch round trips instead of one request per write. The other seven replay sequentially.

One thing only Dart lacks: a per-shard drain. The other seven own a socket per shard, so one shard reconnecting flushes just its writes; Dart has a single connectivity signal, so one reconnect drains everything.

In Dart, that rides the generated mutation:

await api.messages.send(
  MessagesSendArgs(channelId: 'c1', text: 'hello', kind: Kind.TEXT, tags: {}),
  optimisticUpdate: (store, _) => store.setQuery(
    'messages:list',
    [...(store.getQuery('messages:list', args: listArgs)! as List), pending],
    args: listArgs,
  ),
);

Everywhere else it rides submit, which answers straight away with where the write went:

outcome = await client.submit(
    SubmitOptions(
        function_path="messages:send",
        args={"channel": "general", "text": "hi"},
        optimistic_update=lambda store, _: store.set_query(
            "messages:list",
            {"channel": "general"},
            [*(store.get_query("messages:list", {"channel": "general"}) or []), pending],
        ),
    )
)

outcome.status  # "committed", or "queued" while the socket is down

A per-call optimistic patches the query subscribed under the mutation's own path and args — the shorthand for a counter or a document-by-id, where a query and a mutation share both. To patch a differently-named query, which is the usual case, use optimisticUpdate: its store names its targets.

Two things are worth knowing about the models everywhere:

  • An argument or result carrying a v.bigint() or v.bytes() stays untyped. JSON Schema describes both as a plain integer and a plain string, but the wire needs a tagged value no generated field can produce, so no model is emitted and the call takes wire values directly. lunora sdk generate names the functions.
  • A result is only typed if you declare .output(). Without one the return type is inferred by TypeScript and absent from the schema, so the SDK hands back its language's any rather than guessing a shape.

HTTP and the socket are injected in every language rather than assumed, so you keep your own stack, timeouts, retries and socket library, and the conformance suites run with no network.

Conformance

Every SDK is tested against the same golden frames in protocol/fixtures/ as the reference TypeScript client, and protocol/conformance-cases.json lists the cases every suite must exercise. Adding a name there turns all eight languages red until each one covers it.

CI also generates each SDK into a scratch directory outside the repository, then compiles it and runs a call through it. Building alone was not enough: an earlier revision emitted a Java surface that compiled perfectly and threw on its first invocation.

See sdks/README.md for the contributor-side detail.