Skip to content

Commit 4fc3f2f

Browse files
committed
feat(hub): add @devframes/hub/initiate — initHub, the headless hub behind one handler
initHub() serves the whole multi-devframe devtools surface through one web-standard handler under a single namespace (default /__devframes/): every mounted devframe shares one hub context (merged RPC registry, shared state, docks/terminals/messages/commands), one WebSocket transport, and one hub Auth; frames serve at <base><id>/ with per-frame discovery pointing at the shared socket. - reserved layout: __connection.json, __ws, __index.json, __client-imports.js, __mcp (aggregate over the shared registry), embedded.js; frame ids validated against it (DF8000) - DevframeHubUi slot (pure data): ui.viewer owns the namespace root, ui.embedded serves the floating bootstrap at embedded.js — omitted, the hub stays fully headless and the root serves the index document - assembly modes: declarative devframes list (+ configure(ctx)) or a pre-built context (DF8002 when both); key memoization against dev-reload leaks (DF8001) - devframe: the Bun WS tier is promoted to the public devframe/rpc/transports/ws-bun subpath, createContextRpcServer is exported from devframe/node, and adapters/mcp exports mountMcpHttp — the primitives initHub composes - mountDevframe now mounts a frame's connection meta before its SPA statics so route-ordered hosts (h3) resolve the exact meta route ahead of the static catch-all
1 parent b51603a commit 4fc3f2f

30 files changed

Lines changed: 1203 additions & 123 deletions

alias.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.m
77
const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url))
88

99
export const alias = {
10+
'devframe/rpc/transports/ws-bun': r('devframe/src/rpc/transports/ws-bun.ts'),
1011
'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'),
1112
'devframe/rpc/transports/ws-client': r('devframe/src/rpc/transports/ws-client.ts'),
1213
'devframe/rpc/client': r('devframe/src/rpc/client.ts'),
@@ -44,6 +45,7 @@ export const alias = {
4445
'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'),
4546
'@devframes/hub/client': r('hub/src/client/index.ts'),
4647
'@devframes/hub/constants': r('hub/src/constants.ts'),
48+
'@devframes/hub/initiate': r('hub/src/node/initiate.ts'),
4749
'@devframes/hub/node': r('hub/src/node/index.ts'),
4850
'@devframes/hub/types': r('hub/src/types/index.ts'),
4951
'@devframes/hub': r('hub/src/index.ts'),

