Files
punktfunk/sdk/src/api.ts
T
enricobuehler f012ebbcba 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>
2026-07-17 12:40:04 +02:00

99 lines
4.1 KiB
TypeScript

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