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

264 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @punktfunk/host
TypeScript SDK for the [punktfunk](https://git.unom.io/unom/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](https://effect.website).
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](https://git.unom.io/unom/-/packages). Point the
`@punktfunk` scope at it once — in your project's `.npmrc` (or `~/.npmrc`):
```ini
@punktfunk:registry=https://git.unom.io/api/packages/unom/npm/
```
Then install:
```sh
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`:
```ini
//git.unom.io/api/packages/unom/npm/:_authToken=${NODE_AUTH_TOKEN}
```
## Quickstart
```ts
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:
```ts
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/`](./examples) — start at the top:
1. [`tail-events.ts`](./examples/tail-events.ts) — **hello world**: connect, one typed call, tail events.
2. [`notify-pairing.ts`](./examples/notify-pairing.ts) — **event → decision**: approve/deny pairing through the typed API.
3. [`provider-sync.ts`](./examples/provider-sync.ts) — **typed bulk REST**: declaratively reconcile a game-library provider.
4. [`couch-preset.effect.ts`](./examples/couch-preset.effect.ts) — **advanced, 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.ts`](./examples/virtualhere-dualsense.ts) — **USB passthrough**: bind a
real DualSense (shared from the couch over [VirtualHere](https://www.virtualhere.com/)) 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_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, 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-*`)
```ts
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**.
```ts
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:
```sh
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`):
```ini
[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`):
```ini
[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
```sh
bun install
bun run gen # regenerate src/gen/punktfunk.ts from ../api/openapi.json (@effect/openapi-generator)
bun run typecheck
bun test
```