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
+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;
};