Skip to content

Commit 6af7298

Browse files
committed
feat(examples): dogfood the built-in plugins in the minimal hubs
Mount @devframes/plugin-git, -terminals, and -code-server in both the Vite and Next minimal hub examples so they exercise real RPC, streaming, and child-process integrations end to end. Both hosts now serve per-devframe connection meta. The Next host loads the plugins through a bundler-ignored dynamic import and skips trailing-slash redirects so the published SPAs resolve their assets and connect. Adds a bundled-host recipe to the hub guide and the missing plugin-git source alias.
1 parent 0cafdc4 commit 6af7298

11 files changed

Lines changed: 171 additions & 16 deletions

File tree

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const alias = {
6060
'@devframes/plugin-terminals/cli': p('terminals/src/cli.ts'),
6161
'@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'),
6262
'@devframes/plugin-terminals': p('terminals/src/index.ts'),
63+
'@devframes/plugin-git': p('git/src/index.ts'),
6364
'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'),
6465
'devframe/client': r('devframe/src/client/index.ts'),
6566
'devframe': r('devframe/src'),

docs/guide/hub.md

Lines changed: 40 additions & 0 deletions
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:

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
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": {
1414
"@devframes/hub": "workspace:*",
15+
"@devframes/plugin-code-server": "workspace:*",
16+
"@devframes/plugin-git": "workspace:*",
17+
"@devframes/plugin-terminals": "workspace:*",
1518
"devframe": "workspace:*",
1619
"next": "catalog:frontend",
1720
"react": "catalog:frontend",

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 })

examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,39 @@ import { homedir } from 'node:os'
55
import process from 'node:process'
66
import { defineHubRpcFunction } from '@devframes/hub'
77
import { createHubContext, mountDevframe } from '@devframes/hub/node'
8+
import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants'
89
import { startHttpAndWs } from 'devframe/node'
910
import { getPort } from 'get-port-please'
1011
import { join } from 'pathe'
1112
import demoDevframe from './demo-devframe'
1213
import demoDevframeB from './demo-devframe-b'
1314

15+
/**
16+
* Built-in plugin packages dogfooded through the hub mount path.
17+
*
18+
* They are loaded with a runtime dynamic `import()` carrying
19+
* `webpackIgnore` / `turbopackIgnore` magic comments so Next's bundler leaves
20+
* them alone: Node resolves the published `dist` at request time, where the
21+
* plugins' node-side code (git shell-outs, child-process supervisors, the
22+
* native `node-pty` PTY backend) and their `new URL('../dist/...',
23+
* import.meta.url)` SPA-dist lookups all work — none of which survive being
24+
* statically bundled into a Next server chunk.
25+
*/
26+
const BUILTIN_PLUGIN_PACKAGES = [
27+
'@devframes/plugin-git',
28+
'@devframes/plugin-terminals',
29+
'@devframes/plugin-code-server',
30+
] as const
31+
32+
async function loadBuiltinPlugins(): Promise<DevframeDefinition[]> {
33+
const mods = await Promise.all(
34+
BUILTIN_PLUGIN_PACKAGES.map(
35+
pkg => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ pkg),
36+
),
37+
)
38+
return mods.map(mod => mod.default as DevframeDefinition)
39+
}
40+
1441
const STATIC_MOUNTS = new Map<string, string>()
1542

1643
export interface StaticMountHit {
@@ -32,6 +59,23 @@ export function getStaticMount(pathname: string): StaticMountHit | null {
3259
return { distDir: best.distDir, relative }
3360
}
3461

62+
// Bases (without trailing slash, e.g. `/__git`) under which the catch-all
63+
// route should serve the hub's connection meta at `<base>/__connection.json`.
64+
const CONNECTION_META_BASES = new Set<string>()
65+
const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}`
66+
67+
/**
68+
* If `pathname` is a `<base>/__connection.json` request for a base the hub
69+
* registered via `DevframeHost.mountConnectionMeta`, return that base;
70+
* otherwise `null`. The catch-all route uses this to answer the connection-meta
71+
* fetch a mounted devframe SPA makes from inside its iframe.
72+
*/
73+
export function isConnectionMetaPath(pathname: string): boolean {
74+
if (!pathname.endsWith(META_SUFFIX))
75+
return false
76+
return CONNECTION_META_BASES.has(pathname.slice(0, -META_SUFFIX.length))
77+
}
78+
3579
export interface MinimalNextDevframeHubOptions {
3680
/** Preferred port for the side-car RPC/WS server. Default: a free port near 9877. */
3781
port?: number
@@ -85,6 +129,12 @@ export async function minimalNextDevframeHub(
85129
mountStatic(base, distDir) {
86130
STATIC_MOUNTS.set(base.replace(/\/$/, ''), distDir)
87131
},
132+
// Record the base so the catch-all route can answer `<base>/__connection.json`
133+
// with the hub's connection meta — letting the mounted SPA connect without
134+
// relying on same-origin parent-window inheritance.
135+
mountConnectionMeta(base) {
136+
CONNECTION_META_BASES.add(base.replace(/\/$/, ''))
137+
},
88138
resolveOrigin() {
89139
return `http://${hostName}:3000`
90140
},
@@ -116,13 +166,17 @@ export async function minimalNextDevframeHub(
116166
handler: () => 'pong',
117167
})
118168

