- package.json: drop private; point main/types/exports/bin at a tsc-built dist/; add publishConfig (unom/npm registry), files, repo metadata, and the MIT OR Apache-2.0 license; effect becomes a peerDependency (shared instance). - tsconfig.build.json: emit dist/ JS + .d.ts (bun shebang preserved on the bin). - .npmrc: map the @punktfunk scope to the registry (no token committed). - sdk-publish.yml: publish on sdk-v* tags or manual dispatch, reusing the REGISTRY_TOKEN secret; typecheck/test/build/tag-matches-version gate. - README: Install section for consumers. Verified: build green, frozen lockfile stable, bun publish --dry-run packs @punktfunk/host@0.1.0 (dist + README only) to the unom registry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.9 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(), thenpf.api.*(the typed management API — every endpoint autocompletes, every response is typed) andpf.events.on(). You never need to know Effect exists.@punktfunk/host/effect— the Effect-native surface for plugins and composed programs: thePunktfunkHostservice + layer,Stream-based events, typed errors (AuthError | ApiError | TransportError | VersionSkew), and every wire shape as aneffect/Schema(REST shapes generated from the host's OpenAPI spec; event shapes mirroring the host's snapshot-tested wire format).
Install
Published to the unom Gitea npm registry. Point the
@punktfunk scope at it once — in your project's .npmrc (or ~/.npmrc):
@punktfunk:registry=https://git.unom.io/api/packages/unom/npm/
Then install:
bun add @punktfunk/host # or: npm i @punktfunk/host
effect is a peer dependency (auto-installed by bun / npm ≥ 7) — so the SDK and your own
@punktfunk/host/effect code share one Effect instance.
If the registry requires authentication (private org, or from CI), add a token line with a Gitea
PAT that has read:package:
//git.unom.io/api/packages/unom/npm/:_authToken=${NODE_AUTH_TOKEN}
Quickstart
import { connect } from "@punktfunk/host";
const pf = await connect(); // zero config on the host box
// Typed API — autocomplete every endpoint, typed responses, no hand-written paths or casts.
const clients = await pf.api.listPairedClients();
console.log(`${clients.length} paired clients`);
// Live events:
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 typed API:
const pending = await pf.api.listPendingDevices();
const match = pending.find((d) => d.fingerprint === e.device.fingerprint);
if (match) await pf.api.approvePendingDevice(String(match.id), { payload: {} });
});
Need something the generated client doesn't cover? pf.request(method, path, body) is the untyped
escape hatch (returns unknown).
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())));
Examples
A complexity ladder in examples/ — start at the top:
tail-events.ts— hello world: connect, one typed call, tail events.notify-pairing.ts— event → decision: approve/deny pairing through the typed API.provider-sync.ts— typed bulk REST: declaratively reconcile a game-library provider.couch-preset.effect.ts— advanced, Effect-native: only if you're composing Effect programs.
Examples 1–3 are the plain Promise facade and cover most automation; you only need example 4's
Effect surface for composed, interruptible programs. Run any with bun examples/<file>.ts.
Plus a real-world recipe:
virtualhere-dualsense.ts— USB passthrough: bind a real DualSense (shared from the couch over VirtualHere) to the host for the length of each connection and release it after — full gyro, touchpad, adaptive triggers and USB rumble instead of an emulated pad. Shows theclient.connected/disconnectedbracket and clean release onsystemctl stop.
Connection resolution
connect() / PunktfunkHostLive() resolve, in order:
| What | Source |
|---|---|
| URL | { url } → PUNKTFUNK_MGMT_URL → https://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, orsince: Nto 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 aStream<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.
The runner: punktfunk-scripting
Instead of one unit file per script, run everything under the managed runner — it discovers your units and supervises them:
bun src/runner-cli.ts # runs <config_dir>/scripts/* + installed punktfunk-plugin-*
bun src/runner-cli.ts --list # show what it found
- Plugins (a
definePlugindefault export, from the scripts dir or apunktfunk-plugin-*package installed under<config_dir>/plugins/): supervised — a crash restarts them with capped exponential backoff; a clean return completes them. - Bare scripts: importing them is the run — one-shot, no restart (export a plugin to be supervised).
- Shutdown is structural: SIGINT/SIGTERM interrupt every unit's fiber — Effect plugins'
scoped finalizers run (release the preset, deregister cleanly) and facade clients close
before the process exits. This is what makes
systemctl stopclean. - The sshd rule applies: a group/world-writable unit file is refused loudly.
systemd user unit for the runner (~/.config/systemd/user/punktfunk-scripting.service):
[Unit]
Description=punktfunk script/plugin runner
After=punktfunk-host.service
[Service]
ExecStart=/usr/bin/bun /path/to/sdk/src/runner-cli.ts
Restart=on-failure
RestartSec=5
# SIGTERM (the default KillSignal) triggers the runner's structured shutdown.
[Install]
WantedBy=default.target
Running a single script 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
schemabump or aneffectmajor 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
VersionSkewon the Effect surface — a typed nudge to update, not anundefinedthree frames later.
Development
bun install
bun run gen # regenerate src/gen/punktfunk.ts from ../api/openapi.json (@effect/openapi-generator)
bun run typecheck
bun test