From 3a7f41cbde7399e3b197a4b4a75cc9b31b5538ac Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 04:38:36 +0000 Subject: [PATCH 1/5] refactor(storybook-hub): start each Storybook from a launcher dock Each plugin's Storybook is now a first-class `type: 'launcher'` dock bound to a `ctx.commands` command, replacing the iframe docks + bespoke `storybook-hub:ensure` RPC. Hitting Start dispatches the command over `hub:commands:execute`, which spawns `storybook dev` through `ctx.terminals`, streams the boot output onto the launcher's digest, and returns the resolved URL the client iframes in place. --- examples/storybook-hub/README.md | 46 ++--- examples/storybook-hub/src/client/main.ts | 192 ++++++++++++++------ examples/storybook-hub/src/storybook-hub.ts | 171 +++++++++++------ 3 files changed, 277 insertions(+), 132 deletions(-) diff --git a/examples/storybook-hub/README.md b/examples/storybook-hub/README.md index 78c4c82c..d2a5bef4 100644 --- a/examples/storybook-hub/README.md +++ b/examples/storybook-hub/README.md @@ -3,27 +3,31 @@ A devframe hub, built on `@devframes/hub`, that surfaces every built-in plugin's Storybook as its own dock — plus the live terminals plugin running as a real integration. It's a second take on the unified Storybook: instead of Storybook -Composition, the **hub** is the shell and each Storybook is a lazily-mounted -iframe dock (the same on-demand embed pattern the code-server plugin uses). +Composition, the **hub** is the shell and each Storybook is a first-class +`type: 'launcher'` dock that boots its instance on demand. ## How it works The whole host is one Vite plugin (`src/storybook-hub.ts`): it creates a hub -context, implements the framework-neutral `DevframeHost`, registers a dock per -plugin Storybook, mounts the terminals plugin via `mountDevframe`, and starts a -side-car RPC/WS server. - -Each Storybook dock's iframe is created **only when the dock is first opened**, -then kept mounted so its state survives tab switches. Where the iframe points -depends on the mode, unified behind the `storybook-hub:ensure` RPC: - -- **dev** (`vite`) — the plugin's `storybook dev` server is spawned on first - open and the dock iframes it live (HMR). The process is launched through - `ctx.terminals`, the hub's terminals subsystem, so each spawned Storybook is - a read-only terminal session — open the **Terminals** dock to watch its - output stream live. -- **build** (`vite preview`) — the pre-built `storybook/storybook-static/` - is served by the hub on one origin and the dock iframes that. +context, implements the framework-neutral `DevframeHost`, registers a launcher +dock (and a bound command) per plugin Storybook, mounts the terminals plugin via +`mountDevframe`, and starts a side-car RPC/WS server. + +Each Storybook dock is a launcher tile with a **Start** button — the lazy +trigger. The button binds a `ctx.commands` command +(`storybook-hub:launch:`), so the client dispatches it over the serializable +`hub:commands:execute` path. Once launched, the tile swaps in place for the +running Storybook's iframe, kept mounted so its state survives tab switches. +Where the iframe points depends on the mode: + +- **dev** (`vite`) — the launch command spawns the plugin's `storybook dev` + through `ctx.terminals`, the hub's terminals subsystem, so each Storybook is a + read-only terminal session (open the **Terminals** dock to watch its output + stream live). As it boots, the tail of that output is patched onto the + launcher's `digest` and shown beneath the spinner; on ready the command + returns the live dev-server URL the client iframes (HMR). +- **build** (`vite preview`) — the launch resolves immediately to the pre-built + `storybook/storybook-static/` the hub serves on one origin. ## Run it @@ -39,10 +43,10 @@ pnpm build pnpm --filter storybook-hub dev ``` -Open the printed URL, then click a Storybook in the sidebar; its dev server -boots on first open (subsequent opens are instant). The dev servers listen on -their own ports, so reaching them from a remote browser needs those ports -forwarded. +Open the printed URL, pick a Storybook in the sidebar, and hit **Start**; its +dev server boots on demand (subsequent opens are instant). The dev servers +listen on their own ports, so reaching them from a remote browser needs those +ports forwarded. ### Preview — pre-built Storybooks on one origin diff --git a/examples/storybook-hub/src/client/main.ts b/examples/storybook-hub/src/client/main.ts index 06c7dd5f..03a78b53 100644 --- a/examples/storybook-hub/src/client/main.ts +++ b/examples/storybook-hub/src/client/main.ts @@ -1,4 +1,4 @@ -import type { DevframeDockEntry } from '@devframes/hub/types' +import type { DevframeDockEntry, DevframeViewLauncher } from '@devframes/hub/types' import { connectDevframe } from '@devframes/hub/client' import { createIframePanes } from 'iframe-pane' import { iconClass } from './icons' @@ -7,13 +7,16 @@ import '@antfu/design/styles.css' const HUB_BASE = '/__hub/' -// Mirror of the host's `storybook-hub:ensure` return shape. +// Mirror of the launch command's return shape (`storybook-hub:launch:`, +// dispatched over `hub:commands:execute`). type EnsureResult = | { ok: true, kind: 'port', port: number } | { ok: true, kind: 'path', url: string } | { ok: false, error: string } type IframeDock = DevframeDockEntry & { type: 'iframe', url: string } +type LauncherDock = DevframeViewLauncher +type Dock = IframeDock | LauncherDock /** Sidebar section order; anything else follows alphabetically. */ const CATEGORY_ORDER = ['Storybooks', 'Plugins'] @@ -28,13 +31,14 @@ interface DockRuntime { error?: string } -// Every opened dock's iframe is parked here for its whole lifetime — switching +// Every launched dock's iframe is parked here for its whole lifetime — switching // tabs only mounts/unmounts the pane over `#stage`, so background docks keep // their state (Storybook's own routing, scroll, etc.) intact. const panes = createIframePanes({ container: stageEl }) const runtimes = new Map() -let docks: IframeDock[] = [] +let docks: Dock[] = [] let selectedId: string | null = null +let rpc: Awaited> function setStatus(text: string, kind?: 'ready' | 'error') { const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400' @@ -45,8 +49,8 @@ function isIframeDock(d: DevframeDockEntry): d is IframeDock { return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' } -function isStorybookDock(id: string): boolean { - return id.startsWith('sb-') +function isLauncherDock(d: DevframeDockEntry): d is LauncherDock { + return d.type === 'launcher' } function runtimeFor(id: string): DockRuntime { @@ -66,14 +70,23 @@ function dockIcon(entry: DevframeDockEntry): string { return `${initial}` } -function overlay(kind: 'spin' | 'error' | 'idle', title: string, detail = '') { - const glyph = kind === 'spin' - ? '' - : kind === 'error' - ? '' - : '' +function overlay(html: string) { overlayEl.style.display = 'flex' - overlayEl.innerHTML = `
${glyph}
${title}
${detail ? `
${detail}
` : ''}
` + overlayEl.innerHTML = `
${html}
` +} + +/** The idle launcher tile: a start button that lazily boots the Storybook. */ +function launcherTile(entry: LauncherDock): string { + const l = entry.launcher + const cls = iconClass(l.icon ?? entry.icon) + const glyph = cls ? `` : '' + return ` + ${glyph} +
${l.title}
+ ${l.description ? `
${l.description}
` : ''} + ` } function updateStage() { @@ -85,57 +98,90 @@ function updateStage() { } if (!selectedId) { - overlay('idle', 'No dock selected') + overlay('
No dock selected
') return } - const rt = runtimes.get(selectedId) - const title = docks.find(d => d.id === selectedId)?.title ?? selectedId - if (!rt || rt.status === 'starting' || (rt.status !== 'error' && !panes.has(selectedId))) { - overlay('spin', isStorybookDock(selectedId) ? `Starting ${title} Storybook…` : `Loading ${title}…`) + + const entry = docks.find(d => d.id === selectedId) + const rt = runtimeFor(selectedId) + const title = entry?.title ?? selectedId + + // A launched pane is mounted — hide the overlay and show the live iframe. + if (rt.status === 'ready' && panes.has(selectedId)) { + overlayEl.style.display = 'none' return } + if (rt.status === 'error') { - overlay('error', `Failed to start ${title}`, rt.error) + overlay(` + +
Failed to start ${title}
+ ${rt.error ? `
${rt.error}
` : ''} + ${entry && isLauncherDock(entry) ? `` : ''}`) + return + } + + // A launcher: idle shows its start tile; starting mirrors the live `digest` + // (the tail of the `storybook dev` output the host streams onto the tile). + if (entry && isLauncherDock(entry)) { + if (rt.status === 'starting') { + const digest = entry.launcher.digest + overlay(` + +
Starting ${title}…
+ ${digest ? `
${digest}
` : ''} + `) + } + else { + overlay(launcherTile(entry)) + } return } - overlayEl.style.display = 'none' -} -async function ensureUrl(rpc: Awaited>, entry: IframeDock): Promise { - // Live plugin docks already carry a hub-served URL; only Storybook docks are - // resolved on demand (spawned in dev, static in build). - if (!isStorybookDock(entry.id)) - return entry.url + // A plain iframe dock (the live terminals plugin) is still booting. + overlay(`
Loading ${title}…
`) +} - const result = await rpc.call('storybook-hub:ensure' as any, { id: entry.id.slice(3) }) as EnsureResult - if (!result.ok) - throw new Error(result.error) +/** Resolve the URL an EnsureResult points at (spawned dev port, or static path). */ +function resultUrl(result: Extract): string { return result.kind === 'path' ? result.url : `${location.protocol}//${location.hostname}:${result.port}/` } -function initDock(rpc: Awaited>, entry: IframeDock) { +function embedIframe(entry: Dock, url: string) { + const rt = runtimeFor(entry.id) + panes.ensure(entry.id, { + src: url, + attrs: { title: entry.title, allow: 'clipboard-read; clipboard-write' }, + style: { border: '0' }, + onCreated: (iframe) => { + iframe.addEventListener('load', () => { + rt.status = 'ready' + updateStage() + }) + }, + }) + updateStage() +} + +/** Launch a Storybook: dispatch its bound command, then iframe the result. */ +function launch(entry: LauncherDock) { const rt = runtimeFor(entry.id) - if (rt.status !== 'idle') - return rt.status = 'starting' + rt.error = undefined updateStage() - ensureUrl(rpc, entry) - .then((url) => { - panes.ensure(entry.id, { - src: url, - attrs: { title: entry.title, allow: 'clipboard-read; clipboard-write' }, - style: { border: '0' }, - onCreated: (iframe) => { - iframe.addEventListener('load', () => { - rt.status = 'ready' - updateStage() - }) - }, - }) - updateStage() + const command = entry.launcher.command + const dispatch = command + ? rpc.call('hub:commands:execute' as any, command) as Promise + : Promise.reject(new Error('Launcher has no bound command')) + + dispatch + .then((result) => { + if (!result.ok) + throw new Error(result.error) + embedIframe(entry, resultUrl(result)) }) .catch((err: Error) => { rt.status = 'error' @@ -144,19 +190,30 @@ function initDock(rpc: Awaited>, entry: Ifram }) } +/** A plain iframe dock (the live terminals plugin) mounts its URL directly. */ +function openIframe(entry: IframeDock) { + const rt = runtimeFor(entry.id) + if (rt.status !== 'idle') + return + rt.status = 'starting' + embedIframe(entry, entry.url) +} + async function main() { setStatus('Connecting…') - const rpc = await connectDevframe({ baseURL: HUB_BASE }) + rpc = await connectDevframe({ baseURL: HUB_BASE }) setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready') const switchTo = (id: string) => { - if (!docks.some(d => d.id === id)) + const entry = docks.find(d => d.id === id) + if (!entry) return selectedId = id renderSidebar() - const rt = runtimeFor(id) - if (rt.status === 'idle') - initDock(rpc, docks.find(d => d.id === id)!) + // Plain iframe docks open on select; launcher docks wait for their Start + // button (the lazy trigger) — so opening a Storybook dock doesn't spawn it. + if (isIframeDock(entry)) + openIframe(entry) updateStage() } @@ -180,20 +237,21 @@ async function main() { }).join('') } - // Docks — read from `devframe:docks` shared state. + // Docks — read from `devframe:docks` shared state, keeping launcher (Storybook) + // and iframe (live plugin) entries. const docksState = await rpc.sharedState.get('devframe:docks', { initialValue: [] }) const syncDocks = () => { - docks = (docksState.value() ?? []).filter(isIframeDock) + docks = (docksState.value() ?? []).filter((d): d is Dock => isIframeDock(d) || isLauncherDock(d)) if (selectedId && !docks.some(d => d.id === selectedId)) selectedId = null if (!selectedId && docks.length) selectedId = docks[0].id renderSidebar() - if (selectedId) { - const rt = runtimeFor(selectedId) - if (rt.status === 'idle') - initDock(rpc, docks.find(d => d.id === selectedId)!) - } + // Auto-open plain iframe docks (terminals plugin); launcher docks stay idle + // until the user starts them. + const entry = selectedId ? docks.find(d => d.id === selectedId) : undefined + if (entry && isIframeDock(entry) && runtimeFor(entry.id).status === 'idle') + openIframe(entry) updateStage() } docksState.on('updated', syncDocks) @@ -203,6 +261,24 @@ async function main() { if (target?.dataset.dockId) switchTo(target.dataset.dockId) }) + + overlayEl.addEventListener('click', (event) => { + const el = event.target as HTMLElement + const launchId = el.closest('button[data-launch]')?.dataset.launch + if (launchId) { + const entry = docks.find(d => d.id === launchId) + if (entry && isLauncherDock(entry)) + launch(entry) + return + } + if (el.closest('button[data-terminals]')) { + // Jump to the live terminals plugin to watch the spawned dev server stream. + const terminals = docks.find(d => isIframeDock(d) && (d.category ?? '') === 'Plugins') + if (terminals) + switchTo(terminals.id) + } + }) + syncDocks() } diff --git a/examples/storybook-hub/src/storybook-hub.ts b/examples/storybook-hub/src/storybook-hub.ts index 18ba29f9..3b56007d 100644 --- a/examples/storybook-hub/src/storybook-hub.ts +++ b/examples/storybook-hub/src/storybook-hub.ts @@ -1,13 +1,13 @@ import type { DevframeHubContext } from '@devframes/hub/node' -import type { DevframeChildProcessTerminalSession } from '@devframes/hub/types' +import type { DevframeChildProcessTerminalSession, DevframeViewLauncher } from '@devframes/hub/types' import type { DevframeHost } from 'devframe/types' +import type { Buffer } from 'node:buffer' import type { Plugin, PreviewServer, ResolvedConfig, ViteDevServer } from 'vite' import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { homedir } from 'node:os' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import terminalsDevframe from '@devframes/plugin-terminals' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' @@ -45,6 +45,17 @@ const pluginDir = (id: string): string => join(repoRoot, 'plugins', id) const storybookConfigDir = (id: string): string => join(pluginDir(id), '.storybook') const storybookStaticDir = (id: string): string => join(repoRoot, 'storybook', 'storybook-static', id) const sessionIdFor = (id: string): string => `storybook-hub:${id}` +const dockIdFor = (id: string): string => `sb-${id}` +const launchCommandFor = (id: string): string => `storybook-hub:launch:${id}` + +// eslint-disable-next-line no-control-regex +const ANSI = /\u001B\[[0-9;]*[A-Z]/gi + +/** Last non-empty, ANSI-stripped line of a chunk — the launcher's `digest`. */ +function lastLine(chunk: string): string | undefined { + const lines = chunk.replace(ANSI, '').split(/\r?\n/).map(l => l.trim()).filter(Boolean) + return lines.at(-1) +} /** What the client needs to point a dock's iframe at the right place. */ export type EnsureStorybookResult @@ -63,18 +74,23 @@ export interface StorybookHubOptions { * A Vite plugin that turns a Vite dev/preview server into a devframe hub whose * docks are the built-in plugins' Storybooks — plus the live terminals plugin. * - * Each Storybook dock's iframe is created lazily, only when the dock is first - * opened (mirroring how the code-server plugin embeds its editor on demand): + * Each Storybook is a first-class `type: 'launcher'` dock: a process-control + * tile that starts its Storybook lazily, only when the user hits **Start**. The + * launch button binds a `ctx.commands` command (`storybook-hub:launch:`), + * so a viewer dispatches it over the serializable `hub:commands:execute` path. + * The command: * - * - **dev** (`vite`): the plugin's `storybook dev` server is spawned on first - * open — through `ctx.terminals`, so it lives as a read-only hub terminal - * session whose output streams into the Terminals dock — and the dock - * iframes it live (HMR). - * - **build** (`vite preview`): the pre-built `storybook/storybook-static/` - * is served by the hub and the dock iframes that single origin. + * - **dev** (`vite`): spawns the plugin's `storybook dev` through + * `ctx.terminals`, so the process lives as a read-only hub terminal session + * whose output streams into the Terminals dock. As it boots, the tail of + * that output is patched onto the launcher's `digest`, and the session id + * onto `terminalSessionId`; on ready the launcher flips to `success` and the + * command returns the live dev-server URL for the client to iframe (HMR). + * - **build** (`vite preview`): the launch resolves immediately to the + * pre-built `storybook/storybook-static/` the hub serves on one origin. * - * Both paths are unified behind the `storybook-hub:ensure` RPC, so the client - * has one flow regardless of mode. + * Either way the command returns an {@link EnsureStorybookResult}, so the + * client swaps the launcher tile in place for the resolved iframe. */ export function storybookHub(options: StorybookHubOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') @@ -93,9 +109,14 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { * answers on its port. Concurrent callers await the same boot. The process * is owned by the hub's terminals subsystem (`ctx.terminals`), so it shows * up as a read-only session — proper title + icon, output streamed live — - * in the Terminals dock. + * in the Terminals dock. `reportDigest` receives the tail of that output so + * the caller can surface boot progress on the launcher. */ - async function ensureDevServer(ctx: DevframeHubContext, meta: StorybookMeta): Promise { + async function ensureDevServer( + ctx: DevframeHubContext, + meta: StorybookMeta, + reportDigest?: (line: string) => void, + ): Promise { const existing = devServers.get(meta.id) if (existing) return existing.ready @@ -114,7 +135,7 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { const session = await ctx.terminals.startChildProcess( { command: process.execPath, - args: [storybookBin, 'dev', '--config-dir', storybookConfigDir(meta.id), '--port', String(port), '--host', '0.0.0.0', '--no-open', '--quiet'], + args: [storybookBin, 'dev', '--config-dir', storybookConfigDir(meta.id), '--port', String(port), '--host', '0.0.0.0', '--no-open'], cwd: pluginDir(meta.id), env: { STORYBOOK_DISABLE_TELEMETRY: '1' }, }, @@ -127,6 +148,17 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { ) const child = session.getChildProcess() + // Stream the tail of the boot output to the launcher's digest. The full + // stream still lands in the Terminals dock via `ctx.terminals`. + if (reportDigest) { + const onData = (chunk: Buffer | string): void => { + const line = lastLine(chunk.toString()) + if (line) + reportDigest(line) + } + child?.stdout?.on('data', onData) + child?.stderr?.on('data', onData) + } const ready = new Promise((resolvePort, reject) => { // Fail fast when the process dies before serving. child?.once('exit', (code) => { @@ -185,41 +217,11 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { }, } - // Ensure a Storybook is reachable and hand its URL back to the client. In - // dev this spawns the plugin's `storybook dev` on demand; in build it points - // at the pre-built static bundle the hub serves. - const storybookHubEnsure = defineHubRpcFunction({ - name: 'storybook-hub:ensure', - type: 'action', - jsonSerializable: true, - setup: (ctx: DevframeHubContext) => ({ - async handler(input?: { id?: string }): Promise { - const meta = STORYBOOKS.find(s => s.id === input?.id) - if (!meta) - return { ok: false, error: `Unknown storybook "${input?.id}"` } - - if (mode === 'build') { - if (!existsSync(storybookStaticDir(meta.id))) - return { ok: false, error: 'Storybook not built. Run `pnpm storybook:build` first.' } - return { ok: true, kind: 'path', url: `/__sb-${meta.id}/` } - } - - try { - return { ok: true, kind: 'port', port: await ensureDevServer(ctx, meta) } - } - catch (error) { - return { ok: false, error: (error as Error).message } - } - }, - }), - }) - const context = await createHubContext({ cwd, workspaceRoot: cwd, mode, host, - builtinRpcDeclarations: [storybookHubEnsure], }) // In build mode, serve each pre-built Storybook so its dock iframe resolves @@ -231,18 +233,81 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { } } - // One dock per plugin Storybook. `url` is only the build-mode static path; - // the client routes these through `storybook-hub:ensure`, so in dev it is - // superseded by the spawned dev-server URL. + // Live launcher handles, so the launch command can patch each tile's + // status/digest/terminalSessionId as the process boots. + const launchers = new Map) => void }>() + + /** The full launcher payload for a tile (patched wholesale — `update` shallow-merges). */ + const launcherState = ( + meta: StorybookMeta, + patch: Partial, + ): DevframeViewLauncher['launcher'] => ({ + icon: meta.icon, + title: `${meta.title} Storybook`, + description: mode === 'build' + ? `Open the pre-built ${meta.title} Storybook` + : `Start the ${meta.title} plugin's Storybook dev server`, + command: launchCommandFor(meta.id), + buttonStart: mode === 'build' ? 'Open Storybook' : 'Start Storybook', + buttonLoading: 'Starting…', + status: 'idle', + ...patch, + }) + + /** + * The launch handler bound to each launcher's command. Spawns the dev + * server through `ctx.terminals` (in dev), patches the tile as it boots, + * and returns the resolved URL for the client to iframe in place. + */ + const launchStorybook = async (meta: StorybookMeta): Promise => { + const handle = launchers.get(meta.id) + const patch = (p: Partial): void => + handle?.update({ launcher: launcherState(meta, p) }) + + if (mode === 'build') { + if (!existsSync(storybookStaticDir(meta.id))) { + const error = 'Storybook not built. Run `pnpm storybook:build` first.' + patch({ status: 'error', error }) + return { ok: false, error } + } + patch({ status: 'success' }) + return { ok: true, kind: 'path', url: `/__sb-${meta.id}/` } + } + + patch({ status: 'loading', digest: 'Starting Storybook dev server…' }) + try { + const port = await ensureDevServer(context, meta, line => + patch({ status: 'loading', terminalSessionId: sessionIdFor(meta.id), digest: line })) + patch({ status: 'success', terminalSessionId: sessionIdFor(meta.id), digest: `Ready on port ${port}` }) + return { ok: true, kind: 'port', port } + } + catch (error) { + const message = (error as Error).message + patch({ status: 'error', terminalSessionId: sessionIdFor(meta.id), error: message }) + return { ok: false, error: message } + } + } + + // One launcher dock per plugin Storybook, each bound to a command. A viewer + // dispatches the command over `hub:commands:execute` (the serializable + // path — the handler is stripped when the entry crosses into shared state), + // and reads back the {@link EnsureStorybookResult} to iframe the result. for (const meta of STORYBOOKS) { - context.docks.register({ - id: `sb-${meta.id}`, - title: meta.title, + context.commands.register({ + id: launchCommandFor(meta.id), + title: `${mode === 'build' ? 'Open' : 'Start'} ${meta.title} Storybook`, icon: meta.icon, category: 'Storybooks', - type: 'iframe', - url: `/__sb-${meta.id}/`, + handler: () => launchStorybook(meta), }) + launchers.set(meta.id, context.docks.register({ + id: dockIdFor(meta.id), + title: meta.title, + icon: meta.icon, + category: 'Storybooks', + type: 'launcher', + launcher: launcherState(meta, { status: 'idle' }), + })) } // The live terminals plugin — a real integration docked alongside the From 35676b6ddb2d8587303f8fa715604b079817404f Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Tue, 28 Jul 2026 13:55:10 +0900 Subject: [PATCH 2/5] chore: update --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6bbdb325..dd82f9bc 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "docs": "pnpm -C docs run docs", "docs:build": "pnpm -C docs run docs:build", "docs:serve": "pnpm -C docs run docs:serve", - "storybook": "pnpm -r --parallel --if-present run storybook", + "storybook": "pnpm -C storybook dev", "storybook:build": "pnpm --filter @devframes/storybook run storybook:build", "lint": "eslint --cache", "test": "pnpm run build && vitest", From 56dc9461b79050cf3ea8cd9c9bb2f55c2d19187b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 28 Jul 2026 05:15:41 +0000 Subject: [PATCH 3/5] refactor(storybook): rebuild the unified host as a devframe hub Replace the @devframes/storybook Storybook Composition shell with a devframe hub (built on @devframes/hub): each plugin's Storybook is a `type: 'launcher'` dock bound to a `ctx.commands` command that spawns `storybook dev` through `ctx.terminals` on demand, streams boot output to the launcher digest, and iframes the running instance in place. The live terminals plugin docks alongside so the spawned processes are watchable. Drop the now-redundant examples/storybook-hub example (its hub code lives in the storybook package now), and point `pnpm storybook` at the host hub rather than fanning out every plugin's storybook in parallel. --- examples/storybook-hub/README.md | 60 ----------- examples/storybook-hub/package.json | 30 ------ examples/storybook-hub/tsconfig.json | 12 --- examples/storybook-hub/uno.config.ts | 13 --- pnpm-lock.yaml | 100 +++++------------- pnpm-workspace.yaml | 2 - storybook/.storybook/main.ts | 41 ------- storybook/.storybook/preview.ts | 41 ------- storybook/README.md | 58 ++++++++++ .../storybook-hub => storybook}/index.html | 4 +- storybook/package.json | 18 +++- storybook/scripts/build.mjs | 19 ++-- storybook/src/Introduction.stories.ts | 69 ------------ .../src/client/env.d.ts | 0 .../src/client/icons.ts | 0 .../src/client/main.ts | 2 +- .../storybook-hub.ts => storybook/src/hub.ts | 28 ++--- storybook/tsconfig.json | 6 +- storybook/uno.config.ts | 14 +-- .../vite.config.ts | 4 +- turbo.json | 2 +- 21 files changed, 134 insertions(+), 389 deletions(-) delete mode 100644 examples/storybook-hub/README.md delete mode 100644 examples/storybook-hub/package.json delete mode 100644 examples/storybook-hub/tsconfig.json delete mode 100644 examples/storybook-hub/uno.config.ts delete mode 100644 storybook/.storybook/main.ts delete mode 100644 storybook/.storybook/preview.ts create mode 100644 storybook/README.md rename {examples/storybook-hub => storybook}/index.html (95%) delete mode 100644 storybook/src/Introduction.stories.ts rename {examples/storybook-hub => storybook}/src/client/env.d.ts (100%) rename {examples/storybook-hub => storybook}/src/client/icons.ts (100%) rename {examples/storybook-hub => storybook}/src/client/main.ts (99%) rename examples/storybook-hub/src/storybook-hub.ts => storybook/src/hub.ts (94%) rename {examples/storybook-hub => storybook}/vite.config.ts (83%) diff --git a/examples/storybook-hub/README.md b/examples/storybook-hub/README.md deleted file mode 100644 index d2a5bef4..00000000 --- a/examples/storybook-hub/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# storybook-hub - -A devframe hub, built on `@devframes/hub`, that surfaces every built-in plugin's -Storybook as its own dock — plus the live terminals plugin running as a real -integration. It's a second take on the unified Storybook: instead of Storybook -Composition, the **hub** is the shell and each Storybook is a first-class -`type: 'launcher'` dock that boots its instance on demand. - -## How it works - -The whole host is one Vite plugin (`src/storybook-hub.ts`): it creates a hub -context, implements the framework-neutral `DevframeHost`, registers a launcher -dock (and a bound command) per plugin Storybook, mounts the terminals plugin via -`mountDevframe`, and starts a side-car RPC/WS server. - -Each Storybook dock is a launcher tile with a **Start** button — the lazy -trigger. The button binds a `ctx.commands` command -(`storybook-hub:launch:`), so the client dispatches it over the serializable -`hub:commands:execute` path. Once launched, the tile swaps in place for the -running Storybook's iframe, kept mounted so its state survives tab switches. -Where the iframe points depends on the mode: - -- **dev** (`vite`) — the launch command spawns the plugin's `storybook dev` - through `ctx.terminals`, the hub's terminals subsystem, so each Storybook is a - read-only terminal session (open the **Terminals** dock to watch its output - stream live). As it boots, the tail of that output is patched onto the - launcher's `digest` and shown beneath the spinner; on ready the command - returns the live dev-server URL the client iframes (HMR). -- **build** (`vite preview`) — the launch resolves immediately to the pre-built - `storybook/storybook-static/` the hub serves on one origin. - -## Run it - -Build the plugin SPAs the hub mounts (terminals) once: - -```sh -pnpm build -``` - -### Dev — Storybooks spawned on demand - -```sh -pnpm --filter storybook-hub dev -``` - -Open the printed URL, pick a Storybook in the sidebar, and hit **Start**; its -dev server boots on demand (subsequent opens are instant). The dev servers -listen on their own ports, so reaching them from a remote browser needs those -ports forwarded. - -### Preview — pre-built Storybooks on one origin - -```sh -pnpm storybook:build # produces storybook/storybook-static/ -pnpm --filter storybook-hub build -pnpm --filter storybook-hub preview -``` - -Everything is served from the single preview origin, so one forwarded port -reaches the whole hub. diff --git a/examples/storybook-hub/package.json b/examples/storybook-hub/package.json deleted file mode 100644 index c4eef106..00000000 --- a/examples/storybook-hub/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "storybook-hub", - "type": "module", - "version": "0.7.14", - "private": true, - "description": "Example — a devframe hub that docks every built-in plugin's Storybook (lazily spawned in dev, served static in build) alongside the live terminals plugin.", - "homepage": "https://github.com/devframes/devframe/tree/main/examples/storybook-hub", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@antfu/design": "catalog:frontend", - "@devframes/hub": "workspace:*", - "@devframes/plugin-terminals": "workspace:*", - "colorjs.io": "catalog:frontend", - "devframe": "workspace:*", - "iframe-pane": "catalog:frontend" - }, - "devDependencies": { - "@iconify-json/ph": "catalog:frontend", - "get-port-please": "catalog:deps", - "pathe": "catalog:deps", - "storybook": "catalog:storybook", - "unocss": "catalog:frontend", - "vite": "catalog:build" - } -} diff --git a/examples/storybook-hub/tsconfig.json b/examples/storybook-hub/tsconfig.json deleted file mode 100644 index 46dbffd9..00000000 --- a/examples/storybook-hub/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ESNext", "DOM"], - "module": "ESNext", - "moduleResolution": "Bundler", - "noEmit": true, - "esModuleInterop": true, - "isolatedDeclarations": false - }, - "include": ["src", "vite.config.ts"] -} diff --git a/examples/storybook-hub/uno.config.ts b/examples/storybook-hub/uno.config.ts deleted file mode 100644 index be8319c2..00000000 --- a/examples/storybook-hub/uno.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { mergeConfigs } from 'unocss' -import { designConfig } from '../../design/uno.config' - -// The hub UI composes the shared devframe base (see `design/uno.config.ts`). -// Pair with `@antfu/design/styles.css` (imported in `src/client/main.ts`). `.ts` -// is opted into extraction since the hub authors its class strings in vanilla -// `src/client/main.ts`. -export default mergeConfigs([ - designConfig, - { - content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } }, - }, -]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a72ad63..0c627772 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,9 +263,6 @@ catalogs: '@storybook/addon-docs': specifier: ^10.5.3 version: 10.5.3 - '@storybook/html-vite': - specifier: ^10.5.3 - version: 10.5.3 '@storybook/react-vite': specifier: ^10.5.3 version: 10.5.3 @@ -728,46 +725,6 @@ importers: specifier: catalog:deps version: 8.21.1 - examples/storybook-hub: - dependencies: - '@antfu/design': - specifier: catalog:frontend - version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) - '@devframes/hub': - specifier: workspace:* - version: link:../../packages/hub - '@devframes/plugin-terminals': - specifier: workspace:* - version: link:../../plugins/terminals - colorjs.io: - specifier: catalog:frontend - version: 0.7.0 - devframe: - specifier: workspace:* - version: link:../../packages/devframe - iframe-pane: - specifier: catalog:frontend - version: 1.1.0 - devDependencies: - '@iconify-json/ph': - specifier: catalog:frontend - version: 1.2.2 - get-port-please: - specifier: catalog:deps - version: 3.2.0 - pathe: - specifier: catalog:deps - version: 2.0.3 - storybook: - specifier: catalog:storybook - version: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - unocss: - specifier: catalog:frontend - version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) - vite: - specifier: catalog:build - version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) - examples/streaming-chat: dependencies: '@antfu/design': @@ -1577,16 +1534,35 @@ importers: version: 8.21.1 storybook: - devDependencies: + dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + '@devframes/hub': + specifier: workspace:* + version: link:../packages/hub + '@devframes/plugin-terminals': + specifier: workspace:* + version: link:../plugins/terminals + colorjs.io: + specifier: catalog:frontend + version: 0.7.0 + devframe: + specifier: workspace:* + version: link:../packages/devframe + iframe-pane: + specifier: catalog:frontend + version: 1.1.0 + devDependencies: '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 - '@storybook/html-vite': - specifier: catalog:storybook - version: 10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 storybook: specifier: catalog:storybook version: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4358,17 +4334,6 @@ packages: '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - '@storybook/html-vite@10.5.3': - resolution: {integrity: sha512-+nbkMtISF4HdY/ucC2Ni1EspXhUA7FQicobZ0ItJzRPMhOLm9F5zFoHaTmkC95FMqcOY0YnF7uRnFgtYFf8plg==} - peerDependencies: - storybook: ^10.5.3 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@storybook/html@10.5.3': - resolution: {integrity: sha512-YKv7IvRyTLNTfyvo96nBVwLCFe3ZT41VncErC7D84WHMos5E3Ig10o58Gyxa/ozXwjlbUvW92FZ99nTMQ6NEIw==} - peerDependencies: - storybook: ^10.5.3 - '@storybook/icons@2.0.2': resolution: {integrity: sha512-KZBCpXsshAIjczYNXR/rlxEtCUX/eAbpFNwKi8bcOomrLA4t/SyPz5RF+lVPO2oZBUE4sAkt43mfJUevQDSEEw==} peerDependencies: @@ -12327,23 +12292,6 @@ snapshots: '@storybook/global@5.0.0': {} - '@storybook/html-vite@10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': - dependencies: - '@storybook/builder-vite': 10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) - '@storybook/html': 10.5.3(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) - storybook: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) - transitivePeerDependencies: - - esbuild - - rollup - - webpack - - '@storybook/html@10.5.3(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - ts-dedent: 2.2.0 - '@storybook/icons@2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bedfd28b..ae3480c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,6 @@ overrides: crossws: ^0.4.10 semver: ^7.8.5 shell-quote: ^1.10.0 -catalog: catalogs: build: @@ -134,7 +133,6 @@ catalogs: storybook: '@storybook/addon-a11y': ^10.5.3 '@storybook/addon-docs': ^10.5.3 - '@storybook/html-vite': ^10.5.3 '@storybook/react-vite': ^10.5.3 '@storybook/svelte-vite': ^10.5.3 '@storybook/vue3-vite': ^10.5.3 diff --git a/storybook/.storybook/main.ts b/storybook/.storybook/main.ts deleted file mode 100644 index 03c04692..00000000 --- a/storybook/.storybook/main.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { StorybookConfig } from '@storybook/html-vite' -import UnoCSS from 'unocss/vite' - -// Each devframe plugin ships its own framework-specific Storybook (React, Vue, -// Svelte, Solid, vanilla). This host is a thin shell that composes them into one -// UI via Storybook refs — every plugin becomes its own top-level section. -// -// - In DEVELOPMENT each plugin runs its own dev server on a fixed port and the -// host references it live (`pnpm storybook` starts all of them in parallel). -// - In PRODUCTION `scripts/build.mjs` builds each plugin's Storybook into a -// subfolder of this host's static output, so the refs resolve on one origin. -const sections = [ - { id: 'git', title: 'Git', port: 6011 }, - { id: 'inspect', title: 'Inspect', port: 6012 }, - { id: 'code-server', title: 'Code Server', port: 6013 }, - { id: 'terminals', title: 'Terminals', port: 6014 }, - { id: 'a11y', title: 'A11y', port: 6015 }, -] - -const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx|mdx)'], - framework: { - name: '@storybook/html-vite', - options: {}, - }, - refs: (_config, { configType }) => Object.fromEntries( - sections.map(({ id, title, port }) => [ - id, - { title, url: configType === 'DEVELOPMENT' ? `http://localhost:${port}` : `./${id}` }, - ]), - ), - viteFinal(viteConfig) { - viteConfig.plugins ??= [] - viteConfig.plugins.push(UnoCSS()) - // Dev tool reached from arbitrary hostnames (LAN IPs, tunnels, tailnets). - viteConfig.server = { ...viteConfig.server, allowedHosts: true } - return viteConfig - }, -} - -export default config diff --git a/storybook/.storybook/preview.ts b/storybook/.storybook/preview.ts deleted file mode 100644 index 2b1ef963..00000000 --- a/storybook/.storybook/preview.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Decorator, Preview } from '@storybook/html-vite' -import 'virtual:uno.css' -import '@antfu/design/styles.css' - -// The host shell tracks the same theme convention as every composed plugin: dark -// mode is the `.dark` class on ``, and the canvas takes the semantic -// `bg-base`/`color-base` surface. -function applyTheme(theme: string): void { - document.documentElement.classList.toggle('dark', theme !== 'light') - document.body.classList.add('bg-base', 'color-base', 'font-sans') -} - -const withTheme: Decorator = (story, context) => { - applyTheme(context.globals.theme ?? 'dark') - return story(context) -} - -const preview: Preview = { - parameters: { - layout: 'fullscreen', - controls: { expanded: true }, - }, - globalTypes: { - theme: { - description: 'Color theme', - defaultValue: 'dark', - toolbar: { - title: 'Theme', - icon: 'contrast', - items: [ - { value: 'light', title: 'Light', icon: 'sun' }, - { value: 'dark', title: 'Dark', icon: 'moon' }, - ], - dynamicTitle: true, - }, - }, - }, - decorators: [withTheme], -} - -export default preview diff --git a/storybook/README.md b/storybook/README.md new file mode 100644 index 00000000..e3265932 --- /dev/null +++ b/storybook/README.md @@ -0,0 +1,58 @@ +# @devframes/storybook + +The unified Storybook host, built as a **devframe hub** on `@devframes/hub`: the +hub is the shell and each built-in plugin's Storybook is its own dock, alongside +the live terminals plugin running as a real integration. + +## How it works + +The whole host is one Vite plugin (`src/hub.ts`): it creates a hub context, +implements the framework-neutral `DevframeHost`, registers a launcher dock (and +a bound command) per plugin Storybook, mounts the terminals plugin via +`mountDevframe`, and starts a side-car RPC/WS server. + +Each Storybook dock is a `type: 'launcher'` tile with a **Start** button — the +lazy trigger. The button binds a `ctx.commands` command (`storybook:launch:`), +so the client dispatches it over the serializable `hub:commands:execute` path. +Once launched, the tile swaps in place for the running Storybook's iframe, kept +mounted so its state survives tab switches. Where the iframe points depends on +the mode: + +- **dev** (`vite`) — the launch command spawns the plugin's `storybook dev` + through `ctx.terminals`, the hub's terminals subsystem, so each Storybook is a + read-only terminal session (open the **Terminals** dock to watch its output + stream live). As it boots, the tail of that output is patched onto the + launcher's `digest`; on ready the command returns the live dev-server URL the + client iframes (HMR). +- **build** (`vite preview`) — the launch resolves immediately to the pre-built + `storybook-static/` the hub serves on one origin. + +## Run it + +Build the plugin SPAs the hub mounts (terminals) once: + +```sh +pnpm build +``` + +### Dev — Storybooks spawned on demand + +```sh +pnpm storybook +``` + +Open the printed URL, pick a Storybook in the sidebar, and hit **Start**; its +dev server boots on demand (subsequent opens are instant). The dev servers +listen on their own ports, so reaching them from a remote browser needs those +ports forwarded. + +### Preview — pre-built Storybooks on one origin + +```sh +pnpm storybook:build # produces storybook-static/ +pnpm --filter @devframes/storybook build # builds the hub UI → dist/ +pnpm --filter @devframes/storybook preview +``` + +Everything is served from the single preview origin, so one forwarded port +reaches the whole hub. diff --git a/examples/storybook-hub/index.html b/storybook/index.html similarity index 95% rename from examples/storybook-hub/index.html rename to storybook/index.html index e189b0ae..414f432c 100644 --- a/examples/storybook-hub/index.html +++ b/storybook/index.html @@ -3,7 +3,7 @@ - Storybook Hub + devframe · Storybook