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