Skip to content

Commit 8a34e60

Browse files
committed
feat(hub-ui): add a dev-served playground with HMR
Mounts a bare, headless hub instance and hub-ui's own DockStandalone/ DockEmbedded straight from source, so the client Vue components hot-reload during development instead of requiring a build. Run with `pnpm --filter @devframes/hub-ui dev`.
1 parent 595eb81 commit 8a34e60

7 files changed

Lines changed: 284 additions & 1 deletion

File tree

‎packages/hub-ui/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"build:node": "tsdown",
3535
"build:embedded": "vite build --config vite.embedded.config.ts",
3636
"build:standalone": "vite build --config src/client/standalone/vite.config.ts",
37+
"dev": "pnpm -C ../.. exec turbo run build --filter=@devframes/hub... && vite --config playground/vite.config.ts",
3738
"typecheck": "tsc --noEmit",
3839
"prepack": "pnpm build",
3940
"storybook": "storybook dev -p 6014",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { HubInstance } from '@devframes/hub/initiate'
2+
import type { Plugin, ViteDevServer } from 'vite'
3+
import { Server as NodeHttpServer } from 'node:http'
4+
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
5+
import { seedPlayground } from './seed'
6+
7+
/**
8+
* Mounts a bare, headless hub instance as Vite dev-server middleware — just
9+
* enough backend for `main.ts`'s `DockStandalone`/`DockEmbedded` to connect
10+
* to (RPC, WebSocket, `__connection.json`). No `devframes`, no `ui` slot, no
11+
* renderer manifest: this playground is developing hub-ui itself, not
12+
* exercising the wider hub protocol (`examples/hub-vite` already does that).
13+
*
14+
* A hand-rolled slice of `@devframes/vite/hub` rather than that package
15+
* itself — pulling it in here would make `@devframes/hub-ui` and
16+
* `@devframes/vite` depend on each other (`@devframes/vite` already carries
17+
* an optional peer dependency on `@devframes/hub-ui` for its own default UI
18+
* slot), a cyclic workspace dependency for no real benefit.
19+
*/
20+
export function hubUiPlaygroundHub(): Plugin {
21+
let instance: HubInstance | undefined
22+
23+
const teardown = async (): Promise<void> => {
24+
const previous = instance
25+
instance = undefined
26+
await previous?.close().catch(() => {})
27+
}
28+
29+
return {
30+
name: 'hub-ui-playground:hub',
31+
apply: 'serve',
32+
33+
async configureServer(server: ViteDevServer) {
34+
// Vite re-invokes `configureServer` on each restart.
35+
await teardown()
36+
37+
const httpServer = server.httpServer instanceof NodeHttpServer ? server.httpServer : undefined
38+
const hub = initHub({
39+
base: DEVFRAMES_HUB_BASE,
40+
// Storage (if anything writes to it) lands under this package's own
41+
// `node_modules`, not the playground folder.
42+
cwd: new URL('..', import.meta.url).pathname,
43+
origin: () => {
44+
const resolved = server.resolvedUrls?.local?.[0]
45+
return resolved ? new URL(resolved).origin : ''
46+
},
47+
// Frictionless local loop — no interactive OTP gate.
48+
auth: false,
49+
// Share Vite's own HTTP server for the WS upgrade, like
50+
// `@devframes/vite/hub` does.
51+
server: httpServer,
52+
...(httpServer ? {} : { ws: { sidecar: true } }),
53+
configure: seedPlayground,
54+
})
55+
instance = hub
56+
57+
server.middlewares.use(hub.nodeMiddleware)
58+
59+
server.httpServer?.once('close', () => {
60+
if (instance !== hub)
61+
return
62+
void teardown()
63+
})
64+
},
65+
66+
async closeBundle() {
67+
await teardown()
68+
},
69+
}
70+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>hub-ui playground</title>
7+
<style>
8+
html,
9+
body {
10+
margin: 0;
11+
height: 100%;
12+
background: #fff;
13+
font-family: system-ui, sans-serif;
14+
}
15+
html.dark,
16+
html.dark body {
17+
background: #111;
18+
color: #eee;
19+
}
20+
/* `main.ts` marks the mode on `<body>` before mounting — `#app` only
21+
needs to fill the viewport in standalone mode (where `DockStandalone`
22+
mounts into it); in embedded mode it stays empty and out of the way,
23+
leaving `#host-content` visible under the floating dock. */
24+
body.standalone #app {
25+
height: 100%;
26+
}
27+
#host-content {
28+
max-width: 40rem;
29+
margin: 4rem auto;
30+
padding: 0 1.5rem;
31+
line-height: 1.6;
32+
}
33+
</style>
34+
</head>
35+
<body>
36+
<!--
37+
Standalone mode (default) mounts `DockStandalone` into `#app`, filling
38+
the page — this content sits underneath it, unused.
39+
40+
`?embedded` instead leaves this content visible and floats
41+
`DockEmbedded` over it, standing in for a host app the dock is
42+
inspecting.
43+
-->
44+
<div id="app"></div>
45+
<div id="host-content">
46+
<h1>hub-ui playground</h1>
47+
<p>
48+
A sample host page. Load with <code>?embedded</code> to see the
49+
floating dock (<code>DockEmbedded</code>) over this content instead
50+
of the full-page standalone viewer (<code>DockStandalone</code>).
51+
</p>
52+
</div>
53+
<script type="module" src="./main.ts"></script>
54+
</body>
55+
</html>

