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