691c064a37
- 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>
229 lines
8.9 KiB
Markdown
229 lines
8.9 KiB
Markdown
# @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 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`](./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.
|
||
|
||
## 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
|
||
```
|