Files
punktfunk/sdk/src/index.ts
T
enricobuehler ec84b30eae feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):

- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
  /api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
  the mTLS read-only allowlist); plugins.changed event; port-only registration
  (proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
  check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
  pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
  strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
  for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
  polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
  deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.

OpenAPI regen for the new endpoints follows in the next commit (built on Linux).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 09:48:37 +02:00

198 lines
6.1 KiB
TypeScript

// `@punktfunk/host` — the Promise facade, the package's front door (RFC §7): `connect()`,
// `await`, `.on()` — a thin veneer over the same core the Effect surface uses (config
// resolution, one HTTP implementation, one reconnecting SSE source). Effect is the substrate
// and the power surface (`@punktfunk/host/effect`), never a prerequisite:
//
// import { connect } from "@punktfunk/host";
//
// const pf = await connect();
// pf.events.on("pairing.pending", async (e) => {
// await notifyPhone(`Pairing request from ${e.device.name}`);
// });
import { type Effect, Result } from "effect";
import { type HostApi, makeHostApi } from "./api.js";
import type { PunktfunkHost } from "./client.js";
import {
type ConnectOptions,
type ResolvedConfig,
resolveConfig,
} from "./config.js";
import { HttpStatusError, httpRequest } from "./core.js";
import { type SseFrame, sseFrames } from "./sse.js";
import {
decodeHostEvent,
type EventOf,
type HostEvent,
type HostEventKind,
kindMatches,
} from "./wire.js";
export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
export {
type PluginUiHandle,
type PluginUiOptions,
servePluginUi,
} from "./ui.js";
export type {
ClientRef,
DeviceRef,
DisconnectReason,
EventOf,
HostEvent,
HostEventKind,
Plane,
SessionRef,
StreamRef,
} from "./wire.js";
/** `on()` also accepts these beyond the typed kinds. */
type SpecialPattern = "*" | "dropped" | "unknown" | (string & {});
interface Listener {
pattern: string;
cb: (ev: never) => unknown;
}
export interface PunktfunkEvents {
/**
* Subscribe to lifecycle events. `pattern` is an exact kind (`"stream.started"` — the
* callback is typed to it), a `domain.*` prefix, `"*"` (every known event), `"dropped"`
* (the fell-off-the-ring marker), or `"unknown"` (kinds this SDK doesn't know — a newer
* host). Returns the unsubscribe function. Callback errors are caught and warned, never
* fatal to the stream.
*/
on<K extends HostEventKind>(
pattern: K,
cb: (ev: EventOf<K>) => unknown,
): () => void;
on(pattern: SpecialPattern, cb: (ev: HostEvent) => unknown): () => void;
}
export interface Punktfunk {
/** Resolved connection (URL/token/CA). */
readonly config: ResolvedConfig;
/**
* 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;
/** Stop the event stream and release the connection. */
close(): void;
}
/**
* Connect to the host's management API: resolves the URL, bearer token, and the host's
* self-signed identity cert from the environment/host files (zero config on the host box),
* verifies the credentials with a `/health`-adjacent probe, and returns the client.
*/
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;
let closed = false;
const warn = (m: string) => console.warn(`[punktfunk] ${m}`);
const dispatch = (pattern: string, ev: unknown) => {
for (const l of listeners) {
if (l.pattern === pattern || (pattern !== "dropped" && pattern !== "unknown" && (l.pattern === "*" || kindMatches(l.pattern, pattern)))) {
try {
(l.cb as (e: unknown) => unknown)(ev);
} catch (e) {
warn(`listener for "${l.pattern}" threw: ${e}`);
}
}
}
};
const startPump = () => {
if (pump || closed) return;
pump = sseFrames(cfg, { onWarning: warn });
void (async () => {
try {
for await (const frame of pump) {
if (closed) break;
if (frame.event === "dropped") {
dispatch("dropped", frame);
continue;
}
let json: unknown;
try {
json = JSON.parse(frame.data);
} catch {
continue;
}
const decoded = decodeHostEvent(json);
if (Result.isFailure(decoded)) {
dispatch("unknown", json);
continue;
}
dispatch(decoded.success.kind, decoded.success);
}
} catch (e) {
if (!closed) warn(`event stream stopped: ${e}`);
}
})();
};
return {
config: cfg,
api,
request: (method, path, body) => httpRequest(cfg, method, path, body),
events: {
on(pattern: string, cb: (ev: never) => unknown) {
const l: Listener = { pattern, cb };
listeners.add(l);
startPump();
return () => listeners.delete(l);
},
} as PunktfunkEvents,
close() {
closed = true;
pump?.return(undefined).catch(() => {});
pump = undefined;
},
};
};
// ------------------------------------------------------------------- plugins (RFC §8)
/**
* A plugin's `main`: either a plain async function receiving the connected client, or an
* Effect requiring the `PunktfunkHost` service (`@punktfunk/host/effect`) — the shape that
* makes it well-behaved under the managed runner's supervision (structured interruption,
* scoped finalizers).
*/
export type PluginMain =
| ((pf: Punktfunk) => Promise<unknown> | unknown)
| Effect.Effect<unknown, unknown, PunktfunkHost>;
export interface PluginDef {
/** Package-convention name (`punktfunk-plugin-*` drops the prefix): kebab-case. */
name: string;
main: PluginMain;
}
/**
* Declare a plugin (the `punktfunk-plugin-*` convention, RFC §8). In v1 a plugin is a script
* the operator runs; the managed runner (a later, optional package) discovers this default
* export and supervises `main`.
*/
export const definePlugin = (def: PluginDef): PluginDef => {
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
throw new Error(
`plugin name "${def.name}" must be kebab-case ([a-z][a-z0-9-]*)`,
);
}
return def;
};