Files
punktfunk/sdk/examples/plugin-ui.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

86 lines
3.3 KiB
TypeScript

// ── Example · console-hosted plugin UI ────────────────────────────────────────────────────────
// A plugin that surfaces a UI inside the punktfunk web console (plugin-ui-surface design). It
// serves a page + a live SSE feed on a loopback port behind a per-boot secret; `servePluginUi`
// registers it, and the console proxies to it and adds a "Demo UI" nav entry — no second password,
// no second port for the operator to learn. This also exercises the streaming path end-to-end (the
// U0 spike): the SSE feed must arrive through the console's reverse proxy unbuffered.
//
// Run under the scripting runner, or directly for a quick look: bun examples/plugin-ui.ts
import {
connect,
definePlugin,
type Punktfunk,
servePluginUi,
} from "../src/index.js";
// A tiny self-contained page: a heading and a list that prepends one line per SSE tick. The
// EventSource URL is RELATIVE (`./events`) so it resolves under the console proxy prefix.
const PAGE = `<!doctype html><meta charset=utf-8><title>Demo UI</title>
<style>body{font:15px system-ui;margin:2rem;color:#e5e7eb;background:#0b0b0f}li{opacity:.9}</style>
<h1>punktfunk plugin UI demo</h1><p>Live ticks (proxied SSE):</p><ul id=log></ul>
<script>
const log = document.getElementById("log");
new EventSource("./events").onmessage = (e) => {
const li = document.createElement("li"); li.textContent = e.data; log.prepend(li);
};
</script>`;
// The plugin's dynamic handler. Paths arrive prefix-stripped (the console proxy removed
// `/plugin-ui/demo-ui`), so we match `/` and `/events` directly.
const handle = (req: Request): Response | undefined => {
const { pathname } = new URL(req.url);
if (pathname === "/events") {
// A ticking SSE stream — the thing the proxy must forward unbuffered.
let n = 0;
const body = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
const timer = setInterval(() => {
controller.enqueue(
enc.encode(`data: tick ${++n} @ ${new Date().toISOString()}\n\n`),
);
}, 1000);
req.signal.addEventListener("abort", () => {
clearInterval(timer);
controller.close();
});
},
});
return new Response(body, {
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
});
}
if (pathname === "/" || pathname === "/index.html") {
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
}
return undefined; // fall through → 404
};
const plugin = definePlugin({
name: "demo-ui",
main: async (pf) => {
const ui = await servePluginUi(pf, {
id: "demo-ui",
title: "Demo UI",
icon: "puzzle",
fetch: handle,
});
console.log(`[demo-ui] serving on ${ui.url} — open the console's "Demo UI" nav entry`);
// Run until asked to stop, then deregister cleanly (abrupt kills fall back to lease expiry).
await new Promise<void>((resolve) => {
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
await ui.close();
},
});
export default plugin;
// Allow a direct `bun examples/plugin-ui.ts` run outside the managed runner.
if (import.meta.main) {
const pf = await connect();
await (plugin.main as (pf: Punktfunk) => Promise<void>)(pf);
pf.close();
}