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
+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) {