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.

One deliberate gap:

  • Optimistic updates and the offline queue are JS and Dart only. The other six clients speak the protocol; they do not implement the client-side mutation queue.

Three 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.
  • Optimistic updates. Pass optimisticUpdate to a generated mutation to 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 — 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. Tell the client about connectivity with setConnected(true|false); give it a LunoraPersistence to survive a process restart.
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,
  ),
);

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.