docs/errors/DF8000.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8000: Devframe Id Collides With a Reserved Hub Path
6+
7+
## Message
8+
9+
> Devframe id "`{id}`" collides with a reserved hub path — it cannot be mounted directly under the hub base.
10+
11+
## Cause
12+
13+
`initHub` mounts every devframe at `<base><id>/`, 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.
14+
15+
## Example
16+
17+
```ts
18+
import { initHub } from '@devframes/hub/initiate'
19+
20+
initHub({
21+
devframes: [defineDevframe({ id: '__mcp', /**/ })], // ✗ throws DF8000
22+
})
23+
```
24+
25+
## Fix
26+
27+
Rename the devframe id, or mount it at a non-colliding path via `basePath` on the definition.
28+
29+
## Source
30+
31+
- [`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.

docs/errors/DF8001.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8001: Memoized Hub Instance Replaced
6+
7+
## Message
8+
9+
> initHub replaced the live hub instance memoized under key "`{key}`": its options changed since the previous call.
10+
11+
## Cause
12+
13+
`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.
14+
15+
## Example
16+
17+
```ts
18+
import { initHub } from '@devframes/hub/initiate'
19+
20+
// First evaluation:
21+
initHub({ key: 'devtools', devframes: [git] })
22+
23+
// A later reload with a different frame list replaces the live instance:
24+
initHub({ key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
25+
```
26+
27+
## Fix
28+
29+
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.
30+
31+
## Source
32+
33+
- [`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.

docs/errors/DF8002.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8002: Both devframes and context Passed to initHub
6+
7+
## Message
8+
9+
> initHub received both `devframes` and `context` — the two assembly modes are mutually exclusive.
10+
11+
## Cause
12+
13+
`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 `<base><id>/`), 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.
14+
15+
## Example
16+
17+
```ts
18+
// ✗ Bad
19+
initHub({ devframes: [git], context: myCtx })
20+
21+
// ✓ Good — declarative:
22+
initHub({ devframes: [git] })
23+
24+
// ✓ Good — bring your own context:
25+
const ctx = await createHubContext({ host: myHost, cwd })
26+
await mountDevframe(ctx, git)
27+
initHub({ context: ctx })
28+
```
29+
30+
## Fix
31+
32+
Pick one mode. Use `configure(ctx)` on the declarative mode when you need post-mount registrations (docks, commands, terminals) on the instance-created context.
33+
34+
## Source
35+
36+
- [`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.

docs/errors/DF8003.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8003: connectionMeta() Before Hub Instance Ready
6+
7+
## Message
8+
9+
> connectionMeta() was called before initHub finished initializing.
10+
11+
## Cause
12+
13+
`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.
14+
15+
## Example
16+
17+
```ts
18+
import { initHub } from '@devframes/hub/initiate'
19+
20+
const hub = initHub({ devframes: [git] })
21+
hub.connectionMeta() // ✗ throws DF8003 — init is still in flight
22+
23+
await hub.ready
24+
hub.connectionMeta() // ✓ { backend: 'websocket', websocket: { … } }
25+
```
26+
27+
## Fix
28+
29+
Await `instance.ready` (or any request through `instance.handler` — it awaits readiness internally) before reading `connectionMeta()`.
30+
31+
## Source
32+
33+
- [`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.

knip.jsonc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,13 @@
8181
"src/recipes/{common-rpc-functions,interactive-auth,open-helpers}.ts",
8282
"src/rpc/{index,client,server}.ts",
8383
"src/rpc/dump/index.ts",
84-
"src/rpc/transports/{ws-client,ws-server}.ts",
84+
"src/rpc/transports/{ws-bun,ws-client,ws-server}.ts",
8585
"src/types/index.ts",
8686
"src/utils/*.ts"
8787
]
8888
},
8989
"packages/hub": {
90-
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts"]
90+
"entry": ["src/{index,constants}.ts", "src/{client,node,types}/index.ts", "src/node/initiate.ts"]
9191
},
9292
"packages/json-render": {
9393
// `src/node/index.ts` is already picked up via `tsdown.config.ts`

packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"./rpc/client": "./dist/rpc/client.mjs",
4141
"./rpc/dump": "./dist/rpc/dump.mjs",
4242
"./rpc/server": "./dist/rpc/server.mjs",
43+
"./rpc/transports/ws-bun": "./dist/rpc/transports/ws-bun.mjs",
4344
"./rpc/transports/ws-client": "./dist/rpc/transports/ws-client.mjs",
4445
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
4546
"./types": "./dist/types/index.mjs",

packages/devframe/src/adapters/initiate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, Devfr
44
import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
55
import type { DevframeAuthHandler } from '../node/auth/handler'
66
import type { StartedServer } from '../node/server'
7+
import type { BunWsTier } from '../rpc/transports/ws-bun'
78
import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe'
8-
import type { BunWsTier } from './initiate-bun'
99
import process from 'node:process'
1010
import { mountStaticHandler } from 'devframe/utils/serve-static'
1111
import { H3, toNodeHandler } from 'h3'
@@ -456,7 +456,7 @@ function instantiateDevframe(
456456
else {
457457
// Bun fetch-upgrade — same-origin upgrades completed through
458458
// `handler(request, server)`, hooks exposed via `websocket`.
459-
const { attachBunWsTransport } = await import('./initiate-bun')
459+
const { attachBunWsTransport } = await import('../rpc/transports/ws-bun')
460460
const { createContextRpcServer } = await import('../node/rpc-core')
461461
const core = createContextRpcServer({
462462
context: ctx,

packages/devframe/src/adapters/mcp/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ export {
2323
type CreateMcpFetchHandlerOptions,
2424
type McpFetchHandler,
2525
} from './fetch'
26+
27+
export {
28+
type MountedMcpHttp,
29+
mountMcpHttp,
30+
type MountMcpHttpOptions,
31+
} from './http'

packages/devframe/src/node/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export * from './host-views'
1919
// lower-level read/probe/prune helpers stay internal to the connector.
2020
export { listLiveDevframeInstances, registerDevframeInstance } from './instance-registry'
2121
export type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
22+
// The transport-agnostic RPC core is public so hosts that bind their own
23+
// transports (a Bun fetch-upgrade route, a custom relay) reuse the exact
24+
// session/auth wiring `startHttpAndWs` uses — see `createContextRpcServer`.
25+
export * from './rpc-core'
2226
export * from './rpc-shared-state'
2327
export * from './rpc-streaming'
2428
export * from './scope'

0 commit comments

Comments
 (0)