169+
// Demo devframes alongside the dogfooded built-in plugin packages.
170+
const devframes = options.devframes
171+
?? [demoDevframe, demoDevframeB, ...await loadBuiltinPlugins()]
172+
119173
await context.messages.add({
120174
level: 'success',
121175
message: 'Minimal Next Devframe Hub started',
122-
description: `Side-car WS on port ${port}. ${options.devframes?.length ?? 1} devframe(s) registered.`,
176+
description: `Side-car WS on port ${port}. ${devframes.length} devframe(s) registered.`,
123177
})
124178

125-
for (const def of options.devframes ?? [demoDevframe, demoDevframeB]) {
179+
for (const def of devframes) {
126180
await mountDevframe(context, def)
127181
}
128182

examples/minimal-next-devframe-hub/src/client/next.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ const nextConfig = {
33
images: { unoptimized: true },
44
// The workspace typecheck owns source-level project references.
55
typescript: { ignoreBuildErrors: true },
6+
// Mounted devframe SPAs are served at `/__<id>/` and reference their assets
7+
// relatively (`./_next/…`, `./assets/…`). Next's default trailing-slash
8+
// redirect (`/__git/` → `/__git`) would re-root those relative paths and 404
9+
// every asset, leaving the panel unstyled and unable to connect. Serving the
10+
// base path verbatim keeps the SPA's relative asset resolution intact.
11+
skipTrailingSlashRedirect: true,
612
}
713

814
export default nextConfig

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
"description": "Protocol-witness example — a tiny Vite 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-vite-devframe-hub",
88
"scripts": {
9-
"dev": "vite",
9+
"dev": "vite --host",
1010
"build": "vite build"
1111
},
1212
"dependencies": {
1313
"@devframes/hub": "workspace:*",
14+
"@devframes/plugin-code-server": "workspace:*",
15+
"@devframes/plugin-git": "workspace:*",
16+
"@devframes/plugin-terminals": "workspace:*",
1417
"devframe": "workspace:*"
1518
},
1619
"devDependencies": {

examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,29 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions =
7777
started = undefined
7878

7979
const cwd = viteConfig!.root
80+
const port = options.port ?? await getPort({ port: 9777, random: false })
81+
82+
// Serve the side-car's connection meta (`__connection.json`) at a URL
83+
// base so a browser loaded there can discover the WS endpoint via
84+
// `connectDevframe()`'s relative `./__connection.json` fetch.
85+
const serveConnectionMeta = (metaBase: string): void => {
86+
const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}`
87+
server.middlewares.use(metaPath, (_req, res) => {
88+
res.setHeader('Content-Type', 'application/json')
89+
res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
90+
})
91+
}
8092

8193
const host: DevframeHost = {
8294
mountStatic(base, distDir) {
8395
server.middlewares.use(base, serveStaticNodeMiddleware(distDir))
8496
},
97+
// Serve `<base>__connection.json` for each mounted devframe so its
98+
// SPA connects to the hub without relying on same-origin parent-window
99+
// inheritance — which breaks for cross-origin / sandboxed iframes.
100+
mountConnectionMeta(base) {
101+
serveConnectionMeta(base)
102+
},
85103
resolveOrigin() {
86104
const resolved = server.resolvedUrls?.local?.[0]
87105
return resolved ? new URL(resolved).origin : 'http://localhost:5173'
@@ -93,8 +111,6 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions =
93111
},
94112
}
95113

96-
const port = options.port ?? await getPort({ port: 9777, random: false })
97-
98114
const context = await createHubContext({
99115
cwd,
100116
workspaceRoot: cwd,
@@ -135,13 +151,8 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions =
135151
auth: false,
136152
})
137153

138-
// Tell the browser where to find the WS endpoint. `connectDevframe`
139-
// resolves this URL relative to its `baseURL` option.
140-
const metaPath = `${base}${DEVFRAME_CONNECTION_META_FILENAME}`
141-
server.middlewares.use(metaPath, (_req, res) => {
142-
res.setHeader('Content-Type', 'application/json')
143-
res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
144-
})
154+
// Tell the hub UI (served at `base`) where to find the WS endpoint.
155+
serveConnectionMeta(base)
145156

146157
server.httpServer?.once('close', () => {
147158
void started?.close().catch(() => {})

examples/minimal-vite-devframe-hub/vite.config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import codeServerDevframe from '@devframes/plugin-code-server'
2+
import gitDevframe from '@devframes/plugin-git'
3+
import terminalsDevframe from '@devframes/plugin-terminals'
14
import { defineConfig } from 'vite'
25
import { alias } from '../../alias'
36
import demoDevframe from './src/devframe'
@@ -8,7 +11,14 @@ export default defineConfig({
811
resolve: { alias },
912
plugins: [
1013
minimalViteDevframeHub({
11-
devframes: [demoDevframe, demoDevframeB],
14+
devframes: [
15+
demoDevframe,
16+
demoDevframeB,
17+
// Built-in plugins, dogfooded end-to-end through the hub mount path.
18+
gitDevframe,
19+
terminalsDevframe,
20+
codeServerDevframe,
21+
],
1222
}),
1323
],
1424
})

pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)