Skip to content

Commit 285afc5

Browse files
committed
feat(examples): spawn storybook-hub dev servers via ctx.terminals
Storybook dev servers launched by the hub are now owned by the hub's terminals subsystem, so each spawned Storybook appears as a read-only session in the Terminals dock with its output streaming live. The hub UI drops its bottom panel — the Terminals dock is where process activity lives now. - spawn through ctx.terminals.startChildProcess with a stable session id, title and icon; reflect exit status on the session - remove the Activity/Commands footer and its kit-local RPCs - bind the side-car WS dual-stack ('::') so IPv4 clients and remote browsers can dial it (default 'localhost' bind is ::1-only on some hosts) - safelist the remaining plugin dock icons in the terminals SPA so aggregated sessions render their glyphs
1 parent b3d08ee commit 285afc5

5 files changed

Lines changed: 65 additions & 105 deletions

File tree

‎examples/storybook-hub/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ then kept mounted so its state survives tab switches. Where the iframe points
1818
depends on the mode, unified behind the `storybook-hub:ensure` RPC:
1919

2020
- **dev** (`vite`) — the plugin's `storybook dev` server is spawned on first
21-
open and the dock iframes it live (HMR).
21+
open and the dock iframes it live (HMR). The process is launched through
22+
`ctx.terminals`, the hub's terminals subsystem, so each spawned Storybook is
23+
a read-only terminal session — open the **Terminals** dock to watch its
24+
output stream live.
2225
- **build** (`vite preview`) — the pre-built `storybook/storybook-static/<id>`
2326
is served by the hub on one origin and the dock iframes that.
2427

‎examples/storybook-hub/index.html‎

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,6 @@ <h1 class="m0 flex items-baseline gap-1.5 text-sm font-semibold">
3434
<div id="overlay" class="absolute inset-0 z-10 flex items-center justify-center bg-secondary" style="display:none"></div>
3535
</main>
3636
</div>
37-
38-
<footer class="grid grid-cols-2 shrink-0 gap-5 border-t border-base bg-base px4 py3 max-h-30vh of-auto">
39-
<section class="min-w-0">
40-
<h2 class="mb2 text-[0.68rem] uppercase tracking-wider color-muted">Activity</h2>
41-
<ul id="messages" class="m0 flex flex-col list-none gap-1.5 p0"><li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">No activity yet.</li></ul>
42-
</section>
43-
44-
<section class="min-w-0">
45-
<h2 class="mb2 text-[0.68rem] uppercase tracking-wider color-muted">Commands</h2>
46-
<ul id="commands" class="m0 flex flex-col list-none gap-1.5 p0"><li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">Waiting for snapshot…</li></ul>
47-
<div class="mt2.5">
48-
<button id="ping" class="btn-action text-sm">Dispatch <code class="font-mono op-fade">storybook-hub:ping</code></button>
49-
</div>
50-
</section>
51-
</footer>
5237
</div>
5338
<script type="module" src="/src/client/main.ts"></script>
5439
</body>

‎examples/storybook-hub/src/client/main.ts‎

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import type {
2-
DevframeCommandEntry,
3-
DevframeDockEntry,
4-
DevframeMessageEntry,
5-
} from '@devframes/hub/types'
1+
import type { DevframeDockEntry } from '@devframes/hub/types'
62
import { connectDevframe } from '@devframes/hub/client'
73
import { iconClass } from './icons'
84
import 'virtual:uno.css'
@@ -25,9 +21,6 @@ const connEl = document.querySelector<HTMLElement>('#conn')!
2521
const docksEl = document.querySelector<HTMLElement>('#docks')!
2622
const stageEl = document.querySelector<HTMLElement>('#stage')!
2723
const overlayEl = document.querySelector<HTMLElement>('#overlay')!
28-
const messagesEl = document.querySelector<HTMLElement>('#messages')!
29-
const commandsEl = document.querySelector<HTMLElement>('#commands')!
30-
const pingBtn = document.querySelector<HTMLButtonElement>('#ping')!
3124

