feat(sdk): @punktfunk/host — the Effect TypeScript SDK (M3)
apple / swift (push) Successful in 1m15s
apple / screenshots (push) Successful in 4m21s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m42s
docker / deploy-docs (push) Successful in 26s

New top-level sdk/ package (RFC §7): a typed management-API client plus
the lifecycle event stream, built on Effect, two surfaces over one core:

- @punktfunk/host — the Promise facade front door: connect() resolves
  URL/token/TLS pin from the host's own files (zero config on the box),
  fails fast on bad credentials, pf.events.on() with typed callbacks
  (exact kinds, domain.* prefixes, "*", "dropped", "unknown"),
  pf.request() for the REST surface. Effect never required.
- @punktfunk/host/effect — the PunktfunkHost service + PunktfunkHostLive
  layer, Stream-based events()/eventsRaw(), typed errors
  (AuthError | ApiError | TransportError | VersionSkew — a 2xx that
  fails its schema is a typed skew, not undefined later), and every
  wire shape as an effect/Schema: REST generated via orval
  client:'effect' from api/openapi.json (S3 spike: works well; the
  text/event-stream payload is out of its reach), events hand-mirrored
  from the host's snapshot-tested wire format as a kind-discriminated
  union.

One reconnecting SSE core under both surfaces: spec-shaped parser,
exponential+jittered backoff (capped, resets after a healthy
connection), Last-Event-ID resume, 401 terminal. Default is LIVE tail
only — a fresh notify script must not re-fire on the host's replayed
ring (since: 0 opts into full replay).

TLS: the pin trusts exactly the host's self-signed identity cert
(chain-verified; hostname check waived — the cert is deliberately
CN-only for fingerprint pinning). Bun via fetch tls, Node via an undici
dispatcher (optionalDependency).

definePlugin() accepts both main shapes (async fn | Effect requiring
PunktfunkHost). Examples in both styles; README carries the compat
contract + systemd/Task Scheduler templates.

