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>
This commit is contained in:
@@ -150,6 +150,41 @@ export default definePlugin({
|
||||
|
||||
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
|
||||
|
||||
### A plugin UI in the console — `servePluginUi`
|
||||
|
||||
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
|
||||
operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; `servePluginUi`
|
||||
registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the
|
||||
console's own session. Your code implements **zero human auth**.
|
||||
|
||||
```ts
|
||||
import { definePlugin, servePluginUi } from "@punktfunk/host";
|
||||
|
||||
export default definePlugin({
|
||||
name: "rom-manager",
|
||||
main: async (pf) => {
|
||||
const ui = await servePluginUi(pf, {
|
||||
id: "rom-manager",
|
||||
title: "ROM Manager",
|
||||
icon: "gamepad-2", // a lucide icon name
|
||||
staticDir: new URL("../dist/ui", import.meta.url), // your built SPA
|
||||
fetch: (req) => appRouter(req), // plugin-local REST/SSE (after a static miss)
|
||||
});
|
||||
try {
|
||||
await runForever();
|
||||
} finally {
|
||||
await ui.close(); // deregister + stop
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Requests reach `fetch` **prefix-stripped** (the console proxy removed `/plugin-ui/<id>`), so your app
|
||||
sees `/`, `/api/scan`, … — the original prefix is on `X-Forwarded-Prefix`. `servePluginUi` serves
|
||||
`staticDir` first (with an `index.html` SPA fallback for navigations); return `undefined` from `fetch`
|
||||
to fall through to it. Build your SPA with a relative base (`base: "./"` + hash routing) or an absolute
|
||||
`base: "/plugin-ui/<id>/"`, and expect a dark canvas. Requires the Bun runtime (the runner is bun).
|
||||
|
||||
## The runner: `punktfunk-scripting`
|
||||
|
||||
Instead of one unit file per script, run everything under the managed runner — it discovers
|
||||
|
||||
Reference in New Issue
Block a user