‎packages/hub-ui/playground/main.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { DockPanelStorage } from '@devframes/hub/client'
2+
import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client'
3+
import { useLocalStorage } from '@vueuse/core'
4+
import { watchEffect } from 'vue'
5+
import { isDark } from '../src/client/state/color-mode'
6+
import { DEFAULT_DOCK_PANEL_STORE } from '../src/client/state/docks'
7+
8+
/**
9+
* The base `hub-plugin.ts` mounts the playground's hub instance at. Kept as
10+
* an explicit constant (rather than inferred from the page's own URL, like
11+
* the production `standalone`/`embedded` entries do) because this playground
12+
* page is served from Vite's own root (`/`), not colocated with the hub the
13+
* way a built `createUi()` viewer is.
14+
*/
15+
const HUB_BASE = '/__devframes/'
16+
17+
/**
18+
* `?embedded` mounts the floating `DockEmbedded` bootstrap over the sample
19+
* host content in `index.html` — the same surface a host page gets from
20+
* `<script src="<base>embedded.js">`. The default mounts `DockStandalone`
21+
* full-page — the primary surface most hub-ui changes touch.
22+
*/
23+
const mode = new URLSearchParams(location.search).has('embedded') ? 'embedded' : 'standalone'
24+
document.body.classList.add(mode)
25+
26+
// This page runs in the light DOM, so mirror the color mode onto the
27+
// document element like the standalone viewer does.
28+
watchEffect(() => {
29+
const el = document.documentElement
30+
el.classList.toggle('dark', isDark.value)
31+
el.classList.toggle('light', !isDark.value)
32+
el.style.colorScheme = isDark.value ? 'dark' : 'light'
33+
})
34+
35+
async function main(): Promise<void> {
36+
const rpc = await getDevframeRpcClient({ baseURL: HUB_BASE, simpleAuth: false })
37+
const { createDocksContext } = await import('../src/client/state/context')
38+
39+
if (mode === 'embedded') {
40+
const state = useLocalStorage<DockPanelStorage>(
41+
'devframes-hub-ui-playground-dock-state',
42+
DEFAULT_DOCK_PANEL_STORE(),
43+
{ mergeDefaults: true },
44+
)
45+
const context = await createDocksContext('embedded', rpc, state)
46+
setDevframeClientContext(context)
47+
const { DockEmbedded } = await import('../src/client/components/DockEmbedded')
48+
document.body.appendChild(new DockEmbedded({ context }) as unknown as HTMLElement)
49+
return
50+
}
51+
52+
const context = await createDocksContext('standalone', rpc)
53+
setDevframeClientContext(context)
54+
const { DockStandalone } = await import('../src/client/components/DockStandalone')
55+
document.getElementById('app')!.appendChild(new DockStandalone({ context }) as unknown as HTMLElement)
56+
}
57+
58+
void main()

