Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions =
started = undefined

const cwd = viteConfig!.root
const port = options.port ?? await getPort({ port: 9777, random: false })
// Prefer 9777 but keep booting when it's taken (e.g. a lingering
// previous instance) — walk the range, then fall back to a random free
// port. Clients discover whatever was chosen via `__connection.json`.
const port = options.port ?? await getPort({ port: 9777, portRange: [9777, 9877] })

// Serve the side-car's connection meta (`__connection.json`) at a URL
// base so a browser loaded there can discover the WS endpoint via
Expand Down
3 changes: 3 additions & 0 deletions examples/minimal-vite-devframe-hub/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { minimalViteDevframeHub } from './src/minimal-vite-devframe-hub'

export default defineConfig({
resolve: { alias },
// Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels, tailnets):
// accept any Host header and fall back to the next free port when busy.
server: { allowedHosts: true, strictPort: false },
plugins: [
UnoCSS(),
minimalViteDevframeHub({
Expand Down
56 changes: 56 additions & 0 deletions examples/storybook-hub/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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 lazily-mounted
iframe dock (the same on-demand embed pattern the code-server plugin uses).

## 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/<id>`
is served by the hub on one origin and the dock iframes that.

## 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, 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.

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

```sh
pnpm storybook:build # produces storybook/storybook-static/<plugin>
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.
40 changes: 40 additions & 0 deletions examples/storybook-hub/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Storybook Hub</title>
<script>
// Follow the OS theme without a toggle (@antfu/design dark: is class-based).
document.documentElement.classList.toggle(
'dark',
window.matchMedia('(prefers-color-scheme: dark)').matches,
)
</script>
</head>
<body class="h-full m0 of-hidden bg-base color-base">
<div class="h-full flex flex-col">
<header class="shrink-0 flex items-baseline gap-3 border-b border-base bg-base px4 py2.5">
<h1 class="m0 flex items-baseline gap-1.5 text-sm font-semibold">
<span class="i-ph-books-duotone color-active translate-y-0.5"></span>Storybook Hub
</h1>
<p id="status" class="m0 text-xs font-mono op-fade"><span id="conn">Connecting…</span></p>
<p class="m0 ml-auto text-xs font-mono italic color-muted">every plugin's Storybook as a lazy dock, on @devframes/hub</p>
</header>

<div class="grid grid-cols-[244px_1fr] min-h-0 flex-1">
<aside class="flex flex-col gap-0.5 of-auto border-r border-base bg-secondary p2">
<ul id="docks" class="m0 flex flex-col list-none gap-0.5 p0">
<li class="op-mute px2 text-sm">Waiting for snapshot…</li>
</ul>
</aside>

<main class="relative min-w-0 of-hidden bg-secondary">
<div id="stage" class="absolute inset-0"></div>
<div id="overlay" class="absolute inset-0 z-10 flex items-center justify-center bg-secondary" style="display:none"></div>
</main>
</div>
</div>
<script type="module" src="/src/client/main.ts"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions examples/storybook-hub/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "storybook-hub",
"type": "module",
"version": "0.5.4",
"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 --host",
"build": "vite build",
"preview": "vite preview --host",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@antfu/design": "catalog:frontend",
"@devframes/hub": "workspace:*",
"@devframes/plugin-terminals": "workspace:*",
"colorjs.io": "catalog:frontend",
"devframe": "workspace:*"
},
"devDependencies": {
"@iconify-json/ph": "catalog:frontend",
"get-port-please": "catalog:deps",
"pathe": "catalog:deps",
"storybook": "catalog:storybook",
"unocss": "catalog:frontend",
"vite": "catalog:build"
}
}
3 changes: 3 additions & 0 deletions examples/storybook-hub/src/client/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />

declare module 'virtual:uno.css'
25 changes: 25 additions & 0 deletions examples/storybook-hub/src/client/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @unocss-include
// Map a devframe dock `icon` (e.g. `ph:git-branch-duotone`) to a UnoCSS
// `preset-icons` class. Keeping the class strings literal lets UnoCSS
// statically extract them and inline only these glyphs from `@iconify-json/ph`
// at build time. Add a row here to support another dock icon.
const ICON_CLASS: Record<string, string> = {
'ph:git-branch-duotone': 'i-ph-git-branch-duotone',
'ph:magnifying-glass-duotone': 'i-ph-magnifying-glass-duotone',
'ph:code-duotone': 'i-ph-code-duotone',
'ph:terminal-window-duotone': 'i-ph-terminal-window-duotone',
'ph:person-arms-spread-duotone': 'i-ph-person-arms-spread-duotone',
'ph:books-duotone': 'i-ph-books-duotone',
'ph:plug-duotone': 'i-ph-plug-duotone',
}

/**
* Resolve a dock icon to its UnoCSS class, or an empty string when the icon
* isn't mapped (the caller falls back to a text initial).
*/
export function iconClass(name: string | { light: string, dark: string } | undefined): string {
if (!name)
return ''
const id = typeof name === 'string' ? name : name.light
return ICON_CLASS[id] ?? ''
}
207 changes: 207 additions & 0 deletions examples/storybook-hub/src/client/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { DevframeDockEntry } from '@devframes/hub/types'
import { connectDevframe } from '@devframes/hub/client'
import { iconClass } from './icons'
import 'virtual:uno.css'
import '@antfu/design/styles.css'

const HUB_BASE = '/__hub/'

// Mirror of the host's `storybook-hub:ensure` return shape.
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 }

/** Sidebar section order; anything else follows alphabetically. */
const CATEGORY_ORDER = ['Storybooks', 'Plugins']

const connEl = document.querySelector<HTMLElement>('#conn')!
const docksEl = document.querySelector<HTMLElement>('#docks')!
const stageEl = document.querySelector<HTMLElement>('#stage')!
const overlayEl = document.querySelector<HTMLElement>('#overlay')!

interface DockRuntime {
iframe?: HTMLIFrameElement
status: 'idle' | 'starting' | 'ready' | 'error'
error?: string
}

const runtimes = new Map<string, DockRuntime>()
let docks: IframeDock[] = []
let selectedId: string | null = null

function setStatus(text: string, kind?: 'ready' | 'error') {
const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400'
connEl.innerHTML = `<span class="inline-block size-1.5 rounded-full shrink-0 ${dot} mr-1.5 align-middle"></span>${text}`
}

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 runtimeFor(id: string): DockRuntime {
let rt = runtimes.get(id)
if (!rt) {
rt = { status: 'idle' }
runtimes.set(id, rt)
}
return rt
}

function dockIcon(entry: DevframeDockEntry): string {
const cls = iconClass(entry.icon)
if (cls)
return `<span class="${cls} shrink-0 text-lg"></span>`
const initial = (entry.title?.[0] ?? '?').toUpperCase()
return `<span class="grid h-5 w-5 shrink-0 place-items-center rounded bg-active text-[0.7rem] font-bold">${initial}</span>`
}

function overlay(kind: 'spin' | 'error' | 'idle', title: string, detail = '') {
const glyph = kind === 'spin'
? '<span class="i-ph-circle-notch animate-spin text-3xl color-active"></span>'
: kind === 'error'
? '<span class="i-ph-warning-duotone text-3xl text-error"></span>'
: '<span class="i-ph-books-duotone text-3xl op-fade"></span>'
overlayEl.style.display = 'flex'
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>`
}

function updateStage() {
for (const [id, rt] of runtimes) {
if (rt.iframe)
rt.iframe.style.display = id === selectedId ? 'block' : 'none'
}

if (!selectedId) {
overlay('idle', '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' && !rt.iframe)) {
overlay('spin', isStorybookDock(selectedId) ? `Starting ${title} Storybook…` : `Loading ${title}…`)
return
}
if (rt.status === 'error') {
overlay('error', `Failed to start ${title}`, rt.error)
return
}
overlayEl.style.display = 'none'
}

async function ensureUrl(rpc: Awaited<ReturnType<typeof connectDevframe>>, entry: IframeDock): Promise<string> {
// 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

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)
return result.kind === 'path'
? result.url
: `${location.protocol}//${location.hostname}:${result.port}/`
}

function initDock(rpc: Awaited<ReturnType<typeof connectDevframe>>, entry: IframeDock) {
const rt = runtimeFor(entry.id)
if (rt.status !== 'idle')
return
rt.status = 'starting'
updateStage()

ensureUrl(rpc, entry)
.then((url) => {
const frame = document.createElement('iframe')
frame.className = 'absolute inset-0 h-full w-full border-0 bg-base'
frame.title = entry.title
frame.setAttribute('allow', 'clipboard-read; clipboard-write')
frame.addEventListener('load', () => {
rt.status = 'ready'
updateStage()
})
frame.src = url
rt.iframe = frame
// Keep every opened dock mounted so its state survives tab switches.
stageEl.appendChild(frame)
updateStage()
})
.catch((err: Error) => {
rt.status = 'error'
rt.error = err.message
updateStage()
})
}

async function main() {
setStatus('Connecting…')
const rpc = await connectDevframe({ baseURL: HUB_BASE })
setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready')

const switchTo = (id: string) => {
if (!docks.some(d => d.id === id))
return
selectedId = id
renderSidebar()
const rt = runtimeFor(id)
if (rt.status === 'idle')
initDock(rpc, docks.find(d => d.id === id)!)
updateStage()
}

function renderSidebar() {
if (!docks.length) {
docksEl.innerHTML = '<li class="op-mute px2 text-sm">No docks yet…</li>'
return
}
const categories = [...new Set(docks.map(d => d.category ?? 'Other'))].sort(
(a, b) => {
const ia = CATEGORY_ORDER.indexOf(a)
const ib = CATEGORY_ORDER.indexOf(b)
return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib) || a.localeCompare(b)
},
)
docksEl.innerHTML = categories.map((category) => {
const items = docks.filter(d => (d.category ?? 'Other') === category)
const buttons = items.map(d =>
`<li><button type="button" data-dock-id="${d.id}" class="relative inline-flex items-center gap-2.5 w-full px-2 py-1 rounded-md border border-transparent text-sm op-fade select-none cursor-pointer transition hover:op100 hover:bg-active${d.id === selectedId ? ' op100! bg-active border-base! color-base' : ''}" title="${d.title}">${dockIcon(d)}<span class="truncate">${d.title}</span></button></li>`).join('')
return `<li class="px2 pt2 pb1 text-[0.68rem] uppercase tracking-wider color-muted">${category}</li>${buttons}`
}).join('')
}

// Docks — read from `devframe:docks` shared state.
const docksState = await rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks', { initialValue: [] })
const syncDocks = () => {
docks = (docksState.value() ?? []).filter(isIframeDock)
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)!)
}
updateStage()
}
docksState.on('updated', syncDocks)

docksEl.addEventListener('click', (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>('button[data-dock-id]')
if (target?.dataset.dockId)
switchTo(target.dataset.dockId)
})
syncDocks()
}

main().catch((err) => {
setStatus(`Failed: ${(err as Error).message}`, 'error')
console.error(err)
})
Loading
Loading