3225
interface DockRuntime {
3326
iframe?: HTMLIFrameElement
@@ -206,38 +199,6 @@ async function main() {
206199
switchTo(target.dataset.dockId)
207200
})
208201
syncDocks()
209-
210-
// Commands — read from `devframe:commands` shared state.
211-
const commands = await rpc.sharedState.get<DevframeCommandEntry[]>('devframe:commands', { initialValue: [] })
212-
const renderCommands = () => renderList(commandsEl, commands.value() ?? [], c =>
213-
`<li class="rounded-lg border border-base bg-base px2.5 py1.5 text-xs font-mono">${c.title} <code class="op-fade">${c.id}</code></li>`)
214-
commands.on('updated', renderCommands)
215-
renderCommands()
216-
217-
// Activity — poll hub messages (spawn progress, etc.).
218-
const refreshMessages = async () => {
219-
const entries = await rpc.call('storybook-hub:messages:list' as any) as DevframeMessageEntry[]
220-
renderList(messagesEl, entries.slice(-12).reverse(), m =>
221-
`<li class="rounded-lg border border-base bg-base px2.5 py1.5 text-xs font-mono"><span class="op-fade">[${m.level}]</span> ${m.message}</li>`)
222-
}
223-
await refreshMessages()
224-
setInterval(() => void refreshMessages(), 2000)
225-
226-
pingBtn.addEventListener('click', async () => {
227-
try {
228-
const result = await rpc.call('hub:commands:execute' as any, 'storybook-hub:ping')
229-
pingBtn.textContent = `Ping returned ${JSON.stringify(result)}`
230-
}
231-
catch (err) {
232-
pingBtn.textContent = `Error: ${(err as Error).message}`
233-
}
234-
})
235-
}
236-
237-
function renderList<T>(host: HTMLElement, items: readonly T[], render: (item: T) => string) {
238-
host.innerHTML = items.length
239-
? items.map(render).join('')
240-
: '<li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">empty</li>'
241202
}
242203

