Skip to content

Commit 3a7f41c

Browse files
committed
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.
1 parent f39cae8 commit 3a7f41c

3 files changed

Lines changed: 277 additions & 132 deletions

File tree

‎examples/storybook-hub/README.md‎

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,31 @@
33
A devframe hub, built on `@devframes/hub`, that surfaces every built-in plugin's
44
Storybook as its own dock — plus the live terminals plugin running as a real
55
integration. It's a second take on the unified Storybook: instead of Storybook
6-
Composition, the **hub** is the shell and each Storybook is a lazily-mounted
7-
iframe dock (the same on-demand embed pattern the code-server plugin uses).
6+
Composition, the **hub** is the shell and each Storybook is a first-class
7+
`type: 'launcher'` dock that boots its instance on demand.
88

99
## How it works
1010

1111
The whole host is one Vite plugin (`src/storybook-hub.ts`): it creates a hub
12-
context, implements the framework-neutral `DevframeHost`, registers a dock per
13-
plugin Storybook, mounts the terminals plugin via `mountDevframe`, and starts a
14-
side-car RPC/WS server.
15-
16-
Each Storybook dock's iframe is created **only when the dock is first opened**,
17-
then kept mounted so its state survives tab switches. Where the iframe points
18-
depends on the mode, unified behind the `storybook-hub:ensure` RPC:
19-
20-
- **dev** (`vite`) — the plugin's `storybook dev` server is spawned on first
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.
25-
- **build** (`vite preview`) — the pre-built `storybook/storybook-static/<id>`
26-
is served by the hub on one origin and the dock iframes that.
12+
context, implements the framework-neutral `DevframeHost`, registers a launcher
13+
dock (and a bound command) per plugin Storybook, mounts the terminals plugin via
14+
`mountDevframe`, and starts a side-car RPC/WS server.
15+
16+
Each Storybook dock is a launcher tile with a **Start** button — the lazy
17+
trigger. The button binds a `ctx.commands` command
18+
(`storybook-hub:launch:<id>`), so the client dispatches it over the serializable
19+
`hub:commands:execute` path. Once launched, the tile swaps in place for the
20+
running Storybook's iframe, kept mounted so its state survives tab switches.
21+
Where the iframe points depends on the mode:
22+
23+
- **dev** (`vite`) — the launch command spawns the plugin's `storybook dev`
24+
through `ctx.terminals`, the hub's terminals subsystem, so each Storybook is a
25+
read-only terminal session (open the **Terminals** dock to watch its output
26+
stream live). As it boots, the tail of that output is patched onto the
27+
launcher's `digest` and shown beneath the spinner; on ready the command
28+
returns the live dev-server URL the client iframes (HMR).
29+
- **build** (`vite preview`) — the launch resolves immediately to the pre-built
30+
`storybook/storybook-static/<id>` the hub serves on one origin.
2731

2832
## Run it
2933

@@ -39,10 +43,10 @@ pnpm build
3943
pnpm --filter storybook-hub dev
4044
```
4145

42-
Open the printed URL, then click a Storybook in the sidebar; its dev server
43-
boots on first open (subsequent opens are instant). The dev servers listen on
44-
their own ports, so reaching them from a remote browser needs those ports
45-
forwarded.
46+
Open the printed URL, pick a Storybook in the sidebar, and hit **Start**; its
47+
dev server boots on demand (subsequent opens are instant). The dev servers
48+
listen on their own ports, so reaching them from a remote browser needs those
49+
ports forwarded.
4650

4751
### Preview — pre-built Storybooks on one origin
4852

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

Lines changed: 134 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DevframeDockEntry } from '@devframes/hub/types'
1+
import type { DevframeDockEntry, DevframeViewLauncher } from '@devframes/hub/types'
22
import { connectDevframe } from '@devframes/hub/client'
33
import { createIframePanes } from 'iframe-pane'
44
import { iconClass } from './icons'
@@ -7,13 +7,16 @@ import '@antfu/design/styles.css'
77

88
const HUB_BASE = '/__hub/'
99

10-
// Mirror of the host's `storybook-hub:ensure` return shape.
10+
// Mirror of the launch command's return shape (`storybook-hub:launch:<id>`,
11+
// dispatched over `hub:commands:execute`).
1112
type EnsureResult
1213
= | { ok: true, kind: 'port', port: number }
1314
| { ok: true, kind: 'path', url: string }
1415
| { ok: false, error: string }
1516

1617
type IframeDock = DevframeDockEntry & { type: 'iframe', url: string }
18+
type LauncherDock = DevframeViewLauncher
19+
type Dock = IframeDock | LauncherDock
1720

1821
/** Sidebar section order; anything else follows alphabetically. */
1922
const CATEGORY_ORDER = ['Storybooks', 'Plugins']
@@ -28,13 +31,14 @@ interface DockRuntime {
2831
error?: string
2932
}
3033

31-
// Every opened dock's iframe is parked here for its whole lifetime — switching
34+
// Every launched dock's iframe is parked here for its whole lifetime — switching
3235
// tabs only mounts/unmounts the pane over `#stage`, so background docks keep
3336
// their state (Storybook's own routing, scroll, etc.) intact.
3437
const panes = createIframePanes({ container: stageEl })
3538
const runtimes = new Map<string, DockRuntime>()
36-
let docks: IframeDock[] = []
39+
let docks: Dock[] = []
3740
let selectedId: string | null = null
41+
let rpc: Awaited<ReturnType<typeof connectDevframe>>
3842

