From d096811875b4c7cd9bb8ebab59d04b9ce6b7177e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 24 Jun 2026 07:40:22 +0000 Subject: [PATCH 1/8] feat(hub): serve connection meta under each mounted devframe base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mounted devframe's SPA loads in an iframe at its base and discovers the RPC endpoint by fetching a relative ./__connection.json. Hubs only served their own meta, so embedded SPAs could connect only by inheriting it from a same-origin parent window — failing for cross-origin or sandboxed iframes. mountDevframe now asks the host to serve that meta at each devframe's base through a new optional DevframeHost.mountConnectionMeta hook, so embedded SPAs discover the endpoint directly. --- packages/devframe/src/types/host.ts | 14 ++++++++++++++ packages/hub/src/node/mount-devframe.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index 686f311..83a71ba 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -17,6 +17,20 @@ export interface DevframeHost { */ mountStatic: (base: string, distDir: string) => void | Promise + /** + * Serve the host's connection meta (`__connection.json`) at the given URL + * base, so a devframe SPA mounted there can discover the RPC/WS endpoint + * via `connectDevframe()`'s relative `./__connection.json` fetch. + * + * Called by `mountDevframe` for each mounted devframe (alongside + * `mountStatic`). Without it, an embedded SPA can only discover the + * endpoint by inheriting it from a same-origin parent window — which fails + * for cross-origin or sandboxed iframes. Implementations serve the same + * meta they expose at the hub's own base. Optional: hosts that can't serve + * a dynamic route (e.g. static-snapshot builds) may omit it. + */ + mountConnectionMeta?: (base: string) => void | Promise + /** * Return the public origin the host is reachable at, e.g. * `http://localhost:5173`. Used by the dock host to enrich remote diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/mount-devframe.ts index 5eb6f3d..935ebd2 100644 --- a/packages/hub/src/node/mount-devframe.ts +++ b/packages/hub/src/node/mount-devframe.ts @@ -73,6 +73,12 @@ export async function mountDevframe( if (d.cli?.distDir) { ctx.views.hostStatic(base, resolve(d.cli.distDir)) + // Serve the hub's connection meta under the devframe's base so its SPA + // discovers the RPC/WS endpoint via `connectDevframe()`'s relative + // `./__connection.json` fetch — instead of relying on inheriting it from a + // same-origin parent window (which breaks for cross-origin / sandboxed + // iframes). Hosts that can't serve a dynamic route simply omit the hook. + await ctx.host.mountConnectionMeta?.(base) } ctx.docks.register({ From e280c804cf52c9639cae134e67026129f2b055ee Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 24 Jun 2026 07:40:30 +0000 Subject: [PATCH 2/8] feat(plugin-terminals): surface sessions in the hub terminals panel When mounted in a hub, the plugin managed its own sessions while the hub's terminals subsystem stayed empty. The manager now mirrors its live sessions into ctx.terminals when that context is present, so a hub UI lists them alongside other tools. Standalone runtimes have no such context and are unaffected. --- plugins/terminals/src/node/manager.ts | 71 +++++++++++++++++++ .../plugin-terminals/node.snapshot.d.ts | 2 + .../plugin-terminals/node.snapshot.js | 2 + 3 files changed, 75 insertions(+) diff --git a/plugins/terminals/src/node/manager.ts b/plugins/terminals/src/node/manager.ts index c693d8f..705a8c1 100644 --- a/plugins/terminals/src/node/manager.ts +++ b/plugins/terminals/src/node/manager.ts @@ -8,6 +8,7 @@ import type { TerminalSessionInfo, TerminalsOptions, TerminalsSharedState, + TerminalStatus, } from '../types' import type { TerminalProcess } from './backend' import process from 'node:process' @@ -46,6 +47,33 @@ interface ManagedSession { /** How often a PTY session's foreground process name is polled. */ const PROCESS_POLL_INTERVAL = 1000 +/** + * Minimal shape of the hub's terminals subsystem (`DevframeHubContext.terminals`) + * that this manager reflects sessions into. Declared locally and accessed by + * duck-typing so the plugin keeps no build/runtime dependency on + * `@devframes/hub` and works unchanged outside a hub. + */ +interface HubTerminalEntry { + id: string + title: string + description?: string + status: 'running' | 'stopped' | 'error' + icon?: string +} +interface HubTerminalsBridge { + sessions: Map + register: (session: HubTerminalEntry) => unknown + update: (session: HubTerminalEntry) => void + remove?: (session: { id: string }) => void +} + +/** Map the plugin's session status onto the hub's coarser status set. */ +const HUB_STATUS: Record = { + running: 'running', + exited: 'stopped', + error: 'error', +} + function defaultShell(): string { if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe' @@ -70,6 +98,8 @@ export class TerminalManager { private sessionsState?: SharedState private sessions = new Map() private ptyAvailable = false + /** Session ids this manager has registered into the hub (when hubbed). */ + private hubOwned = new Set() constructor( private ctx: DevframeNodeContext, @@ -388,5 +418,46 @@ export class TerminalManager { this.sessionsState?.mutate((draft) => { draft.sessions = this.list() }) + this.syncHub() + } + + /** + * Reflect the live session list into the hub's terminals subsystem when this + * devframe is mounted in a hub. `ctx.terminals` only exists on a + * `DevframeHubContext`, so it's accessed by duck-typing — standalone runtimes + * (CLI / Vite / build) have no such property and skip silently. This is what + * surfaces the plugin's sessions in the hub's own terminals panel. + */ + private syncHub(): void { + const hub = (this.ctx as { terminals?: HubTerminalsBridge }).terminals + if (!hub?.register) + return + + const live = new Set() + for (const { info } of this.sessions.values()) { + live.add(info.id) + const entry: HubTerminalEntry = { + id: info.id, + title: info.customTitle || info.processName || info.title, + description: `${info.mode} · ${info.backend}${info.pid ? ` · pid ${info.pid}` : ''}`, + status: HUB_STATUS[info.status] ?? 'stopped', + icon: 'ph:terminal-window-duotone', + } + if (hub.sessions.has(info.id)) { + hub.update(entry) + } + else { + hub.register(entry) + this.hubOwned.add(info.id) + } + } + + // Drop hub entries this manager owns once their session is gone. + for (const id of [...this.hubOwned]) { + if (!live.has(id)) { + hub.remove?.({ id }) + this.hubOwned.delete(id) + } + } } } diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts index abf2913..a86d4e3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts @@ -15,6 +15,7 @@ export declare class TerminalManager { private sessionsState?; private sessions; private ptyAvailable; + private hubOwned; constructor(_: DevframeNodeContext, _?: TerminalsOptions); init(): Promise; list(): TerminalSessionInfo[]; @@ -33,6 +34,7 @@ export declare class TerminalManager { remove(_: string): void; dispose(): void; private publish; + private syncHub; } // #endregion diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js index c009b9c..1ff68c3 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js @@ -15,6 +15,7 @@ export class TerminalManager { sessionsState sessions ptyAvailable + hubOwned constructor(_, _) {} async init() {} list() {} @@ -33,6 +34,7 @@ export class TerminalManager { remove(_) {} dispose() {} publish() {} + syncHub() {} } // #endregion From 0cafdc45822b3f0c7419085bb344f71ab96bf7b7 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 24 Jun 2026 07:40:36 +0000 Subject: [PATCH 3/8] fix(plugin-code-server): read version from the first semver token A cold-start `code-server --version` can print i18n initialization output before the version line, which leaked into the reported version. Match the first semver-looking token instead of the leading whitespace token. --- plugins/code-server/src/node/detect.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/code-server/src/node/detect.ts b/plugins/code-server/src/node/detect.ts index ebe1366..b952cc0 100644 --- a/plugins/code-server/src/node/detect.ts +++ b/plugins/code-server/src/node/detect.ts @@ -15,7 +15,10 @@ export interface DetectCodeServerResult { * launcher can fall back to install instructions. * * `code-server --version` prints e.g. `4.96.4 abc123 with Code 1.96.4`; the - * leading token is taken as the version. + * first semver-looking token is taken as the version. Matching a semver + * pattern (rather than the leading whitespace token) keeps cold-start i18n + * noise — e.g. an `i18next: …` initialization line printed before the version + * — from leaking into the reported version. */ export function detectCodeServer(bin = 'code-server', timeoutMs = 5000): Promise { return new Promise((resolve) => { @@ -58,7 +61,8 @@ export function detectCodeServer(bin = 'code-server', timeoutMs = 5000): Promise child.on('exit', (code) => { clearTimeout(timer) if (code === 0) { - const version = stdout.trim().split(/\s+/)[0] || undefined + const version = stdout.match(/\d+\.\d+\.\d\S*/)?.[0] + ?? (stdout.trim().split(/\s+/)[0] || undefined) finish({ installed: true, version, bin }) } else { From 6af729800ea8c606c63f94f9371e1a8ccfd91297 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 24 Jun 2026 07:40:44 +0000 Subject: [PATCH 4/8] feat(examples): dogfood the built-in plugins in the minimal hubs Mount @devframes/plugin-git, -terminals, and -code-server in both the Vite and Next minimal hub examples so they exercise real RPC, streaming, and child-process integrations end to end. Both hosts now serve per-devframe connection meta. The Next host loads the plugins through a bundler-ignored dynamic import and skips trailing-slash redirects so the published SPAs resolve their assets and connect. Adds a bundled-host recipe to the hub guide and the missing plugin-git source alias. --- alias.ts | 1 + docs/guide/hub.md | 40 +++++++++++++ .../minimal-next-devframe-hub/package.json | 5 +- .../client/app/%5F_[id]/[[...path]]/route.ts | 10 +++- .../devframe/minimal-next-devframe-hub.ts | 58 ++++++++++++++++++- .../src/client/next.config.mjs | 6 ++ .../minimal-vite-devframe-hub/package.json | 5 +- .../src/minimal-vite-devframe-hub.ts | 29 +++++++--- .../minimal-vite-devframe-hub/vite.config.ts | 12 +++- pnpm-lock.yaml | 18 ++++++ tsconfig.base.json | 3 + 11 files changed, 171 insertions(+), 16 deletions(-) diff --git a/alias.ts b/alias.ts index cb28bf8..0405b76 100644 --- a/alias.ts +++ b/alias.ts @@ -60,6 +60,7 @@ export const alias = { '@devframes/plugin-terminals/cli': p('terminals/src/cli.ts'), '@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'), '@devframes/plugin-terminals': p('terminals/src/index.ts'), + '@devframes/plugin-git': p('git/src/index.ts'), 'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'), 'devframe/client': r('devframe/src/client/index.ts'), 'devframe': r('devframe/src'), diff --git a/docs/guide/hub.md b/docs/guide/hub.md index a3ba86d..6a5d127 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -43,6 +43,46 @@ await mountDevframe(ctx, myDevframe) Framework kits typically wrap this in a plugin shell. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a Vite `Plugin` whose `devtools.setup` calls into `mountDevframe`. +### Connecting embedded SPAs + +A mounted devframe's SPA loads in an iframe at its base (`/__/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base. `mountDevframe` serves it there by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`, so the SPA discovers the RPC/WS endpoint directly. Implement `mountConnectionMeta` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base: + +```ts +const host: DevframeHost = { + mountStatic(base, distDir) { /* serve files */ }, + mountConnectionMeta(base) { + // serve `${base}__connection.json` → { backend: 'websocket', websocket: port } + }, + resolveOrigin() { /* … */ }, + getStorageDir(scope) { /* … */ }, +} +``` + +Hosts that omit `mountConnectionMeta` fall back to same-origin window inheritance, which connects an embedded SPA only when it shares an origin with the hub UI. + +### Bundled hosts (Next.js) + +Dev servers with a module bundler (Next's Turbopack/webpack) statically analyse server imports. Plugin packages resolve their SPA dist with `new URL('../dist/...', import.meta.url)` and lazy-load node-side code — child processes, the native `node-pty` PTY backend — that resolves at runtime, not at bundle time. Load them with a dynamic `import()` carrying ignore comments so the bundler keeps them as a runtime Node import: + +```ts +const pkgs = ['@devframes/plugin-git', '@devframes/plugin-terminals'] +const defs = await Promise.all( + pkgs.map(p => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ p)), +).then(mods => mods.map(m => m.default)) + +for (const def of defs) + await mountDevframe(ctx, def) +``` + +Each mounted SPA is served at `/__/` and references its assets relatively (`./_next/…`, `./assets/…`). Disable the bundler's trailing-slash redirect so those paths resolve under the mount base: + +```js +// next.config.mjs +export default { skipTrailingSlashRedirect: true } +``` + +[`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) is a working Next.js App Router host that mounts the built-in plugins this way. + ### Duplicate devframes When a devframe sharing an already-mounted `id` is mounted onto the same hub, its `duplicationStrategy` decides what happens. By default the first registration wins: diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index 8fac2dc..e9ead76 100644 --- a/examples/minimal-next-devframe-hub/package.json +++ b/examples/minimal-next-devframe-hub/package.json @@ -6,12 +6,15 @@ "description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub", "scripts": { - "dev": "next dev src/client", + "dev": "next dev src/client -H 0.0.0.0", "build": "next build src/client", "test": "vitest run --config vitest.config.ts" }, "dependencies": { "@devframes/hub": "workspace:*", + "@devframes/plugin-code-server": "workspace:*", + "@devframes/plugin-git": "workspace:*", + "@devframes/plugin-terminals": "workspace:*", "devframe": "workspace:*", "next": "catalog:frontend", "react": "catalog:frontend", diff --git a/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts index ea1d1b6..6409239 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts +++ b/examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' import { extname, join, normalize, resolve, sep } from 'pathe' -import { ensureMinimalNextDevframeHub, getStaticMount } from '../../../devframe/minimal-next-devframe-hub' +import { ensureMinimalNextDevframeHub, getStaticMount, isConnectionMetaPath } from '../../../devframe/minimal-next-devframe-hub' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -80,9 +80,15 @@ async function resolveTarget(absDir: string, urlPath: string): Promise { - await ensureMinimalNextDevframeHub() + const hub = await ensureMinimalNextDevframeHub() const pathname = new URL(request.url).pathname + + // A mounted devframe SPA fetches `/__connection.json` to discover the + // side-car WS endpoint. Answer it with the hub's connection meta. + if (isConnectionMetaPath(pathname)) + return Response.json(hub.connectionMeta) + const hit = getStaticMount(pathname) if (!hit) return new Response(null, { status: 404 }) diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts index b3fb673..313f3da 100644 --- a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts +++ b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts @@ -5,12 +5,39 @@ import { homedir } from 'node:os' import process from 'node:process' import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { startHttpAndWs } from 'devframe/node' import { getPort } from 'get-port-please' import { join } from 'pathe' import demoDevframe from './demo-devframe' import demoDevframeB from './demo-devframe-b' +/** + * Built-in plugin packages dogfooded through the hub mount path. + * + * They are loaded with a runtime dynamic `import()` carrying + * `webpackIgnore` / `turbopackIgnore` magic comments so Next's bundler leaves + * them alone: Node resolves the published `dist` at request time, where the + * plugins' node-side code (git shell-outs, child-process supervisors, the + * native `node-pty` PTY backend) and their `new URL('../dist/...', + * import.meta.url)` SPA-dist lookups all work — none of which survive being + * statically bundled into a Next server chunk. + */ +const BUILTIN_PLUGIN_PACKAGES = [ + '@devframes/plugin-git', + '@devframes/plugin-terminals', + '@devframes/plugin-code-server', +] as const + +async function loadBuiltinPlugins(): Promise { + const mods = await Promise.all( + BUILTIN_PLUGIN_PACKAGES.map( + pkg => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ pkg), + ), + ) + return mods.map(mod => mod.default as DevframeDefinition) +} + const STATIC_MOUNTS = new Map() export interface StaticMountHit { @@ -32,6 +59,23 @@ export function getStaticMount(pathname: string): StaticMountHit | null { return { distDir: best.distDir, relative } } +// Bases (without trailing slash, e.g. `/__git`) under which the catch-all +// route should serve the hub's connection meta at `/__connection.json`. +const CONNECTION_META_BASES = new Set() +const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}` + +/** + * If `pathname` is a `/__connection.json` request for a base the hub + * registered via `DevframeHost.mountConnectionMeta`, return that base; + * otherwise `null`. The catch-all route uses this to answer the connection-meta + * fetch a mounted devframe SPA makes from inside its iframe. + */ +export function isConnectionMetaPath(pathname: string): boolean { + if (!pathname.endsWith(META_SUFFIX)) + return false + return CONNECTION_META_BASES.has(pathname.slice(0, -META_SUFFIX.length)) +} + export interface MinimalNextDevframeHubOptions { /** Preferred port for the side-car RPC/WS server. Default: a free port near 9877. */ port?: number @@ -85,6 +129,12 @@ export async function minimalNextDevframeHub( mountStatic(base, distDir) { STATIC_MOUNTS.set(base.replace(/\/$/, ''), distDir) }, + // Record the base so the catch-all route can answer `/__connection.json` + // with the hub's connection meta — letting the mounted SPA connect without + // relying on same-origin parent-window inheritance. + mountConnectionMeta(base) { + CONNECTION_META_BASES.add(base.replace(/\/$/, '')) + }, resolveOrigin() { return `http://${hostName}:3000` }, @@ -116,13 +166,17 @@ export async function minimalNextDevframeHub( handler: () => 'pong', }) + // Demo devframes alongside the dogfooded built-in plugin packages. + const devframes = options.devframes + ?? [demoDevframe, demoDevframeB, ...await loadBuiltinPlugins()] + await context.messages.add({ level: 'success', message: 'Minimal Next Devframe Hub started', - description: `Side-car WS on port ${port}. ${options.devframes?.length ?? 1} devframe(s) registered.`, + description: `Side-car WS on port ${port}. ${devframes.length} devframe(s) registered.`, }) - for (const def of options.devframes ?? [demoDevframe, demoDevframeB]) { + for (const def of devframes) { await mountDevframe(context, def) } diff --git a/examples/minimal-next-devframe-hub/src/client/next.config.mjs b/examples/minimal-next-devframe-hub/src/client/next.config.mjs index d551674..af0d54b 100644 --- a/examples/minimal-next-devframe-hub/src/client/next.config.mjs +++ b/examples/minimal-next-devframe-hub/src/client/next.config.mjs @@ -3,6 +3,12 @@ const nextConfig = { images: { unoptimized: true }, // The workspace typecheck owns source-level project references. typescript: { ignoreBuildErrors: true }, + // Mounted devframe SPAs are served at `/__/` and reference their assets + // relatively (`./_next/…`, `./assets/…`). Next's default trailing-slash + // redirect (`/__git/` → `/__git`) would re-root those relative paths and 404 + // every asset, leaving the panel unstyled and unable to connect. Serving the + // base path verbatim keeps the SPA's relative asset resolution intact. + skipTrailingSlashRedirect: true, } export default nextConfig diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json index 30681de..29828fe 100644 --- a/examples/minimal-vite-devframe-hub/package.json +++ b/examples/minimal-vite-devframe-hub/package.json @@ -6,11 +6,14 @@ "description": "Protocol-witness example — a tiny Vite Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vite build" }, "dependencies": { "@devframes/hub": "workspace:*", + "@devframes/plugin-code-server": "workspace:*", + "@devframes/plugin-git": "workspace:*", + "@devframes/plugin-terminals": "workspace:*", "devframe": "workspace:*" }, "devDependencies": { diff --git a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts index 52295f5..3125416 100644 --- a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts +++ b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts @@ -77,11 +77,29 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = started = undefined const cwd = viteConfig!.root + const port = options.port ?? await getPort({ port: 9777, random: false }) + + // Serve the side-car's connection meta (`__connection.json`) at a URL + // base so a browser loaded there can discover the WS endpoint via + // `connectDevframe()`'s relative `./__connection.json` fetch. + const serveConnectionMeta = (metaBase: string): void => { + const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}` + server.middlewares.use(metaPath, (_req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + }) + } const host: DevframeHost = { mountStatic(base, distDir) { server.middlewares.use(base, serveStaticNodeMiddleware(distDir)) }, + // Serve `__connection.json` for each mounted devframe so its + // SPA connects to the hub without relying on same-origin parent-window + // inheritance — which breaks for cross-origin / sandboxed iframes. + mountConnectionMeta(base) { + serveConnectionMeta(base) + }, resolveOrigin() { const resolved = server.resolvedUrls?.local?.[0] return resolved ? new URL(resolved).origin : 'http://localhost:5173' @@ -93,8 +111,6 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = }, } - const port = options.port ?? await getPort({ port: 9777, random: false }) - const context = await createHubContext({ cwd, workspaceRoot: cwd, @@ -135,13 +151,8 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = auth: false, }) - // Tell the browser where to find the WS endpoint. `connectDevframe` - // resolves this URL relative to its `baseURL` option. - const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}` - server.middlewares.use(metaPath, (_req, res) => { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ backend: 'websocket', websocket: port })) - }) + // Tell the hub UI (served at `base`) where to find the WS endpoint. + serveConnectionMeta(base) server.httpServer?.once('close', () => { void started?.close().catch(() => {}) diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index e1b17cc..5872d20 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,3 +1,6 @@ +import codeServerDevframe from '@devframes/plugin-code-server' +import gitDevframe from '@devframes/plugin-git' +import terminalsDevframe from '@devframes/plugin-terminals' import { defineConfig } from 'vite' import { alias } from '../../alias' import demoDevframe from './src/devframe' @@ -8,7 +11,14 @@ export default defineConfig({ resolve: { alias }, plugins: [ minimalViteDevframeHub({ - devframes: [demoDevframe, demoDevframeB], + devframes: [ + demoDevframe, + demoDevframeB, + // Built-in plugins, dogfooded end-to-end through the hub mount path. + gitDevframe, + terminalsDevframe, + codeServerDevframe, + ], }), ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b33039b..4d4d0a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,15 @@ importers: '@devframes/hub': specifier: workspace:* version: link:../../packages/hub + '@devframes/plugin-code-server': + specifier: workspace:* + version: link:../../plugins/code-server + '@devframes/plugin-git': + specifier: workspace:* + version: link:../../plugins/git + '@devframes/plugin-terminals': + specifier: workspace:* + version: link:../../plugins/terminals devframe: specifier: workspace:* version: link:../../packages/devframe @@ -418,6 +427,15 @@ importers: '@devframes/hub': specifier: workspace:* version: link:../../packages/hub + '@devframes/plugin-code-server': + specifier: workspace:* + version: link:../../plugins/code-server + '@devframes/plugin-git': + specifier: workspace:* + version: link:../../plugins/git + '@devframes/plugin-terminals': + specifier: workspace:* + version: link:../../plugins/terminals devframe: specifier: workspace:* version: link:../../packages/devframe diff --git a/tsconfig.base.json b/tsconfig.base.json index c5a9ee2..2e42535 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -166,6 +166,9 @@ "@devframes/plugin-terminals": [ "./plugins/terminals/src/index.ts" ], + "@devframes/plugin-git": [ + "./plugins/git/src/index.ts" + ], "devframe/recipes/open-helpers": [ "./packages/devframe/src/recipes/open-helpers.ts" ], From 5fdbda8d3c112dd4ad878f8b272760b26125536d Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 25 Jun 2026 02:35:00 +0000 Subject: [PATCH 5/8] build(turbo): order example builds after the plugins they mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal hub examples now import the built-in plugin packages, so their build must wait for those packages' dist. Add the plugin build tasks to each example's dependsOn — otherwise the example's config load races a plugin's dist rewrite and fails to resolve its entry. --- turbo.json | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/turbo.json b/turbo.json index 07a8efd..a910e9b 100644 --- a/turbo.json +++ b/turbo.json @@ -35,12 +35,28 @@ }, "minimal-vite-devframe-hub#build": { "outputLogs": "new-only", - "dependsOn": ["@devframes/hub#build", "devframe#build"], + "dependsOn": [ + "@devframes/hub#build", + "devframe#build", + "@devframes/plugin-git#build", + "@devframes/plugin-terminals#build", + "@devframes/plugin-code-server#build", + "@devframes/plugin-inspect#build", + "@devframes/a11y#build" + ], "outputs": ["dist/**"] }, "minimal-next-devframe-hub#build": { "outputLogs": "new-only", - "dependsOn": ["@devframes/hub#build", "devframe#build"], + "dependsOn": [ + "@devframes/hub#build", + "devframe#build", + "@devframes/plugin-git#build", + "@devframes/plugin-terminals#build", + "@devframes/plugin-code-server#build", + "@devframes/plugin-inspect#build", + "@devframes/a11y#build" + ], "outputs": ["dist/**"] }, "files-inspector-example#build": { From 6edc8c64152716a8c5593e7a94bfa6737ea62b51 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 25 Jun 2026 02:35:11 +0000 Subject: [PATCH 6/8] feat(examples): dogfood inspect + a11y and add an icon dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mount all five built-in plugins (git, terminals, code-server, inspect, a11y) in both the Vite and Next minimal hubs, and reshape their UI into a minimal vite-devtools-style icon dock: each tool shows its Phosphor icon (inlined offline), the selected one fills the stage, and the hub subsystems sit in a bottom drawer. Frames both examples as the smallest copyable vite-devtools — a reference for building a viewer on @devframes/hub. The a11y plugin loads from its TypeScript entry; inspect mounts like the other published plugins. --- alias.ts | 1 + docs/guide/hub.md | 7 +- examples/minimal-next-devframe-hub/README.md | 37 +-- .../minimal-next-devframe-hub/package.json | 2 + .../src/client/app/globals.css | 273 +++++++++++++----- .../src/client/app/icons.ts | 31 ++ .../src/client/app/page.tsx | 21 +- .../devframe/minimal-next-devframe-hub.ts | 2 + examples/minimal-vite-devframe-hub/README.md | 36 ++- examples/minimal-vite-devframe-hub/index.html | 1 + .../minimal-vite-devframe-hub/package.json | 2 + .../src/client/icons.ts | 31 ++ .../src/client/main.ts | 14 +- .../src/client/style.css | 271 ++++++++++++----- .../minimal-vite-devframe-hub/vite.config.ts | 7 +- pnpm-lock.yaml | 12 + tsconfig.base.json | 3 + 17 files changed, 567 insertions(+), 184 deletions(-) create mode 100644 examples/minimal-next-devframe-hub/src/client/app/icons.ts create mode 100644 examples/minimal-vite-devframe-hub/src/client/icons.ts diff --git a/alias.ts b/alias.ts index e54e32e..9588fbe 100644 --- a/alias.ts +++ b/alias.ts @@ -69,6 +69,7 @@ export const alias = { '@devframes/plugin-inspect/cli': p('inspect/src/cli.ts'), '@devframes/plugin-inspect/vite': p('inspect/src/vite.ts'), '@devframes/plugin-inspect': p('inspect/src/index.ts'), + '@devframes/a11y': p('a11y/src/devframe.ts'), } // update tsconfig.base.json diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 6a5d127..d039810 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -145,7 +145,12 @@ Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:u ## Example -See [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) for a ~120-line Vite plugin that wires the hub end to end with a vanilla DOM UI. Every framework's hub host follows the same shape: a thin layer that adapts the framework's dev server to the hub. +Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-server, inspect, a11y) behind an icon dock — the same shape [vite-devtools](https://github.com/vitejs/devtools) wears as the full Vite viewer, shrunk to the smallest thing you can build your own viewer from: + +- [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) — a ~120-line Vite plugin host with a vanilla DOM UI. +- [`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) — the same protocol hosted from a Next.js App Router app. + +Every framework's hub host follows the same shape: a thin `DevframeHost` adapter over the framework's dev server, with the dock/commands/messages/terminals protocol unchanged above it. ## Diagnostics diff --git a/examples/minimal-next-devframe-hub/README.md b/examples/minimal-next-devframe-hub/README.md index bd89a0e..a53cfec 100644 --- a/examples/minimal-next-devframe-hub/README.md +++ b/examples/minimal-next-devframe-hub/README.md @@ -1,6 +1,8 @@ # Minimal Next Devframe Hub -A protocol-witness example. The `src/client/devframe/minimal-next-devframe-hub.ts` file wires `@devframes/hub` into a Next.js App Router app by lazily starting a side-car RPC/WS server from a Node route handler. +A tiny, copyable **vite-devtools-style hub on Next.js**. [vite-devtools](https://github.com/vitejs/devtools) is the full Vite viewer built on `@devframes/hub`; this example wears the same shape — an icon dock, an iframe stage, a subsystem drawer — but hosts it from a Next.js App Router app, lazily starting a side-car RPC/WS server from a Node route handler. It's the reference for bringing the same integrations to any non-Vite host. + +`src/client/devframe/minimal-next-devframe-hub.ts` is the entire host. ## Run it @@ -9,28 +11,29 @@ pnpm install pnpm --filter minimal-next-devframe-hub dev ``` -Open the printed URL. You should see: +Open the printed URL. The dock on the left lists every mounted tool with its icon: + +- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each mounted with `mountDevframe` +- **Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path -- A status line showing the RPC backend -- A **Docks** list with hub built-ins and the mounted demo devframe -- A **Commands** list populated from server-side registrations -- A **Messages** list populated via `messages.add()` on the server -- A **Terminals** list, empty unless a devframe registers one -- A button that exercises `hub:commands:execute` by dispatching the sample ping command +Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`. ## What the example proves -- `createHubContext()` boots a hub without any Vite-specific code path -- A `DevframeHost` impl plugs Next host specifics into the hub uniformly -- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock -- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed -- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json` +- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs Next specifics (static mounts, connection meta, storage, origin) in uniformly +- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock and serves both its SPA and its `__connection.json`, so the embedded SPA connects straight back to the hub +- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — byte-for-byte the same protocol the Vite host speaks + +## Hosting built-in plugins in a bundler + +The plugins run node-side (child processes, the native `node-pty` PTY backend) and resolve their SPA dist via `new URL(..., import.meta.url)`. Next's bundler would try to inline that, so the host loads them through a bundler-ignored dynamic `import()` and sets `skipTrailingSlashRedirect` (see `next.config.mjs`) so each SPA's relative assets resolve under `/__/`. This is the recipe for any bundled (webpack/Turbopack) host. ## Files | File | Role | |---|---| -| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — creates hub context and side-car WS | -| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Connection-meta endpoint for `/__hub/__connection.json` that starts the singleton host | -| `src/client/devframe/demo-devframe.ts` | A sample `DevframeDefinition` that plugs into the host | -| `src/client/app/page.tsx` | The browser-side UI that consumes the hub protocol | +| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — hub context, static-mount registry, side-car WS | +| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Boots the singleton host and serves `/__hub/__connection.json` | +| `src/client/app/%5F_[id]/[[...path]]/route.ts` | Serves each mounted SPA and its connection meta under `/__/` | +| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol | +| `src/client/app/icons.ts` | Offline Phosphor icons for the dock | diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index e9ead76..7ab1b8b 100644 --- a/examples/minimal-next-devframe-hub/package.json +++ b/examples/minimal-next-devframe-hub/package.json @@ -11,9 +11,11 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@devframes/a11y": "workspace:*", "@devframes/hub": "workspace:*", "@devframes/plugin-code-server": "workspace:*", "@devframes/plugin-git": "workspace:*", + "@devframes/plugin-inspect": "workspace:*", "@devframes/plugin-terminals": "workspace:*", "devframe": "workspace:*", "next": "catalog:frontend", diff --git a/examples/minimal-next-devframe-hub/src/client/app/globals.css b/examples/minimal-next-devframe-hub/src/client/app/globals.css index 41a0966..ad4099d 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/globals.css +++ b/examples/minimal-next-devframe-hub/src/client/app/globals.css @@ -1,18 +1,55 @@ +/* + * A deliberately small "minimal vite-devtools" skin (Next.js edition): an icon + * dock on the left, the selected tool's iframe filling the stage, and a drawer + * of hub subsystems (commands / messages / terminals) along the bottom. Mirrors + * the Vite example so the same protocol reads identically across hosts. + */ :root { color-scheme: light dark; - font-family: system-ui, sans-serif; + font-family: Inter, system-ui, -apple-system, sans-serif; line-height: 1.5; + + --accent: #845eee; + --bg: #ffffff; + --bg-sunken: #f6f6f8; + --bg-raised: #ffffff; + --fg: #1c1c22; + --fg-muted: #6b6b78; + --border: #e6e6ec; + --border-strong: #d6d6e0; + --ok: #16a34a; + --err: #e1326a; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0e0e12; + --bg-sunken: #131318; + --bg-raised: #1a1a22; + --fg: #e9e9f0; + --fg-muted: #9b9bac; + --border: #26262f; + --border-strong: #34343f; + --ok: #4ade80; + --err: #ff6b9d; + } +} + +* { + box-sizing: border-box; } body { margin: 0; height: 100vh; overflow: hidden; + background: var(--bg); + color: var(--fg); } .app-shell { display: grid; - grid-template-columns: 220px 1fr; + grid-template-columns: 248px 1fr; grid-template-rows: auto 1fr auto; grid-template-areas: "header header" @@ -21,37 +58,157 @@ body { height: 100vh; } +/* ── Header ───────────────────────────────────────────────── */ .app-header { grid-area: header; - padding: 0.75rem 1rem; - border-bottom: 1px solid color-mix(in srgb, currentcolor 20%, transparent); + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.7rem 1.1rem; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); } .app-header h1 { margin: 0; - font-size: 1rem; - letter-spacing: 0.05em; - text-transform: uppercase; + font-size: 0.92rem; + font-weight: 650; + letter-spacing: 0.02em; } .app-header p { - margin: 0.25rem 0 0; + margin: 0; font-family: ui-monospace, monospace; - font-size: 0.8rem; - opacity: 0.7; + font-size: 0.74rem; + color: var(--fg-muted); +} + +.app-tagline { + margin-left: auto !important; + font-style: italic; + opacity: 0.8; +} + +#status span::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + margin-right: 0.4em; + border-radius: 50%; + background: var(--fg-muted); + vertical-align: middle; } +#status.ready span { color: var(--ok); } +#status.ready span::before { background: var(--ok); } +#status.error span { color: var(--err); } +#status.error span::before { background: var(--err); } + +/* ── Dock (sidebar) ───────────────────────────────────────── */ .app-sidebar { grid-area: sidebar; - border-right: 1px solid color-mix(in srgb, currentcolor 20%, transparent); - padding: 0.75rem; + border-right: 1px solid var(--border); + padding: 0.6rem; overflow: auto; + background: var(--bg-sunken); +} + +.app-sidebar h2 { + padding: 0 0.5rem; +} + +.app-sidebar ul { + gap: 0.15rem; +} + +.app-sidebar li { + padding: 0; + border: 0; + background: transparent; +} + +.dock-btn { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + text-align: left; + padding: 0.5rem 0.6rem; + font: inherit; + font-size: 0.85rem; + color: var(--fg); + background: transparent; + border: 0; + border-radius: 0.5rem; + cursor: pointer; + position: relative; + transition: background 0.12s ease, color 0.12s ease; +} + +.dock-btn:hover { + background: color-mix(in srgb, var(--accent) 9%, transparent); +} + +.dock-btn.active { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: color-mix(in srgb, var(--accent) 65%, var(--fg)); + font-weight: 550; } +.dock-btn.active::before { + content: ""; + position: absolute; + left: -0.6rem; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 1.1rem; + border-radius: 0 3px 3px 0; + background: var(--accent); +} + +.dock-ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: none; + color: var(--fg-muted); +} + +.dock-btn.active .dock-ico { + color: var(--accent); +} + +.dock-ico svg { + width: 20px; + height: 20px; +} + +.dock-initial { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 0.35rem; + background: color-mix(in srgb, var(--accent) 20%, transparent); + font-size: 0.7rem; + font-weight: 700; +} + +.dock-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Stage ────────────────────────────────────────────────── */ .app-main { grid-area: main; overflow: hidden; - background: color-mix(in srgb, currentcolor 3%, transparent); + background: var(--bg-sunken); } .app-main iframe { @@ -59,14 +216,17 @@ body { height: 100%; border: 0; display: block; + background: var(--bg); } +/* ── Drawer (footer) ──────────────────────────────────────── */ .app-footer { grid-area: footer; display: flex; - gap: 1rem; - padding: 0.75rem 1rem; - border-top: 1px solid color-mix(in srgb, currentcolor 20%, transparent); + gap: 1.25rem; + padding: 0.8rem 1.1rem; + border-top: 1px solid var(--border); + background: var(--bg-raised); max-height: 30vh; overflow: auto; } @@ -77,11 +237,11 @@ body { } h2 { - font-size: 0.75rem; + font-size: 0.68rem; text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.8; - margin: 0 0 0.5rem; + letter-spacing: 0.07em; + color: var(--fg-muted); + margin: 0 0 0.55rem; } ul { @@ -90,55 +250,31 @@ ul { margin: 0; display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.3rem; } -li { +.app-footer li { padding: 0.4rem 0.6rem; - border: 1px solid color-mix(in srgb, currentcolor 15%, transparent); - border-radius: 0.4rem; + border: 1px solid var(--border); + border-radius: 0.45rem; + background: var(--bg-sunken); font-family: ui-monospace, monospace; - font-size: 0.8rem; + font-size: 0.78rem; } li.muted { - opacity: 0.5; + opacity: 0.55; font-style: italic; + border-style: dashed; } code { font-family: ui-monospace, monospace; - background: color-mix(in srgb, currentcolor 10%, transparent); + font-size: 0.92em; + background: color-mix(in srgb, var(--fg) 8%, transparent); padding: 0.1em 0.35em; border-radius: 0.25em; -} - -.app-sidebar ul li { - padding: 0; - border: 0; - background: transparent; -} - -.app-sidebar button { - width: 100%; - text-align: left; - padding: 0.5rem 0.6rem; - font: inherit; - font-size: 0.85rem; - background: transparent; - border: 1px solid transparent; - border-radius: 0.4rem; - cursor: pointer; - color: inherit; -} - -.app-sidebar button:hover { - background: color-mix(in srgb, currentcolor 8%, transparent); -} - -.app-sidebar button.active { - background: color-mix(in srgb, currentcolor 15%, transparent); - border-color: color-mix(in srgb, currentcolor 30%, transparent); + color: var(--fg-muted); } .actions { @@ -146,31 +282,22 @@ code { flex-wrap: wrap; gap: 0.75rem; align-items: flex-start; + margin-top: 0.6rem; } button { font: inherit; - font-size: 0.85rem; + font-size: 0.82rem; padding: 0.4rem 0.8rem; - border-radius: 0.4rem; - border: 1px solid currentcolor; - background: transparent; + border-radius: 0.45rem; + border: 1px solid var(--border-strong); + background: var(--bg-raised); cursor: pointer; - color: inherit; + color: var(--fg); + transition: background 0.12s ease, border-color 0.12s ease; } button:hover { - background: color-mix(in srgb, currentcolor 10%, transparent); -} - -#status.error span { - color: #c33; -} - -#status.ready span { - color: #2a7; -} - -.badge { - margin-left: 0.35rem; + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, transparent); } diff --git a/examples/minimal-next-devframe-hub/src/client/app/icons.ts b/examples/minimal-next-devframe-hub/src/client/app/icons.ts new file mode 100644 index 0000000..288b34f --- /dev/null +++ b/examples/minimal-next-devframe-hub/src/client/app/icons.ts @@ -0,0 +1,31 @@ +// Offline dock icons. SVG bodies are inlined from `@iconify-json/ph` — the +// same Phosphor set vite-devtools uses — so the dock renders crisp glyphs with +// no runtime network and no multi-megabyte icon-set dependency in the bundle. +// To add an icon, copy its `body` from the `ph` set under a `ph:` key. +const ICONS: Record = { + 'ph:git-branch-duotone': '', + 'ph:terminal-window-duotone': '', + 'ph:code-duotone': '', + 'ph:stethoscope-duotone': '', + 'ph:wheelchair-duotone': '', + 'ph:rocket-duotone': '', + 'ph:wrench-duotone': '', + 'ph:plug-duotone': '', + 'ph:gear-duotone': '', + 'ph:gauge-duotone': '', +} + +/** + * Render a devframe dock `icon` (e.g. `ph:git-branch-duotone`) as an inline + * SVG string. Returns an empty string for icons absent from {@link ICONS} so + * the caller can fall back to a text glyph. + */ +export function iconSvg(name: string | { light: string, dark: string } | undefined, size = 18): string { + if (!name) + return '' + const id = typeof name === 'string' ? name : name.light + const body = ICONS[id] + if (!body) + return '' + return `` +} diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index 7225ea7..d11040f 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -10,6 +10,7 @@ import type { import type { ReactNode } from 'react' import { connectDevframe } from '@devframes/hub/client' import { useEffect, useMemo, useRef, useState } from 'react' +import { iconSvg } from './icons' const HUB_BASE = '/__hub/' @@ -25,6 +26,15 @@ function isIframeDock(d: DevframeDockEntry): d is IframeDock { return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' } +/** Render a dock icon, falling back to the title's initial when unknown. */ +function dockIcon(entry: DevframeDockEntry): string { + const svg = iconSvg(entry.icon, 20) + if (svg) + return svg + const initial = (entry.title?.[0] ?? '?').toUpperCase() + return `${initial}` +} + export default function Page() { const [status, setStatus] = useState({ text: 'Connecting...' }) const [docks, setDocks] = useState([]) @@ -57,8 +67,8 @@ export default function Page() { { initialValue: [] }, ) - const renderDocks = () => setDocks(docksState.value() ?? []) - const renderCommands = () => setCommands(commandsState.value() ?? []) + const renderDocks = () => setDocks([...(docksState.value() ?? [])] as DevframeDockEntry[]) + const renderCommands = () => setCommands([...(commandsState.value() ?? [])] as DevframeCommandEntry[]) docksState.on('updated', renderDocks) commandsState.on('updated', renderCommands) renderDocks() @@ -140,6 +150,7 @@ export default function Page() {

{status.text}

+

a vite-devtools-style hub on Next.js you can copy