243204
main().catch((err) => {

‎examples/storybook-hub/src/storybook-hub.ts‎

Lines changed: 58 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { DevframeHubContext } from '@devframes/hub/node'
2+
import type { DevframeChildProcessTerminalSession } from '@devframes/hub/types'
23
import type { DevframeHost } from 'devframe/types'
3-
import type { ChildProcess } from 'node:child_process'
44
import type { Plugin, PreviewServer, ResolvedConfig, ViteDevServer } from 'vite'
5-
import { spawn } from 'node:child_process'
65
import { existsSync } from 'node:fs'
76
import { createRequire } from 'node:module'
87
import { homedir } from 'node:os'
@@ -45,25 +44,14 @@ const storybookBin = join(dirname(require.resolve('storybook/package.json')), 'd
4544
const pluginDir = (id: string): string => join(repoRoot, 'plugins', id)
4645
const storybookConfigDir = (id: string): string => join(pluginDir(id), '.storybook')
4746
const storybookStaticDir = (id: string): string => join(repoRoot, 'storybook', 'storybook-static', id)
47+
const sessionIdFor = (id: string): string => `storybook-hub:${id}`
4848

4949
/** What the client needs to point a dock's iframe at the right place. */
5050
export type EnsureStorybookResult
5151
= | { ok: true, kind: 'port', port: number }
5252
| { ok: true, kind: 'path', url: string }
5353
| { ok: false, error: string }
5454

55-
// Read-side RPC so the UI can list hub messages (spawn progress, etc.).
56-
const storybookHubMessagesList = defineHubRpcFunction({
57-
name: 'storybook-hub:messages:list',
58-
type: 'static',
59-
jsonSerializable: true,
60-
setup: (ctx: DevframeHubContext) => ({
61-
async handler() {
62-
return Array.from(ctx.messages.entries.values())
63-
},
64-
}),
65-
})
66-
6755
export interface StorybookHubOptions {
6856
/** Mount path for the hub's connection-meta endpoint. Default: `/__hub/`. */
6957
base?: string
@@ -79,7 +67,9 @@ export interface StorybookHubOptions {
7967
* opened (mirroring how the code-server plugin embeds its editor on demand):
8068
*
8169
* - **dev** (`vite`): the plugin's `storybook dev` server is spawned on first
82-
* open and the dock iframes it live (HMR).
70+
* open — through `ctx.terminals`, so it lives as a read-only hub terminal
71+
* session whose output streams into the Terminals dock — and the dock
72+
* iframes it live (HMR).
8373
* - **build** (`vite preview`): the pre-built `storybook/storybook-static/<id>`
8474
* is served by the hub and the dock iframes that single origin.
8575
*
@@ -90,43 +80,70 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin {
9080
const base = normalizeBase(options.base ?? '/__hub/')
9181
let viteConfig: ResolvedConfig | undefined
9282
let started: { close: () => Promise<void> } | undefined
93-
const devServers = new Map<string, { port: number, proc: ChildProcess, ready: Promise<number> }>()
83+
const devServers = new Map<string, { ready: Promise<number>, session: DevframeChildProcessTerminalSession }>()
9484

9585
function killDevServers(): void {
96-
for (const { proc } of devServers.values())
97-
proc.kill()
86+
for (const { session } of devServers.values())
87+
void session.terminate().catch(() => {})
9888
devServers.clear()
9989
}
10090

10191
/**
10292
* Spawn (once) the `storybook dev` server for a plugin and resolve when it
103-
* answers on its port. Concurrent callers await the same boot.
93+
* answers on its port. Concurrent callers await the same boot. The process
94+
* is owned by the hub's terminals subsystem (`ctx.terminals`), so it shows
95+
* up as a read-only session — proper title + icon, output streamed live —
96+
* in the Terminals dock.
10497
*/
10598
async function ensureDevServer(ctx: DevframeHubContext, meta: StorybookMeta): Promise<number> {
10699
const existing = devServers.get(meta.id)
107100
if (existing)
108101
return existing.ready
109102

110103
const port = await getPort({ port: 6100 + STORYBOOKS.findIndex(s => s.id === meta.id), random: true })
111-
const cwd = pluginDir(meta.id)
112-
const proc = spawn(
113-
process.execPath,
114-
[storybookBin, 'dev', '--config-dir', storybookConfigDir(meta.id), '--port', String(port), '--host', '0.0.0.0', '--no-open', '--quiet'],
115-
{ cwd, env: { ...process.env, STORYBOOK_DISABLE_TELEMETRY: '1' }, stdio: 'inherit' },
104+
const sessionId = sessionIdFor(meta.id)
105+
const title = `${meta.title} Storybook`
106+
107+
// Drop a stale session left by a crashed/stopped previous run so the
108+
// stable id is free to re-register. (`remove` exists on the hub's
109+
// terminals host; the public interface doesn't surface it yet.)
110+
const stale = ctx.terminals.sessions.get(sessionId)
111+
if (stale)
112+
(ctx.terminals as unknown as { remove?: (s: typeof stale) => void }).remove?.(stale)
113+
114+
const session = await ctx.terminals.startChildProcess(
115+
{
116+
command: process.execPath,
117+
args: [storybookBin, 'dev', '--config-dir', storybookConfigDir(meta.id), '--port', String(port), '--host', '0.0.0.0', '--no-open', '--quiet'],
118+
cwd: pluginDir(meta.id),
119+
env: { STORYBOOK_DISABLE_TELEMETRY: '1' },
120+
},
121+
{
122+
id: sessionId,
123+
title,
124+
description: `storybook dev · port ${port}`,
125+
icon: meta.icon,
126+
},
116127
)
117-
proc.on('exit', () => devServers.delete(meta.id))
118128

119-
void ctx.messages.add({
120-
level: 'info',
121-
message: `Starting ${meta.title} Storybook…`,
122-
description: `storybook dev on port ${port}`,
129+
const child = session.getChildProcess()
130+
const ready = new Promise<number>((resolvePort, reject) => {
131+
// Fail fast when the process dies before serving.
132+
child?.once('exit', (code) => {
133+
reject(new Error(`storybook dev exited before becoming ready (code ${code ?? 'null'})`))
134+
})
135+
waitForPort(port, 180_000).then(() => resolvePort(port), reject)
123136
})
124137

125-
const ready = waitForPort(port, 180_000).then(() => {
126-
void ctx.messages.add({ level: 'success', message: `${meta.title} Storybook ready`, description: `port ${port}` })
127-
return port
138+
// Reflect the outcome on the hub terminal session (the hub does not
139+
// update a child-process session's status on its own exit).
140+
child?.on('exit', (code) => {
141+
devServers.delete(meta.id)
142+
if (ctx.terminals.sessions.has(sessionId))
143+
ctx.terminals.update({ id: sessionId, title, status: code === 0 ? 'stopped' : 'error' })
128144
})
129-
devServers.set(meta.id, { port, proc, ready })
145+
146+
devServers.set(meta.id, { ready, session })
130147
return ready
131148
}
132149

@@ -197,7 +214,7 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin {
197214
workspaceRoot: cwd,
198215
mode,
199216
host,
200-
builtinRpcDeclarations: [storybookHubEnsure, storybookHubMessagesList],
217+
builtinRpcDeclarations: [storybookHubEnsure],
201218
})
202219

203220
// In build mode, serve each pre-built Storybook so its dock iframe resolves
@@ -225,23 +242,15 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin {
225242

226243
// The live terminals plugin — a real integration docked alongside the
227244
// Storybooks, grouped separately so its "Terminals" reads apart from the
228-
// "Terminals" Storybook.
245+
// "Terminals" Storybook. It also mirrors the hub's `ctx.terminals`
246+
// sessions, so the spawned `storybook dev` processes appear inside it.
229247
await mountDevframe(context, terminalsDevframe, { dock: { category: 'Plugins' } })
230248

231-
context.commands.register({
232-
id: 'storybook-hub:ping',
233-
title: 'Storybook Hub · Ping',
234-
icon: 'ph:bell-duotone',
235-
category: 'kit',
236-
handler: () => 'pong',
237-
})
238-
await context.messages.add({
239-
level: 'success',
240-
message: 'Storybook Hub started',
241-
description: `${mode} mode · side-car WS on port ${port} · ${STORYBOOKS.length} Storybook dock(s).`,
242-
})
243-
244-
started = await startHttpAndWs({ context, port, auth: false })
249+
// Bind dual-stack (`::` accepts IPv6 + IPv4-mapped) so the side-car is
250+
// dialable via `::1`, `127.0.0.1`, and from outside the machine — the
251+
// default `localhost` bind resolves to `::1` only on some hosts, which
252+
// strands IPv4 clients and remote browsers.
253+
started = await startHttpAndWs({ context, port, host: '::', auth: false })
245254
serveConnectionMeta(base)
246255

247256
server.httpServer?.once('close', () => {

‎plugins/terminals/uno.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export default defineConfig({
2929
'i-ph-code-duotone',
3030
'i-ph-terminal-window-duotone',
3131
'i-ph-git-branch-duotone',
32+
'i-ph-magnifying-glass-duotone',
33+
'i-ph-person-arms-spread-duotone',
3234
],
3335
// Wind4 leaves bare `border`/`border-b` at currentColor; restore the subtle
3436
// shared border color (matching `border-base`) for unqualified borders.

0 commit comments

Comments
 (0)