11import type { DevframeHubContext } from '@devframes/hub/node'
2+ import type { DevframeChildProcessTerminalSession } from '@devframes/hub/types'
23import type { DevframeHost } from 'devframe/types'
3- import type { ChildProcess } from 'node:child_process'
44import type { Plugin , PreviewServer , ResolvedConfig , ViteDevServer } from 'vite'
5- import { spawn } from 'node:child_process'
65import { existsSync } from 'node:fs'
76import { createRequire } from 'node:module'
87import { homedir } from 'node:os'
@@ -45,25 +44,14 @@ const storybookBin = join(dirname(require.resolve('storybook/package.json')), 'd
4544const pluginDir = ( id : string ) : string => join ( repoRoot , 'plugins' , id )
4645const storybookConfigDir = ( id : string ) : string => join ( pluginDir ( id ) , '.storybook' )
4746const 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. */
5050export 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-
6755export 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' , ( ) => {
0 commit comments