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 + * `