diff --git a/alias.ts b/alias.ts
index 3efeb54a..1b1f8806 100644
--- a/alias.ts
+++ b/alias.ts
@@ -7,6 +7,7 @@ const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.m
const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url))
export const alias = {
+ 'devframe/rpc/transports/ws-bun': r('devframe/src/rpc/transports/ws-bun.ts'),
'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'),
'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'),
'devframe/rpc/client': r('devframe/src/rpc/client.ts'),
@@ -44,6 +45,7 @@ export const alias = {
'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'),
'@devframes/hub/client': r('hub/src/client/index.ts'),
'@devframes/hub/constants': r('hub/src/constants.ts'),
+ '@devframes/hub/initiate': r('hub/src/node/initiate.ts'),
'@devframes/hub/node': r('hub/src/node/index.ts'),
'@devframes/hub/types': r('hub/src/types/index.ts'),
'@devframes/hub': r('hub/src/index.ts'),
diff --git a/docs/errors/DF8000.md b/docs/errors/DF8000.md
new file mode 100644
index 00000000..2b1dfaaf
--- /dev/null
+++ b/docs/errors/DF8000.md
@@ -0,0 +1,31 @@
+---
+outline: deep
+---
+
+# DF8000: Devframe Id Collides With a Reserved Hub Path
+
+## Message
+
+> Devframe id "`{id}`" collides with a reserved hub path — it cannot be mounted directly under the hub base.
+
+## Cause
+
+`initHub` mounts every devframe at `/`, directly under the hub base. The filenames that live at that same level — `__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, `__mcp`, and `embedded.js` — are the hub protocol's own endpoints, so a devframe id equal to one of them would shadow the endpoint.
+
+## Example
+
+```ts
+import { initHub } from '@devframes/hub/initiate'
+
+initHub({
+ devframes: [defineDevframe({ id: '__mcp', /* … */ })], // ✗ throws DF8000
+})
+```
+
+## Fix
+
+Rename the devframe id, or mount it at a non-colliding path via `basePath` on the definition.
+
+## Source
+
+- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` throws this while mounting the `devframes` list.
diff --git a/docs/errors/DF8001.md b/docs/errors/DF8001.md
new file mode 100644
index 00000000..34aad8f7
--- /dev/null
+++ b/docs/errors/DF8001.md
@@ -0,0 +1,33 @@
+---
+outline: deep
+---
+
+# DF8001: Memoized Hub Instance Replaced
+
+## Message
+
+> initHub replaced the live hub instance memoized under key "`{key}`": its options changed since the previous call.
+
+## Cause
+
+`initHub` was called with a `key` that already maps to a live instance, but the option fingerprint differs from the memoized one's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `initHub` on every reload; the `key` memoization normally returns the live instance, but when the options genuinely changed the old instance — including its side-car WebSocket server — is closed and a fresh one starts.
+
+## Example
+
+```ts
+import { initHub } from '@devframes/hub/initiate'
+
+// First evaluation:
+initHub({ key: 'devtools', devframes: [git] })
+
+// A later reload with a different frame list replaces the live instance:
+initHub({ key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
+```
+
+## Fix
+
+This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, keep the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different hubs distinct keys.
+
+## Source
+
+- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` warns this before closing and replacing a memoized instance whose options fingerprint changed.
diff --git a/docs/errors/DF8002.md b/docs/errors/DF8002.md
new file mode 100644
index 00000000..c13c309c
--- /dev/null
+++ b/docs/errors/DF8002.md
@@ -0,0 +1,36 @@
+---
+outline: deep
+---
+
+# DF8002: Both devframes and context Passed to initHub
+
+## Message
+
+> initHub received both `devframes` and `context` — the two assembly modes are mutually exclusive.
+
+## Cause
+
+`initHub` assembles a hub in one of two ways: **declaratively** (`devframes: [...]` — the instance creates the hub context with its own host and mounts each frame under `/`), or **from a pre-built context** (`context: ctx` — your host already created the context and mounted the frames; the instance serves only the hub-level endpoints and transport). A `devframes` list cannot be mounted into a context whose host the instance doesn't own, so passing both is a contradiction.
+
+## Example
+
+```ts
+// ✗ Bad
+initHub({ devframes: [git], context: myCtx })
+
+// ✓ Good — declarative:
+initHub({ devframes: [git] })
+
+// ✓ Good — bring your own context:
+const ctx = await createHubContext({ host: myHost, cwd })
+await mountDevframe(ctx, git)
+initHub({ context: ctx })
+```
+
+## Fix
+
+Pick one mode. Use `configure(ctx)` on the declarative mode when you need post-mount registrations (docks, commands, terminals) on the instance-created context.
+
+## Source
+
+- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` throws this during initialization when both options are present.
diff --git a/docs/errors/DF8003.md b/docs/errors/DF8003.md
new file mode 100644
index 00000000..cbe488c2
--- /dev/null
+++ b/docs/errors/DF8003.md
@@ -0,0 +1,33 @@
+---
+outline: deep
+---
+
+# DF8003: connectionMeta() Before Hub Instance Ready
+
+## Message
+
+> connectionMeta() was called before initHub finished initializing.
+
+## Cause
+
+`initHub` is a synchronous factory that kicks off asynchronous initialization eagerly — creating the hub context, mounting every frame, and binding the WebSocket tier. `connectionMeta()` describes the WebSocket binding, which only exists once that initialization completes; calling it earlier has nothing correct to return.
+
+## Example
+
+```ts
+import { initHub } from '@devframes/hub/initiate'
+
+const hub = initHub({ devframes: [git] })
+hub.connectionMeta() // ✗ throws DF8003 — init is still in flight
+
+await hub.ready
+hub.connectionMeta() // ✓ { backend: 'websocket', websocket: { … } }
+```
+
+## Fix
+
+Await `instance.ready` (or any request through `instance.handler` — it awaits readiness internally) before reading `connectionMeta()`.
+
+## Source
+
+- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub`'s `connectionMeta()` throws this while initialization is still pending.
diff --git a/knip.jsonc b/knip.jsonc
index 6923fed4..4ab1553f 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -81,13 +81,13 @@
"src/recipes/{common-rpc-functions,interactive-auth,open-helpers}.ts",
"src/rpc/{index,client,server}.ts",
"src/rpc/dump/index.ts",
- "src/rpc/transports/{ws-client,ws-server}.ts",
+ "src/rpc/transports/{ws-bun,ws-client,ws-server}.ts",
"src/types/index.ts",
"src/utils/*.ts"
]
},
"packages/hub": {
- "entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts"]
+ "entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/initiate.ts"]
},
"packages/json-render": {
// `src/node/index.ts` is already picked up via `tsdown.config.ts`
diff --git a/packages/devframe/package.json b/packages/devframe/package.json
index 4d7ab5fb..fec54b16 100644
--- a/packages/devframe/package.json
+++ b/packages/devframe/package.json
@@ -40,6 +40,7 @@
"./rpc/client": "./dist/rpc/client.mjs",
"./rpc/dump": "./dist/rpc/dump.mjs",
"./rpc/server": "./dist/rpc/server.mjs",
+ "./rpc/transports/ws-bun": "./dist/rpc/transports/ws-bun.mjs",
"./rpc/transports/ws-client": "./dist/rpc/transports/ws-client.mjs",
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
"./types": "./dist/types/index.mjs",
diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts
index 6d4de4de..76d29e46 100644
--- a/packages/devframe/src/adapters/initiate.ts
+++ b/packages/devframe/src/adapters/initiate.ts
@@ -4,8 +4,8 @@ import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, Devfr
import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
import type { DevframeAuthHandler } from '../node/auth/handler'
import type { StartedServer } from '../node/server'
+import type { BunWsTier } from '../rpc/transports/ws-bun'
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
-import type { BunWsTier } from './initiate-bun'
import process from 'node:process'
import { mountStaticHandler } from 'devframe/utils/serve-static'
import { H3, toNodeHandler } from 'h3'
@@ -456,7 +456,7 @@ function instantiateDevframe(
else {
// Bun fetch-upgrade — same-origin upgrades completed through
// `handler(request, server)`, hooks exposed via `websocket`.
- const { attachBunWsTransport } = await import('./initiate-bun')
+ const { attachBunWsTransport } = await import('../rpc/transports/ws-bun')
const { createContextRpcServer } = await import('../node/rpc-core')
const core = createContextRpcServer({
context: ctx,
diff --git a/packages/devframe/src/adapters/mcp/index.ts b/packages/devframe/src/adapters/mcp/index.ts
index 485e083e..3704ca4b 100644
--- a/packages/devframe/src/adapters/mcp/index.ts
+++ b/packages/devframe/src/adapters/mcp/index.ts
@@ -23,3 +23,9 @@ export {
type CreateMcpFetchHandlerOptions,
type McpFetchHandler,
} from './fetch'
+
+export {
+ type MountedMcpHttp,
+ mountMcpHttp,
+ type MountMcpHttpOptions,
+} from './http'
diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts
index a511f40c..6df48382 100644
--- a/packages/devframe/src/node/index.ts
+++ b/packages/devframe/src/node/index.ts
@@ -19,6 +19,10 @@ export * from './host-views'
// lower-level read/probe/prune helpers stay internal to the connector.
export { listLiveDevframeInstances, registerDevframeInstance } from './instance-registry'
export type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
+// The transport-agnostic RPC core is public so hosts that bind their own
+// transports (a Bun fetch-upgrade route, a custom relay) reuse the exact
+// session/auth wiring `startHttpAndWs` uses — see `createContextRpcServer`.
+export * from './rpc-core'
export * from './rpc-shared-state'
export * from './rpc-streaming'
export * from './scope'
diff --git a/packages/devframe/src/adapters/initiate-bun.ts b/packages/devframe/src/rpc/transports/ws-bun.ts
similarity index 79%
rename from packages/devframe/src/adapters/initiate-bun.ts
rename to packages/devframe/src/rpc/transports/ws-bun.ts
index 1954df45..c4247ba9 100644
--- a/packages/devframe/src/adapters/initiate-bun.ts
+++ b/packages/devframe/src/rpc/transports/ws-bun.ts
@@ -1,6 +1,6 @@
-import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
-import type { ContextRpcServer } from '../node/rpc-core'
-import { createWsRpcPeerHooks, isAllowedOrigin } from 'devframe/rpc/transports/ws-server'
+import type { ContextRpcServer } from '../../node/rpc-core'
+import type { WsOriginRegistry } from './ws-server'
+import { createWsRpcPeerHooks, isAllowedOrigin } from './ws-server'
export interface AttachBunWsTransportOptions {
/** Same contract as `WsRpcTransportOptions.allowedOrigins`. */
@@ -12,7 +12,7 @@ export interface AttachBunWsTransportOptions {
* crossws Bun adapter produces — typed loosely so devframe carries no
* dependency on Bun's own types.
*/
-interface BunWsTierWebSocket {
+export interface BunWsTierWebSocket {
open?: (ws: unknown) => unknown
message: (ws: unknown, message: unknown) => unknown
close?: (ws: unknown, code?: number, reason?: string) => unknown
@@ -28,11 +28,11 @@ export interface BunWsTier {
}
/**
- * The Bun fetch-upgrade WebSocket tier for `createHandler` — the same RPC
- * peer wiring as `attachWsRpcTransport`, driven by crossws's Bun adapter so
- * upgrades complete through `fetch(request, server)` on the app's own
- * origin, with no side-car server. Loaded dynamically so the Bun adapter
- * never enters a Node-only bundle path.
+ * The Bun fetch-upgrade WebSocket tier for `initDevframe` / `initHub` — the
+ * same RPC peer wiring as `attachWsRpcTransport`, driven by crossws's Bun
+ * adapter so upgrades complete through `handler(request, server)` on the
+ * app's own origin, with no side-car server. Load it dynamically so the Bun
+ * adapter never enters a Node-only bundle path.
*/
export async function attachBunWsTransport(
core: ContextRpcServer,
diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts
index 70c9ee92..e1c79dbe 100644
--- a/packages/devframe/tsdown.config.ts
+++ b/packages/devframe/tsdown.config.ts
@@ -93,6 +93,7 @@ const serverEntries = {
'rpc/client': 'src/rpc/client.ts',
'rpc/dump': 'src/rpc/dump/index.ts',
'rpc/server': 'src/rpc/server.ts',
+ 'rpc/transports/ws-bun': 'src/rpc/transports/ws-bun.ts',
'rpc/transports/ws-client': 'src/rpc/transports/ws-client.ts',
'rpc/transports/ws-server': 'src/rpc/transports/ws-server.ts',
'node/index': 'src/node/index.ts',
diff --git a/packages/hub/package.json b/packages/hub/package.json
index 2c2405db..4d2d8e39 100644
--- a/packages/hub/package.json
+++ b/packages/hub/package.json
@@ -24,6 +24,7 @@
".": "./dist/index.mjs",
"./client": "./dist/client/index.mjs",
"./constants": "./dist/constants.mjs",
+ "./initiate": "./dist/node/initiate.mjs",
"./node": "./dist/node/index.mjs",
"./types": "./dist/types/index.mjs",
"./package.json": "./package.json"
@@ -44,15 +45,18 @@
"dependencies": {
"@standard-schema/spec": "catalog:deps",
"destr": "catalog:deps",
+ "h3": "catalog:deps",
"nostics": "catalog:deps",
"pathe": "catalog:deps",
"perfect-debounce": "catalog:deps",
"tinyexec": "catalog:deps",
+ "ufo": "catalog:deps",
"zigpty": "catalog:deps"
},
"devDependencies": {
"@types/node": "catalog:types",
"devframe": "workspace:*",
+ "get-port-please": "catalog:deps",
"mlly": "catalog:build",
"tsdown": "catalog:build",
"valibot": "catalog:deps"
diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts
new file mode 100644
index 00000000..065ce1aa
--- /dev/null
+++ b/packages/hub/src/node/__tests__/initiate.test.ts
@@ -0,0 +1,283 @@
+import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types'
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createRpcClient } from 'devframe/rpc/client'
+import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
+import { getPort } from 'get-port-please'
+import { describe, expect, it } from 'vitest'
+import { initHub } from '../initiate'
+
+function makeDist(html: string): string {
+ const dir = mkdtempSync(join(tmpdir(), 'hub-initiate-'))
+ writeFileSync(join(dir, 'index.html'), html, 'utf-8')
+ return dir
+}
+
+function makeFrame(id: string, distDir?: string): DevframeDefinition {
+ return {
+ id,
+ name: `Frame ${id}`,
+ version: '0.0.0',
+ packageName: `@test/${id}`,
+ homepage: '',
+ description: '',
+ ...(distDir ? { cli: { distDir } } : {}),
+ setup(ctx: DevframeNodeContext) {
+ ctx.rpc.register({ name: `${id}:probe`, type: 'query', handler: () => `ok:${id}` })
+ ctx.agent.registerTool({
+ id: `${id}-tool`,
+ description: `Tool from ${id}.`,
+ safety: 'read',
+ handler: () => ({ from: id }),
+ })
+ },
+ }
+}
+
+function connectWsClient(url: string) {
+ return createRpcClient(
+ {} as DevframeRpcClientFunctions,
+ { channel: createWsRpcChannel({ url }) },
+ )
+}
+
+describe('initHub', () => {
+ it('connectionMeta() before ready throws DF8003', () => {
+ const hub = initHub({ auth: false })
+ expect(() => hub.connectionMeta()).toThrow(/DF8003|finished initializing/)
+ return hub.close()
+ })
+
+ it('rejects a devframe id that shadows a reserved hub path', async () => {
+ const hub = initHub({ auth: false, devframes: [makeFrame('__mcp')] })
+ await expect(hub.ready).rejects.toThrow(/DF8000|reserved hub path/)
+ await hub.close()
+ })
+
+ it('rejects devframes together with a pre-built context', async () => {
+ const hub = initHub({ auth: false })
+ await hub.ready
+ const ctx = await hub.context
+ const conflicting = initHub({ auth: false, context: ctx, devframes: [makeFrame('git')] })
+ await expect(conflicting.ready).rejects.toThrow(/DF8002|mutually exclusive/)
+ await conflicting.close()
+ await hub.close()
+ })
+
+ it('shared-server tier: one namespace serves frames, discovery, and one shared socket', async () => {
+ const host = '127.0.0.1'
+ const port = await getPort({ port: 18210, host })
+ const distA = makeDist('frame a')
+ const distB = makeDist('frame b')
+
+ let hubRef!: ReturnType
+ const server = createServer((req, res) => {
+ hubRef.nodeMiddleware(req, res, () => {
+ res.statusCode = 418
+ res.end('host app')
+ })
+ })
+ hubRef = initHub({
+ auth: false,
+ server,
+ devframes: [makeFrame('alpha', distA), makeFrame('beta', distB)],
+ })
+ await new Promise(resolve => server.listen(port, host, resolve))
+
+ try {
+ await hubRef.ready
+ // One shared socket, advertised hub-base-absolute so the same meta
+ // resolves correctly from the hub base and from every frame base.
+ expect(hubRef.connectionMeta()).toEqual({
+ backend: 'websocket',
+ websocket: { path: '/__devframes/__ws' },
+ })
+
+ // Frame SPAs under /.
+ const alpha = await fetch(`http://${host}:${port}/__devframes/alpha/`)
+ expect(await alpha.text()).toContain('frame a')
+ const beta = await fetch(`http://${host}:${port}/__devframes/beta/`)
+ expect(await beta.text()).toContain('frame b')
+
+ // Per-frame discovery points at the shared hub socket.
+ const frameMeta = await (await fetch(`http://${host}:${port}/__devframes/alpha/__connection.json`)).json()
+ expect(frameMeta.websocket).toEqual({ path: '/__devframes/__ws' })
+
+ // The index document names every frame; the headless root serves it too.
+ const index = await (await fetch(`http://${host}:${port}/__devframes/__index.json`)).json()
+ expect(index.frames.map((f: { id: string }) => f.id)).toEqual(['alpha', 'beta'])
+ const root = await (await fetch(`http://${host}:${port}/__devframes/`)).json()
+ expect(root.frames.length).toBe(2)
+
+ // No ui slot → embedded.js is a reserved 404.
+ const embedded = await fetch(`http://${host}:${port}/__devframes/embedded.js`)
+ expect(embedded.status).toBe(404)
+
+ // The client-imports module is served as an ES module.
+ const imports = await fetch(`http://${host}:${port}/__devframes/__client-imports.js`)
+ expect(imports.headers.get('content-type')).toContain('javascript')
+ expect(await imports.text()).toContain('export const clientImports')
+
+ // Outside the namespace stays the host app's.
+ const outside = await fetch(`http://${host}:${port}/app`)
+ expect(outside.status).toBe(418)
+
+ // Cross-frame RPC: one merged registry on the one shared socket.
+ const client = connectWsClient(`ws://${host}:${port}/__devframes/__ws`)
+ await expect(client.$call('alpha:probe' as any)).resolves.toBe('ok:alpha')
+ await expect(client.$call('beta:probe' as any)).resolves.toBe('ok:beta')
+ client.$close()
+ }
+ finally {
+ await hubRef.close()
+ server.close()
+ server.closeAllConnections()
+ }
+ })
+
+ it('ui slot: viewer owns the root, embedded.js serves the entry, discovery still wins', async () => {
+ const viewerDist = makeDist('hub viewer')
+ const embeddedDir = mkdtempSync(join(tmpdir(), 'hub-embedded-'))
+ const embeddedEntry = join(embeddedDir, 'embedded.js')
+ writeFileSync(embeddedEntry, 'console.log("embedded bootstrap")', 'utf-8')
+ const wsPort = await getPort({ port: 18220, host: '127.0.0.1' })
+
+ const hub = initHub({
+ auth: false,
+ host: '127.0.0.1',
+ ws: { port: wsPort },
+ devframes: [makeFrame('alpha', makeDist('frame a'))],
+ ui: {
+ viewer: { distDir: viewerDist },
+ embedded: { entry: embeddedEntry },
+ },
+ })
+
+ try {
+ await hub.ready
+ const origin = 'http://localhost:5173'
+
+ // The viewer SPA owns the namespace root…
+ const root = await hub.handler(new Request(`${origin}/__devframes/`))
+ expect(await root.text()).toContain('hub viewer')
+
+ // …while exact protocol endpoints still win over its SPA fallback.
+ const index = await hub.handler(new Request(`${origin}/__devframes/__index.json`))
+ expect((await index.json()).endpoints.embedded).toBe('embedded.js')
+ const meta = await hub.handler(new Request(`${origin}/__devframes/__connection.json`))
+ expect((await meta.json()).websocket).toEqual({ port: wsPort, path: '__ws' })
+
+ // The embedded bootstrap serves from the ui slot.
+ const embedded = await hub.handler(new Request(`${origin}/__devframes/embedded.js`))
+ expect(embedded.headers.get('content-type')).toContain('javascript')
+ expect(await embedded.text()).toContain('embedded bootstrap')
+
+ // Frames still mount beneath the viewer's base.
+ const alpha = await hub.handler(new Request(`${origin}/__devframes/alpha/`))
+ expect(await alpha.text()).toContain('frame a')
+ }
+ finally {
+ await hub.close()
+ }
+ })
+
+ it('aggregate MCP: one endpoint lists tools from every mounted frame', async () => {
+ const wsPort = await getPort({ port: 18230, host: '127.0.0.1' })
+ const hub = initHub({
+ auth: false,
+ host: '127.0.0.1',
+ ws: { port: wsPort },
+ mcp: true,
+ devframes: [makeFrame('alpha'), makeFrame('beta')],
+ })
+
+ try {
+ await hub.ready
+ expect(hub.connectionMeta().mcp).toEqual({ path: '__mcp' })
+
+ const origin = 'http://localhost:3000'
+ const init = await hub.handler(new Request(`${origin}/__devframes/__mcp`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'accept': 'application/json, text/event-stream',
+ origin,
+ },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'initialize',
+ params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
+ }),
+ }))
+ expect(init.status).toBe(200)
+ const sessionId = init.headers.get('mcp-session-id')
+ expect(sessionId).toBeTruthy()
+ await init.body?.cancel()
+
+ const initialized = await hub.handler(new Request(`${origin}/__devframes/__mcp`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'accept': 'application/json, text/event-stream',
+ 'mcp-session-id': sessionId!,
+ origin,
+ },
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
+ }))
+ await initialized.body?.cancel()
+
+ const list = await hub.handler(new Request(`${origin}/__devframes/__mcp`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'accept': 'application/json, text/event-stream',
+ 'mcp-session-id': sessionId!,
+ origin,
+ },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
+ }))
+ expect(list.status).toBe(200)
+ const raw = await list.text()
+ // Tools from both frames surface through the one aggregate endpoint.
+ expect(raw).toContain('alpha-tool')
+ expect(raw).toContain('beta-tool')
+ }
+ finally {
+ await hub.close()
+ }
+ })
+
+ it('single hub Auth: one gate covers every frame on the shared socket', async () => {
+ const wsPort = await getPort({ port: 18240, host: '127.0.0.1' })
+ const hub = initHub({
+ host: '127.0.0.1',
+ ws: { port: wsPort },
+ devframes: [makeFrame('alpha')],
+ })
+
+ try {
+ await hub.ready
+ const client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`)
+ const handshake = await client.$call('anonymous:devframe:auth' as any, { authToken: '', ua: 'test', origin: 'http://localhost' }) as { isTrusted: boolean }
+ expect(handshake.isTrusted).toBe(false)
+ // Untrusted callers reach neither frame functions nor hub built-ins.
+ await expect(client.$call('alpha:probe' as any)).rejects.toThrow()
+ client.$close()
+ }
+ finally {
+ await hub.close()
+ }
+ })
+
+ it('key memoization returns the live instance', async () => {
+ const wsPort = await getPort({ port: 18250, host: '127.0.0.1' })
+ const a = initHub({ auth: false, key: 'hub-memo', host: '127.0.0.1', ws: { port: wsPort } })
+ const b = initHub({ auth: false, key: 'hub-memo', host: '127.0.0.1', ws: { port: wsPort } })
+ expect(b).toBe(a)
+ await a.ready
+ await a.close()
+ })
+})
diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts
index 75211a2f..eaf01246 100644
--- a/packages/hub/src/node/diagnostics.ts
+++ b/packages/hub/src/node/diagnostics.ts
@@ -14,6 +14,22 @@ export const diagnostics = defineDiagnostics({
docsBase: 'https://devfra.me/errors',
reporters: [hubReporter],
codes: {
+ DF8000: {
+ why: (p: { id: string }) => `Devframe id "${p.id}" collides with a reserved hub path — it cannot be mounted directly under the hub base.`,
+ fix: 'The filenames directly under the hub base (`__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, `__mcp`, `embedded.js`) are reserved for the hub protocol. Rename the devframe id, or override its mount with a non-colliding `basePath`.',
+ },
+ DF8001: {
+ why: (p: { key: string }) => `initHub replaced the live hub instance memoized under key "${p.key}": its options changed since the previous call.`,
+ fix: 'A dev-time module reload re-ran initHub with different options, so the old instance (and its side-car WebSocket server) was closed and a new one started. If this is unexpected, keep the options stable across reloads — or use distinct keys for genuinely different hubs.',
+ },
+ DF8002: {
+ why: 'initHub received both `devframes` and `context` — the two assembly modes are mutually exclusive.',
+ fix: 'Pass `devframes` to let the instance create the hub context and mount each frame itself, or pass a pre-built `context` (your host already mounted the frames) — never both.',
+ },
+ DF8003: {
+ why: 'connectionMeta() was called before initHub finished initializing.',
+ fix: 'Await `instance.ready` (or any request through `instance.handler`) before reading `connectionMeta()` — the WebSocket binding it describes is only known once initialization completes.',
+ },
DF8100: {
why: (p: { id: string }) => `Dock with id "${p.id}" is already registered`,
fix: 'Use the `force` parameter to overwrite an existing registration.',
diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts
new file mode 100644
index 00000000..66f95c41
--- /dev/null
+++ b/packages/hub/src/node/initiate.ts
@@ -0,0 +1,580 @@
+import type { StartedServer } from 'devframe/node'
+import type { DevframeAuthHandler } from 'devframe/node/auth'
+import type { BunWsTier } from 'devframe/rpc/transports/ws-bun'
+import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
+import type { ConnectionMeta, DevframeDefinition, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types'
+import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
+import type { ClientScriptEntry } from '../types/docks'
+import type { DevframeHubContext } from './context'
+import { createReadStream } from 'node:fs'
+import process from 'node:process'
+import { Readable } from 'node:stream'
+import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_DOCK_IMPORTS_FILENAME, DEVFRAME_MCP_ROUTE, DEVFRAME_WS_ROUTE } from 'devframe/constants'
+import { createH3DevframeHost, startHttpAndWs } from 'devframe/node'
+import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'
+import { mountStaticHandler } from 'devframe/utils/serve-static'
+import { getPort } from 'get-port-please'
+import { H3, toNodeHandler } from 'h3'
+import { resolve } from 'pathe'
+import { cleanDoubleSlashes, joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash, withTrailingSlash } from 'ufo'
+import { createHubContext } from './context'
+import { diagnostics } from './diagnostics'
+import { mountDevframe } from './mount-devframe'
+
+/** Default mount base for a hub instance — one namespace, one catch-all. */
+export const DEVFRAMES_HUB_BASE = '/__devframes/'
+
+/** Reserved filenames directly under the hub base — a frame id can't shadow them. */
+const RESERVED_HUB_PATHS = [
+ DEVFRAME_CONNECTION_META_FILENAME,
+ DEVFRAME_DOCK_IMPORTS_FILENAME,
+ DEVFRAME_WS_ROUTE,
+ DEVFRAME_MCP_ROUTE,
+ '__index.json',
+ 'embedded.js',
+] as const
+
+/**
+ * The UI slot of a hub instance — pure data, zero policy. The hub itself is
+ * headless: whoever fills this slot decides what a viewer looks like.
+ * `@devframes/hub-ui` ships the reference implementation (`createUi()`);
+ * Vite DevTools or any community viewer supplies its own object to the same
+ * slot and reuses all the infrastructure.
+ */
+export interface DevframeHubUi {
+ /**
+ * A standalone viewer SPA (built with relative asset paths) served at the
+ * hub base itself — open `` in a tab and the devtools are there.
+ */
+ viewer?: {
+ /** Directory of the prebuilt viewer SPA. */
+ distDir: string
+ }
+ /**
+ * A prebuilt, self-contained script served at `embedded.js` — the
+ * floating-devtools bootstrap a host page loads with one
+ * `