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
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:
+135
@@ -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
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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}`));
|
||||
@@ -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]"));
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user