feat(sdk): @punktfunk/host — the Effect TypeScript SDK (M3)
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

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>
This commit is contained in:
2026-07-17 00:14:39 +02:00
parent aaa3dcec32
commit 87114ab186
19 changed files with 3340 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
# @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()`, `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
```ts
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:
```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())));
```
## 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.
## Running 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/schemas.ts from ../api/openapi.json
bun run typecheck
bun test
```
+303
View File
@@ -0,0 +1,303 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@punktfunk/host",
"dependencies": {
"effect": "^3.19.0",
},
"devDependencies": {
"@types/bun": "^1.3.0",
"orval": "^8.20.0",
"typescript": "^5.9.3",
},
"optionalDependencies": {
"undici": "^7.0.0",
},
},
},
"packages": {
"@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="],
"@orval/angular": ["@orval/angular@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-lSvaNj+VHGIWBQOG104HfPNrk2xGjpArwy67u6ZBPzHo/OtDE5Tm1t+ARYseCOElFr4z3i3A62qjFLFy6zj00Q=="],
"@orval/axios": ["@orval/axios@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-fFu0UgTbpI9a1ayM7qCs55MMy6MlVgOpE3BBXGukCTBSwNwFLN47WdzQev/x0mAcs58pNKYT0KL4R9MXGnmgfg=="],
"@orval/core": ["@orval/core@8.22.0", "", { "dependencies": { "@scalar/openapi-types": "0.8.0", "acorn": "^8.15.0", "compare-versions": "^6.1.1", "debug": "^4.4.3", "esbuild": "^0.28.0", "esutils": "2.0.3", "fs-extra": "^11.3.2", "jiti": "^2.6.1", "jsesc": "^3.0.0", "remeda": "^2.33.6", "tinyglobby": "^0.2.16", "typedoc": "^0.28.19" }, "peerDependencies": { "@faker-js/faker": ">=10" }, "optionalPeers": ["@faker-js/faker"] }, "sha512-uKYi7+Smg6oQ2MxE0AS2FNI7bwssoxGLh391Uk0FU+DcTcSv9q2GmvoM8uwd827Hok29kD7AegHE8Mhmsa5uWg=="],
"@orval/effect": ["@orval/effect@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-DACiw2+0ZsPJPKQbYlb9qFFFppaCBaT/HgIq2S1asH9ZCOOTZKm9VrRXpeCWINHC2w1BpdSATpytv+61UT5Y/A=="],
"@orval/fetch": ["@orval/fetch@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-G0r6hOdZG963H/8S4Vk1kWfU/hkQC/cwpDZL+mzcXgzrdG9NDloshTqmge/jP5lPsFAwfcRUmmts10OmuIE4BQ=="],
"@orval/hono": ["@orval/hono@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/zod": "8.22.0", "fs-extra": "^11.3.2" }, "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-GMCpGZqCuGYSu10KTt2q3WYzCqEcTGxr18z3HgHv9dX22sv/V76dlLBh3SE72xp8Z7/jmoq3GKBdgU9k98ju0w=="],
"@orval/mcp": ["@orval/mcp@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "@orval/zod": "8.22.0" } }, "sha512-ySa4EenAF8YMCpbmddJGO3lItTPC8Uf/iT+P2ezaowVgxOCPX/fjG7dd0fnVmrr0vaphi53yWx5o5DlT17FzLg=="],
"@orval/mock": ["@orval/mock@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "remeda": "^2.33.6" } }, "sha512-ZCEFpdi4z+YOCg0Tsgz6DO/M1TSfYxE5urDWJgYNUKeD8XDEHFb2IgTiiaAwJunqWnRQuiMfGp9G81+u10Kx6w=="],
"@orval/query": ["@orval/query@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0", "remeda": "^2.33.6" } }, "sha512-IH9049z860CLUeGEezGv+yOvUvcAyDnd9doDe1Ryi0WxsDul2Vh3LjCRUEQf4JZiyZezadWYYGzs0lwnCn/afA=="],
"@orval/solid-start": ["@orval/solid-start@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0" } }, "sha512-Q1ZCWOrA6/VJnyj62XpPxXti9NpAA4lDZVqPL2uQ5gbxv+T+azEaazKqYIpSBJXbl1aEezujdcyg/DVUakWKEw=="],
"@orval/swr": ["@orval/swr@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "@orval/fetch": "8.22.0" } }, "sha512-OSeWX3Af8ESapKIo3XpbOaTMaG8oeEtuucFyOUauqg6NyC6/9Ikcf2csBdF0AuIKA0QTcjcXxra/ccj1f7KnRg=="],
"@orval/zod": ["@orval/zod@8.22.0", "", { "dependencies": { "@orval/core": "8.22.0", "jsesc": "^3.0.0", "remeda": "^2.33.6" } }, "sha512-briHtUTz79fvCeIFfGW3IbmUIF9DOotUoWFa/sKUjRUG8PMGDWSjQzO8QdLzLoETVGo9g4TfGwp5Xjcs81GZTA=="],
"@scalar/helpers": ["@scalar/helpers@0.9.1", "", {}, "sha512-UKSLIPfN++f+zzbsZ+F6I0lNrR4yX4PtokjHgGwGiHapxT5VJqAF4zFTIP2KCd27XG7KnAIA7qq62O4xRBxc6w=="],
"@scalar/json-magic": ["@scalar/json-magic@0.12.18", "", { "dependencies": { "@scalar/helpers": "0.9.1", "pathe": "^2.0.3", "yaml": "^2.8.3" } }, "sha512-3oZr+jUUiwD3C+x2CROBtlyIUnwt9ScRhFfczuOTPZlD7Pnb5cyTTfkiHbxxU4qd66ij0Z3JA4X/Nj0OADyEiA=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.28.9", "", { "dependencies": { "@scalar/helpers": "0.9.1", "@scalar/json-magic": "0.12.18", "@scalar/openapi-types": "0.9.2", "@scalar/openapi-upgrader": "0.2.10", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.3" } }, "sha512-AYmqO7dR6zc1TSVhW5BpwY0nUMfG5JuF85/8YXn5ZXcCvjgoJu8dbxX52vIGK9rRwSn07HXCdKk7KPIF4xfOTA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.8.0", "", {}, "sha512-WmaxVSfvY5K/TwcG2B2TU1WOe1As1uc2s7myswtP6dBlcjU3hM08SApxv/jmyGaCE8t4gO5BBhmHY4pDUfmr2g=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.10", "", { "dependencies": { "@scalar/openapi-types": "0.9.2" } }, "sha512-FRYwCl4IXRi7yAF5/3Jho0jc2akTZfr/vRTYMjhm+96Fq0LaEuHJAvPcSkkU+j3IWGie6LaCQMZTngcSjoIzgA=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="],
"@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="],
"@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
"@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"effect": ["effect@3.22.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="],
"fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="],
"linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="],
"locate-path": ["locate-path@8.0.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg=="],
"lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="],
"markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"orval": ["orval@8.22.0", "", { "dependencies": { "@commander-js/extra-typings": "^15.0.0", "@orval/angular": "8.22.0", "@orval/axios": "8.22.0", "@orval/core": "8.22.0", "@orval/effect": "8.22.0", "@orval/fetch": "8.22.0", "@orval/hono": "8.22.0", "@orval/mcp": "8.22.0", "@orval/mock": "8.22.0", "@orval/query": "8.22.0", "@orval/solid-start": "8.22.0", "@orval/swr": "8.22.0", "@orval/zod": "8.22.0", "@scalar/json-magic": "^0.12.16", "@scalar/openapi-parser": "^0.28.7", "@scalar/openapi-types": "0.8.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "execa": "^9.6.1", "find-up": "8.0.0", "fs-extra": "^11.3.2", "get-tsconfig": "^4.14.0", "jiti": "^2.6.1", "js-yaml": "4.2.0", "remeda": "^2.33.6", "string-argv": "^0.3.2", "typedoc": "^0.28.19", "typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-markdown": "^4.10.0" }, "peerDependencies": { "prettier": ">=3.0.0" }, "optionalPeers": ["prettier"], "bin": { "orval": "dist/bin/orval.mjs" } }, "sha512-N8UmB4DOhW+Z2SzVq4oS/pN3DsRPq+msrRU8NyRldP1i0yiqT6qo8mUxHPk2umqYjyY0FlR2mvPbi/Jf5CiP7A=="],
"p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
"p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"remeda": ["remeda@2.39.0", "", {}, "sha512-3Ki8dU1o3OVu4dwIQ2Pj+yiuP7OnEbmWAGmJ3yDRqopily5jsj8NWzPvbS89H85d6UdONKEcUnrfuHY6jN9vyw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typedoc": ["typedoc@0.28.20", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.3.0", "minimatch": "^10.2.5", "yaml": "^2.9.0" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg=="],
"typedoc-plugin-coverage": ["typedoc-plugin-coverage@4.0.3", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-baim3wyMkqpX7rBzL/6iZ7wzKJuSr9ffP16RHOsdTUNoHUZeXLIZHSUBtUhXmNHaUNRgfqdmKLBwyggbJjGdeQ=="],
"typedoc-plugin-markdown": ["typedoc-plugin-markdown@4.12.0", "", { "peerDependencies": { "typedoc": "0.28.x" } }, "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"@scalar/openapi-parser/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="],
"@scalar/openapi-upgrader/@scalar/openapi-types": ["@scalar/openapi-types@0.9.2", "", {}, "sha512-J0SZkNPgCrsFN0Rv8sRqZpDOQcRV44TCT82LsQcyWmcglTr7qtXukDmMlIyXs7JDd+DoPLbT2ea65bEHRs2W2g=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
}
}
+20
View File
@@ -0,0 +1,20 @@
// The RFC §7 quickstart, Effect-native: when the living-room TV starts a stream, apply a
// display preset — a composed, typed, interruptible program.
import { Effect, Stream } from "effect";
import { events, PunktfunkHost, PunktfunkHostLive } from "../src/effect.js";
const program = Effect.gen(function* () {
const pf = yield* PunktfunkHost;
yield* events().pipe(
Stream.filter(
(e) => e.kind === "stream.started" && e.stream.client === "Living Room TV",
),
Stream.runForEach(() =>
pf
.request("PUT", "/display/settings", { mode: "preset", preset: "couch" })
.pipe(Effect.catchAll((e) => Effect.logWarning(`preset failed: ${e}`))),
),
);
});
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive()))).catch(console.error);
+15
View File
@@ -0,0 +1,15 @@
// The flagship hook-with-a-decision pattern (Promise facade): watch pairing requests, notify,
// and approve/deny THROUGH the API — asynchronously, the punktfunk way (hooks never veto).
import { connect } from "../src/index.js";
const pf = await connect();
console.log("watching for pairing requests…");
pf.events.on("pairing.pending", async (e) => {
console.log(`pairing request: ${e.device.name} (${e.device.fingerprint})`);
// Wire your real notifier here (ntfy, Pushover, Home Assistant, …), then decide:
// const pending = await pf.request("GET", "/native/pending") as { id: number }[];
// await pf.request("POST", `/native/pending/${pending[0].id}/approve`);
});
pf.events.on("pairing.completed", (e) => console.log(`paired: ${e.device.name}`));
pf.events.on("pairing.denied", (e) => console.log(`denied: ${e.device.name}`));
+13
View File
@@ -0,0 +1,13 @@
// Tail the host's lifecycle events — the SDK "hello world" (Promise facade).
//
// bun examples/tail-events.ts (on the host box: zero config)
// PUNKTFUNK_MGMT_URL=… PUNKTFUNK_MGMT_TOKEN=… bun examples/tail-events.ts
import { connect } from "../src/index.js";
const pf = await connect();
const host = (await pf.request("GET", "/host")) as { hostname?: string };
console.log(`connected to ${host.hostname ?? "host"} — tailing events (^C to stop)`);
pf.events.on("*", (e) => console.log(`[${e.seq}] ${e.kind}`, JSON.stringify(e)));
pf.events.on("unknown", (e) => console.log("[unknown kind]", JSON.stringify(e)));
pf.events.on("dropped", () => console.log("[cursor fell off the ring — resync]"));
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "orval";
// Generates the SDK's Effect Schemas from the host's checked-in OpenAPI document — the same
// single source of truth the web console's react-query client is generated from (RFC §7: the
// chain is Rust structs (utoipa) → OpenAPI → Effect Schemas → inferred TS types). Regenerate
// after any management-API change: `bun run gen` (CI drift-tests the output).
export default defineConfig({
punktfunk: {
input: {
target: "../api/openapi.json",
},
output: {
mode: "single",
target: "./src/gen/schemas.ts",
client: "effect",
clean: true,
},
},
});
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@punktfunk/host",
"version": "0.1.0",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./effect": "./src/effect.ts"
},
"scripts": {
"gen": "orval --config ./orval.config.ts",
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"effect": "^3.19.0"
},
"devDependencies": {
"orval": "^8.20.0",
"typescript": "^5.9.3",
"@types/bun": "^1.3.0"
},
"optionalDependencies": {
"undici": "^7.0.0"
}
}
+168
View File
@@ -0,0 +1,168 @@
// The Effect-native surface (RFC §7): the `PunktfunkHost` service — a typed management-API
// client plus the lifecycle-event `Stream` — provided by [`PunktfunkHostLive`]. Wire shapes
// are Effect Schemas (generated for REST in ./gen/schemas.ts, hand-mirrored for events in
// ./wire.ts); API responses are validated by default, so host/SDK version skew surfaces as a
// typed [`VersionSkew`] instead of an `undefined` three frames later.
import {
Context,
Data,
Effect,
Layer,
Option,
Schema as S,
Stream,
} from "effect";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import {
type EventStreamOptions,
type SseFrame,
SseAuthError,
sseFrames,
} from "./sse.js";
import { decodeHostEvent, type HostEvent } from "./wire.js";
/** Bad credentials — the token (or paired cert) was rejected. */
export class AuthError extends Data.TaggedError("AuthError")<{
message: string;
}> {}
/** The host answered with a non-2xx (the message is its `ApiError` envelope). */
export class ApiError extends Data.TaggedError("ApiError")<{
status: number;
message: string;
}> {}
/** The request never completed (connection refused, TLS, abort). */
export class TransportError extends Data.TaggedError("TransportError")<{
cause: unknown;
}> {}
/** A 2xx body did not match its schema — host and SDK disagree on the wire shape. */
export class VersionSkew extends Data.TaggedError("VersionSkew")<{
path: string;
issue: string;
}> {}
/** The event stream failed unrecoverably (auth) — transient trouble self-heals via reconnect. */
export class EventStreamError extends Data.TaggedError("EventStreamError")<{
cause: unknown;
}> {}
export type RequestError = AuthError | ApiError | TransportError;
export interface PunktfunkHostService {
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1`; the parsed JSON body. */
readonly request: (
method: string,
path: string,
body?: unknown,
) => Effect.Effect<unknown, RequestError>;
/** GET + schema-validate (the generated schemas from `@punktfunk/host/effect`'s `api`). */
readonly get: <A, I>(
path: string,
schema: S.Schema<A, I>,
) => Effect.Effect<A, RequestError | VersionSkew>;
/**
* The lifecycle-event stream: decoded [`HostEvent`]s with automatic reconnect +
* `Last-Event-ID` resume. Unknown kinds and the `dropped` marker surface on
* [`eventsRaw`] (and the warning callback), never as a failure here.
*/
readonly events: (
opts?: EventStreamOptions,
) => Stream.Stream<HostEvent, EventStreamError>;
/** Every SSE frame verbatim — the `dropped` marker and unknown kinds included. */
readonly eventsRaw: (
opts?: EventStreamOptions,
) => Stream.Stream<SseFrame, EventStreamError>;
}
export class PunktfunkHost extends Context.Tag("@punktfunk/host/PunktfunkHost")<
PunktfunkHost,
PunktfunkHostService
>() {}
const toRequestError = (path: string, cause: unknown): RequestError => {
if (cause instanceof HttpStatusError) {
return cause.status === 401
? new AuthError({ message: cause.message })
: new ApiError({ status: cause.status, message: cause.message });
}
return new TransportError({ cause });
};
export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => {
const request = (method: string, path: string, body?: unknown) =>
Effect.tryPromise({
try: () => httpRequest(cfg, method, path, body),
catch: (cause) => toRequestError(path, cause),
});
const get = <A, I>(path: string, schema: S.Schema<A, I>) =>
request("GET", path).pipe(
Effect.flatMap((body) =>
S.decodeUnknown(schema)(body).pipe(
Effect.mapError(
(e) => new VersionSkew({ path, issue: String(e) }),
),
),
),
);
const eventsRaw = (opts?: EventStreamOptions) =>
// suspend: each run must get a FRESH generator (a generator is single-use).
Stream.suspend(() =>
Stream.fromAsyncIterable(
sseFrames(cfg, opts),
(cause) => new EventStreamError({ cause }),
),
);
const events = (opts?: EventStreamOptions) => {
const warn =
opts?.onWarning ?? ((m: string) => console.warn(`[punktfunk] ${m}`));
return eventsRaw(opts).pipe(
Stream.filterMap((frame) => {
if (frame.event === "dropped") {
warn(
"event cursor fell off the host's ring — resync via the REST snapshots",
);
return Option.none();
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
warn(`unparseable event frame (${frame.event})`);
return Option.none();
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
// An unknown kind from a NEWER host is expected (additive-only wire) —
// it rides the raw channel; a consumer that wants it uses eventsRaw.
warn(`unknown/undecodable event kind "${frame.event}"`);
return Option.none();
}
return Option.some(decoded.right);
}),
);
};
return { config: cfg, request, get, events, eventsRaw };
};
/**
* The live layer: resolves URL/token/CA (env → host files) and provides [`PunktfunkHost`].
*/
export const layer = (
options?: ConnectOptions,
): Layer.Layer<PunktfunkHost, TransportError> =>
Layer.effect(
PunktfunkHost,
Effect.tryPromise({
try: () => resolveConfig(options),
catch: (cause) => new TransportError({ cause }),
}).pipe(Effect.map(makeService)),
);
/** RFC-spelled alias of [`layer`]. */
export const PunktfunkHostLive = layer;
export { SseAuthError };
+128
View File
@@ -0,0 +1,128 @@
// Connection resolution (RFC §7): loopback URL + bearer token + the host's self-signed
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
// needs zero configuration.
//
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
//
// The CA is the host's own identity certificate — trusting exactly it (not the system roots)
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
// Node (undici) takes a dispatcher with a CA-carrying TLS connector; anything else falls back
// to plain fetch (document PUNKTFUNK_MGMT_CA + NODE_EXTRA_CA_CERTS there).
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export interface ConnectOptions {
/** Management API base URL (default `https://127.0.0.1:47990`). */
url?: string;
/** Bearer token (default: `PUNKTFUNK_MGMT_TOKEN`, else the host's `mgmt-token` file). */
token?: string;
/** PEM of the CA to trust — the host's identity cert (default: `PUNKTFUNK_MGMT_CA`, else `cert.pem`). */
ca?: string;
}
export interface ResolvedConfig {
url: string;
token: string;
ca?: string;
/** A fetch honoring `ca` on this runtime. */
fetch: typeof fetch;
}
/** The host's config dir — the same resolution the host itself uses. */
export const configDir = (): string => {
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
if (explicit) return explicit;
if (process.platform === "win32") {
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
return path.join(base, "punktfunk");
}
const base =
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
return path.join(base, "punktfunk");
};
const readIfExists = (p: string): string | undefined => {
try {
return fs.readFileSync(p, "utf8");
} catch {
return undefined;
}
};
/** First token-looking line of the mgmt-token file (tolerates `TOKEN=`-style and blank lines). */
const parseTokenFile = (raw: string): string | undefined => {
for (const line of raw.split(/\r?\n/)) {
const t = line.trim();
if (t.length === 0 || t.startsWith("#")) continue;
return t.includes("=") ? t.slice(t.indexOf("=") + 1).trim() : t;
}
return undefined;
};
export const resolveConfig = async (
options?: ConnectOptions,
): Promise<ResolvedConfig> => {
const url = (
options?.url ??
process.env.PUNKTFUNK_MGMT_URL ??
"https://127.0.0.1:47990"
).replace(/\/+$/, "");
const token =
options?.token ??
process.env.PUNKTFUNK_MGMT_TOKEN ??
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
if (!token) {
throw new Error(
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
);
}
const caPath = process.env.PUNKTFUNK_MGMT_CA;
const ca =
options?.ca ??
(caPath ? readIfExists(caPath) : undefined) ??
(url.startsWith("https://")
? readIfExists(path.join(configDir(), "cert.pem"))
: undefined);
return { url, token, ca, fetch: await makeFetch(ca) };
};
/**
* A fetch that PINS `ca` — the host's self-signed identity cert — on this runtime.
*
* The pin is chain verification against exactly that certificate (nothing else can pass),
* with the HOSTNAME check waived: the host identity cert is deliberately CN-only/no-SAN
* (native clients pin its fingerprint; see `web/nitro-entry/bun-https.mjs` for the same
* finding), so standard SAN matching would always fail — and it adds nothing when the chain
* already admits only the one pinned cert.
*/
const makeFetch = async (ca: string | undefined): Promise<typeof fetch> => {
if (!ca) return fetch;
const skipHostname = { checkServerIdentity: () => undefined };
// Bun: fetch takes node-compatible `tls` options.
if (typeof (globalThis as Record<string, unknown>).Bun !== "undefined") {
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
fetch(input, {
...init,
tls: { ca, ...skipHostname },
} as RequestInit)) as typeof fetch;
}
// Node: global fetch is undici — a per-request dispatcher carries the pin.
try {
// Optional dependency — declared in package.json optionalDependencies; absent on
// runtimes that don't need it (the catch below falls back).
const { Agent } = (await import("undici" as string)) as {
Agent: new (opts: unknown) => unknown;
};
const dispatcher = new Agent({ connect: { ca, ...skipHostname } });
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
fetch(input, { ...init, dispatcher } as RequestInit)) as typeof fetch;
} catch {
// Unknown runtime: plain fetch (system trust) — PUNKTFUNK_MGMT_CA via the runtime's
// own CA mechanism (e.g. NODE_EXTRA_CA_CERTS / --cert) is the documented fallback.
return fetch;
}
};
+47
View File
@@ -0,0 +1,47 @@
// The one HTTP implementation both surfaces share (RFC §7: two surfaces, one core): the
// Effect service wraps these with typed errors; the Promise facade calls them directly.
import type { ResolvedConfig } from "./config.js";
/** A non-2xx response, with the host's `ApiError` envelope message when present. */
export class HttpStatusError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
/**
* One management-API request under `/api/v1`. Returns the parsed JSON body (or `undefined`
* for 204/empty). Throws [`HttpStatusError`] on a non-2xx (401 included — callers type it).
*/
export const httpRequest = async (
cfg: ResolvedConfig,
method: string,
apiPath: string,
body?: unknown,
): Promise<unknown> => {
const headers: Record<string, string> = {
authorization: `Bearer ${cfg.token}`,
};
if (body !== undefined) headers["content-type"] = "application/json";
const resp = await cfg.fetch(`${cfg.url}/api/v1${apiPath}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
let message = `HTTP ${resp.status}`;
try {
const err = (await resp.json()) as { error?: string };
if (typeof err.error === "string") message = err.error;
} catch {
// non-JSON error body — keep the status message
}
throw new HttpStatusError(resp.status, message);
}
if (resp.status === 204) return undefined;
const text = await resp.text();
return text.length === 0 ? undefined : JSON.parse(text);
};
+70
View File
@@ -0,0 +1,70 @@
// `@punktfunk/host/effect` — the Effect-native surface (RFC §7): the `PunktfunkHost` service
// tag + live layer, the wire schemas, typed errors, and Stream accessors. The package root
// (`@punktfunk/host`) is the Promise facade; this entry is for plugins and Effect programs.
//
// import { Effect, Stream } from "effect";
// import { PunktfunkHost, PunktfunkHostLive, events } from "@punktfunk/host/effect";
//
// const program = Effect.gen(function* () {
// yield* events().pipe(
// Stream.filter((e) => e.kind === "stream.started"),
// Stream.runForEach((e) => Effect.log(`stream started: ${e.stream.mode}`)),
// );
// });
// Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
import { Effect, type Schema as S, Stream } from "effect";
import {
EventStreamError,
PunktfunkHost,
type RequestError,
type VersionSkew,
} from "./client.js";
import type { EventStreamOptions, SseFrame } from "./sse.js";
import type { HostEvent } from "./wire.js";
export {
ApiError,
AuthError,
EventStreamError,
layer,
makeService,
PunktfunkHost,
type PunktfunkHostService,
PunktfunkHostLive,
type RequestError,
SseAuthError,
TransportError,
VersionSkew,
} from "./client.js";
export { type ConnectOptions, configDir, resolveConfig } from "./config.js";
export type { EventStreamOptions, SseFrame } from "./sse.js";
export * from "./wire.js";
/** The generated REST wire schemas (orval `client: 'effect'` over `api/openapi.json`). */
export * as api from "./gen/schemas.js";
/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */
export const events = (
opts?: EventStreamOptions,
): Stream.Stream<HostEvent, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.events(opts)));
/** Every SSE frame verbatim (the `dropped` marker + unknown kinds included). */
export const eventsRaw = (
opts?: EventStreamOptions,
): Stream.Stream<SseFrame, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.eventsRaw(opts)));
/** One management-API request under `/api/v1` on the ambient service. */
export const request = (
method: string,
path: string,
body?: unknown,
): Effect.Effect<unknown, RequestError, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.request(method, path, body));
/** GET + schema-validate on the ambient service (schemas from [`api`]). */
export const get = <A, I>(
path: string,
schema: S.Schema<A, I>,
): Effect.Effect<A, RequestError | VersionSkew, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema));
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`,
// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config
// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate
// and the power surface (`@punktfunk/host/effect`), never a prerequisite:
//
// import { connect } from "@punktfunk/host";
//
// const pf = await connect();
// pf.events.on("pairing.pending", async (e) => {
// await notifyPhone(`Pairing request from ${e.device.name}`);
// });
import type { Effect } from "effect";
import type { PunktfunkHost } from "./client.js";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import { type SseFrame, sseFrames } from "./sse.js";
import {
decodeHostEvent,
type EventOf,
type HostEvent,
type HostEventKind,
kindMatches,
} from "./wire.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
export type {
ClientRef,
DeviceRef,
DisconnectReason,
EventOf,
HostEvent,
HostEventKind,
Plane,
SessionRef,
StreamRef,
} from "./wire.js";
/** `on()` also accepts these beyond the typed kinds. */
type SpecialPattern = "*" | "dropped" | "unknown" | (string & {});
interface Listener {
pattern: string;
cb: (ev: never) => unknown;
}
export interface PunktfunkEvents {
/**
* Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` — the
* callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"`
* (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know — a newer
* host). Returns the unsubscribe function. Callback errors are caught and warned, never
* fatal to the stream.
*/
on<K extends HostEventKind>(
pattern: K,
cb: (ev: EventOf<K>) => unknown,
): () => void;
on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void;
}
export interface Punktfunk {
/** Resolved connection (URL/token/CA). */
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1` (`request("GET", "/status")`). */
request(method: string, path: string, body?: unknown): Promise<unknown>;
/** The lifecycle-event subscription surface. */
readonly events: PunktfunkEvents;
/** Stop the event stream and release the connection. */
close(): void;
}
/**
* Connect to the host's management API: resolves the URL, bearer token, and the host's
* self-signed identity cert from the environment/host files (zero config on the host box),
* verifies the credentials with a `/health`-adjacent probe, and returns the client.
*/
export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
const cfg = await resolveConfig(options);
// Fail fast on bad credentials/URL: one cheap authenticated probe.
await httpRequest(cfg, "GET", "/host");
const listeners = new Set<Listener>();
let pump: AsyncGenerator<SseFrame> | undefined;
let closed = false;
const warn = (m: string) => console.warn(`[punktfunk] ${m}`);
const dispatch = (pattern: string, ev: unknown) => {
for (const l of listeners) {
if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) {
try {
(l.cb as (e: unknown) => unknown)(ev);
} catch (e) {
warn(`listener for "${l.pattern}" threw: ${e}`);
}
}
}
};
const startPump = () => {
if (pump || closed) return;
pump = sseFrames(cfg, { onWarning: warn });
void (async () => {
try {
for await (const frame of pump) {
if (closed) break;
if (frame.event === "dropped") {
dispatch("dropped", frame);
continue;
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
continue;
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
dispatch("unknown", json);
continue;
}
dispatch(decoded.right.kind, decoded.right);
}
} catch (e) {
if (!closed) warn(`event stream stopped: ${e}`);
}
})();
};
return {
config: cfg,
request: (method, path, body) => httpRequest(cfg, method, path, body),
events: {
on(pattern: string, cb: (ev: never) => unknown) {
const l: Listener = { pattern, cb };
listeners.add(l);
startPump();
return () => listeners.delete(l);
},
} as PunktfunkEvents,
close() {
closed = true;
pump?.return(undefined).catch(() => {});
pump = undefined;
},
};
};
// ------------------------------------------------------------------- plugins (RFC §8)
/**
* A plugin's `main`: either a plain async function receiving the connected client, or an
* Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) — the shape that
* makes it well-behaved under the managed runner's supervision (structured interruption,
* scoped finalizers).
*/
export type PluginMain =
| ((pf: Punktfunk) => Promise<unknown> | unknown)
| Effect.Effect<unknown, unknown, PunktfunkHost>;
export interface PluginDef {
/** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */
name: string;
main: PluginMain;
}
/**
* Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script
* the operator runs; the managed runner (a later, optional package) discovers this default
* export and supervises `main`.
*/
export const definePlugin = (def: PluginDef): PluginDef => {
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
throw new Error(
`plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`,
);
}
return def;
};
+172
View File
@@ -0,0 +1,172 @@
// The SSE half of the SDK: a spec-shaped parser plus one shared reconnecting frame source both
// surfaces consume — the Effect `Stream` wraps it, the Promise facade iterates it directly, so
// there is exactly one implementation of connect/parse/resume (RFC §7: "two surfaces, one
// core"). Reconnects carry `Last-Event-ID` (the host replays from its ring); backoff is
// exponential + jittered, capped, and resets after a healthy connection.
import type { ResolvedConfig } from "./config.js";
/** One parsed SSE frame. */
export interface SseFrame {
/** The `event:` name — an event kind, or `dropped`, or `message` when absent. */
event: string;
/** The joined `data:` payload. */
data: string;
/** The `id:` field (the host sets it to the event's `seq`). */
id?: string;
}
/** Incremental SSE parser (the WHATWG dispatch rules the host's frames need). */
export class SseParser {
private buf = "";
private event = "";
private data: string[] = [];
private id: string | undefined;
/** Feed a chunk; returns the frames it completed. */
push(chunk: string): SseFrame[] {
this.buf += chunk;
const frames: SseFrame[] = [];
for (;;) {
const nl = this.buf.indexOf("\n");
if (nl < 0) break;
let line = this.buf.slice(0, nl);
this.buf = this.buf.slice(nl + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line.length === 0) {
// Blank line = dispatch.
if (this.data.length > 0 || this.event.length > 0) {
frames.push({
event: this.event.length > 0 ? this.event : "message",
data: this.data.join("\n"),
id: this.id,
});
}
this.event = "";
this.data = [];
continue;
}
if (line.startsWith(":")) continue; // comment (the host's keep-alives)
const colon = line.indexOf(":");
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
switch (field) {
case "event":
this.event = value;
break;
case "data":
this.data.push(value);
break;
case "id":
this.id = value;
break;
default: // unknown fields are ignored per spec (retry: handled by our own backoff)
}
}
return frames;
}
}
export interface EventStreamOptions {
/**
* Resume cursor (`?since=`); reconnects use the newer `Last-Event-ID` automatically.
* **Omitted = live tail only** (events from now on — a fresh notify script must not
* re-fire on the host's replayed history); `0` = replay the host's full ring first;
* `N` = resume after seq N.
*/
since?: number;
/** Server-side kind filter (`["stream.*", "pairing.pending"]`). */
kinds?: string[];
/** Called on transient trouble (reconnects, decode warnings). Default: console.warn. */
onWarning?: (message: string) => void;
}
/**
* The "live tail only" cursor: beyond any realistic seq, so the host's catch-up is empty and
* (being > 0 yet ≥ the ring head) it never trips the `dropped` marker. The first received
* frame's real id replaces it for reconnects.
*/
const LIVE_ONLY_CURSOR = Number.MAX_SAFE_INTEGER;
/** Thrown when the stream cannot ever work (bad credentials) — retrying would loop 401s. */
export class SseAuthError extends Error {
readonly _tag = "SseAuthError";
}
const BACKOFF_INITIAL_MS = 500;
const BACKOFF_CAP_MS = 15_000;
/** A connection that lived this long resets the backoff (it was healthy, not flapping). */
const HEALTHY_MS = 30_000;
/**
* The shared frame source: connect → parse → yield frames, forever — reconnecting with
* `Last-Event-ID` on any hiccup. Ends only by consumer break/return (both surfaces cancel by
* dropping the iterator, which aborts the in-flight request) or throws [`SseAuthError`].
*/
export async function* sseFrames(
cfg: ResolvedConfig,
opts: EventStreamOptions = {},
): AsyncGenerator<SseFrame> {
const warn = opts.onWarning ?? ((m) => console.warn(`[punktfunk] ${m}`));
let lastId: number = opts.since ?? LIVE_ONLY_CURSOR;
let backoff = BACKOFF_INITIAL_MS;
const abort = new AbortController();
try {
for (;;) {
const url = new URL(`${cfg.url}/api/v1/events`);
if (opts.kinds && opts.kinds.length > 0)
url.searchParams.set("kinds", opts.kinds.join(","));
const headers: Record<string, string> = {
authorization: `Bearer ${cfg.token}`,
accept: "text/event-stream",
};
if (lastId !== 0) headers["last-event-id"] = String(lastId);
const connectedAt = Date.now();
try {
const resp = await cfg.fetch(url, {
headers,
signal: abort.signal,
});
if (resp.status === 401) throw new SseAuthError("invalid credentials");
if (!resp.ok || !resp.body)
throw new Error(`event stream HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break; // server closed (slow-consumer cut / shutdown) → reconnect
for (const frame of parser.push(
decoder.decode(value, { stream: true }),
)) {
if (frame.id !== undefined) {
const id = Number(frame.id);
if (Number.isFinite(id)) lastId = id;
}
yield frame;
}
}
} finally {
reader.cancel().catch(() => {});
}
throw new Error("event stream ended");
} catch (e) {
if (e instanceof SseAuthError) throw e;
if (abort.signal.aborted) return;
if (Date.now() - connectedAt >= HEALTHY_MS) backoff = BACKOFF_INITIAL_MS;
const jittered = backoff * (0.5 + Math.random());
warn(
`event stream reconnecting in ${Math.round(jittered)}ms (${
e instanceof Error ? e.message : String(e)
})`,
);
await new Promise((r) => setTimeout(r, jittered));
backoff = Math.min(backoff * 2, BACKOFF_CAP_MS);
}
}
} finally {
abort.abort(); // consumer went away — tear down any in-flight request
}
}
+160
View File
@@ -0,0 +1,160 @@
// The lifecycle-event wire schemas (RFC §4/§7) — hand-written as a discriminated union on
// `kind` for precise types and decode errors. The REST surface is generated (./gen/schemas.ts);
// the event stream's `text/event-stream` payload is not expressible there, and the host's
// Rust-side JSON snapshot tests (crates/punktfunk-host/src/events.rs) are the wire's source of
// truth — this file mirrors them. Additive-only within `schema: 1`: decoding tolerates unknown
// keys (Effect's default), and an unknown `kind` surfaces on the raw channel, never a throw.
import { Schema as S } from "effect";
export const Plane = S.Literal("native", "gamestream");
export type Plane = S.Schema.Type<typeof Plane>;
export const DisconnectReason = S.Literal("quit", "timeout", "error");
export type DisconnectReason = S.Schema.Type<typeof DisconnectReason>;
export const ClientRef = S.Struct({
name: S.String,
fingerprint: S.optional(S.String),
plane: Plane,
});
export type ClientRef = S.Schema.Type<typeof ClientRef>;
export const SessionRef = S.Struct({
id: S.Number,
client: S.String,
mode: S.String,
hdr: S.Boolean,
});
export type SessionRef = S.Schema.Type<typeof SessionRef>;
export const StreamRef = S.Struct({
mode: S.String,
hdr: S.Boolean,
client: S.String,
app: S.optional(S.String),
plane: Plane,
});
export type StreamRef = S.Schema.Type<typeof StreamRef>;
export const DeviceRef = S.Struct({
name: S.String,
fingerprint: S.String,
plane: Plane,
});
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
/** The `{seq, ts_ms, schema}` envelope every event carries. */
const envelope = {
seq: S.Number,
ts_ms: S.Number,
schema: S.Number,
} as const;
export const ClientConnected = S.Struct({
...envelope,
kind: S.Literal("client.connected"),
client: ClientRef,
});
export const ClientDisconnected = S.Struct({
...envelope,
kind: S.Literal("client.disconnected"),
client: ClientRef,
reason: DisconnectReason,
});
export const SessionStarted = S.Struct({
...envelope,
kind: S.Literal("session.started"),
session: SessionRef,
});
export const SessionEnded = S.Struct({
...envelope,
kind: S.Literal("session.ended"),
session: SessionRef,
});
export const StreamStarted = S.Struct({
...envelope,
kind: S.Literal("stream.started"),
stream: StreamRef,
});
export const StreamStopped = S.Struct({
...envelope,
kind: S.Literal("stream.stopped"),
stream: StreamRef,
});
export const PairingPending = S.Struct({
...envelope,
kind: S.Literal("pairing.pending"),
device: DeviceRef,
});
export const PairingCompleted = S.Struct({
...envelope,
kind: S.Literal("pairing.completed"),
device: DeviceRef,
});
export const PairingDenied = S.Struct({
...envelope,
kind: S.Literal("pairing.denied"),
device: DeviceRef,
});
export const DisplayCreated = S.Struct({
...envelope,
kind: S.Literal("display.created"),
backend: S.String,
mode: S.String,
});
export const DisplayReleased = S.Struct({
...envelope,
kind: S.Literal("display.released"),
count: S.Number,
});
export const LibraryChanged = S.Struct({
...envelope,
kind: S.Literal("library.changed"),
source: S.String,
});
export const HostStarted = S.Struct({
...envelope,
kind: S.Literal("host.started"),
version: S.String,
gamestream: S.Boolean,
});
export const HostStopping = S.Struct({
...envelope,
kind: S.Literal("host.stopping"),
});
/** Every known lifecycle event — discriminated on `kind`. */
export const HostEvent = S.Union(
ClientConnected,
ClientDisconnected,
SessionStarted,
SessionEnded,
StreamStarted,
StreamStopped,
PairingPending,
PairingCompleted,
PairingDenied,
DisplayCreated,
DisplayReleased,
LibraryChanged,
HostStarted,
HostStopping,
);
export type HostEvent = S.Schema.Type<typeof HostEvent>;
/** The known event kinds (for filters and the facade's `on()`). */
export type HostEventKind = HostEvent["kind"];
/** Narrow a HostEvent by kind: `EventOf<"stream.started">`. */
export type EventOf<K extends HostEventKind> = Extract<HostEvent, { kind: K }>;
export const decodeHostEvent = S.decodeUnknownEither(HostEvent);
/**
* Does `pattern` select `kind`? Exact kinds (`stream.started`) or `domain.*` prefixes on the
* dot boundary — the same vocabulary as the host's SSE `?kinds=` filter and hooks `on:` field.
*/
export const kindMatches = (pattern: string, kind: string): boolean =>
pattern.endsWith(".*")
? kind.startsWith(pattern.slice(0, -1)) // "stream.*" → prefix "stream."
: pattern === kind;
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test";
import { SseAuthError, SseParser, sseFrames } from "../src/sse.js";
import type { ResolvedConfig } from "../src/config.js";
describe("SseParser", () => {
test("parses frames split across arbitrary chunks, skipping comments", () => {
const p = new SseParser();
let frames = p.push("id: 4\nevent: library.ch");
expect(frames.length).toBe(0);
frames = p.push('anged\ndata: {"seq":4}\n\n: keep-alive\n\nid: 5\n');
expect(frames.length).toBe(1);
expect(frames[0]).toEqual({ event: "library.changed", data: '{"seq":4}', id: "4" });
frames = p.push("data: x\n\n");
expect(frames.length).toBe(1);
expect(frames[0]?.id).toBe("5");
expect(frames[0]?.event).toBe("message");
});
test("joins multi-line data and handles CRLF", () => {
const p = new SseParser();
const frames = p.push("data: a\r\ndata: b\r\n\r\n");
expect(frames[0]?.data).toBe("a\nb");
});
});
const cfgFor = (port: number, token = "t"): ResolvedConfig => ({
url: `http://127.0.0.1:${port}`,
token,
fetch,
});
describe("sseFrames", () => {
test("reads frames, reconnects with Last-Event-ID after a server close", async () => {
const lastEventIds: Array<string | null> = [];
let connection = 0;
const server = Bun.serve({
port: 0,
fetch(req) {
lastEventIds.push(req.headers.get("last-event-id"));
connection += 1;
const first = connection === 1;
const body = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
if (first) {
controller.enqueue(enc.encode('id: 1\nevent: library.changed\ndata: {"seq":1}\n\n'));
controller.close(); // server closes → client must reconnect
} else {
controller.enqueue(enc.encode('id: 2\nevent: library.changed\ndata: {"seq":2}\n\n'));
// stay open
}
},
});
return new Response(body, { headers: { "content-type": "text/event-stream" } });
},
});
try {
const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} });
const f1 = await gen.next();
expect(f1.value?.id).toBe("1");
const f2 = await gen.next(); // spans the reconnect
expect(f2.value?.id).toBe("2");
await gen.return(undefined);
// No `since` = live-tail-only: the first connect carries the beyond-tip cursor,
// the reconnect carries the last REAL id.
expect(lastEventIds[0]).toBe(String(Number.MAX_SAFE_INTEGER));
expect(lastEventIds[1]).toBe("1");
} finally {
server.stop(true);
}
});
test("401 is terminal (no retry loop)", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("{}", { status: 401 }),
});
try {
const gen = sseFrames(cfgFor(server.port as number), { onWarning: () => {} });
await expect(gen.next()).rejects.toBeInstanceOf(SseAuthError);
} finally {
server.stop(true);
}
});
});
+117
View File
@@ -0,0 +1,117 @@
// Both surfaces against one mock host: the Promise facade end to end, and the Effect surface's
// request/get/events with typed errors.
import { describe, expect, test } from "bun:test";
import { Effect, Schema as S, Stream } from "effect";
import { connect } from "../src/index.js";
import * as pf from "../src/effect.js";
const TOKEN = "test-token";
/** A minimal mock host: auth-checked /host, /status, and an SSE /events feed. */
const mockHost = () => {
const emitted = new TextEncoder().encode(
'id: 7\nevent: pairing.pending\ndata: {"seq":7,"ts_ms":3,"schema":1,"kind":"pairing.pending","device":{"name":"iPad Pro","fingerprint":"ab12","plane":"native"}}\n\n' +
'id: 8\nevent: future.kind\ndata: {"seq":8,"ts_ms":4,"schema":1,"kind":"future.kind"}\n\n' +
'id: 9\nevent: library.changed\ndata: {"seq":9,"ts_ms":5,"schema":1,"kind":"library.changed","source":"manual"}\n\n',
);
return Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (req.headers.get("authorization") !== `Bearer ${TOKEN}`) {
return Response.json({ error: "missing or invalid credentials" }, { status: 401 });
}
switch (url.pathname) {
case "/api/v1/host":
return Response.json({ name: "mock", version: "0.12.0" });
case "/api/v1/status":
return Response.json({ ok: true, sessions: 0 });
case "/api/v1/boom":
return Response.json({ error: "kaput" }, { status: 500 });
case "/api/v1/events": {
const body = new ReadableStream({
start(c) {
c.enqueue(emitted); // then stay open
},
});
return new Response(body, { headers: { "content-type": "text/event-stream" } });
}
default:
return Response.json({ error: "not found" }, { status: 404 });
}
},
});
};
describe("promise facade", () => {
test("connect → request → typed events → unknown channel → close", async () => {
const server = mockHost();
try {
const client = await connect({ url: `http://127.0.0.1:${server.port}`, token: TOKEN });
const status = (await client.request("GET", "/status")) as { ok: boolean };
expect(status.ok).toBe(true);
const got: string[] = [];
const unknown: unknown[] = [];
const done = new Promise<void>((resolve) => {
client.events.on("pairing.pending", (e) => {
got.push(`pending:${e.device.name}`);
});
client.events.on("library.*", (e) => {
got.push(`lib:${e.kind}`);
resolve();
});
client.events.on("unknown", (e) => unknown.push(e));
});
await done;
expect(got).toEqual(["pending:iPad Pro", "lib:library.changed"]);
expect(unknown.length).toBe(1);
client.close();
} finally {
server.stop(true);
}
});
test("connect fails fast on a bad token", async () => {
const server = mockHost();
try {
await expect(
connect({ url: `http://127.0.0.1:${server.port}`, token: "wrong" }),
).rejects.toThrow(/credentials/);
} finally {
server.stop(true);
}
});
});
describe("effect surface", () => {
test("request + schema get + typed errors + event stream", async () => {
const server = mockHost();
const live = pf.PunktfunkHostLive({ url: `http://127.0.0.1:${server.port}`, token: TOKEN });
try {
const program = Effect.gen(function* () {
const status = yield* pf.get("/status", S.Struct({ ok: S.Boolean }));
expect(status.ok).toBe(true);
// A wrong shape is VersionSkew, not undefined-later.
const skew = yield* pf
.get("/status", S.Struct({ nope: S.String }))
.pipe(Effect.flip);
expect(skew._tag).toBe("VersionSkew");
// A host error carries its ApiError envelope message.
const boom = yield* pf.request("GET", "/boom").pipe(Effect.flip);
expect(boom._tag).toBe("ApiError");
if (boom._tag === "ApiError") expect(boom.message).toBe("kaput");
// The decoded stream skips the unknown kind and delivers the known ones.
const events = yield* pf.events().pipe(Stream.take(2), Stream.runCollect);
const kinds = [...events].map((e) => e.kind);
expect(kinds).toEqual(["pairing.pending", "library.changed"]);
});
await Effect.runPromise(program.pipe(Effect.provide(live)));
} finally {
server.stop(true);
}
});
});
+63
View File
@@ -0,0 +1,63 @@
// The wire schemas must decode EXACTLY what the host emits — the JSON literals here are the
// Rust side's snapshot-test strings (crates/punktfunk-host/src/events.rs), the schema gate.
import { describe, expect, test } from "bun:test";
import { decodeHostEvent, kindMatches } from "../src/wire.js";
describe("wire", () => {
test("decodes the host's snapshot frames", () => {
const stream = decodeHostEvent(
JSON.parse(
'{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}',
),
);
expect(stream._tag).toBe("Right");
if (stream._tag === "Right" && stream.right.kind === "stream.started") {
expect(stream.right.stream.mode).toBe("3840x2160@120");
expect(stream.right.stream.app).toBe("steam:570");
}
const disc = decodeHostEvent(
JSON.parse(
'{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}',
),
);
expect(disc._tag).toBe("Right");
if (disc._tag === "Right" && disc.right.kind === "client.disconnected") {
expect(disc.right.reason).toBe("timeout");
}
const stopping = decodeHostEvent(
JSON.parse('{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}'),
);
expect(stopping._tag).toBe("Right");
});
test("tolerates unknown keys (additive-only wire)", () => {
const r = decodeHostEvent({
seq: 9,
ts_ms: 1,
schema: 1,
kind: "library.changed",
source: "manual",
future_field: { anything: true },
});
expect(r._tag).toBe("Right");
});
test("unknown kinds fail decode (they ride the raw channel)", () => {
const r = decodeHostEvent({
seq: 9,
ts_ms: 1,
schema: 1,
kind: "totally.new",
});
expect(r._tag).toBe("Left");
});
test("kindMatches mirrors the host filter semantics", () => {
expect(kindMatches("stream.started", "stream.started")).toBe(true);
expect(kindMatches("stream.*", "stream.stopped")).toBe(true);
expect(kindMatches("stream.*", "streamx.started")).toBe(false);
expect(kindMatches("stream.started", "stream.stopped")).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["bun"]
},
"include": ["src", "test"]
}