Files
punktfunk/sdk/README.md
T
enricobuehler ec84b30eae feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):

- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
  /api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
  the mTLS read-only allowlist); plugins.changed event; port-only registration
  (proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
  check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
  pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
  strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
  for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
  polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
  deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.

OpenAPI regen for the new endpoints follows in the next commit (built on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00

10 KiB
Raw Blame History

@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(), then pf.api.* (the typed management API — every endpoint autocompletes, every response is typed) and pf.events.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).

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:

  1. tail-events.tshello world: connect, one typed call, tail events.
  2. notify-pairing.tsevent → decision: approve/deny pairing through the typed API.
  3. provider-sync.tstyped bulk REST: declaratively reconcile a game-library provider.
  4. couch-preset.effect.tsadvanced, Effect-native: only if you're composing Effect programs.

Examples 13 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.tsUSB 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 the client.connected/disconnected bracket and clean release on systemctl stop.

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.

A plugin UI in the console — servePluginUi

A plugin can surface a web UI inside the punktfunk console — no second password or port for the operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; servePluginUi registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the console's own session. Your code implements zero human auth.

import { definePlugin, servePluginUi } from "@punktfunk/host";

export default definePlugin({
  name: "rom-manager",
  main: async (pf) => {
    const ui = await servePluginUi(pf, {
      id: "rom-manager",
      title: "ROM Manager",
      icon: "gamepad-2",                            // a lucide icon name
      staticDir: new URL("../dist/ui", import.meta.url), // your built SPA
      fetch: (req) => appRouter(req),               // plugin-local REST/SSE (after a static miss)
    });
    try {
      await runForever();
    } finally {
      await ui.close();                             // deregister + stop
    }
  },
});

Requests reach fetch prefix-stripped (the console proxy removed /plugin-ui/<id>), so your app sees /, /api/scan, … — the original prefix is on X-Forwarded-Prefix. servePluginUi serves staticDir first (with an index.html SPA fallback for navigations); return undefined from fetch to fall through to it. Build your SPA with a relative base (base: "./" + hash routing) or an absolute base: "/plugin-ui/<id>/", and expect a dark canvas. Requires the Bun runtime (the runner is bun).

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 definePlugin default export, from the scripts dir or a punktfunk-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 stop clean.
  • 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 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/punktfunk.ts from ../api/openapi.json (@effect/openapi-generator)
bun run typecheck
bun test