Skip to content

sdk-ts — the TypeScript client

lemonfiber

Lemonfiber — sdk-ts

The TypeScript client for lemonfiber's local web API. Typed calls, a typed event stream, and a typed error — published as @lemonfiber/sdk-ts.

Status Licence


Status: unreleased. The typed calls, the event stream and the generated types are all in and exported; the package is not on npm yet. Full account in the spec: 30-repos/sdk-ts.md.

A library with no user interface and no server. It speaks the web API and exposes it as typed calls, a typed event stream, and a typed error. It is the only thing lemonfiber-web uses to talk to the core, and the first thing any other consumer should reach for.

It is a peer of sdk-php, not its original — both implement one specification, and neither is the reference for the other. Where this client disagrees with the contract, this client is wrong.

Terminal window
npm install @lemonfiber/sdk-ts

Requires Node 26 or newer, or any modern browser. It has no runtime dependencies — a client library’s dependency tree becomes every consumer’s.

lemonfiber prints an address and a token when it starts serving. Pass both in; the token is sent as a header and never placed in a URL.

import { Client, follow } from "@lemonfiber/sdk-ts";
const opened = Client.at({
url: "http://127.0.0.1:9000", // loopback only — anything else is refused
token: printedByLemonfiber,
sending: fetch,
});
if (!opened.ok) throw new Error(opened.problem.message);
const status = await opened.client.read("status");
if (status.ok) {
status.value.kind; // "status"
status.value.data; // the payload, shaped by kind
}
await opened.client.act("restart", { forms: ["tv"], services: ["sonarr"] });

An action’s name and its arguments are the command line’s own. A name this surface does not offer is refused rather than invented, and a field no action takes is refused rather than ignored.

Live updates arrive as envelopes. Anything gathered before a break in the connection is marked out of date rather than shown as current:

for await (const arrival of follow({ url, token: printedByLemonfiber, fetching: fetch })) {
if (arrival.at === "live") draw(arrival.kind, arrival.data);
if (arrival.at === "stale") markOutOfDate(arrival.quietForMs);
if (arrival.at === "lost") report(arrival.problem.message);
}

Nothing throws for an expected failure. A call returns either a value or a Problem carrying a sentence written for a person to read.

problem.kind says which sort of refusal it was, so a caller need not read the sentence to know what to do with it:

kind What it means
missing lemonfiber has nothing by the name the request gave
misasked It could not answer the request as it was asked
failed It understood the request and its own answering failed
refused The key this page is using is not the one this run expects
unreachable Nothing lemonfiber wrote came back at all

refused is the key and nothing else. A caller reading it may ask for a new key without reading the sentence, which is the point of a kind — and a caller reading failed may not, because what failed is behind the answer rather than in front of it. A stopped container engine is failed, and asking again once it is running will succeed.

missing, misasked and failed always carry lemonfiber’s own sentence: a body this package cannot read is reported as unreachable whatever status carried it, so a page from something standing in front of lemonfiber is never passed off as its account of what there is.

Everything under src/generated/ is written by npm run contract:generate from the vendored contract/web-api.contract.json, which lemonfiber produces from the Rust types that actually serialise the reply. Never edit it by hand — a hand-written response shape is a second source of truth for the contract, which ARCH-R58 forbids.

A change belongs in those Rust types; everything downstream follows from that. The contract:check gate regenerates and diffs, so a hand edit fails CI rather than merging:

Terminal window
npm run contract:sync # pull a newer contract from lemonfiber
npm run contract:generate # rewrite src/generated/ from it

Why it works this way: ADR-0014.

Everything CI runs, in one command:

Terminal window
npm ci
npm run ci

The individual steps are the scripts in package.json, and each runs on its own while you work — npm test for the fast loop.

npm ci is also what turns on this repository’s pre-push hook, which refuses a push that would leave a branch carrying no commit origin/main does not — what pushing the trunk over a feature branch looks like. npm’s prepare script does it, so npm install serves too. A clone nobody has installed into has no hook: it is git config core.hooksPath .githooks, per clone, and git cannot read .githooks/ on its own.

The bar is the Rust workspace’s, in its TypeScript equivalents: 100% coverage across lines, statements, branches and functions; strict with noUncheckedIndexedAccess and exactOptionalPropertyTypes; typescript-eslint strictTypeChecked with zero warnings tolerated. There are no escape hatches, and a threshold is not a target to negotiate.

They do different jobs, and conflating them is the mistake to avoid. The package carries semver. api_version is a monotonic integer describing the wire, and CONTRACT_API_VERSION is the one this package speaks. Many package versions may speak one wire version. A reply in a version it cannot speak is refused, naming both (ARCH-R55).

Releases are recorded in CHANGELOG.md, generated from the commit history rather than kept by hand.

The spec is canonical: every change cites a spec identifier that already exists. Routine maintenance cites GOV-R12.

Hippocratic License 3.0 — ethical-source, source-available, deliberately not OSI-approved. See the rationale.


NightWorks.io  · Discord

This page lives in another repository Rendered from lemonfiber/sdk-ts at af34d9c, 2026-09-09. Read the source of this page