11 bun tests green (wire decode against the Rust snapshot strings,
SSE parser/reconnect/Last-Event-ID/401, both surfaces vs a mock host).
Live-verified against a real host on Bun AND Node through the pinned
loopback hop: connect → REST mutate → live event received → resume
cursor advanced; a wrong CA is rejected. npm publish + CI wiring
deferred (npm org = RFC open question 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 00:14:39 +02:00
parent aaa3dcec32
commit 87114ab186
19 changed files with 3340 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
// The Effect-native surface (RFC §7): the `PunktfunkHost` service — a typed management-API
// client plus the lifecycle-event `Stream` — provided by [`PunktfunkHostLive`]. Wire shapes
// are Effect Schemas (generated for REST in ./gen/schemas.ts, hand-mirrored for events in
// ./wire.ts); API responses are validated by default, so host/SDK version skew surfaces as a
// typed [`VersionSkew`] instead of an `undefined` three frames later.
import {
Context,
Data,
Effect,
Layer,
Option,
Schema as S,
Stream,
} from "effect";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import {
type EventStreamOptions,
type SseFrame,
SseAuthError,
sseFrames,
} from "./sse.js";
import { decodeHostEvent, type HostEvent } from "./wire.js";
/** Bad credentials — the token (or paired cert) was rejected. */
export class AuthError extends Data.TaggedError("AuthError")<{
message: string;
}> {}
/** The host answered with a non-2xx (the message is its `ApiError` envelope). */
export class ApiError extends Data.TaggedError("ApiError")<{
status: number;
message: string;
}> {}
/** The request never completed (connection refused, TLS, abort). */
export class TransportError extends Data.TaggedError("TransportError")<{
cause: unknown;
}> {}
/** A 2xx body did not match its schema — host and SDK disagree on the wire shape. */
export class VersionSkew extends Data.TaggedError("VersionSkew")<{
path: string;
issue: string;
}> {}
/** The event stream failed unrecoverably (auth) — transient trouble self-heals via reconnect. */
export class EventStreamError extends Data.TaggedError("EventStreamError")<{
cause: unknown;
}> {}
export type RequestError = AuthError | ApiError | TransportError;
export interface PunktfunkHostService {
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1`; the parsed JSON body. */
readonly request: (
method: string,
path: string,
body?: unknown,
) => Effect.Effect<unknown, RequestError>;
/** GET + schema-validate (the generated schemas from `@punktfunk/host/effect`'s `api`). */
readonly get: <A, I>(
path: string,
schema: S.Schema<A, I>,
) => Effect.Effect<A, RequestError | VersionSkew>;
/**
* The lifecycle-event stream: decoded [`HostEvent`]s with automatic reconnect +
* `Last-Event-ID` resume. Unknown kinds and the `dropped` marker surface on
* [`eventsRaw`] (and the warning callback), never as a failure here.
*/
readonly events: (
opts?: EventStreamOptions,
) => Stream.Stream<HostEvent, EventStreamError>;
/** Every SSE frame verbatim — the `dropped` marker and unknown kinds included. */
readonly eventsRaw: (
opts?: EventStreamOptions,
) => Stream.Stream<SseFrame, EventStreamError>;
}
export class PunktfunkHost extends Context.Tag("@punktfunk/host/PunktfunkHost")<
PunktfunkHost,
PunktfunkHostService
>() {}
const toRequestError = (path: string, cause: unknown): RequestError => {
if (cause instanceof HttpStatusError) {
return cause.status === 401
? new AuthError({ message: cause.message })
: new ApiError({ status: cause.status, message: cause.message });
}
return new TransportError({ cause });
};
export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => {
const request = (method: string, path: string, body?: unknown) =>
Effect.tryPromise({
try: () => httpRequest(cfg, method, path, body),
catch: (cause) => toRequestError(path, cause),
});
const get = <A, I>(path: string, schema: S.Schema<A, I>) =>
request("GET", path).pipe(
Effect.flatMap((body) =>
S.decodeUnknown(schema)(body).pipe(
Effect.mapError(
(e) => new VersionSkew({ path, issue: String(e) }),
),
),
),
);
const eventsRaw = (opts?: EventStreamOptions) =>
// suspend: each run must get a FRESH generator (a generator is single-use).
Stream.suspend(() =>
Stream.fromAsyncIterable(
sseFrames(cfg, opts),
(cause) => new EventStreamError({ cause }),
),
);
const events = (opts?: EventStreamOptions) => {
const warn =
opts?.onWarning ?? ((m: string) => console.warn(`[punktfunk] ${m}`));
return eventsRaw(opts).pipe(
Stream.filterMap((frame) => {
if (frame.event === "dropped") {
warn(
"event cursor fell off the host's ring — resync via the REST snapshots",
);
return Option.none();
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
warn(`unparseable event frame (${frame.event})`);
return Option.none();
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
// An unknown kind from a NEWER host is expected (additive-only wire) —
// it rides the raw channel; a consumer that wants it uses eventsRaw.
warn(`unknown/undecodable event kind "${frame.event}"`);
return Option.none();
}
return Option.some(decoded.right);
}),
);
};
return { config: cfg, request, get, events, eventsRaw };
};
/**
* The live layer: resolves URL/token/CA (env → host files) and provides [`PunktfunkHost`].
*/
export const layer = (
options?: ConnectOptions,
): Layer.Layer<PunktfunkHost, TransportError> =>
Layer.effect(
PunktfunkHost,
Effect.tryPromise({
try: () => resolveConfig(options),
catch: (cause) => new TransportError({ cause }),
}).pipe(Effect.map(makeService)),
);
/** RFC-spelled alias of [`layer`]. */
export const PunktfunkHostLive = layer;
export { SseAuthError };
+128
View File
@@ -0,0 +1,128 @@
// Connection resolution (RFC §7): loopback URL + bearer token + the host's self-signed
// identity cert, from the environment with file fallbacks — so `connect()` on the host machine
// needs zero configuration.
//
// PUNKTFUNK_MGMT_URL (default https://127.0.0.1:47990)
// PUNKTFUNK_MGMT_TOKEN (else <config_dir>/mgmt-token)
// PUNKTFUNK_MGMT_CA (path; else <config_dir>/cert.pem when present)
//
// The CA is the host's own identity certificate — trusting exactly it (not the system roots)
// IS the pin for the loopback hop. Per-runtime plumbing differs: Bun takes `tls.ca` on fetch,
// Node (undici) takes a dispatcher with a CA-carrying TLS connector; anything else falls back
// to plain fetch (document PUNKTFUNK_MGMT_CA + NODE_EXTRA_CA_CERTS there).
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export interface ConnectOptions {
/** Management API base URL (default `https://127.0.0.1:47990`). */
url?: string;
/** Bearer token (default: `PUNKTFUNK_MGMT_TOKEN`, else the host's `mgmt-token` file). */
token?: string;
/** PEM of the CA to trust — the host's identity cert (default: `PUNKTFUNK_MGMT_CA`, else `cert.pem`). */
ca?: string;
}
export interface ResolvedConfig {
url: string;
token: string;
ca?: string;
/** A fetch honoring `ca` on this runtime. */
fetch: typeof fetch;
}
/** The host's config dir — the same resolution the host itself uses. */
export const configDir = (): string => {
const explicit = process.env.PUNKTFUNK_CONFIG_DIR;
if (explicit) return explicit;
if (process.platform === "win32") {
const base = process.env.ProgramData ?? process.env.APPDATA ?? ".";
return path.join(base, "punktfunk");
}
const base =
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
return path.join(base, "punktfunk");
};
const readIfExists = (p: string): string | undefined => {
try {
return fs.readFileSync(p, "utf8");
} catch {
return undefined;
}
};
/** First token-looking line of the mgmt-token file (tolerates `TOKEN=`-style and blank lines). */
const parseTokenFile = (raw: string): string | undefined => {
for (const line of raw.split(/\r?\n/)) {
const t = line.trim();
if (t.length === 0 || t.startsWith("#")) continue;
return t.includes("=") ? t.slice(t.indexOf("=") + 1).trim() : t;
}
return undefined;
};
export const resolveConfig = async (
options?: ConnectOptions,
): Promise<ResolvedConfig> => {
const url = (
options?.url ??
process.env.PUNKTFUNK_MGMT_URL ??
"https://127.0.0.1:47990"
).replace(/\/+$/, "");
const token =
options?.token ??
process.env.PUNKTFUNK_MGMT_TOKEN ??
parseTokenFile(readIfExists(path.join(configDir(), "mgmt-token")) ?? "");
if (!token) {
throw new Error(
"no management token: set PUNKTFUNK_MGMT_TOKEN, pass { token }, or run where " +
`the host's token file exists (${path.join(configDir(), "mgmt-token")})`,
);
}
const caPath = process.env.PUNKTFUNK_MGMT_CA;
const ca =
options?.ca ??
(caPath ? readIfExists(caPath) : undefined) ??
(url.startsWith("https://")
? readIfExists(path.join(configDir(), "cert.pem"))
: undefined);
return { url, token, ca, fetch: await makeFetch(ca) };
};
/**
* A fetch that PINS `ca` — the host's self-signed identity cert — on this runtime.
*
* The pin is chain verification against exactly that certificate (nothing else can pass),
* with the HOSTNAME check waived: the host identity cert is deliberately CN-only/no-SAN
* (native clients pin its fingerprint; see `web/nitro-entry/bun-https.mjs` for the same
* finding), so standard SAN matching would always fail — and it adds nothing when the chain
* already admits only the one pinned cert.
*/
const makeFetch = async (ca: string | undefined): Promise<typeof fetch> => {
if (!ca) return fetch;
const skipHostname = { checkServerIdentity: () => undefined };
// Bun: fetch takes node-compatible `tls` options.
if (typeof (globalThis as Record<string, unknown>).Bun !== "undefined") {
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
fetch(input, {
...init,
tls: { ca, ...skipHostname },
} as RequestInit)) as typeof fetch;
}
// Node: global fetch is undici — a per-request dispatcher carries the pin.
try {
// Optional dependency — declared in package.json optionalDependencies; absent on
// runtimes that don't need it (the catch below falls back).
const { Agent } = (await import("undici" as string)) as {
Agent: new (opts: unknown) => unknown;
};
const dispatcher = new Agent({ connect: { ca, ...skipHostname } });
return ((input: Parameters<typeof fetch>[0], init?: RequestInit) =>
fetch(input, { ...init, dispatcher } as RequestInit)) as typeof fetch;
} catch {
// Unknown runtime: plain fetch (system trust) — PUNKTFUNK_MGMT_CA via the runtime's
// own CA mechanism (e.g. NODE_EXTRA_CA_CERTS / --cert) is the documented fallback.
return fetch;
}
};
+47
View File
@@ -0,0 +1,47 @@
// The one HTTP implementation both surfaces share (RFC §7: two surfaces, one core): the
// Effect service wraps these with typed errors; the Promise facade calls them directly.
import type { ResolvedConfig } from "./config.js";
/** A non-2xx response, with the host's `ApiError` envelope message when present. */
export class HttpStatusError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
/**
* One management-API request under `/api/v1`. Returns the parsed JSON body (or `undefined`
* for 204/empty). Throws [`HttpStatusError`] on a non-2xx (401 included — callers type it).
*/
export const httpRequest = async (
cfg: ResolvedConfig,
method: string,
apiPath: string,
body?: unknown,
): Promise<unknown> => {
const headers: Record<string, string> = {
authorization: `Bearer ${cfg.token}`,
};
if (body !== undefined) headers["content-type"] = "application/json";
const resp = await cfg.fetch(`${cfg.url}/api/v1${apiPath}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
let message = `HTTP ${resp.status}`;
try {
const err = (await resp.json()) as { error?: string };
if (typeof err.error === "string") message = err.error;
} catch {
// non-JSON error body — keep the status message
}
throw new HttpStatusError(resp.status, message);
}
if (resp.status === 204) return undefined;
const text = await resp.text();
return text.length === 0 ? undefined : JSON.parse(text);
};
+70
View File
@@ -0,0 +1,70 @@
// `@punktfunk/host/effect` — the Effect-native surface (RFC §7): the `PunktfunkHost` service
// tag + live layer, the wire schemas, typed errors, and Stream accessors. The package root
// (`@punktfunk/host`) is the Promise facade; this entry is for plugins and Effect programs.
//
// import { Effect, Stream } from "effect";
// import { PunktfunkHost, PunktfunkHostLive, events } from "@punktfunk/host/effect";
//
// const program = Effect.gen(function* () {
// yield* events().pipe(
// Stream.filter((e) => e.kind === "stream.started"),
// Stream.runForEach((e) => Effect.log(`stream started: ${e.stream.mode}`)),
// );
// });
// Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));
import { Effect, type Schema as S, Stream } from "effect";
import {
EventStreamError,
PunktfunkHost,
type RequestError,
type VersionSkew,
} from "./client.js";
import type { EventStreamOptions, SseFrame } from "./sse.js";
import type { HostEvent } from "./wire.js";
export {
ApiError,
AuthError,
EventStreamError,
layer,
makeService,
PunktfunkHost,
type PunktfunkHostService,
PunktfunkHostLive,
type RequestError,
SseAuthError,
TransportError,
VersionSkew,
} from "./client.js";
export { type ConnectOptions, configDir, resolveConfig } from "./config.js";
export type { EventStreamOptions, SseFrame } from "./sse.js";
export * from "./wire.js";
/** The generated REST wire schemas (orval `client: 'effect'` over `api/openapi.json`). */
export * as api from "./gen/schemas.js";
/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */
export const events = (
opts?: EventStreamOptions,
): Stream.Stream<HostEvent, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.events(opts)));
/** Every SSE frame verbatim (the `dropped` marker + unknown kinds included). */
export const eventsRaw = (
opts?: EventStreamOptions,
): Stream.Stream<SseFrame, EventStreamError, PunktfunkHost> =>
Stream.unwrap(Effect.map(PunktfunkHost, (s) => s.eventsRaw(opts)));
/** One management-API request under `/api/v1` on the ambient service. */
export const request = (
method: string,
path: string,
body?: unknown,
): Effect.Effect<unknown, RequestError, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.request(method, path, body));
/** GET + schema-validate on the ambient service (schemas from [`api`]). */
export const get = <A, I>(
path: string,
schema: S.Schema<A, I>,
): Effect.Effect<A, RequestError | VersionSkew, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema));
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`,
// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config
// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate
// and the power surface (`@punktfunk/host/effect`), never a prerequisite:
//
// import { connect } from "@punktfunk/host";
//
// const pf = await connect();
// pf.events.on("pairing.pending", async (e) => {
// await notifyPhone(`Pairing request from ${e.device.name}`);
// });
import type { Effect } from "effect";
import type { PunktfunkHost } from "./client.js";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import { type SseFrame, sseFrames } from "./sse.js";
import {
decodeHostEvent,
type EventOf,
type HostEvent,
type HostEventKind,
kindMatches,
} from "./wire.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
export type {
ClientRef,
DeviceRef,
DisconnectReason,
EventOf,
HostEvent,
HostEventKind,
Plane,
SessionRef,
StreamRef,
} from "./wire.js";
/** `on()` also accepts these beyond the typed kinds. */
type SpecialPattern = "*" | "dropped" | "unknown" | (string & {});
interface Listener {
pattern: string;
cb: (ev: never) => unknown;
}
export interface PunktfunkEvents {
/**
* Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` — the
* callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"`
* (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know — a newer
* host). Returns the unsubscribe function. Callback errors are caught and warned, never
* fatal to the stream.
*/
on<K extends HostEventKind>(
pattern: K,
cb: (ev: EventOf<K>) => unknown,
): () => void;
on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void;
}
export interface Punktfunk {
/** Resolved connection (URL/token/CA). */
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1` (`request("GET", "/status")`). */
request(method: string, path: string, body?: unknown): Promise<unknown>;
/** The lifecycle-event subscription surface. */
readonly events: PunktfunkEvents;
/** Stop the event stream and release the connection. */
close(): void;
}
/**
* Connect to the host's management API: resolves the URL, bearer token, and the host's
* self-signed identity cert from the environment/host files (zero config on the host box),
* verifies the credentials with a `/health`-adjacent probe, and returns the client.
*/
export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
const cfg = await resolveConfig(options);
// Fail fast on bad credentials/URL: one cheap authenticated probe.
await httpRequest(cfg, "GET", "/host");
const listeners = new Set<Listener>();
let pump: AsyncGenerator<SseFrame> | undefined;
let closed = false;
const warn = (m: string) => console.warn(`[punktfunk] ${m}`);
const dispatch = (pattern: string, ev: unknown) => {
for (const l of listeners) {
if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) {
try {
(l.cb as (e: unknown) => unknown)(ev);
} catch (e) {
warn(`listener for "${l.pattern}" threw: ${e}`);
}
}
}
};
const startPump = () => {
if (pump || closed) return;
pump = sseFrames(cfg, { onWarning: warn });
void (async () => {
try {
for await (const frame of pump) {
if (closed) break;
if (frame.event === "dropped") {
dispatch("dropped", frame);
continue;
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
continue;
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
dispatch("unknown", json);
continue;
}
dispatch(decoded.right.kind, decoded.right);
}
} catch (e) {
if (!closed) warn(`event stream stopped: ${e}`);
}
})();
};
return {
config: cfg,
request: (method, path, body) => httpRequest(cfg, method, path, body),
events: {
on(pattern: string, cb: (ev: never) => unknown) {
const l: Listener = { pattern, cb };
listeners.add(l);
startPump();
return () => listeners.delete(l);
},
} as PunktfunkEvents,
close() {
closed = true;
pump?.return(undefined).catch(() => {});
pump = undefined;
},
};
};
// ------------------------------------------------------------------- plugins (RFC §8)
/**
* A plugin's `main`: either a plain async function receiving the connected client, or an
* Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) — the shape that
* makes it well-behaved under the managed runner's supervision (structured interruption,
* scoped finalizers).
*/
export type PluginMain =
| ((pf: Punktfunk) => Promise<unknown> | unknown)
| Effect.Effect<unknown, unknown, PunktfunkHost>;
export interface PluginDef {
/** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */
name: string;
main: PluginMain;
}
/**
* Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script
* the operator runs; the managed runner (a later, optional package) discovers this default
* export and supervises `main`.
*/
export const definePlugin = (def: PluginDef): PluginDef => {
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
throw new Error(
`plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`,
);
}
return def;
};
+172
View File
@@ -0,0 +1,172 @@
// The SSE half of the SDK: a spec-shaped parser plus one shared reconnecting frame source both
// surfaces consume — the Effect `Stream` wraps it, the Promise facade iterates it directly, so
// there is exactly one implementation of connect/parse/resume (RFC §7: "two surfaces, one
// core"). Reconnects carry `Last-Event-ID` (the host replays from its ring); backoff is
// exponential + jittered, capped, and resets after a healthy connection.
import type { ResolvedConfig } from "./config.js";
/** One parsed SSE frame. */
export interface SseFrame {
/** The `event:` name — an event kind, or `dropped`, or `message` when absent. */
event: string;
/** The joined `data:` payload. */
data: string;
/** The `id:` field (the host sets it to the event's `seq`). */
id?: string;
}
/** Incremental SSE parser (the WHATWG dispatch rules the host's frames need). */
export class SseParser {
private buf = "";
private event = "";
private data: string[] = [];
private id: string | undefined;
/** Feed a chunk; returns the frames it completed. */
push(chunk: string): SseFrame[] {
this.buf += chunk;
const frames: SseFrame[] = [];
for (;;) {
const nl = this.buf.indexOf("\n");
if (nl < 0) break;
let line = this.buf.slice(0, nl);
this.buf = this.buf.slice(nl + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line.length === 0) {
// Blank line = dispatch.
if (this.data.length > 0 || this.event.length > 0) {
frames.push({
event: this.event.length > 0 ? this.event : "message",
data: this.data.join("\n"),
id: this.id,
});
}
this.event = "";
this.data = [];
continue;
}
if (line.startsWith(":")) continue; // comment (the host's keep-alives)
const colon = line.indexOf(":");
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
switch (field) {
case "event":
this.event = value;
break;
case "data":
this.data.push(value);
break;
case "id":
this.id = value;
break;
default: // unknown fields are ignored per spec (retry: handled by our own backoff)
}
}
return frames;
}
}
export interface EventStreamOptions {
/**
* Resume cursor (`?since=`); reconnects use the newer `Last-Event-ID` automatically.
* **Omitted = live tail only** (events from now on — a fresh notify script must not
* re-fire on the host's replayed history); `0` = replay the host's full ring first;
* `N` = resume after seq N.
*/
since?: number;
/** Server-side kind filter (`["stream.*", "pairing.pending"]`). */
kinds?: string[];
/** Called on transient trouble (reconnects, decode warnings). Default: console.warn. */
onWarning?: (message: string) => void;
}
/**
* The "live tail only" cursor: beyond any realistic seq, so the host's catch-up is empty and
* (being > 0 yet ≥ the ring head) it never trips the `dropped` marker. The first received
* frame's real id replaces it for reconnects.
*/
const LIVE_ONLY_CURSOR = Number.MAX_SAFE_INTEGER;
/** Thrown when the stream cannot ever work (bad credentials) — retrying would loop 401s. */
export class SseAuthError extends Error {
readonly _tag = "SseAuthError";
}
const BACKOFF_INITIAL_MS = 500;
const BACKOFF_CAP_MS = 15_000;
/** A connection that lived this long resets the backoff (it was healthy, not flapping). */
const HEALTHY_MS = 30_000;
/**
* The shared frame source: connect → parse → yield frames, forever — reconnecting with
* `Last-Event-ID` on any hiccup. Ends only by consumer break/return (both surfaces cancel by
* dropping the iterator, which aborts the in-flight request) or throws [`SseAuthError`].
*/
export async function* sseFrames(
cfg: ResolvedConfig,
opts: EventStreamOptions = {},
): AsyncGenerator<SseFrame> {
const warn = opts.onWarning ?? ((m) => console.warn(`[punktfunk] ${m}`));
let lastId: number = opts.since ?? LIVE_ONLY_CURSOR;
let backoff = BACKOFF_INITIAL_MS;
const abort = new AbortController();
try {
for (;;) {
const url = new URL(`${cfg.url}/api/v1/events`);
if (opts.kinds && opts.kinds.length > 0)
url.searchParams.set("kinds", opts.kinds.join(","));
const headers: Record<string, string> = {
authorization: `Bearer ${cfg.token}`,
accept: "text/event-stream",
};
if (lastId !== 0) headers["last-event-id"] = String(lastId);
const connectedAt = Date.now();
try {
const resp = await cfg.fetch(url, {
headers,
signal: abort.signal,
});
if (resp.status === 401) throw new SseAuthError("invalid credentials");
if (!resp.ok || !resp.body)
throw new Error(`event stream HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break; // server closed (slow-consumer cut / shutdown) → reconnect
for (const frame of parser.push(
decoder.decode(value, { stream: true }),
)) {
if (frame.id !== undefined) {
const id = Number(frame.id);
if (Number.isFinite(id)) lastId = id;
}
yield frame;
}
}
} finally {
reader.cancel().catch(() => {});
}
throw new Error("event stream ended");
} catch (e) {
if (e instanceof SseAuthError) throw e;
if (abort.signal.aborted) return;
if (Date.now() - connectedAt >= HEALTHY_MS) backoff = BACKOFF_INITIAL_MS;
const jittered = backoff * (0.5 + Math.random());
warn(
`event stream reconnecting in ${Math.round(jittered)}ms (${
e instanceof Error ? e.message : String(e)
})`,
);
await new Promise((r) => setTimeout(r, jittered));
backoff = Math.min(backoff * 2, BACKOFF_CAP_MS);
}
}
} finally {
abort.abort(); // consumer went away — tear down any in-flight request
}
}
+160
View File
@@ -0,0 +1,160 @@
// The lifecycle-event wire schemas (RFC §4/§7) — hand-written as a discriminated union on
// `kind` for precise types and decode errors. The REST surface is generated (./gen/schemas.ts);
// the event stream's `text/event-stream` payload is not expressible there, and the host's
// Rust-side JSON snapshot tests (crates/punktfunk-host/src/events.rs) are the wire's source of
// truth — this file mirrors them. Additive-only within `schema: 1`: decoding tolerates unknown
// keys (Effect's default), and an unknown `kind` surfaces on the raw channel, never a throw.
import { Schema as S } from "effect";
export const Plane = S.Literal("native", "gamestream");
export type Plane = S.Schema.Type<typeof Plane>;
export const DisconnectReason = S.Literal("quit", "timeout", "error");
export type DisconnectReason = S.Schema.Type<typeof DisconnectReason>;
export const ClientRef = S.Struct({
name: S.String,
fingerprint: S.optional(S.String),
plane: Plane,
});
export type ClientRef = S.Schema.Type<typeof ClientRef>;
export const SessionRef = S.Struct({
id: S.Number,
client: S.String,
mode: S.String,
hdr: S.Boolean,
});
export type SessionRef = S.Schema.Type<typeof SessionRef>;
export const StreamRef = S.Struct({
mode: S.String,
hdr: S.Boolean,
client: S.String,
app: S.optional(S.String),
plane: Plane,
});
export type StreamRef = S.Schema.Type<typeof StreamRef>;
export const DeviceRef = S.Struct({
name: S.String,
fingerprint: S.String,
plane: Plane,
});
export type DeviceRef = S.Schema.Type<typeof DeviceRef>;
/** The `{seq, ts_ms, schema}` envelope every event carries. */
const envelope = {
seq: S.Number,
ts_ms: S.Number,
schema: S.Number,
} as const;
export const ClientConnected = S.Struct({
...envelope,
kind: S.Literal("client.connected"),
client: ClientRef,
});
export const ClientDisconnected = S.Struct({
...envelope,
kind: S.Literal("client.disconnected"),
client: ClientRef,
reason: DisconnectReason,
});
export const SessionStarted = S.Struct({
...envelope,
kind: S.Literal("session.started"),
session: SessionRef,
});
export const SessionEnded = S.Struct({
...envelope,
kind: S.Literal("session.ended"),
session: SessionRef,
});
export const StreamStarted = S.Struct({
...envelope,
kind: S.Literal("stream.started"),
stream: StreamRef,
});
export const StreamStopped = S.Struct({
...envelope,
kind: S.Literal("stream.stopped"),
stream: StreamRef,
});
export const PairingPending = S.Struct({
...envelope,
kind: S.Literal("pairing.pending"),
device: DeviceRef,
});
export const PairingCompleted = S.Struct({
...envelope,
kind: S.Literal("pairing.completed"),
device: DeviceRef,
});
export const PairingDenied = S.Struct({
...envelope,
kind: S.Literal("pairing.denied"),
device: DeviceRef,
});
export const DisplayCreated = S.Struct({
...envelope,
kind: S.Literal("display.created"),
backend: S.String,
mode: S.String,
});
export const DisplayReleased = S.Struct({
...envelope,
kind: S.Literal("display.released"),
count: S.Number,
});
export const LibraryChanged = S.Struct({
...envelope,
kind: S.Literal("library.changed"),
source: S.String,
});
export const HostStarted = S.Struct({
...envelope,
kind: S.Literal("host.started"),
version: S.String,
gamestream: S.Boolean,
});
export const HostStopping = S.Struct({
...envelope,
kind: S.Literal("host.stopping"),
});
/** Every known lifecycle event — discriminated on `kind`. */
export const HostEvent = S.Union(
ClientConnected,
ClientDisconnected,
SessionStarted,
SessionEnded,
StreamStarted,
StreamStopped,
PairingPending,
PairingCompleted,
PairingDenied,
DisplayCreated,
DisplayReleased,
LibraryChanged,
HostStarted,
HostStopping,
);
export type HostEvent = S.Schema.Type<typeof HostEvent>;
/** The known event kinds (for filters and the facade's `on()`). */
export type HostEventKind = HostEvent["kind"];
/** Narrow a HostEvent by kind: `EventOf<"stream.started">`. */
export type EventOf<K extends HostEventKind> = Extract<HostEvent, { kind: K }>;
export const decodeHostEvent = S.decodeUnknownEither(HostEvent);
/**
* Does `pattern` select `kind`? Exact kinds (`stream.started`) or `domain.*` prefixes on the
* dot boundary — the same vocabulary as the host's SSE `?kinds=` filter and hooks `on:` field.
*/
export const kindMatches = (pattern: string, kind: string): boolean =>
pattern.endsWith(".*")
? kind.startsWith(pattern.slice(0, -1)) // "stream.*" → prefix "stream."
: pattern === kind;