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