3943
function setStatus(text: string, kind?: 'ready' | 'error') {
4044
const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400'
@@ -45,8 +49,8 @@ function isIframeDock(d: DevframeDockEntry): d is IframeDock {
4549
return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string'
4650
}
4751

48-
function isStorybookDock(id: string): boolean {
49-
return id.startsWith('sb-')
52+
function isLauncherDock(d: DevframeDockEntry): d is LauncherDock {
53+
return d.type === 'launcher'
5054
}
5155

5256
function runtimeFor(id: string): DockRuntime {
@@ -66,14 +70,23 @@ function dockIcon(entry: DevframeDockEntry): string {
6670
return `<span class="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">${initial}</span>`
6771
}
6872

69-
function overlay(kind: 'spin' | 'error' | 'idle', title: string, detail = '') {
70-
const glyph = kind === 'spin'
71-
? '<span class="i-ph-circle-notch animate-spin text-3xl color-active"></span>'
72-
: kind === 'error'
73-
? '<span class="i-ph-warning-duotone text-3xl text-error"></span>'
74-
: '<span class="i-ph-books-duotone text-3xl op-fade"></span>'
73+
function overlay(html: string) {
7574
overlayEl.style.display = 'flex'
76-
overlayEl.innerHTML = `<div class="flex flex-col items-center gap-3 text-center px6">${glyph}<div class="text-sm font-medium">${title}</div>${detail ? `<div class="text-xs font-mono op-mute max-w-md break-words">${detail}</div>` : ''}</div>`
75+
overlayEl.innerHTML = `<div class="flex flex-col items-center gap-4 text-center px6 max-w-md">${html}</div>`
76+
}
77+
78+
/** The idle launcher tile: a start button that lazily boots the Storybook. */
79+
function launcherTile(entry: LauncherDock): string {
80+
const l = entry.launcher
81+
const cls = iconClass(l.icon ?? entry.icon)
82+
const glyph = cls ? `<span class="${cls} text-4xl color-active"></span>` : '<span class="i-ph-books-duotone text-4xl op-fade"></span>'
83+
return `
84+
${glyph}
85+
<div class="text-base font-medium">${l.title}</div>
86+
${l.description ? `<div class="text-sm color-muted">${l.description}</div>` : ''}
87+
<button type="button" data-launch="${entry.id}" class="btn-primary">
88+
<span class="i-ph-play-duotone"></span>${l.buttonStart ?? 'Start'}
89+
</button>`
7790
}
7891

7992
function updateStage() {
@@ -85,57 +98,90 @@ function updateStage() {
8598
}
8699

87100
if (!selectedId) {
88-
overlay('idle', 'No dock selected')
101+
overlay('<span class="i-ph-books-duotone text-3xl op-fade"></span><div class="text-sm font-medium">No dock selected</div>')
89102
return
90103
}
91-
const rt = runtimes.get(selectedId)
92-
const title = docks.find(d => d.id === selectedId)?.title ?? selectedId
93-
if (!rt || rt.status === 'starting' || (rt.status !== 'error' && !panes.has(selectedId))) {
94-
overlay('spin', isStorybookDock(selectedId) ? `Starting ${title} Storybook…` : `Loading ${title}…`)
104+
105+
const entry = docks.find(d => d.id === selectedId)
106+
const rt = runtimeFor(selectedId)
107+
const title = entry?.title ?? selectedId
108+
109+
// A launched pane is mounted — hide the overlay and show the live iframe.
110+
if (rt.status === 'ready' && panes.has(selectedId)) {
111+
overlayEl.style.display = 'none'
95112
return
96113
}
114+
97115
if (rt.status === 'error') {
98-
overlay('error', `Failed to start ${title}`, rt.error)
116+
overlay(`
117+
<span class="i-ph-warning-duotone text-4xl text-error"></span>
118+
<div class="text-sm font-medium">Failed to start ${title}</div>
119+
${rt.error ? `<div class="text-xs font-mono op-mute break-words">${rt.error}</div>` : ''}
120+
${entry && isLauncherDock(entry) ? `<button type="button" data-launch="${entry.id}" class="btn-action"><span class="i-ph-arrow-clockwise-duotone"></span>Retry</button>` : ''}`)
121+
return
122+
}
123+
124+
// A launcher: idle shows its start tile; starting mirrors the live `digest`
125+
// (the tail of the `storybook dev` output the host streams onto the tile).
126+
if (entry && isLauncherDock(entry)) {
127+
if (rt.status === 'starting') {
128+
const digest = entry.launcher.digest
129+
overlay(`
130+
<span class="i-ph-circle-notch animate-spin text-3xl color-active"></span>
131+
<div class="text-sm font-medium">Starting ${title}…</div>
132+
${digest ? `<div class="text-xs font-mono op-mute break-words">${digest}</div>` : ''}
133+
<button type="button" data-terminals class="btn-action text-xs"><span class="i-ph-terminal-window-duotone"></span>Watch output in Terminals</button>`)
134+
}
135+
else {
136+
overlay(launcherTile(entry))
137+
}
99138
return
100139
}
101-
overlayEl.style.display = 'none'
102-
}
103140

104-
async function ensureUrl(rpc: Awaited<ReturnType<typeof connectDevframe>>, entry: IframeDock): Promise<string> {
105-
// Live plugin docks already carry a hub-served URL; only Storybook docks are
106-
// resolved on demand (spawned in dev, static in build).
107-
if (!isStorybookDock(entry.id))
108-
return entry.url
141+
// A plain iframe dock (the live terminals plugin) is still booting.
142+
overlay(`<span class="i-ph-circle-notch animate-spin text-3xl color-active"></span><div class="text-sm font-medium">Loading ${title}…</div>`)
143+
}
109144

110-
const result = await rpc.call('storybook-hub:ensure' as any, { id: entry.id.slice(3) }) as EnsureResult
111-
if (!result.ok)
112-
throw new Error(result.error)
145+
/** Resolve the URL an EnsureResult points at (spawned dev port, or static path). */
146+
function resultUrl(result: Extract<EnsureResult, { ok: true }>): string {
113147
return result.kind === 'path'
114148
? result.url
115149
: `${location.protocol}//${location.hostname}:${result.port}/`
116150
}
117151

118-
function initDock(rpc: Awaited<ReturnType<typeof connectDevframe>>, entry: IframeDock) {
152+
function embedIframe(entry: Dock, url: string) {
153+
const rt = runtimeFor(entry.id)
154+
panes.ensure(entry.id, {
155+
src: url,
156+
attrs: { title: entry.title, allow: 'clipboard-read; clipboard-write' },
157+
style: { border: '0' },
158+
onCreated: (iframe) => {
159+
iframe.addEventListener('load', () => {
160+
rt.status = 'ready'
161+
updateStage()
162+
})
163+
},
164+
})
165+
updateStage()
166+
}
167+
168+
/** Launch a Storybook: dispatch its bound command, then iframe the result. */
169+
function launch(entry: LauncherDock) {
119170
const rt = runtimeFor(entry.id)
120-
if (rt.status !== 'idle')
121-
return
122171
rt.status = 'starting'
172+
rt.error = undefined
123173
updateStage()
124174

125-
ensureUrl(rpc, entry)
126-
.then((url) => {
127-
panes.ensure(entry.id, {
128-
src: url,
129-
attrs: { title: entry.title, allow: 'clipboard-read; clipboard-write' },
130-
style: { border: '0' },
131-
onCreated: (iframe) => {
132-
iframe.addEventListener('load', () => {
133-
rt.status = 'ready'
134-
updateStage()
135-
})
136-
},
137-
})
138-
updateStage()
175+
const command = entry.launcher.command
176+
const dispatch = command
177+
? rpc.call('hub:commands:execute' as any, command) as Promise<EnsureResult>
178+
: Promise.reject(new Error('Launcher has no bound command'))
179+
180+
dispatch
181+
.then((result) => {
182+
if (!result.ok)
183+
throw new Error(result.error)
184+
embedIframe(entry, resultUrl(result))
139185
})
140186
.catch((err: Error) => {
141187
rt.status = 'error'
@@ -144,19 +190,30 @@ function initDock(rpc: Awaited<ReturnType<typeof connectDevframe>>, entry: Ifram
144190
})
145191
}
146192

193+
/** A plain iframe dock (the live terminals plugin) mounts its URL directly. */
194+
function openIframe(entry: IframeDock) {
195+
const rt = runtimeFor(entry.id)
196+
if (rt.status !== 'idle')
197+
return
198+
rt.status = 'starting'
199+
embedIframe(entry, entry.url)
200+
}
201+
147202
async function main() {
148203
setStatus('Connecting…')
149-
const rpc = await connectDevframe({ baseURL: HUB_BASE })
204+
rpc = await connectDevframe({ baseURL: HUB_BASE })
150205
setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready')
151206

152207
const switchTo = (id: string) => {
153-
if (!docks.some(d => d.id === id))
208+
const entry = docks.find(d => d.id === id)
209+
if (!entry)
154210
return
155211
selectedId = id
156212
renderSidebar()
157-
const rt = runtimeFor(id)
158-
if (rt.status === 'idle')
159-
initDock(rpc, docks.find(d => d.id === id)!)
213+
// Plain iframe docks open on select; launcher docks wait for their Start
214+
// button (the lazy trigger) — so opening a Storybook dock doesn't spawn it.
215+
if (isIframeDock(entry))
216+
openIframe(entry)
160217
updateStage()
161218
}
162219

@@ -180,20 +237,21 @@ async function main() {
180237
}).join('')
181238
}
182239

183-
// Docks — read from `devframe:docks` shared state.
240+
// Docks — read from `devframe:docks` shared state, keeping launcher (Storybook)
241+
// and iframe (live plugin) entries.
184242
const docksState = await rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks', { initialValue: [] })
185243
const syncDocks = () => {
186-
docks = (docksState.value() ?? []).filter(isIframeDock)
244+
docks = (docksState.value() ?? []).filter((d): d is Dock => isIframeDock(d) || isLauncherDock(d))
187245
if (selectedId && !docks.some(d => d.id === selectedId))
188246
selectedId = null
189247
if (!selectedId && docks.length)
190248
selectedId = docks[0].id
191249
renderSidebar()
192-
if (selectedId) {
193-
const rt = runtimeFor(selectedId)
194-
if (rt.status === 'idle')
195-
initDock(rpc, docks.find(d => d.id === selectedId)!)
196-
}
250+
// Auto-open plain iframe docks (terminals plugin); launcher docks stay idle
251+
// until the user starts them.
252+
const entry = selectedId ? docks.find(d => d.id === selectedId) : undefined
253+
if (entry && isIframeDock(entry) && runtimeFor(entry.id).status === 'idle')
254+
openIframe(entry)
197255
updateStage()
198256
}
199257
docksState.on('updated', syncDocks)
@@ -203,6 +261,24 @@ async function main() {
203261
if (target?.dataset.dockId)
204262
switchTo(target.dataset.dockId)
205263
})
264+
265+
overlayEl.addEventListener('click', (event) => {
266+
const el = event.target as HTMLElement
267+
const launchId = el.closest<HTMLButtonElement>('button[data-launch]')?.dataset.launch
268+
if (launchId) {
269+
const entry = docks.find(d => d.id === launchId)
270+
if (entry && isLauncherDock(entry))
271+
launch(entry)
272+
return
273+
}
274+
if (el.closest('button[data-terminals]')) {
275+
// Jump to the live terminals plugin to watch the spawned dev server stream.
276+
const terminals = docks.find(d => isIframeDock(d) && (d.category ?? '') === 'Plugins')
277+
if (terminals)
278+
switchTo(terminals.id)
279+
}
280+
})
281+
206282
syncDocks()
207283
}
208284

0 commit comments

Comments
 (0)