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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user