‎packages/hub-ui/playground/seed.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { DevframeHubContext } from '@devframes/hub/node'
2+
import type { DevframeDockEntryBase } from '@devframes/hub/types'
3+
4+
/**
5+
* A dock type no renderer covers — registering it exercises the viewer's
6+
* fallback view (`No renderer for "playground-unrendered" in the current
7+
* environment`) instead of leaving the dock bar empty. Mirrors
8+
* `examples/hub-vite/src/unrendered-dock.ts`.
9+
*/
10+
interface PlaygroundUnrenderedDockEntry extends DevframeDockEntryBase {
11+
type: 'playground-unrendered'
12+
}
13+
14+
declare module '@devframes/hub/types' {
15+
interface DevframeDockEntryRegistry {
16+
'playground-unrendered': PlaygroundUnrenderedDockEntry
17+
}
18+
}
19+
20+
const unrenderedDockEntry: PlaygroundUnrenderedDockEntry = {
21+
type: 'playground-unrendered',
22+
id: 'playground:unrendered',
23+
title: 'No Renderer',
24+
icon: 'ph:puzzle-piece-duotone',
25+
category: 'app',
26+
}
27+
28+
/**
29+
* Seeds the playground's hub context with just enough content to exercise
30+
* hub-ui's own surfaces — the dock bar, message center, and command palette —
31+
* without needing a real mounted devframe SPA. Called from `hub-plugin.ts`'s
32+
* `configure` hook once the context exists.
33+
*/
34+
export async function seedPlayground(ctx: DevframeHubContext): Promise<void> {
35+
ctx.docks.register(unrenderedDockEntry)
36+
37+
ctx.commands.register({
38+
id: 'playground:say-hello',
39+
title: 'Playground · Say Hello',
40+
icon: 'ph:hand-waving-duotone',
41+
category: 'playground',
42+
handler: () => 'Hello from the hub-ui playground!',
43+
})
44+
ctx.commands.register({
45+
id: 'playground:throw',
46+
title: 'Playground · Throw an Error',
47+
icon: 'ph:bomb-duotone',
48+
category: 'playground',
49+
handler: () => {
50+
throw new Error('Deliberate playground error — exercises the command palette\'s failure toast.')
51+
},
52+
})
53+
54+
await ctx.messages.add({
55+
level: 'info',
56+
message: 'Hub UI playground started',
57+
description: 'Editing anything under packages/hub-ui/src/client hot-reloads this page.',
58+
})
59+
await ctx.messages.add({
60+
level: 'success',
61+
message: 'Sample success message',
62+
})
63+
await ctx.messages.add({
64+
level: 'warn',
65+
message: 'Sample warning message',
66+
description: 'Messages support an optional description line like this one.',
67+
})
68+
await ctx.messages.add({
69+
level: 'error',
70+
message: 'Sample error message',
71+
})
72+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { fileURLToPath } from 'node:url'
2+
import Vue from '@vitejs/plugin-vue'
3+
import { defineConfig } from 'vite'
4+
import { alias } from '../../../alias'
5+
import { hubUiPlaygroundHub } from './hub-plugin'
6+
7+
/**
8+
* Dev-serves this package's own client source (the floating `DockEmbedded`
9+
* bootstrap and the standalone `DockStandalone` viewer) against a real,
10+
* local hub instance — full HMR on every `.vue`/`.ts` file under
11+
* `src/client`, no build step. Run with `pnpm --filter @devframes/hub-ui dev`.
12+
*
13+
* `main.ts` mounts `DockStandalone`/`DockEmbedded` straight from source, the
14+
* same way a built `createUi()` viewer would, just unbundled — see
15+
* `hub-plugin.ts` for why this doesn't reuse `@devframes/vite/hub`.
16+
*/
17+
export default defineConfig({
18+
root: fileURLToPath(new URL('.', import.meta.url)),
19+
resolve: { alias },
20+
// Reached from arbitrary hostnames (LAN IPs, tunnels, tailnets) like the
21+
// other reference hub hosts in this repo.
22+
server: { allowedHosts: true, strictPort: false },
23+
plugins: [
24+
Vue(),
25+
hubUiPlaygroundHub(),
26+
],
27+
})

‎packages/hub-ui/tsconfig.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
"lib": ["esnext", "dom", "dom.iterable"],
77
"types": ["vite/client"]
88
},
9-
"include": ["src", "scripts", "*.config.ts"],
9+
"include": ["src", "scripts", "playground", "*.config.ts"],
1010
"exclude": ["dist", "node_modules"]
1111
}

0 commit comments

Comments
 (0)