f012ebbcba
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>
99 lines
4.1 KiB
TypeScript
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;
|
|
};
|