Skip to content

Commit a262259

Browse files
authored
feat: minimal vite-devtools-style hubs mounting every built-in plugin (#46)
1 parent 3f5f1d2 commit a262259

32 files changed

Lines changed: 669 additions & 520 deletions

File tree

alias.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const alias = {
6565
'@devframes/plugin-terminals/cli': p('terminals/src/cli.ts'),
6666
'@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'),
6767
'@devframes/plugin-terminals': p('terminals/src/index.ts'),
68+
'@devframes/plugin-git': p('git/src/index.ts'),
6869
'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'),
6970
'devframe/client': r('devframe/src/client/index.ts'),
7071
'devframe': r('devframe/src'),
@@ -73,6 +74,7 @@ export const alias = {
7374
'@devframes/plugin-inspect/cli': p('inspect/src/cli.ts'),
7475
'@devframes/plugin-inspect/vite': p('inspect/src/vite.ts'),
7576
'@devframes/plugin-inspect': p('inspect/src/index.ts'),
77+
'@devframes/a11y': p('a11y/src/devframe.ts'),
7678
}
7779

7880
// update tsconfig.base.json — CSS aliases exist for Vite resolution only;

docs/guide/hub.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,46 @@ await mountDevframe(ctx, myDevframe)
4343

4444
Framework kits typically wrap this in a plugin shell. `@vitejs/devtools-kit`'s `createPluginFromDevframe` returns a Vite `Plugin` whose `devtools.setup` calls into `mountDevframe`.
4545

