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:
@@ -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;
|
||||
Reference in New Issue
Block a user