Files
punktfunk/sdk/README.md
T
enricobuehler 87114ab186
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m21s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m42s
docker / deploy-docs (push) Successful in 26s
feat(sdk): @punktfunk/host — the Effect TypeScript SDK (M3)
New top-level sdk/ package (RFC §7): a typed management-API client plus
the lifecycle event stream, built on Effect, two surfaces over one core:

- @punktfunk/host — the Promise facade front door: connect() resolves
  URL/token/TLS pin from the host's own files (zero config on the box),
  fails fast on bad credentials, pf.events.on() with typed callbacks
  (exact kinds, domain.* prefixes, "*", "dropped", "unknown"),
  pf.request() for the REST surface. Effect never required.
- @punktfunk/host/effect — the PunktfunkHost service + PunktfunkHostLive
  layer, Stream-based events()/eventsRaw(), typed errors
  (AuthError | ApiError | TransportError | VersionSkew — a 2xx that
  fails its schema is a typed skew, not undefined later), and every
  wire shape as an effect/Schema: REST generated via orval
  client:'effect' from api/openapi.json (S3 spike: works well; the
  text/event-stream payload is out of its reach), events hand-mirrored
  from the host's snapshot-tested wire format as a kind-discriminated
  union.

One reconnecting SSE core under both surfaces: spec-shaped parser,
exponential+jittered backoff (capped, resets after a healthy
connection), Last-Event-ID resume, 401 terminal. Default is LIVE tail
only — a fresh notify script must not re-fire on the host's replayed
ring (since: 0 opts into full replay).

TLS: the pin trusts exactly the host's self-signed identity cert
(chain-verified; hostname check waived — the cert is deliberately
CN-only for fingerprint pinning). Bun via fetch tls, Node via an undici
dispatcher (optionalDependency).

definePlugin() accepts both main shapes (async fn | Effect requiring
PunktfunkHost). Examples in both styles; README carries the compat
contract + systemd/Task Scheduler templates.

11 bun tests green (wire decode against the Rust snapshot strings,
SSE parser/reconnect/Last-Event-ID/401, both surfaces vs a mock host).
Live-verified against a real host on Bun AND Node through the pinned
loopback hop: connect → REST mutate → live event received → resume
cursor advanced; a wrong CA is rejected. npm publish + CI wiring
deferred (npm org = RFC open question 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:14:39 +02:00

5.0 KiB

@punktfunk/host

TypeScript SDK for the punktfunk streaming host: a typed management-API client plus the host's lifecycle event stream (client connect/disconnect, stream start/stop, pairing, displays, library) — built on Effect.

Two surfaces, one core:

  • @punktfunk/host — the Promise facade, the front door. connect(), await, .on(). You never need to know Effect exists.
  • @punktfunk/host/effect — the Effect-native surface for plugins and composed programs: the PunktfunkHost service + layer, Stream-based events, typed errors (AuthError | ApiError | TransportError | VersionSkew), and every wire shape as an effect/Schema (REST shapes generated from the host's OpenAPI spec; event shapes mirroring the host's snapshot-tested wire format).

Quickstart

import { connect } from "@punktfunk/host";

const pf = await connect(); // zero config on the host box

pf.events.on("stream.started", (e) => {
  console.log(`${e.stream.client} started ${e.stream.mode}${e.stream.hdr ? " HDR" : ""}`);
});
pf.events.on("pairing.pending", async (e) => {
  // notify your phone, then decide through the API:
  // await pf.request("POST", `/native/pending/${id}/approve`);
});

The same, Effect-native:

import { Effect, Stream } from "effect";
import { events, PunktfunkHostLive } from "@punktfunk/host/effect";

const program = events().pipe(
  Stream.filter((e) => e.kind === "stream.started"),
  Stream.runForEach((e) => Effect.log(`stream: ${e.stream.mode}`)),
);
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));

Connection resolution

connect() / PunktfunkHostLive() resolve, in order:

What Source
URL { url }PUNKTFUNK_MGMT_URLhttps://127.0.0.1:47990
Token { token }PUNKTFUNK_MGMT_TOKEN<config_dir>/mgmt-token
TLS pin { ca }PUNKTFUNK_MGMT_CA (path) → <config_dir>/cert.pem

<config_dir> is ~/.config/punktfunk (Linux/macOS) or %ProgramData%\punktfunk (Windows) — so a script running on the host box needs zero configuration. The TLS pin trusts exactly the host's self-signed identity cert (chain-verified; the hostname check is waived — the cert is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class; other runtimes fall back to system trust (point your runtime's CA option at cert.pem).

The bearer token is the host's admin credential and is honored from loopback only — run scripts on the host box (or through an SSH tunnel).

Events

  • Reconnects automatically (exponential backoff + jitter, capped) and resumes with Last-Event-ID — the host replays what you missed from its ring.
  • Default is live tail only (a fresh notify script must not re-fire on history); pass { since: 0 } on the Effect surface to replay the host's full ring, or since: N to resume after a seq you persisted.
  • on() patterns: exact kinds ("stream.started", typed callback), "domain.*" prefixes, "*", plus "dropped" (your cursor fell off the ring — resync via REST) and "unknown" (an event kind newer than this SDK — the additive-only wire at work).
  • Effect surface: events() is a Stream<HostEvent, EventStreamError>; eventsRaw() carries every SSE frame verbatim.

Plugins (punktfunk-plugin-*)

import { definePlugin } from "@punktfunk/host";
import { Effect } from "effect";
import { PunktfunkHost } from "@punktfunk/host/effect";

export default definePlugin({
  name: "romm-library",
  main: Effect.gen(function* () {
    const pf = yield* PunktfunkHost;
    // subscribe, sync, reconcile — scoped finalizers run on shutdown/interruption
  }),
  // …or the simple shape: main: async (pf) => { … }
});

In v1 a plugin is a script you run (see below); the managed runner package is a later step.

Running as a service

systemd user unit (~/.config/systemd/user/punktfunk-myscript.service):

[Unit]
Description=punktfunk automation: myscript
After=punktfunk-host.service

[Service]
ExecStart=/usr/bin/bun /home/me/punktfunk-scripts/myscript.ts
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

Windows Task Scheduler: a task triggered At log on running bun C:\Users\me\punktfunk-scripts\myscript.ts (the SDK reads %ProgramData%\punktfunk\mgmt-token — run the task as an account that can).

Compatibility

  • SDK majors track the management-API major; an event schema bump or an effect major is an SDK major too.
  • The wire is additive-only within a major: an older SDK keeps working against a newer host (unknown response keys are ignored; unknown event kinds ride the "unknown" channel).
  • A 2xx response that doesn't match its schema surfaces as VersionSkew on the Effect surface — a typed nudge to update, not an undefined three frames later.

Development

bun install
bun run gen        # regenerate src/gen/schemas.ts from ../api/openapi.json
bun run typecheck
bun test