// ── 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 = `
Demo UI
punktfunk plugin UI demo
Live ticks (proxied SSE):
`;
// 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((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)(pf);
pf.close();
}