feat(sdk): Effect v4 + @effect/openapi-generator; typed pf.api & example ladder

Drop Orval for the first-party @effect/openapi-generator (OpenAPI 3.1 ->
Effect Schema + a typed HttpClient client) and bump effect 3.19 ->
4.0.0-beta.98. Port the hand-written surfaces to the v4 API (Result over
Either, Context.Service, Codec, Literals/Union arrays, Stream/Schedule/
Effect renames). Transport (CA-pinning fetch) and the reconnecting SSE
source are kept intact.

Make the SDK approachable for non-Effect users:
- Add pf.api.* on the Promise facade: the generated client surfaced as
  typed, Promise-native methods (await pf.api.listPairedClients()), so REST
  calls are autocompleted and checked instead of stringly-typed
  pf.request(method, path, body) + `as` casts. Zero-drift veneer over
  make(httpClient), backed by the same pinning fetch. pf.request stays as
  the untyped escape hatch.
- Re-tier examples into a 1-4 complexity ladder, rewritten onto pf.api.*
  (the typed payloads caught a wrong `launch` shape in provider-sync);
  the Effect example is labelled advanced. Add examples/ to tsconfig so
  they are typechecked (stops rot).

typecheck + 19 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 12:40:04 +02:00
parent 27a5d8daac
commit f012ebbcba
19 changed files with 1645 additions and 1974 deletions
+98
View File
@@ -0,0 +1,98 @@
// The typed management API for the Promise facade (RFC §7): every REST endpoint of the host as
// an autocompletable method with checked request/response types — named exactly as in the
// OpenAPI document (`listPairedClients`, `stopSession`, `reconcileProviderEntries`, …). This is
// the typed front door; `pf.request(method, path, body)` stays as the untyped escape hatch.
//
// It's a thin, zero-drift veneer over the generated client (`./gen/punktfunk.ts`, from
// `@effect/openapi-generator`): the generated methods return Effects, so each is run to a Promise
// here. The transport is the SAME CA-pinning fetch the rest of the SDK uses (config.ts), fed to
// Effect's `HttpClient` via the overridable `FetchHttpClient.Fetch` reference — so the loopback
// pin is preserved and there is still exactly one place that knows how to reach the host.
import { Effect } from "effect";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import type { ResolvedConfig } from "./config.js";
import * as gen from "./gen/punktfunk.js";
/**
* Make a trailing argument that merely *accepts* `undefined` genuinely optional. The generated
* methods type their options bag as `{…} | undefined` (a required param that tolerates `undefined`),
* so without this you'd have to write `listPairedClients(undefined)`. Endpoints with a required
* body (`{ payload }`, not `undefined`-able) are left untouched.
*/
type OptionalizeTail<A extends readonly unknown[]> = A extends readonly [
...infer Init,
infer Last,
]
? undefined extends Last
? [...Init, Last?]
: A
: A;
/** Each generated method returns an Effect; expose it as a Promise of the decoded success value. */
type Promiseify<T> = T extends (
...args: infer A
) => Effect.Effect<infer Success, infer _E, infer _R>
? (...args: OptionalizeTail<A>) => Promise<Success>
: never;
/** The event stream (an Effect `Stream`) and the void catch-all — served by `pf.events` instead. */
type NonApiMethods = "httpClient" | "streamEvents" | "streamEventsSse";
/**
* The host's management API, fully typed: `await pf.api.listPairedClients()` gives a typed array,
* `await pf.api.reconcileProviderEntries("romm", { payload })` checks the body at compile time —
* no hand-written paths, no `unknown` casts. A failing call rejects with the endpoint's typed
* error. The live-event SSE endpoint is intentionally absent — subscribe via `pf.events`.
*/
export type HostApi = {
readonly [K in Exclude<keyof gen.Punktfunk, NonApiMethods>]: Promiseify<
gen.Punktfunk[K]
>;
};
/** Build the pinning `HttpClient`: base URL + bearer + the config's CA-pinning fetch. */
const httpClientFor = (cfg: ResolvedConfig): Promise<HttpClient.HttpClient> =>
Effect.runPromise(
HttpClient.HttpClient.pipe(
Effect.map((client) =>
HttpClient.mapRequest((request: HttpClientRequest.HttpClientRequest) =>
request.pipe(
HttpClientRequest.prependUrl(cfg.url),
HttpClientRequest.bearerToken(cfg.token),
HttpClientRequest.acceptJson,
),
)(client),
),
Effect.provide(FetchHttpClient.layer),
// Override the default `globalThis.fetch` with the CA-pinning one (config.ts).
Effect.provideService(FetchHttpClient.Fetch, cfg.fetch),
),
);
/** Skipped at runtime too (not just in the type): these aren't Promise-shaped API calls. */
const NON_API: ReadonlySet<string> = new Set<NonApiMethods>([
"httpClient",
"streamEvents",
"streamEventsSse",
]);
/** The typed API surface over a resolved connection — each method runs its Effect to a Promise. */
export const makeHostApi = async (cfg: ResolvedConfig): Promise<HostApi> => {
const client = gen.make(await httpClientFor(cfg)) as unknown as Record<
string,
unknown
>;
const api: Record<string, unknown> = {};
for (const key of Object.keys(client)) {
if (NON_API.has(key)) continue;
const method = client[key];
if (typeof method !== "function") continue;
api[key] = (...args: unknown[]) =>
Effect.runPromise(
(method as (...a: unknown[]) => Effect.Effect<unknown>)(...args),
);
}
return api as HostApi;
};
+15 -12
View File
@@ -8,7 +8,7 @@ import {
Data,
Effect,
Layer,
Option,
Result,
Schema as S,
Stream,
} from "effect";
@@ -62,7 +62,7 @@ export interface PunktfunkHostService {
/** GET + schema-validate (the generated schemas from `@punktfunk/host/effect`'s `api`). */
readonly get: <A, I>(
path: string,
schema: S.Schema<A, I>,
schema: S.Codec<A, I>,
) => Effect.Effect<A, RequestError | VersionSkew>;
/**
* The lifecycle-event stream: decoded [`HostEvent`]s with automatic reconnect +
@@ -78,10 +78,10 @@ export interface PunktfunkHostService {
) => Stream.Stream<SseFrame, EventStreamError>;
}
export class PunktfunkHost extends Context.Tag("@punktfunk/host/PunktfunkHost")<
export class PunktfunkHost extends Context.Service<
PunktfunkHost,
PunktfunkHostService
>() {}
>()("@punktfunk/host/PunktfunkHost") {}
const toRequestError = (path: string, cause: unknown): RequestError => {
if (cause instanceof HttpStatusError) {
@@ -98,10 +98,10 @@ export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => {
try: () => httpRequest(cfg, method, path, body),
catch: (cause) => toRequestError(path, cause),
});
const get = <A, I>(path: string, schema: S.Schema<A, I>) =>
const get = <A, I>(path: string, schema: S.Codec<A, I>) =>
request("GET", path).pipe(
Effect.flatMap((body) =>
S.decodeUnknown(schema)(body).pipe(
S.decodeUnknownEffect(schema)(body).pipe(
Effect.mapError(
(e) => new VersionSkew({ path, issue: String(e) }),
),
@@ -119,29 +119,32 @@ export const makeService = (cfg: ResolvedConfig): PunktfunkHostService => {
const events = (opts?: EventStreamOptions) => {
const warn =
opts?.onWarning ?? ((m: string) => console.warn(`[punktfunk] ${m}`));
// Drop-or-emit per frame. v4's `Stream.filterMap` wants a `Filter`; flat-mapping to
// `Stream.empty` (drop) / `Stream.succeed` (emit) expresses the same in stable
// primitives and keeps the `warn` side effects exactly where they were.
return eventsRaw(opts).pipe(
Stream.filterMap((frame) => {
Stream.flatMap((frame) => {
if (frame.event === "dropped") {
warn(
"event cursor fell off the host's ring — resync via the REST snapshots",
);
return Option.none();
return Stream.empty;
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
warn(`unparseable event frame (${frame.event})`);
return Option.none();
return Stream.empty;
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
if (Result.isFailure(decoded)) {
// 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 Stream.empty;
}
return Option.some(decoded.right);
return Stream.succeed(decoded.success);
}),
);
};
+6 -3
View File
@@ -39,8 +39,11 @@ export {
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 generated REST surface: wire Schemas plus a typed `HttpClient`-based client (`make`),
* from `@effect/openapi-generator` over `api/openapi.json`. Regenerate with `bun run gen`.
*/
export * as api from "./gen/punktfunk.js";
/** The decoded lifecycle-event stream of the ambient [`PunktfunkHost`]. */
export const events = (
@@ -65,6 +68,6 @@ export const request = (
/** GET + schema-validate on the ambient service (schemas from [`api`]). */
export const get = <A, I>(
path: string,
schema: S.Schema<A, I>,
schema: S.Codec<A, I>,
): Effect.Effect<A, RequestError | VersionSkew, PunktfunkHost> =>
Effect.flatMap(PunktfunkHost, (s) => s.get(path, schema));
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -9,7 +9,8 @@
// pf.events.on("pairing.pending", async (e) => {
// await notifyPhone(`Pairing request from ${e.device.name}`);
// });
import type { Effect } from "effect";
import { type Effect, Result } from "effect";
import { type HostApi, makeHostApi } from "./api.js";
import type { PunktfunkHost } from "./client.js";
import {
type ConnectOptions,
@@ -26,6 +27,7 @@ import {
kindMatches,
} from "./wire.js";
export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
export type {
@@ -66,7 +68,13 @@ export interface PunktfunkEvents {
export interface Punktfunk {
/** Resolved connection (URL/token/CA). */
readonly config: ResolvedConfig;
/** One management-API request under `/api/v1` (`request("GET", "/status")`). */
/**
* The **typed** management API — every REST endpoint as an autocompletable method with
* checked request/response types (`await pf.api.listPairedClients()`). This is the front
* door; reach for [`request`] only for something the generated client doesn't cover.
*/
readonly api: HostApi;
/** Untyped escape hatch: one raw `/api/v1` request (`request("GET", "/status")`). */
request(method: string, path: string, body?: unknown): Promise<unknown>;
/** The lifecycle-event subscription surface. */
readonly events: PunktfunkEvents;
@@ -83,6 +91,7 @@ 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 api = await makeHostApi(cfg);
const listeners = new Set<Listener>();
let pump: AsyncGenerator<SseFrame> | undefined;
@@ -118,11 +127,11 @@ export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
continue;
}
const decoded = decodeHostEvent(json);
if (decoded._tag === "Left") {
if (Result.isFailure(decoded)) {
dispatch("unknown", json);
continue;
}
dispatch(decoded.right.kind, decoded.right);
dispatch(decoded.success.kind, decoded.success);
}
} catch (e) {
if (!closed) warn(`event stream stopped: ${e}`);
@@ -132,6 +141,7 @@ export const connect = async (options?: ConnectOptions): Promise<Punktfunk> => {
return {
config: cfg,
api,
request: (method, path, body) => httpRequest(cfg, method, path, body),
events: {
on(pattern: string, cb: (ev: never) => unknown) {
+9 -7
View File
@@ -38,7 +38,7 @@ export interface RunnerOptions {
/** Connection overrides handed to every unit's client/layer. */
connect?: ConnectOptions;
/** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */
restartBase?: Duration.DurationInput;
restartBase?: Duration.Input;
/** Line sink. Default: stamped stdout. */
log?: (line: string) => void;
}
@@ -167,10 +167,12 @@ export const superviseUnit = (
options: RunnerOptions = {},
): Effect.Effect<void> => {
const log = options.log ?? defaultLog;
const restart = Schedule.exponential(options.restartBase ?? "1 second").pipe(
Schedule.union(Schedule.spaced("60 seconds")), // cap
Schedule.jittered,
);
// Exponential backoff, capped at 60 s (min-delay of the two schedules), then jittered.
// (v4 replaced `Schedule.union` with the array-form `Schedule.min`.)
const restart = Schedule.min([
Schedule.exponential(options.restartBase ?? "1 second"),
Schedule.spaced("60 seconds"),
]).pipe(Schedule.jittered);
let attempt = 0;
const once = Effect.suspend(() => {
attempt += 1;
@@ -187,13 +189,13 @@ export const superviseUnit = (
),
),
),
Effect.tapErrorCause((cause) =>
Effect.tapCause((cause) =>
Effect.sync(() =>
log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`),
),
),
Effect.retry(restart),
Effect.catchAllCause((cause) =>
Effect.catchCause((cause) =>
// A retry schedule that gives up (it doesn't, but stay total) — log and end.
Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)),
),
+10 -5
View File
@@ -6,10 +6,10 @@
// 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 const Plane = S.Literals(["native", "gamestream"]);
export type Plane = S.Schema.Type<typeof Plane>;
export const DisconnectReason = S.Literal("quit", "timeout", "error");
export const DisconnectReason = S.Literals(["quit", "timeout", "error"]);
export type DisconnectReason = S.Schema.Type<typeof DisconnectReason>;
export const ClientRef = S.Struct({
@@ -124,7 +124,7 @@ export const HostStopping = S.Struct({
});
/** Every known lifecycle event — discriminated on `kind`. */
export const HostEvent = S.Union(
export const HostEvent = S.Union([
ClientConnected,
ClientDisconnected,
SessionStarted,
@@ -139,7 +139,7 @@ export const HostEvent = S.Union(
LibraryChanged,
HostStarted,
HostStopping,
);
]);
export type HostEvent = S.Schema.Type<typeof HostEvent>;
/** The known event kinds (for filters and the facade's `on()`). */
@@ -148,7 +148,12 @@ 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);
/**
* Decode one event JSON into a [`HostEvent`], as a [`Result`]: `Success` for a known kind,
* `Failure` for an unknown/undecodable one (a newer host — rides the raw channel, never throws).
* (v4 replaced `Either` with `Result`; the callers branch on `Result.isFailure`.)
*/
export const decodeHostEvent = S.decodeUnknownResult(HostEvent);
/**
* Does `pattern` select `kind`? Exact kinds (`stream.started`) or `domain.*` prefixes on the