46+
### Connecting embedded SPAs
47+
48+
A mounted devframe's SPA loads in an iframe at its base (`/__<id>/`) and calls `connectDevframe()`, which fetches `./__connection.json` relative to that base. `mountDevframe` serves it there by calling the host's `mountConnectionMeta(base)` alongside `mountStatic`, so the SPA discovers the RPC/WS endpoint directly. Implement `mountConnectionMeta` on your `DevframeHost` to serve the same connection meta you expose at the hub's own base:
49+
50+
```ts
51+
const host: DevframeHost = {
52+
mountStatic(base, distDir) { /* serve files */ },
53+
mountConnectionMeta(base) {
54+
// serve `${base}__connection.json` → { backend: 'websocket', websocket: port }
55+
},
56+
resolveOrigin() { /**/ },
57+
getStorageDir(scope) { /**/ },
58+
}
59+
```
60+
61+
Hosts that omit `mountConnectionMeta` fall back to same-origin window inheritance, which connects an embedded SPA only when it shares an origin with the hub UI.
62+
63+
### Bundled hosts (Next.js)
64+
65+
Dev servers with a module bundler (Next's Turbopack/webpack) statically analyse server imports. Plugin packages resolve their SPA dist with `new URL('../dist/...', import.meta.url)` and lazy-load node-side code — child processes, the native `node-pty` PTY backend — that resolves at runtime, not at bundle time. Load them with a dynamic `import()` carrying ignore comments so the bundler keeps them as a runtime Node import:
66+
67+
```ts
68+
const pkgs = ['@devframes/plugin-git', '@devframes/plugin-terminals']
69+
const defs = await Promise.all(
70+
pkgs.map(p => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ p)),
71+
).then(mods => mods.map(m => m.default))
72+
73+
for (const def of defs)
74+
await mountDevframe(ctx, def)
75+
```
76+
77+
Each mounted SPA is served at `/__<id>/` and references its assets relatively (`./_next/…`, `./assets/…`). Disable the bundler's trailing-slash redirect so those paths resolve under the mount base:
78+
79+
```js
80+
// next.config.mjs
81+
export default { skipTrailingSlashRedirect: true }
82+
```
83+
84+
[`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) is a working Next.js App Router host that mounts the built-in plugins this way.
85+
4686
### Duplicate devframes
4787

4888
When a devframe sharing an already-mounted `id` is mounted onto the same hub, its `duplicationStrategy` decides what happens. By default the first registration wins:
@@ -105,7 +145,12 @@ Plus broadcast notifications (`devframe:terminals:updated`, `devframe:messages:u
105145

106146
## Example
107147

108-
See [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) for a ~120-line Vite plugin that wires the hub end to end with a vanilla DOM UI. Every framework's hub host follows the same shape: a thin layer that adapts the framework's dev server to the hub.
148+
Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-server, inspect, a11y) behind an icon dock — the same shape [vite-devtools](https://github.com/vitejs/devtools) wears as the full Vite viewer, shrunk to the smallest thing you can build your own viewer from:
149+
150+
- [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) — a ~120-line Vite plugin host with a vanilla DOM UI.
151+
- [`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) — the same protocol hosted from a Next.js App Router app.
152+
153+
Every framework's hub host follows the same shape: a thin `DevframeHost` adapter over the framework's dev server, with the dock/commands/messages/terminals protocol unchanged above it.
109154

110155
## Diagnostics
111156

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Minimal Next Devframe Hub
22

3-
A protocol-witness example. The `src/client/devframe/minimal-next-devframe-hub.ts` file wires `@devframes/hub` into a Next.js App Router app by lazily starting a side-car RPC/WS server from a Node route handler.
3+
A tiny, copyable **vite-devtools-style hub on Next.js**. [vite-devtools](https://github.com/vitejs/devtools) is the full Vite viewer built on `@devframes/hub`; this example wears the same shape — an icon dock, an iframe stage, a subsystem drawer — but hosts it from a Next.js App Router app, lazily starting a side-car RPC/WS server from a Node route handler. It's the reference for bringing the same integrations to any non-Vite host.
4+
5+
`src/client/devframe/minimal-next-devframe-hub.ts` is the entire host.
46

57
## Run it
68

@@ -9,28 +11,29 @@ pnpm install
911
pnpm --filter minimal-next-devframe-hub dev
1012
```
1113

12-
Open the printed URL. You should see:
14+
Open the printed URL. The dock on the left lists every mounted tool with its icon:
15+
16+
- **Git**, **Terminals**, **Code Server**, **RPC & State Inspector**, **A11y Inspector** — the built-in plugins, each mounted with `mountDevframe`
17+
- **Next Demo Tool** / **Next Demo Tool B** — two trivial static SPAs that show the bare mount path
1318

14-
- A status line showing the RPC backend
15-
- A **Docks** list with hub built-ins and the mounted demo devframe
16-
- A **Commands** list populated from server-side registrations
17-
- A **Messages** list populated via `messages.add()` on the server
18-
- A **Terminals** list, empty unless a devframe registers one
19-
- A button that exercises `hub:commands:execute` by dispatching the sample ping command
19+
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`.
2020

2121
## What the example proves
2222

23-
- `createHubContext()` boots a hub without any Vite-specific code path
24-
- A `DevframeHost` impl plugs Next host specifics into the hub uniformly
25-
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock
26-
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed
27-
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json`
23+
- `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs Next specifics (static mounts, connection meta, storage, origin) in uniformly
24+
- `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock and serves both its SPA and its `__connection.json`, so the embedded SPA connects straight back to the hub
25+
- The browser reads `devframe:docks` / `devframe:commands` shared state and dispatches commands over RPC — byte-for-byte the same protocol the Vite host speaks
26+
27+
## Hosting built-in plugins in a bundler
28+
29+
The plugins run node-side (child processes, the native `node-pty` PTY backend) and resolve their SPA dist via `new URL(..., import.meta.url)`. Next's bundler would try to inline that, so the host loads them through a bundler-ignored dynamic `import()` and sets `skipTrailingSlashRedirect` (see `next.config.mjs`) so each SPA's relative assets resolve under `/__<id>/`. This is the recipe for any bundled (webpack/Turbopack) host.
2830

2931
## Files
3032

3133
| File | Role |
3234
|---|---|
33-
| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — creates hub context and side-car WS |
34-
| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Connection-meta endpoint for `/__hub/__connection.json` that starts the singleton host |
35-
| `src/client/devframe/demo-devframe.ts` | A sample `DevframeDefinition` that plugs into the host |
36-
| `src/client/app/page.tsx` | The browser-side UI that consumes the hub protocol |
35+
| `src/client/devframe/minimal-next-devframe-hub.ts` | The Next host — hub context, static-mount registry, side-car WS |
36+
| `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Boots the singleton host and serves `/__hub/__connection.json` |
37+
| `src/client/app/%5F_[id]/[[...path]]/route.ts` | Serves each mounted SPA and its connection meta under `/__<id>/` |
38+
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol |
39+
| `src/client/app/icons.ts` | Offline Phosphor icons for the dock |

examples/minimal-next-devframe-hub/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,31 @@
66
"description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.",
77
"homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub",
88
"scripts": {
9-
"dev": "next dev src/client",
9+
"dev": "next dev src/client -H 0.0.0.0",
1010
"build": "next build src/client",
1111
"test": "vitest run --config vitest.config.ts"
1212
},
1313
"dependencies": {
14+
"@devframes/a11y": "workspace:*",
1415
"@devframes/hub": "workspace:*",
16+
"@devframes/plugin-code-server": "workspace:*",
17+
"@devframes/plugin-git": "workspace:*",
18+
"@devframes/plugin-inspect": "workspace:*",
19+
"@devframes/plugin-terminals": "workspace:*",
20+
"@internal/design": "workspace:*",
1521
"devframe": "workspace:*",
1622
"next": "catalog:frontend",
1723
"react": "catalog:frontend",
1824
"react-dom": "catalog:frontend"
1925
},
2026
"devDependencies": {
27+
"@iconify-json/ph": "catalog:frontend",
2128
"@types/react": "catalog:types",
2229
"@types/react-dom": "catalog:types",
30+
"@unocss/postcss": "catalog:frontend",
2331
"get-port-please": "catalog:deps",
2432
"pathe": "catalog:deps",
33+
"unocss": "catalog:frontend",
2534
"vitest": "catalog:testing",
2635
"ws": "catalog:deps"
2736
}

examples/minimal-next-devframe-hub/src/client/app/%5F_[id]/[[...path]]/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createReadStream } from 'node:fs'
22
import { stat } from 'node:fs/promises'
33
import { Readable } from 'node:stream'
44
import { extname, join, normalize, resolve, sep } from 'pathe'
5-
import { ensureMinimalNextDevframeHub, getStaticMount } from '../../../devframe/minimal-next-devframe-hub'
5+
import { ensureMinimalNextDevframeHub, getStaticMount, isConnectionMetaPath } from '../../../devframe/minimal-next-devframe-hub'
66

77
export const runtime = 'nodejs'
88
export const dynamic = 'force-dynamic'
@@ -80,9 +80,15 @@ async function resolveTarget(absDir: string, urlPath: string): Promise<ResolvedF
8080
}
8181

8282
export async function GET(request: Request): Promise<Response> {
83-
await ensureMinimalNextDevframeHub()
83+
const hub = await ensureMinimalNextDevframeHub()
8484

8585
const pathname = new URL(request.url).pathname
86+
87+
// A mounted devframe SPA fetches `<base>/__connection.json` to discover the
88+
// side-car WS endpoint. Answer it with the hub's connection meta.
89+
if (isConnectionMetaPath(pathname))
90+
return Response.json(hub.connectionMeta)
91+
8692
const hit = getStaticMount(pathname)
8793
if (!hit)
8894
return new Response(null, { status: 404 })
Lines changed: 6 additions & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -1,176 +1,7 @@
1-
:root {
2-
color-scheme: light dark;
3-
font-family: system-ui, sans-serif;
4-
line-height: 1.5;
5-
}
1+
/* The hub UI's design tokens, base layer, and component vocabulary come from the
2+
shared devframe design system. `@unocss` is replaced by UnoCSS (via
3+
`@unocss/postcss`, see ../postcss.config.mjs) with the generated preflights +
4+
utilities; `@internal/design/theme.css` (imported after this file in
5+
layout.tsx) supplies the `--df-*` token values + base element styling. */
66

7-
body {
8-
margin: 0;
9-
height: 100vh;
10-
overflow: hidden;
11-
}
12-
13-
.app-shell {
14-
display: grid;
15-
grid-template-columns: 220px 1fr;
16-
grid-template-rows: auto 1fr auto;
17-
grid-template-areas:
18-
"header header"
19-
"sidebar main"
20-
"footer footer";
21-
height: 100vh;
22-
}
23-
24-
.app-header {
25-
grid-area: header;
26-
padding: 0.75rem 1rem;
27-
border-bottom: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
28-
}
29-
30-
.app-header h1 {
31-
margin: 0;
32-
font-size: 1rem;
33-
letter-spacing: 0.05em;
34-
text-transform: uppercase;
35-
}
36-
37-
.app-header p {
38-
margin: 0.25rem 0 0;
39-
font-family: ui-monospace, monospace;
40-
font-size: 0.8rem;
41-
opacity: 0.7;
42-
}
43-
44-
.app-sidebar {
45-
grid-area: sidebar;
46-
border-right: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
47-
padding: 0.75rem;
48-
overflow: auto;
49-
}
50-
51-
.app-main {
52-
grid-area: main;
53-
overflow: hidden;
54-
background: color-mix(in srgb, currentcolor 3%, transparent);
55-
}
56-
57-
.app-main iframe {
58-
width: 100%;
59-
height: 100%;
60-
border: 0;
61-
display: block;
62-
}
63-
64-
.app-footer {
65-
grid-area: footer;
66-
display: flex;
67-
gap: 1rem;
68-
padding: 0.75rem 1rem;
69-
border-top: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
70-
max-height: 30vh;
71-
overflow: auto;
72-
}
73-
74-
.app-footer section {
75-
flex: 1;
76-
min-width: 0;
77-
}
78-
79-
h2 {
80-
font-size: 0.75rem;
81-
text-transform: uppercase;
82-
letter-spacing: 0.05em;
83-
opacity: 0.8;
84-
margin: 0 0 0.5rem;
85-
}
86-
87-
ul {
88-
list-style: none;
89-
padding-left: 0;
90-
margin: 0;
91-
display: flex;
92-
flex-direction: column;
93-
gap: 0.25rem;
94-
}
95-
96-
li {
97-
padding: 0.4rem 0.6rem;
98-
border: 1px solid color-mix(in srgb, currentcolor 15%, transparent);
99-
border-radius: 0.4rem;
100-
font-family: ui-monospace, monospace;
101-
font-size: 0.8rem;
102-
}
103-
104-
li.muted {
105-
opacity: 0.5;
106-
font-style: italic;
107-
}
108-
109-
code {
110-
font-family: ui-monospace, monospace;
111-
background: color-mix(in srgb, currentcolor 10%, transparent);
112-
padding: 0.1em 0.35em;
113-
border-radius: 0.25em;
114-
}
115-
116-
.app-sidebar ul li {
117-
padding: 0;
118-
border: 0;
119-
background: transparent;
120-
}
121-
122-
.app-sidebar button {
123-
width: 100%;
124-
text-align: left;
125-
padding: 0.5rem 0.6rem;
126-
font: inherit;
127-
font-size: 0.85rem;
128-
background: transparent;
129-
border: 1px solid transparent;
130-
border-radius: 0.4rem;
131-
cursor: pointer;
132-
color: inherit;
133-
}
134-
135-
.app-sidebar button:hover {
136-
background: color-mix(in srgb, currentcolor 8%, transparent);
137-
}
138-
139-
.app-sidebar button.active {
140-
background: color-mix(in srgb, currentcolor 15%, transparent);
141-
border-color: color-mix(in srgb, currentcolor 30%, transparent);
142-
}
143-
144-
.actions {
145-
display: flex;
146-
flex-wrap: wrap;
147-
gap: 0.75rem;
148-
align-items: flex-start;
149-
}
150-
151-
button {
152-
font: inherit;
153-
font-size: 0.85rem;
154-
padding: 0.4rem 0.8rem;
155-
border-radius: 0.4rem;
156-
border: 1px solid currentcolor;
157-
background: transparent;
158-
cursor: pointer;
159-
color: inherit;
160-
}
161-
162-
button:hover {
163-
background: color-mix(in srgb, currentcolor 10%, transparent);
164-
}
165-
166-
#status.error span {
167-
color: #c33;
168-
}
169-
170-
#status.ready span {
171-
color: #2a7;
172-
}
173-
174-
.badge {
175-
margin-left: 0.35rem;
176-
}
7+
@unocss;

0 commit comments

Comments
 (0)