diff --git a/alias.ts b/alias.ts index 305572d3..3efeb54a 100644 --- a/alias.ts +++ b/alias.ts @@ -40,6 +40,7 @@ export const alias = { 'devframe/adapters/build': r('devframe/src/adapters/build.ts'), 'devframe/helpers/vite': r('devframe/src/helpers/vite.ts'), 'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'), + 'devframe/initiate': r('devframe/src/adapters/initiate.ts'), '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'), diff --git a/docs/adapters/dev.md b/docs/adapters/dev.md index ebc1b5ab..a15c813b 100644 --- a/docs/adapters/dev.md +++ b/docs/adapters/dev.md @@ -35,18 +35,18 @@ process.on('SIGINT', () => handle.close().then(() => process.exit(0))) ## WebSocket endpoint -By default the RPC socket shares the HTTP server's port and binds to the `__devframe_ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option): +By default the RPC socket shares the HTTP server's port and binds to the `__ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option): ```ts defineDevframe({ - // 1. Same server, a custom route (default route is `__devframe_ws`): + // 1. Same server, a custom route (default route is `__ws`): cli: { ws: { route: '__sockets' } }, // 2. A dedicated port on the same host: cli: { ws: { port: 9788 } }, // 3. A remote, fully-qualified endpoint (e.g. a tunnel/relay): - cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } }, + cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } }, }) ``` diff --git a/docs/errors/DF0053.md b/docs/errors/DF0053.md new file mode 100644 index 00000000..bb99b893 --- /dev/null +++ b/docs/errors/DF0053.md @@ -0,0 +1,33 @@ +--- +outline: deep +--- + +# DF0053: Memoized Instance Replaced + +## Message + +> initDevframe("`{id}`") replaced the live instance memoized under key "`{key}`": its options changed since the previous call. + +## Cause + +`initDevframe` 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 `initDevframe` 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 { initDevframe } from 'devframe/initiate' + +// First evaluation: +initDevframe(def, { key: 'devtools', ws: { port: 7811 } }) + +// A later reload with a different port replaces the live instance: +initDevframe(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053 +``` + +## Fix + +This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, make the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different instances distinct keys. + +## Source + +- [`packages/devframe/src/adapters/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/initiate.ts) — `initDevframe` warns this before closing and replacing a memoized instance whose options fingerprint changed. diff --git a/docs/errors/DF0054.md b/docs/errors/DF0054.md new file mode 100644 index 00000000..c1b1d909 --- /dev/null +++ b/docs/errors/DF0054.md @@ -0,0 +1,33 @@ +--- +outline: deep +--- + +# DF0054: connectionMeta() Before Instance Ready + +## Message + +> connectionMeta() was called before initDevframe("`{id}`") finished initializing. + +## Cause + +`initDevframe` is a synchronous factory that kicks off asynchronous initialization eagerly — running `def.setup`, binding the WebSocket tier, and mounting the routes. `connectionMeta()` describes the WebSocket binding, which only exists once that initialization completes; calling it earlier has nothing correct to return. + +## Example + +```ts +import { initDevframe } from 'devframe/initiate' + +const devtools = initDevframe(def) +devtools.connectionMeta() // ✗ throws DF0054 — init is still in flight + +await devtools.ready +devtools.connectionMeta() // ✓ { backend: 'websocket', websocket: { … } } +``` + +## Fix + +Await `instance.ready` (or any request through `instance.handler` — it awaits readiness internally) before reading `connectionMeta()`. + +## Source + +- [`packages/devframe/src/adapters/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/initiate.ts) — `initDevframe`'s `connectionMeta()` throws this while initialization is still pending. diff --git a/docs/guide/client.md b/docs/guide/client.md index 18c0bc2f..8b101874 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -229,12 +229,12 @@ With caching on, `query` / `static` function responses are memoized per argument ## Discovery (`__connection.json`) -Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (`__devframe_ws`) next to the meta file — and advertises it as a relative path: +Devframe writes a JSON descriptor at `/__connection.json` so the client knows where to connect. The dev server shares one port for HTTP and the WebSocket — the socket is bound to a route (`__ws`) next to the meta file — and advertises it as a relative path: ```json { "backend": "websocket", - "websocket": { "path": "__devframe_ws" } + "websocket": { "path": "__ws" } } ``` diff --git a/docs/helpers/vite-bridge.md b/docs/helpers/vite-bridge.md index 6eabe767..71ce2fc1 100644 --- a/docs/helpers/vite-bridge.md +++ b/docs/helpers/vite-bridge.md @@ -21,7 +21,7 @@ export default defineConfig({ ## Modes - **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__/` by default). No RPC server. Useful when you only need the SPA bundle served from a known path. -- **Bridge mode** (`devMiddleware: true | {…}`) — skips the static mount; the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port, so the descriptor carries that port alongside the `/__devframe_ws` route. +- **Bridge mode** (`devMiddleware: true | {…}`) — skips the static mount; the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port, so the descriptor carries that port alongside the `/__ws` route. To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass an existing HTTP server and a route to [`startHttpAndWs`](/adapters/dev) via its `server` and `path` options. Devframe routes only that upgrade path and leaves the rest (Vite's HMR socket included) untouched. diff --git a/knip.jsonc b/knip.jsonc index e6c64bc5..6923fed4 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -73,7 +73,7 @@ "entry": [ "src/{index,constants}.ts", "src/helpers/vite.ts", - "src/adapters/{build,cac,cli,dev,embedded}.ts", + "src/adapters/{build,cac,cli,dev,embedded,initiate}.ts", "src/adapters/mcp/index.ts", "src/client/index.ts", "src/node/index.ts", diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 7e352071..3c75b6a3 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -29,6 +29,7 @@ "./client": "./dist/client/index.mjs", "./constants": "./dist/constants.mjs", "./helpers/vite": "./dist/helpers/vite.mjs", + "./initiate": "./dist/adapters/initiate.mjs", "./node": "./dist/node/index.mjs", "./node/auth": "./dist/node/auth.mjs", "./node/hub-internals": "./dist/node/hub-internals.mjs", diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 416b05a3..bca0fdb2 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -19,7 +19,7 @@ vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) })) function connectWsClient(host: string, port: number, authToken?: string) { return createRpcClient( {} as DevframeRpcClientFunctions, - { channel: createWsRpcChannel({ url: `ws://${host}:${port}/__devframe_ws`, authToken }) }, + { channel: createWsRpcChannel({ url: `ws://${host}:${port}/__ws`, authToken }) }, ) } @@ -64,7 +64,7 @@ describe('adapters/dev', () => { const meta = await res.json() // Proxy-safe: the WS endpoint is advertised as a same-origin route // relative to `__connection.json`, never a baked-in host/port. - expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } }) + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } }) } finally { await handle.close() @@ -129,7 +129,7 @@ describe('adapters/dev', () => { try { // Connects on the bound route. - const ok = new WebSocket(`ws://${host}:${port}/__devframe_ws`) + const ok = new WebSocket(`ws://${host}:${port}/__ws`) await expect(new Promise((resolve, reject) => { ok.on('open', () => resolve('open')) ok.on('error', reject) @@ -205,11 +205,11 @@ describe('adapters/dev', () => { const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() expect(meta).toEqual({ backend: 'websocket', - websocket: { port: wsPort, path: '__devframe_ws' }, + websocket: { port: wsPort, path: '__ws' }, }) // The socket is reachable on its own port, rooted at `/`. - const ok = new WebSocket(`ws://${host}:${wsPort}/__devframe_ws`) + const ok = new WebSocket(`ws://${host}:${wsPort}/__ws`) await expect(new Promise((resolve, reject) => { ok.on('open', () => resolve('open')) ok.on('error', reject) @@ -234,7 +234,7 @@ describe('adapters/dev', () => { homepage: 'https://example.test', description: 'Test devframe.', setup: () => {}, - cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } }, + cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } }, }) const host = '127.0.0.1' const port = await getPort({ port: 19860, host }) @@ -244,7 +244,7 @@ describe('adapters/dev', () => { const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json() expect(meta).toEqual({ backend: 'websocket', - websocket: 'wss://devtools.example.com/relay/__devframe_ws', + websocket: 'wss://devtools.example.com/relay/__ws', }) } finally { @@ -276,7 +276,7 @@ describe('adapters/dev', () => { const res = await fetch(`http://${host}:${port}/__connection.json`) expect(res.ok).toBe(true) const meta = await res.json() - expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } }) + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } }) // The SPA mount is absent — without a distDir, no static handler // is wired, so the basePath returns a 404 from h3 instead of an @@ -559,7 +559,7 @@ describe('adapters/dev', () => { }) try { - const ws = new WebSocket(`ws://${host}:${port}/__devframe_ws`) + const ws = new WebSocket(`ws://${host}:${port}/__ws`) await new Promise((resolve, reject) => { ws.on('open', () => resolve()) ws.on('error', reject) diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts new file mode 100644 index 00000000..7877a23d --- /dev/null +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -0,0 +1,344 @@ +import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../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, vi } from 'vitest' +import { WebSocket } from 'ws' +import { getTempAuthCode } from '../../node/auth/state' +import { defineDevframe } from '../../types/devframe' +import { initDevframe } from '../initiate' + +const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' } + +function connectWsClient(url: string, authToken?: string) { + return createRpcClient( + {} as DevframeRpcClientFunctions, + { channel: createWsRpcChannel({ url, authToken }) }, + ) +} + +function makeTmpDist(): string { + const dir = mkdtempSync(join(tmpdir(), 'devframe-handler-')) + writeFileSync(join(dir, 'index.html'), 'handler test', 'utf-8') + return dir +} + +function defineTestDef(id: string) { + return defineDevframe({ + id, + name: 'Handler Test', + version: '0.0.0', + packageName: 'devframe-handler-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: (ctx: DevframeNodeContext) => { + ctx.rpc.register({ name: 'test:probe', type: 'query', handler: () => 'ok' }) + }, + }) +} + +describe('adapters/handler', () => { + it('connectionMeta() before ready throws DF0054', () => { + const devtools = initDevframe(defineTestDef('handler-early'), { auth: false }) + expect(() => devtools.connectionMeta()).toThrow(/DF0054|finished initializing/) + return devtools.close() + }) + + it('default tier: eager side-car — SPA, meta, and WS RPC through fetch', async () => { + const distDir = makeTmpDist() + const wsPort = await getPort({ port: 18110, host: '127.0.0.1' }) + const devtools = initDevframe(defineTestDef('handler-test'), { + auth: false, + distDir, + host: '127.0.0.1', + ws: { port: wsPort }, + }) + + try { + await devtools.ready + // The advertised meta carries the side-car port with the unified route. + expect(devtools.connectionMeta()).toEqual({ + backend: 'websocket', + websocket: { port: wsPort, path: '__ws' }, + }) + + // Hosted default base: /__/. + const index = await devtools.handler(new Request('http://localhost:3000/__handler-test/')) + expect(index.status).toBe(200) + expect(await index.text()).toContain('handler test') + + const metaRes = await devtools.handler(new Request('http://localhost:3000/__handler-test/__connection.json')) + expect(metaRes.status).toBe(200) + expect(await metaRes.json()).toEqual({ + backend: 'websocket', + websocket: { port: wsPort, path: '__ws' }, + }) + + // Outside the base — and inside it on a miss — the fetch surface 404s. + const outside = await devtools.handler(new Request('http://localhost:3000/app')) + expect(outside.status).toBe(404) + + // RPC round-trips against the side-car. + const client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await devtools.close() + } + + // Teardown is real: the side-car no longer accepts connections. + const gone = new WebSocket(`ws://127.0.0.1:${wsPort}/__ws`) + await expect(new Promise((resolve, reject) => { + gone.on('open', () => reject(new Error('should not connect after close'))) + gone.on('error', () => resolve('closed')) + })).resolves.toBe('closed') + }) + + it('gates by default: untrusted calls reject until the OTP exchange', async () => { + const wsPort = await getPort({ port: 18120, host: '127.0.0.1' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const devtools = initDevframe(defineTestDef('handler-auth'), { + host: '127.0.0.1', + ws: { port: wsPort }, + }) + + try { + await devtools.ready + // The banner waits for the public origin: unknown until a request + // arrives, then printed exactly once (the magic link points at the + // origin the handler is actually mounted on). + expect(spy).not.toHaveBeenCalled() + await devtools.handler(new Request('http://localhost:4321/__handler-auth/__connection.json')) + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('http://localhost:4321') + await devtools.handler(new Request('http://localhost:4321/__handler-auth/__connection.json')) + expect(spy).toHaveBeenCalledTimes(1) + + const client = connectWsClient(`ws://127.0.0.1:${wsPort}/__ws`) + const handshake = await client.$call('anonymous:devframe:auth' as any, HANDSHAKE) + expect(handshake).toEqual({ isTrusted: false }) + await expect(client.$call('test:probe' as any)).rejects.toThrow() + + const code = getTempAuthCode() + const exchange = await client.$call('anonymous:devframe:auth:exchange' as any, { code, ua: 'test', origin: 'http://localhost' }) as { authToken: string | null } + expect(exchange.authToken).toBeTruthy() + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + spy.mockRestore() + await devtools.close() + } + }) + + it('shared-server tier: nodeMiddleware + upgrade at __ws on the host server', async () => { + const distDir = makeTmpDist() + const host = '127.0.0.1' + const port = await getPort({ port: 18130, host }) + + let devtoolsRef!: ReturnType + const server = createServer((req, res) => { + // The middleware self-filters by base; everything else stays the + // host app's. + devtoolsRef.nodeMiddleware(req, res, () => { + res.statusCode = 418 + res.end('host app') + }) + }) + devtoolsRef = initDevframe(defineTestDef('handler-shared'), { + auth: false, + distDir, + server, + }) + await new Promise(resolve => server.listen(port, host, resolve)) + + try { + await devtoolsRef.ready + // Zero extra ports: the meta advertises a same-origin relative route, + // resolved against __connection.json's own URL. + expect(devtoolsRef.connectionMeta()).toEqual({ + backend: 'websocket', + websocket: { path: '__ws' }, + }) + + const index = await fetch(`http://${host}:${port}/__handler-shared/`) + expect(index.status).toBe(200) + expect(await index.text()).toContain('handler test') + + const meta = await (await fetch(`http://${host}:${port}/__handler-shared/__connection.json`)).json() + expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } }) + + // Outside the base, next() ran and the host app answered. + const outside = await fetch(`http://${host}:${port}/app`) + expect(outside.status).toBe(418) + expect(await outside.text()).toBe('host app') + + // The WS upgrade is bound on the host server at __ws. + const client = connectWsClient(`ws://${host}:${port}/__handler-shared/__ws`) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + + // Off-route upgrades are left alone for the host's own sockets to + // claim (never upgraded by devframe on a shared server). + const off = new WebSocket(`ws://${host}:${port}/other-socket`) + const offOpened = await new Promise((resolve) => { + const timer = setTimeout(resolve, 300, false) + off.on('open', () => { + clearTimeout(timer) + resolve(true) + }) + off.on('error', () => { + clearTimeout(timer) + resolve(false) + }) + }) + off.terminate() + expect(offOpened).toBe(false) + } + finally { + await devtoolsRef.close() + // Fire-and-forget teardown for the host-owned test server: the + // deliberately dangling off-route upgrade socket sits outside the + // http server's tracked connections, so a graceful close never + // settles. The handler already detached; the port frees on close. + server.close() + server.closeAllConnections() + } + }) + + it('ws.url tier: advertises the external endpoint verbatim, owns no transport', async () => { + const devtools = initDevframe(defineTestDef('handler-remote'), { + ws: { url: 'wss://devtools.example.com/relay/__ws' }, + }) + + try { + await devtools.ready + expect(devtools.connectionMeta()).toEqual({ + backend: 'websocket', + websocket: 'wss://devtools.example.com/relay/__ws', + }) + } + finally { + await devtools.close() + } + }) + + it('tunnel pattern: ws.url with a server binds locally, advertises the relay', async () => { + const host = '127.0.0.1' + const port = await getPort({ port: 18170, host }) + let devtoolsRef!: ReturnType + const server = createServer((req, res) => { + devtoolsRef.nodeMiddleware(req, res) + }) + devtoolsRef = initDevframe(defineTestDef('handler-tunnel'), { + auth: false, + server, + ws: { url: 'wss://devtools.example.com/relay/__ws' }, + }) + await new Promise(resolve => server.listen(port, host, resolve)) + + try { + await devtoolsRef.ready + // The browser is told to dial the relay… + expect(devtoolsRef.connectionMeta().websocket).toBe('wss://devtools.example.com/relay/__ws') + // …while the local socket keeps serving (the relay's forward target). + const client = connectWsClient(`ws://${host}:${port}/__handler-tunnel/__ws`) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await devtoolsRef.close() + server.close() + server.closeAllConnections() + } + }) + + it('mcp: mounts __mcp and advertises it in the meta', async () => { + const wsPort = await getPort({ port: 18140, host: '127.0.0.1' }) + const devtools = initDevframe(defineTestDef('handler-mcp'), { + auth: false, + mcp: true, + ws: { port: wsPort }, + }) + + try { + await devtools.ready + expect(devtools.connectionMeta().mcp).toEqual({ path: '__mcp' }) + // The route is mounted: a bare GET is answered by the MCP transport + // (405 for a session-less GET), not the 404 an unmounted path gets. + const res = await devtools.handler(new Request('http://localhost:3000/__handler-mcp/__mcp', { + headers: { origin: 'http://localhost:3000' }, + })) + expect(res.status).not.toBe(404) + } + finally { + await devtools.close() + } + }) + + it('key memoization: re-runs return the live instance; changed options replace it', async () => { + const def = defineTestDef('handler-memo') + const wsPort = await getPort({ port: 18150, host: '127.0.0.1' }) + const a = initDevframe(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort } }) + const b = initDevframe(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort } }) + expect(b).toBe(a) + + try { + await a.ready + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const wsPort2 = await getPort({ port: 18151, host: '127.0.0.1' }) + const c = initDevframe(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort2 } }) + try { + expect(c).not.toBe(a) + expect(String(warn.mock.calls)).toContain('DF0053') + await c.ready + expect(c.connectionMeta()).toEqual({ + backend: 'websocket', + websocket: { port: wsPort2, path: '__ws' }, + }) + + // The replaced instance's side-car was closed. + await vi.waitFor(async () => { + const gone = new WebSocket(`ws://127.0.0.1:${wsPort}/__ws`) + await expect(new Promise((resolve, reject) => { + gone.on('open', () => reject(new Error('old side-car still accepting'))) + gone.on('error', () => resolve('closed')) + })).resolves.toBe('closed') + }) + } + finally { + warn.mockRestore() + await c.close() + } + } + finally { + await a.close() + } + }) + + it('bridge mode: without a distDir only meta + WS are served', async () => { + const wsPort = await getPort({ port: 18160, host: '127.0.0.1' }) + const devtools = initDevframe(defineTestDef('handler-bridge'), { + auth: false, + ws: { port: wsPort }, + }) + + try { + await devtools.ready + const meta = await devtools.handler(new Request('http://localhost:3000/__handler-bridge/__connection.json')) + expect(meta.status).toBe(200) + // No SPA mount: the base itself is a miss, normalized to a bare 404. + const spa = await devtools.handler(new Request('http://localhost:3000/__handler-bridge/')) + expect(spa.status).toBe(404) + expect(await spa.text()).toBe('') + } + finally { + await devtools.close() + } + }) +}) diff --git a/packages/devframe/src/adapters/initiate-bun.ts b/packages/devframe/src/adapters/initiate-bun.ts new file mode 100644 index 00000000..1954df45 --- /dev/null +++ b/packages/devframe/src/adapters/initiate-bun.ts @@ -0,0 +1,70 @@ +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' + +export interface AttachBunWsTransportOptions { + /** Same contract as `WsRpcTransportOptions.allowedOrigins`. */ + allowedOrigins?: readonly string[] | WsOriginRegistry | false +} + +/** + * Structural view of the Bun `Bun.serve({ websocket })` handler object the + * crossws Bun adapter produces — typed loosely so devframe carries no + * dependency on Bun's own types. + */ +interface BunWsTierWebSocket { + open?: (ws: unknown) => unknown + message: (ws: unknown, message: unknown) => unknown + close?: (ws: unknown, code?: number, reason?: string) => unknown + drain?: (ws: unknown) => unknown +} + +export interface BunWsTier { + /** Complete a WS upgrade request — `Bun.serve`'s server as 2nd argument. */ + handleUpgrade: (request: Request, server: unknown) => Promise + /** The handlers to spread into `Bun.serve({ websocket })`. */ + websocket: BunWsTierWebSocket + close: () => Promise +} + +/** + * 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. + */ +export async function attachBunWsTransport( + core: ContextRpcServer, + options: AttachBunWsTransportOptions = {}, +): Promise { + const { default: bunAdapter } = await import('crossws/adapters/bun') + const { allowedOrigins } = options + + const ws = bunAdapter({ + hooks: { + ...createWsRpcPeerHooks(core.rpcGroup, { + onConnected: core.onConnected, + onDisconnected: core.onDisconnected, + }), + // The same origin policy `routeUpgrades` applies for the Node + // transport, enforced at the upgrade hook since Bun upgrades arrive + // as fetch requests rather than `upgrade` socket events. + upgrade(request) { + const origin = request.headers.get('origin') ?? undefined + const allowed = allowedOrigins && !Array.isArray(allowedOrigins) + ? (allowedOrigins as WsOriginRegistry).isAllowed(origin) + : isAllowedOrigin(origin, (allowedOrigins as readonly string[] | false | undefined) || []) + if (allowedOrigins !== false && !allowed) + return new Response('Forbidden', { status: 403 }) + }, + }, + }) + + return { + handleUpgrade: (request, server) => + ws.handleUpgrade(request, server as Parameters[1]), + websocket: ws.websocket as unknown as BunWsTierWebSocket, + close: () => ws.close(), + } +} diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts new file mode 100644 index 00000000..bfc300c5 --- /dev/null +++ b/packages/devframe/src/adapters/initiate.ts @@ -0,0 +1,511 @@ +import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' +import type { ConnectionMeta, DevframeNodeContext } from 'devframe/types' +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 { 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' +import { resolve } from 'pathe' +import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo' +import { DEVFRAME_CONNECTION_META_FILENAME, DEVFRAME_WS_ROUTE } from '../constants' +import { createHostContext } from '../node/context' +import { diagnostics } from '../node/diagnostics' +import { createH3DevframeHost } from '../node/host-h3' +import { startHttpAndWs } from '../node/server' +import { createInteractiveAuth } from '../recipes/interactive-auth' +import { normalizeBasePath, resolveBasePath } from './_shared' +import { resolveDevServerPort, resolveMcpConnectionMeta } from './dev' + +export interface InitDevframeOptions { + /** + * Mount base the handler answers under. Defaults to + * `resolveBasePath(def, 'hosted')` (i.e. `def.basePath` or `/__/`) — + * a handler is by definition mounted *inside* a host app's origin. + */ + base?: string + /** + * Override `def.cli?.distDir`. When neither is set the handler runs in + * **bridge mode** — only `__connection.json`, the WS endpoint, and the MCP + * route (when enabled) are served; the SPA is hosted elsewhere. + */ + distDir?: string + /** + * Share the host's `node:http` server for the WebSocket RPC endpoint: the + * upgrade listener binds to `__ws` on this server, so no extra port + * is needed and the socket follows the app through proxies/HTTPS. When + * omitted (and no `ws.url`/`ws.port` is given), an **eager side-car** + * WebSocket server starts on its own port at handler creation. Under Bun, + * the default is the fetch-upgrade tier instead — no side-car; pass the + * `Bun.serve` server as `handler`'s second argument and wire + * {@link DevframeInstance.websocket}. + */ + server?: NodeHttpServer + /** + * Explicit control over how the browser reaches the RPC WebSocket — + * advertised in `__connection.json`. Precedence `url` > `port` > `route` + * (see {@link DevframeWsOptions}). `url` controls the *advertisement* + * only: the browser dials it verbatim (a tunnel/relay). The local + * binding still follows `server`/`ws.port` when given — the tunnel + * pattern, where the relay forwards to the locally-bound socket — and + * when neither is given, the handler starts **no transport of its own** + * (run `startHttpAndWs({ context, server, path })` against + * {@link DevframeInstance.context} to serve RPC from your own server). + */ + ws?: DevframeWsOptions + /** + * Bind host for a side-car WebSocket server (default: `def.cli?.host ?? + * 'localhost'`). Irrelevant for the `server` / `ws.url` / Bun tiers. + */ + host?: string + /** + * Authentication for the RPC endpoint. A handler mounted inside an app + * server is reachable by anything that can open its socket, so it **gates + * by default**: when unset (or `true`), devframe's interactive OTP handler + * is wired and its code/link banner prints once the public origin is known + * (derived from the first request, or `origin`). Pass a + * {@link DevframeAuthHandler} for a custom scheme, or `false` to opt out + * for a single-user localhost setup that owns the trust boundary another + * way. Ignored for the `ws.url` tier — the server behind that URL owns auth. + */ + auth?: boolean | DevframeAuthHandler + /** + * Expose a route-based MCP server (Streamable-HTTP) at `__mcp` and + * advertise it in `__connection.json`. Overrides `def.cli?.mcp`; + * `undefined` falls through to it. See {@link McpRouteOptions}. + */ + mcp?: boolean | McpRouteOptions + /** + * Memoize the handler on `globalThis` under this key. Dev servers that + * re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run + * `initDevframe` on every reload — without a key each run would leak an + * eager side-car WebSocket server. With a key, a re-run returns the live + * instance; if the options changed, the old instance is closed and + * replaced (reported as `DF0053`). + */ + key?: string + /** + * Public origin the host app is reachable at (e.g. `http://localhost:3000`). + * When omitted, it is derived lazily from the first request the handler + * serves — used for the auth banner's magic link and absolute dock URLs. + */ + origin?: string + /** Parsed flag bag forwarded to `def.setup(ctx, { flags })`. */ + flags?: Record + /** + * Extra origins to accept on the WS upgrade beyond the loopback default. + * Add your LAN/tunnel origin here when reaching the tool from another + * host. Pass `false` to disable origin checking entirely (not + * recommended). Default: loopback-only. + */ + allowedOrigins?: readonly string[] | WsOriginRegistry | false +} + +/** + * Bun `Bun.serve({ websocket })` handlers, delegating to the handler's + * WebSocket transport. Only active under Bun (the fetch-upgrade tier); + * inert no-ops elsewhere. Typed structurally so devframe carries no + * dependency on Bun's types — cast to Bun's `WebSocketHandler` at the + * `Bun.serve` call site if your host file typechecks against `bun-types`. + */ +export interface DevframeInstanceWebSocket { + open: (ws: unknown) => void + message: (ws: unknown, message: unknown) => void + close: (ws: unknown, code?: number, reason?: string) => void + drain: (ws: unknown) => void +} + +export interface DevframeInstance { + /** + * Web-standard request handler — mount it on a catch-all route under + * {@link InitDevframeOptions.base} (Next.js route handler, SvelteKit + * `+server.ts`, Hono `c.req.raw`, Nitro `toWebRequest(event)`, …). + * Requests outside the base 404. Under Bun, pass the `Bun.serve` server + * as the second argument so WS upgrade requests can be completed (an + * upgraded request resolves to `undefined` per Bun's contract, typed as + * `Response` for drop-in route-handler compatibility). + */ + handler: (request: Request, server?: unknown) => Promise + /** + * Connect/Express-style middleware over the same surface — for + * `viteServer.middlewares.use(handler.nodeMiddleware)` or any other + * node middleware stack. Mount it un-prefixed: paths outside the base + * call `next()` so the rest of the stack keeps working. + */ + nodeMiddleware: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void + /** See {@link DevframeInstanceWebSocket}. */ + websocket: DevframeInstanceWebSocket + /** + * Resolves once `def.setup` has run and the WebSocket binding is live. + * `handler`/`nodeMiddleware` await it internally, so hosts never race + * initialization — await it yourself only when you need the timing. + */ + ready: Promise + /** The node context, once initialized — for advanced wiring (own WS transport, extra RPC registration). */ + context: Promise + /** + * The `ConnectionMeta` this handler serves at `__connection.json`. + * Only readable after initialization (`DF0054` otherwise). + */ + connectionMeta: () => ConnectionMeta + /** Tear down: WS transport/side-car, MCP sessions, memo-registry entry. */ + close: () => Promise +} + +interface InstanceRegistryEntry { + hash: string + handler: DevframeInstance +} + +const REGISTRY_KEY = Symbol.for('devframe:instance-registry') + +function instanceRegistry(): Map { + const holder = globalThis as { [REGISTRY_KEY]?: Map } + holder[REGISTRY_KEY] ??= new Map() + return holder[REGISTRY_KEY] +} + +/** + * Fingerprint the option surface that changes a handler's observable + * behavior, for `key` memoization. Non-serializable options (a custom auth + * handler, the shared server) participate as identity markers only — a new + * object on every module re-evaluation would defeat memoization, which is + * exactly the scenario `key` exists for. + */ +function optionsHash(def: DevframeDefinition, options: InitDevframeOptions): string { + return JSON.stringify({ + id: def.id, + base: options.base, + distDir: options.distDir, + host: options.host, + origin: options.origin, + ws: options.ws, + server: options.server != null, + auth: typeof options.auth === 'object' ? 'custom' : options.auth, + mcp: options.mcp, + allowedOrigins: Array.isArray(options.allowedOrigins) ? options.allowedOrigins : typeof options.allowedOrigins, + cwd: process.cwd(), + }) +} + +/** Compare two URL paths ignoring a trailing slash. */ +function samePath(a: string, b: string): boolean { + return withoutTrailingSlash(a) === withoutTrailingSlash(b) +} + +/** + * Serve a devframe through one framework-agnostic, web-standard handler — + * the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the + * auth gate, and the optional MCP route, all under a single mount base. + * Mount `handler` on any framework's catch-all route (or `nodeMiddleware` on + * a connect stack) and the devframe is live inside that app. + * + * The factory is synchronous and kicks off initialization eagerly; + * `handler`/`nodeMiddleware` await readiness internally. How the WebSocket is + * bound resolves in precedence order — `ws.url` (external, advertise-only) + * > `ws.port` (explicit side-car) > `server` (shared upgrade at + * `__ws`) > Bun fetch-upgrade (under Bun) > an eager side-car on a + * free port — and `__connection.json` reflects whichever tier is active. + */ +export function initDevframe( + def: DevframeDefinition, + options: InitDevframeOptions = {}, +): DevframeInstance { + if (options.key) { + const registry = instanceRegistry() + const hash = optionsHash(def, options) + const existing = registry.get(options.key) + if (existing) { + if (existing.hash === hash) + return existing.handler + diagnostics.DF0053({ key: options.key, id: def.id }) + void existing.handler.close().catch(() => {}) + } + const handler = instantiateDevframe(def, options) + registry.set(options.key, { hash, handler }) + return handler + } + return instantiateDevframe(def, options) +} + +function instantiateDevframe( + def: DevframeDefinition, + options: InitDevframeOptions, +): DevframeInstance { + const base = options.base ? normalizeBasePath(options.base) : resolveBasePath(def, 'hosted') + const baseNoSlash = withoutTrailingSlash(base) + const distDir = options.distDir ?? def.cli?.distDir + const app = new H3() + + // The public origin is often unknowable at creation (the host app owns the + // listener) — derive it from the first request and let the auth banner + // wait for it, unless the caller pinned one. + let resolvedOrigin: string | undefined = options.origin + let authHandler: DevframeAuthHandler | undefined + let bannerPrinted = false + function maybePrintBanner(): void { + if (bannerPrinted || !authHandler || !resolvedOrigin) + return + bannerPrinted = true + authHandler.printBanner() + } + function noteOrigin(origin: string): void { + resolvedOrigin ??= origin + maybePrintBanner() + } + + let meta: ConnectionMeta | undefined + let started: StartedServer | undefined + let bunTier: BunWsTier | undefined + let bunUpgradePath: string | undefined + let mcpDispose: (() => Promise) | undefined + let ctx: DevframeNodeContext + + async function init(): Promise { + const host = createH3DevframeHost({ + origin: () => resolvedOrigin ?? 'http://localhost', + appName: def.id, + mount: (mountBase, dir) => { + mountStaticHandler(app, mountBase, dir) + }, + }) + ctx = await createHostContext({ + cwd: process.cwd(), + mode: 'dev', + host, + }) + const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} } + await def.setup(ctx, setupInfo) + + // Route-based MCP server (opt-in). Mounted before the SPA static + // catch-all so the exact `__mcp` route wins, and advertised in + // `__connection.json`. The MCP SDK stays an optional peer — its code is + // only pulled in (dynamically) when the route is enabled. + const mcpOption = options.mcp ?? def.cli?.mcp + const mcpMeta = resolveMcpConnectionMeta(def, mcpOption) + if (mcpMeta) { + const mcpConfig = mcpOption === true || mcpOption === undefined ? {} : mcpOption as McpRouteOptions + const mcpPath = joinURL(base, mcpMeta.path) + let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp + try { + ;({ mountMcpHttp } = await import('./mcp/http')) + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw diagnostics.DF0017({ transport: 'http', reason, cause: error }) + } + const mounted = mountMcpHttp(app, ctx, mcpPath, { + serverName: `${def.id} (devframe)`, + serverVersion: def.version ?? '0.0.0', + exposeSharedState: true, + allowedOrigins: mcpConfig.allowedOrigins, + }) + mcpDispose = mounted.dispose + } + + // WebSocket binding resolution — explicit `ws.port` side-car > shared + // `server` > Bun fetch-upgrade > eager auto side-car; `ws.url`, when + // set, overrides only the *advertised* endpoint (the tunnel pattern: + // the relay forwards to whatever local binding the rest configured), + // and suppresses the default binding entirely when no explicit + // `server`/`ws.port` is given (an external server owns the transport). + const ws = options.ws ?? def.cli?.ws ?? {} + const route = withoutLeadingSlash(ws.route ?? DEVFRAME_WS_ROUTE) + + // Auth resolution mirrors `createDevServer`: gate by default, explicit + // `false` opts out, a handler object installs a custom scheme. + const authOption = options.auth !== undefined ? options.auth : def.cli?.auth + let resolvedAuth: boolean | DevframeAuthHandler + if (authOption === false) { + resolvedAuth = false + } + else if (typeof authOption === 'object') { + authHandler = authOption + resolvedAuth = authOption + } + else { + authHandler = createInteractiveAuth(ctx) + resolvedAuth = authHandler + } + + let websocketMeta: ConnectionMeta['websocket'] + if (ws.port != null) { + // Explicit side-car port. + const sidecarHost = options.host ?? def.cli?.host ?? 'localhost' + started = await startHttpAndWs({ + context: ctx, + host: sidecarHost, + port: ws.port, + path: withLeadingSlash(route), + auth: resolvedAuth, + allowedOrigins: options.allowedOrigins, + }) + websocketMeta = { port: started.port, path: route } + } + else if (options.server) { + // Shared upgrade on the host's own server at `` — zero + // extra ports, proxy/HTTPS friendly. + started = await startHttpAndWs({ + context: ctx, + port: 0, + server: options.server, + path: joinURL(base, route), + auth: resolvedAuth, + allowedOrigins: options.allowedOrigins, + }) + websocketMeta = { path: route } + } + else if (ws.url) { + // Advertise-only: an external server owns transport and auth. + authHandler = undefined + websocketMeta = ws.url + } + else if (typeof (globalThis as any).Bun === 'undefined') { + // Eager auto side-car on a free port — the default when no host + // server is shared (and we're not under Bun). + const sidecarHost = options.host ?? def.cli?.host ?? 'localhost' + const port = await resolveDevServerPort(def, { host: sidecarHost }) + started = await startHttpAndWs({ + context: ctx, + host: sidecarHost, + port, + path: withLeadingSlash(route), + auth: resolvedAuth, + allowedOrigins: options.allowedOrigins, + }) + websocketMeta = { port: started.port, path: route } + } + else { + // Bun fetch-upgrade — same-origin upgrades completed through + // `handler(request, server)`, hooks exposed via `websocket`. + const { attachBunWsTransport } = await import('./initiate-bun') + const { createContextRpcServer } = await import('../node/rpc-core') + const core = createContextRpcServer({ context: ctx, auth: resolvedAuth }) + bunTier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins }) + bunUpgradePath = joinURL(base, route) + websocketMeta = { path: route } + } + // The tunnel pattern: `ws.url` overrides the advertisement while the + // local binding above keeps serving (the relay forwards to it). + if (ws.url) + websocketMeta = ws.url + + // Discovery meta before the SPA mount so its SPA-fallback can't swallow + // the route; both sit at the SPA root for relative `./__connection.json` + // fetches. + meta = { + backend: 'websocket', + websocket: websocketMeta, + ...(mcpMeta ? { mcp: mcpMeta } : {}), + } + app.use(joinURL(base, DEVFRAME_CONNECTION_META_FILENAME), () => meta) + + if (distDir) + mountStaticHandler(app, base, resolve(distDir)) + + // A pinned origin means the banner needn't wait for a first request. + maybePrintBanner() + } + + const initPromise = init() + // Surface init failures through `ready`/`handler`, never as an unhandled + // rejection from the eager kick-off. + initPromise.catch(() => {}) + const contextPromise = initPromise.then(() => ctx) + contextPromise.catch(() => {}) + + async function handleRequest(request: Request, server?: unknown): Promise { + await initPromise + const url = new URL(request.url) + noteOrigin(url.origin) + if (bunTier && samePath(url.pathname, bunUpgradePath!) + && request.headers.get('upgrade')?.toLowerCase() === 'websocket') { + if (!server) { + return new Response( + 'Upgrade Required: pass the Bun server as the second argument to instance.handler(request, server)', + { status: 426 }, + ) + } + // An upgraded request resolves to `undefined` per Bun's contract; the + // cast keeps `handler` drop-in assignable to route handlers that expect + // a `Response`. + return await bunTier.handleUpgrade(request, server) as Response + } + const response = await app.fetch(request) + // Normalize a miss to a bare 404: an unmounted path falls through to + // h3's default JSON-error handler, but for an asset host a body-less + // 404 is cleaner and matches a plain static server. + if (response.status === 404) + return new Response(null, { status: 404 }) + return response + } + + const nodeHandler = toNodeHandler(app) + function nodeMiddleware(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void): void { + let pathname = req.url ?? '/' + try { + pathname = new URL(pathname, 'http://localhost').pathname + } + catch {} + if (!(samePath(pathname, baseNoSlash) || pathname.startsWith(base))) { + if (next) { + next() + return + } + res.statusCode = 404 + res.end() + return + } + void initPromise + .then(() => { + const host = req.headers.host + if (host) { + const encrypted = (req.socket as { encrypted?: boolean }).encrypted + noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`) + } + return nodeHandler(req, res) + }) + .catch((err: unknown) => { + if (next) { + next(err) + return + } + res.statusCode = 500 + res.end() + }) + } + + const websocket: DevframeInstanceWebSocket = { + open: ws => void bunTier?.websocket.open?.(ws), + message: (ws, message) => void bunTier?.websocket.message(ws, message), + close: (ws, code, reason) => void bunTier?.websocket.close?.(ws, code, reason), + drain: ws => void bunTier?.websocket.drain?.(ws), + } + + const handler: DevframeInstance = { + handler: handleRequest, + nodeMiddleware, + websocket, + ready: initPromise, + context: contextPromise, + connectionMeta: () => { + if (!meta) + throw diagnostics.DF0054({ id: def.id }) + return meta + }, + close: async () => { + if (options.key) { + const registry = instanceRegistry() + if (registry.get(options.key)?.handler === handler) + registry.delete(options.key) + } + await initPromise.catch(() => {}) + await mcpDispose?.() + await started?.close() + await bunTier?.close() + }, + } + return handler +} diff --git a/packages/devframe/src/client/rpc-ws-status.test.ts b/packages/devframe/src/client/rpc-ws-status.test.ts index 30778623..939ef31e 100644 --- a/packages/devframe/src/client/rpc-ws-status.test.ts +++ b/packages/devframe/src/client/rpc-ws-status.test.ts @@ -56,7 +56,7 @@ class FakeWebSocket { const connectionMeta: ConnectionMeta = { backend: 'websocket', - websocket: { path: '__devframe_ws' }, + websocket: { path: '__ws' }, } function setup(callTimeout?: number) { diff --git a/packages/devframe/src/client/rpc-ws.test.ts b/packages/devframe/src/client/rpc-ws.test.ts index b636470c..f5c5ac58 100644 --- a/packages/devframe/src/client/rpc-ws.test.ts +++ b/packages/devframe/src/client/rpc-ws.test.ts @@ -27,39 +27,39 @@ const extensionLoc: WsUrlLocation = { describe('resolveWsUrl', () => { it('resolves a relative path against the meta base, same-origin', () => { const url = resolveWsUrl( - { path: '__devframe_ws' }, + { path: '__ws' }, 'http://localhost:5173/__foo/__connection.json', httpLoc, ) - expect(url).toBe('ws://localhost:5173/__foo/__devframe_ws') + expect(url).toBe('ws://localhost:5173/__foo/__ws') }) it('follows the page origin through a proxy (host + subpath + tls)', () => { // The server has no idea about the proxy's host — the client reuses its own. const url = resolveWsUrl( - { path: '__devframe_ws' }, + { path: '__ws' }, 'https://devtools.example.com/app/__foo/__connection.json', httpsProxyLoc, ) - expect(url).toBe('wss://devtools.example.com/app/__foo/__devframe_ws') + expect(url).toBe('wss://devtools.example.com/app/__foo/__ws') }) it('roots an explicit-port endpoint at the page hostname (side-car)', () => { const url = resolveWsUrl( - { port: 9777, path: '/__devframe_ws' }, + { port: 9777, path: '/__ws' }, 'http://localhost:5173/__hub/__connection.json', httpLoc, ) - expect(url).toBe('ws://localhost:9777/__devframe_ws') + expect(url).toBe('ws://localhost:9777/__ws') }) it('honors an explicit host override', () => { const url = resolveWsUrl( - { host: 'inner:1234', path: '/__devframe_ws' }, + { host: 'inner:1234', path: '/__ws' }, 'http://localhost:5173/__connection.json', httpLoc, ) - expect(url).toBe('ws://inner:1234/__devframe_ws') + expect(url).toBe('ws://inner:1234/__ws') }) it('keeps the legacy numeric-port form on the metadata hostname', () => { diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index fd3d32c1..c970dc51 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -1,8 +1,4 @@ // Devframe runtime routes and static output conventions. -export const DEVFRAME_MOUNT_PATH = '/__devframe/' -export const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH = '/__devframe' -export const DEVFRAME_DIRNAME = '__devframe' - export const DEVFRAME_CONNECTION_META_FILENAME = '__connection.json' /** @@ -19,7 +15,7 @@ export const DEVFRAME_CONNECTION_KEY = '__DEVFRAME_CONNECTION__' * both HTTP and WS, and a host server (Vite, etc.) can mount the WS upgrade * handler here without colliding with its own routes (HMR, asset serving). */ -export const DEVFRAME_WS_ROUTE = '__devframe_ws' +export const DEVFRAME_WS_ROUTE = '__ws' /** * Route the Streamable-HTTP MCP endpoint is bound to, relative to a diff --git a/packages/devframe/src/helpers/__tests__/vite.test.ts b/packages/devframe/src/helpers/__tests__/vite.test.ts index dae89fa6..1ca5fed2 100644 --- a/packages/devframe/src/helpers/__tests__/vite.test.ts +++ b/packages/devframe/src/helpers/__tests__/vite.test.ts @@ -75,7 +75,7 @@ describe('viteDevBridge (bridge mode mcp)', () => { expect(metaHandler).toBeDefined() const meta = await readJsonMiddleware(metaHandler) expect(meta.backend).toBe('websocket') - expect(meta.websocket).toEqual({ port, path: '/__devframe_ws' }) + expect(meta.websocket).toEqual({ port, path: '/__ws' }) expect(meta.mcp).toEqual({ port, path: '/__mcp' }) // The advertised endpoint is live: a real MCP client presenting a loopback @@ -120,7 +120,7 @@ describe('viteDevBridge (auth default)', () => { /** Handshake result on a fresh, unauthenticated WS connection. */ async function handshakeIsTrusted(port: number): Promise { const rpc = createRpcClient({}, { - channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}/__devframe_ws` }), + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}/__ws` }), }) try { const res = await rpc.$call('anonymous:devframe:auth', { authToken: '', ua: 'test', origin: 'http://localhost' }) as { isTrusted: boolean } diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index 68f64f4a..ab951093 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -151,7 +151,7 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio // The side-car listens on its own port, so the browser must target that // port explicitly (it can't reach the WS on Vite's origin). The route is - // `/__devframe_ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS + // `/__ws` — the bridge `createDevServer` mounts the SPA at `/`, so its WS // upgrade handler is bound there. The MCP route (when enabled) lives on // the same side-car origin, advertised with the same explicit port. const mcpMeta = resolveMcpConnectionMeta(d, options.mcp, port) diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index f16ed74a..dd936c87 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -116,5 +116,13 @@ export const diagnostics = defineDiagnostics({ why: (p: { host: string, port: number, reason: string }) => `Failed to listen on ${p.host}:${p.port}: ${p.reason}`, fix: 'The port is likely already taken by another process (often a previous devframe instance). Free it, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `devMiddleware.port` on `viteDevBridge`. The original node error is available as `error.cause`.', }, + DF0053: { + why: (p: { key: string, id: string }) => `initDevframe("${p.id}") replaced the live instance memoized under key "${p.key}": its options changed since the previous call.`, + fix: 'A dev-time module reload re-ran initDevframe 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 instances.', + }, + DF0054: { + why: (p: { id: string }) => `connectionMeta() was called before initDevframe("${p.id}") 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.', + }, }, }) diff --git a/packages/devframe/src/node/host-h3.ts b/packages/devframe/src/node/host-h3.ts index dcd39d40..90093c74 100644 --- a/packages/devframe/src/node/host-h3.ts +++ b/packages/devframe/src/node/host-h3.ts @@ -9,8 +9,10 @@ export interface CreateH3DevframeHostOptions { /** * Host the standalone server listens on, e.g. `http://localhost:9999`. * Consumed by `resolveOrigin` for dock entries that need an absolute URL. + * Pass a function for hosts that only learn their public origin later + * (e.g. `createHandler` derives it from the first incoming request). */ - origin: string + origin: string | (() => string) /** * Register a static-file handler at `base` serving files from `distDir`. * Wired into the h3 app once the CLI adapter lands (commit 5). For now @@ -43,7 +45,7 @@ export function createH3DevframeHost(options: CreateH3DevframeHostOptions): Devf return options.mount?.(base, distDir) }, resolveOrigin() { - return options.origin + return typeof options.origin === 'function' ? options.origin() : options.origin }, getStorageDir(scope) { const namespace = `.${options.appName}/devframe` diff --git a/packages/devframe/src/node/rpc-core.ts b/packages/devframe/src/node/rpc-core.ts new file mode 100644 index 00000000..d75f2774 --- /dev/null +++ b/packages/devframe/src/node/rpc-core.ts @@ -0,0 +1,151 @@ +import type { BirpcGroup, EventOptions } from 'birpc' +import type { Peer } from 'crossws' +import type { DevframeNodeContext, DevframeNodeRpcSession, DevframeNodeRpcSessionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import type { DevframeAuthHandler } from './auth' +import type { RpcFunctionsHostImpl } from './host-functions' +import { AsyncLocalStorage } from 'node:async_hooks' +import { createRpcServer } from 'devframe/rpc/server' +import { diagnostics } from './diagnostics' + +export interface CreateContextRpcServerOptions { + context: DevframeNodeContext + /** See `StartHttpAndWsOptions.auth` — same contract, transport-agnostic. */ + auth?: boolean | DevframeAuthHandler + /** See `StartHttpAndWsOptions.authorize`. */ + authorize?: (methodName: string, session: DevframeNodeRpcSession) => boolean + /** See `StartHttpAndWsOptions.onPeerConnect`. */ + onPeerConnect?: (peer: Peer, session: DevframeNodeRpcSession) => void + /** See `StartHttpAndWsOptions.onPeerDisconnect`. */ + onPeerDisconnect?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void + /** See `StartHttpAndWsOptions.rpcOptions`. */ + rpcOptions?: Pick< + EventOptions, + 'onFunctionError' | 'onGeneralError' + > +} + +export interface ContextRpcServer { + rpcGroup: BirpcGroup + /** The resolved auth handler when `auth` was passed as one. */ + authHandler?: DevframeAuthHandler + /** + * Peer lifecycle handlers to wire into a WS transport + * (`attachWsRpcTransport`'s `onConnected` / `onDisconnected`, or any other + * crossws adapter's peer hooks via `createWsRpcPeerHooks`). + */ + onConnected?: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void + onDisconnected: (peer: Peer, meta: DevframeNodeRpcSessionMeta) => void +} + +/** + * Bind a devframe context's registered RPC functions to a birpc group, + * transport-agnostically — the shared core under `startHttpAndWs` (Node + * http + WS) and the Bun fetch-upgrade tier of `createHandler`. + * + * Owns everything about serving RPC that is independent of *how* peers + * connect: the auth handler's function registration, the + * `AsyncLocalStorage`-based session resolver (so + * `ctx.rpc.getCurrentRpcSession()` works inside handlers), the + * `authorize` gate, and the `auth: false` auto-trust handshake shim. + */ +export function createContextRpcServer(options: CreateContextRpcServerOptions): ContextRpcServer { + const { context } = options + const rpcHost = context.rpc as unknown as RpcFunctionsHostImpl + + const asyncStorage = new AsyncLocalStorage() + + // A full auth handler (e.g. from `createInteractiveAuth`) registers its own + // RPC functions and supplies both the resolver gate and the connect-time + // trust hook. `authorize`/`onPeerConnect` are the lower-level escape + // hatches for callers not using a full handler. + const authHandler: DevframeAuthHandler | undefined = typeof options.auth === 'object' ? options.auth : undefined + const effectiveAuthorize = options.authorize ?? authHandler?.authorize + + if (authHandler) { + for (const fn of authHandler.rpcFunctions) { + if (!rpcHost.definitions.has(fn.name)) + rpcHost.register(fn) + } + } + + const rpcGroup = createRpcServer( + rpcHost.functions, + { + rpcOptions: { + // Forwarded as-is so a host with its own structured diagnostics + // keeps seeing RPC failures; see `StartHttpAndWsOptions.rpcOptions`. + onFunctionError: options.rpcOptions?.onFunctionError, + onGeneralError: options.rpcOptions?.onGeneralError, + // Wrap each RPC handler in an AsyncLocalStorage context so + // `ctx.rpc.getCurrentRpcSession()` works inside handlers (used + // by streaming subscribe/unsubscribe/cancel and shared-state + // sync), and — when an `authorize` gate is configured — reject + // the call before it ever reaches the handler. Mirrors + // `packages/core/src/node/ws.ts`'s resolver. + resolver(name, fn) { + // eslint-disable-next-line ts/no-this-alias + const rpc = this + if (!fn) + return undefined + return async function (this: any, ...args) { + const meta = rpc.$meta as DevframeNodeRpcSessionMeta + if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any })) + throw diagnostics.DF0036({ name }) + return await asyncStorage.run({ + rpc, + meta, + }, async () => { + return (await fn).apply(this, args) + }) + } + }, + }, + }, + ) + + ;(rpcHost as any)._rpcGroup = rpcGroup + ;(rpcHost as any)._asyncStorage = asyncStorage + ;(rpcHost as any)._authDisabled = options.auth === false + + // The browser client unconditionally calls `anonymous:devframe:auth` on + // connect (see `client/rpc-ws.ts`). When `auth: false` is set on the + // standalone server, register a noop handler that auto-trusts so the + // client's hardcoded handshake succeeds. A host passing a full + // `DevframeAuthHandler` already registered the real handler above, and + // never opts into `auth: false`, so the two paths never overlap. + if (options.auth === false && !rpcHost.definitions.has('anonymous:devframe:auth')) { + rpcHost.register({ + name: 'anonymous:devframe:auth', + type: 'action', + handler: () => { + const session = rpcHost.getCurrentRpcSession() + if (session) + session.meta.isTrusted = true + return { isTrusted: true } + }, + }) + } + + const onConnected = (authHandler || options.onPeerConnect) + ? (peer: Peer, meta: DevframeNodeRpcSessionMeta) => { + const session: DevframeNodeRpcSession = { + meta, + rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any, + } + authHandler?.onConnect(peer, session) + options.onPeerConnect?.(peer, session) + } + : undefined + + const onDisconnected = (peer: Peer, meta: DevframeNodeRpcSessionMeta): void => { + options.onPeerDisconnect?.(peer, meta) + rpcHost._emitSessionDisconnected(meta) + } + + return { + rpcGroup, + authHandler, + onConnected, + onDisconnected, + } +} diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 7e8f2025..262fa823 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -6,13 +6,12 @@ import type { ConnectionMeta, DevframeNodeContext, DevframeNodeRpcSession, Devfr import type { Server as NodeHttpServer } from 'node:http' import type { DevframeAuthHandler } from './auth' import type { RpcFunctionsHostImpl } from './host-functions' -import { AsyncLocalStorage } from 'node:async_hooks' import { createServer } from 'node:http' -import { createRpcServer } from 'devframe/rpc/server' import { attachWsRpcTransport } from 'devframe/rpc/transports/ws-server' import { H3, toNodeHandler } from 'h3' import { diagnostics } from './diagnostics' import { getInternalContext } from './hub-internals/context' +import { createContextRpcServer } from './rpc-core' import { formatHostForUrl, normalizeHttpServerUrl } from './utils' export interface StartHttpAndWsOptions { @@ -26,7 +25,7 @@ export interface StartHttpAndWsOptions { */ app?: H3 /** - * Bind the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`) instead of + * Bind the WS endpoint to a single upgrade route (e.g. `/__ws`) instead of * claiming every upgrade on the port. This lets the socket share a server * with other upgrade handlers (Vite HMR, a host framework's own sockets) * and is what the SPA's `__connection.json` points at. When omitted, the WS @@ -163,56 +162,16 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise() - - // A full auth handler (e.g. from `createInteractiveAuth`) registers its own - // RPC functions and supplies both the resolver gate and the connect-time - // trust hook. `authorize`/`onPeerConnect` are the lower-level escape - // hatches for callers not using a full handler. - const authHandler: DevframeAuthHandler | undefined = typeof options.auth === 'object' ? options.auth : undefined - const effectiveAuthorize = options.authorize ?? authHandler?.authorize - - if (authHandler) { - for (const fn of authHandler.rpcFunctions) { - if (!rpcHost.definitions.has(fn.name)) - rpcHost.register(fn) - } - } - - const rpcGroup = createRpcServer( - rpcHost.functions, - { - rpcOptions: { - // Forwarded as-is so a host with its own structured diagnostics - // keeps seeing RPC failures; see `StartHttpAndWsOptions.rpcOptions`. - onFunctionError: options.rpcOptions?.onFunctionError, - onGeneralError: options.rpcOptions?.onGeneralError, - // Wrap each RPC handler in an AsyncLocalStorage context so - // `ctx.rpc.getCurrentRpcSession()` works inside handlers (used - // by streaming subscribe/unsubscribe/cancel and shared-state - // sync), and — when an `authorize` gate is configured — reject - // the call before it ever reaches the handler. Mirrors - // `packages/core/src/node/ws.ts`'s resolver. - resolver(name, fn) { - // eslint-disable-next-line ts/no-this-alias - const rpc = this - if (!fn) - return undefined - return async function (this: any, ...args) { - const meta = rpc.$meta as DevframeNodeRpcSessionMeta - if (effectiveAuthorize && !effectiveAuthorize(name, { meta, rpc: rpc as any })) - throw diagnostics.DF0036({ name }) - return await asyncStorage.run({ - rpc, - meta, - }, async () => { - return (await fn).apply(this, args) - }) - } - }, - }, - }, - ) + // Transport-agnostic RPC core: auth wiring, session resolver, and the + // peer lifecycle handlers the WS transport below plugs into. + const { rpcGroup, onConnected, onDisconnected } = createContextRpcServer({ + context, + auth: options.auth, + authorize: options.authorize, + onPeerConnect: options.onPeerConnect, + onPeerDisconnect: options.onPeerDisconnect, + rpcOptions: options.rpcOptions, + }) // A dedicated WS port (the "different port" scenario) only applies when we // own the HTTP server — a shared host server already dictates the port. @@ -231,45 +190,10 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise { - const session: DevframeNodeRpcSession = { - meta, - rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any, - } - authHandler?.onConnect(peer, session) - options.onPeerConnect?.(peer, session) - } - : undefined, - onDisconnected: (peer, meta) => { - options.onPeerDisconnect?.(peer, meta) - rpcHost._emitSessionDisconnected(meta) - }, + onConnected, + onDisconnected, }) - ;(rpcHost as any)._rpcGroup = rpcGroup - ;(rpcHost as any)._asyncStorage = asyncStorage - ;(rpcHost as any)._authDisabled = options.auth === false - - // The browser client unconditionally calls `anonymous:devframe:auth` on - // connect (see `client/rpc-ws.ts`). When `auth: false` is set on the - // standalone server, register a noop handler that auto-trusts so the - // client's hardcoded handshake succeeds. A host passing a full - // `DevframeAuthHandler` already registered the real handler above, and - // never opts into `auth: false`, so the two paths never overlap. - if (options.auth === false && !rpcHost.definitions.has('anonymous:devframe:auth')) { - rpcHost.register({ - name: 'anonymous:devframe:auth', - type: 'action', - handler: () => { - const session = rpcHost.getCurrentRpcSession() - if (session) - session.meta.isTrusted = true - return { isTrusted: true } - }, - }) - } - // Only start listening on a server we created. A shared server is already // (or about to be) listening under the caller's control. if (ownsHttpServer) { diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index 3319d91c..85800c1e 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -1,5 +1,5 @@ import type { BirpcGroup, ChannelOptions } from 'birpc' -import type { Peer } from 'crossws' +import type { Hooks, Peer } from 'crossws' import type { NodeAdapter } from 'crossws/adapters/node' import type { Buffer } from 'node:buffer' import type { Server as HttpServer, IncomingMessage } from 'node:http' @@ -49,7 +49,7 @@ export interface WsRpcTransportOptions { /** Host for a newly-created standalone WS server. Defaults to `localhost`. */ host?: string /** - * Restrict the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`). When + * Restrict the WS endpoint to a single upgrade route (e.g. `/__ws`). When * sharing a `server`, non-matching upgrade requests are left untouched for * other listeners to handle, so devframe's socket can sit alongside * framework sockets (Vite HMR, etc.). @@ -270,6 +270,113 @@ function routeUpgrades( return () => server.off('upgrade', listener) } +/** + * The per-peer lifecycle hooks driving a devframe RPC WebSocket, shaped for + * any [crossws](https://crossws.h3.dev) adapter. {@link attachWsRpcTransport} + * feeds them to the Node adapter; runtime-specific attachments (e.g. Bun's + * fetch-upgrade adapter) reuse the same hooks so every transport speaks the + * identical wire protocol — one birpc channel per peer, per-method + * `jsonSerializable` dispatch between strict JSON and structured-clone. + */ +export function createWsRpcPeerHooks< + ClientFunctions extends object, + ServerFunctions extends object, +>( + rpcGroup: BirpcGroup, + options: Pick = {}, +): Partial { + const { + onConnected = NOOP, + onDisconnected = NOOP, + definitions = EMPTY_DEFS, + serialize: serializeOverride, + deserialize: deserializeOverride, + } = options + + interface PeerState { + meta: DevframeNodeRpcSessionMeta + channel: ChannelOptions + /** birpc's inbound-message handler, registered via the channel's `on`. */ + onMessage?: (data: string) => void + } + const states = new WeakMap() + + return { + open: (peer) => { + const meta: DevframeNodeRpcSessionMeta = { + id: sessionId++, + peer, + subscribedStates: new Set(), + } + + // Per-connection state: maps an incoming request id to its method + // name so the matching outgoing response can look the method back + // up in `definitions` and pick the right encoder. One map per + // session — request-id spaces don't collide across sessions. + const pendingRequestMethods = new Map() + const state: PeerState = { meta, channel: undefined as unknown as ChannelOptions } + const channel: ChannelOptions = { + post: (data) => { + peer.send(data) + }, + on: (fn) => { + state.onMessage = fn + }, + serialize: serializeOverride ?? ((msg: any): string => { + let method: string | undefined + if (msg.t === 'q') { + method = msg.m + } + else { + method = pendingRequestMethods.get(msg.i) + pendingRequestMethods.delete(msg.i) + } + // `jsonSerializable` constrains the return-value path (args + return). + // Error envelopes (`{ t: 's', i, e }`) carry a thrown value — fall back + // to structured-clone so they round-trip instead of crashing the serializer. + // Detect via `'e' in msg` so `throw undefined` still routes through SC. + const isErrorResponse = msg.t === 's' && 'e' in msg + const useJson = !isErrorResponse && !!method && definitions.get(method)?.jsonSerializable === true + if (useJson) + return strictJsonStringify(msg, method ?? '') + return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` + }), + deserialize: deserializeOverride ?? ((raw: string): any => { + const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX) + ? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) + : JSON.parse(raw) + if (msg.t === 'q' && msg.i && msg.m) + pendingRequestMethods.set(msg.i, msg.m) + return msg + }), + meta, + } + state.channel = channel + states.set(peer, state) + + rpcGroup.updateChannels((channels) => { + channels.push(channel) + }) + onConnected(peer, meta) + }, + message: (peer, message) => { + states.get(peer)?.onMessage?.(message.text()) + }, + close: (peer) => { + const state = states.get(peer) + if (!state) + return + states.delete(peer) + rpcGroup.updateChannels((channels) => { + const index = channels.indexOf(state.channel) + if (index >= 0) + channels.splice(index, 1) + }) + onDisconnected(peer, state.meta) + }, + } +} + /** * Attach a WebSocket transport to an existing RPC group, powered by * [crossws](https://crossws.h3.dev). Either attach to an existing HTTP(S) @@ -295,96 +402,10 @@ export function attachWsRpcTransport< destroyUnmatched = false, https, allowedOrigins, - onConnected = NOOP, - onDisconnected = NOOP, - definitions = EMPTY_DEFS, - serialize: serializeOverride, - deserialize: deserializeOverride, } = options - interface PeerState { - meta: DevframeNodeRpcSessionMeta - channel: ChannelOptions - /** birpc's inbound-message handler, registered via the channel's `on`. */ - onMessage?: (data: string) => void - } - const states = new WeakMap() - const ws = crossws({ - hooks: { - open: (peer) => { - const meta: DevframeNodeRpcSessionMeta = { - id: sessionId++, - peer, - subscribedStates: new Set(), - } - - // Per-connection state: maps an incoming request id to its method - // name so the matching outgoing response can look the method back - // up in `definitions` and pick the right encoder. One map per - // session — request-id spaces don't collide across sessions. - const pendingRequestMethods = new Map() - const state: PeerState = { meta, channel: undefined as unknown as ChannelOptions } - const channel: ChannelOptions = { - post: (data) => { - peer.send(data) - }, - on: (fn) => { - state.onMessage = fn - }, - serialize: serializeOverride ?? ((msg: any): string => { - let method: string | undefined - if (msg.t === 'q') { - method = msg.m - } - else { - method = pendingRequestMethods.get(msg.i) - pendingRequestMethods.delete(msg.i) - } - // `jsonSerializable` constrains the return-value path (args + return). - // Error envelopes (`{ t: 's', i, e }`) carry a thrown value — fall back - // to structured-clone so they round-trip instead of crashing the serializer. - // Detect via `'e' in msg` so `throw undefined` still routes through SC. - const isErrorResponse = msg.t === 's' && 'e' in msg - const useJson = !isErrorResponse && !!method && definitions.get(method)?.jsonSerializable === true - if (useJson) - return strictJsonStringify(msg, method ?? '') - return `${STRUCTURED_CLONE_PREFIX}${structuredCloneStringify(msg)}` - }), - deserialize: deserializeOverride ?? ((raw: string): any => { - const msg: any = raw.startsWith(STRUCTURED_CLONE_PREFIX) - ? structuredCloneParse(raw.slice(STRUCTURED_CLONE_PREFIX.length)) - : JSON.parse(raw) - if (msg.t === 'q' && msg.i && msg.m) - pendingRequestMethods.set(msg.i, msg.m) - return msg - }), - meta, - } - state.channel = channel - states.set(peer, state) - - rpcGroup.updateChannels((channels) => { - channels.push(channel) - }) - onConnected(peer, meta) - }, - message: (peer, message) => { - states.get(peer)?.onMessage?.(message.text()) - }, - close: (peer) => { - const state = states.get(peer) - if (!state) - return - states.delete(peer) - rpcGroup.updateChannels((channels) => { - const index = channels.indexOf(state.channel) - if (index >= 0) - channels.splice(index, 1) - }) - onDisconnected(peer, state.meta) - }, - }, + hooks: createWsRpcPeerHooks(rpcGroup, options), }) let detach = NOOP diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index b4606146..b1f9176b 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -250,11 +250,11 @@ describe('devframe rpc', () => { const serverFunctions = { ping: () => 'pong' } const server = createRpcServer, typeof serverFunctions>(serverFunctions) - const { close } = attachWsRpcTransport(server, { server: httpServer, path: '/__devframe_ws' }) + const { close } = attachWsRpcTransport(server, { server: httpServer, path: '/__ws' }) try { const client = createRpcClient>({}, { - channel: createWsRpcChannel({ url: `ws://${HOST}:${PORT}/__devframe_ws` }), + channel: createWsRpcChannel({ url: `ws://${HOST}:${PORT}/__ws` }), }) expect(await client.$call('ping')).toBe('pong') diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 4a812bec..d49b304f 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -92,8 +92,8 @@ export interface DevframeNodeContext { */ export interface ConnectionMetaWebsocket { /** - * Path to the WS endpoint. Relative paths (the default, e.g. `__devframe_ws`) are - * resolved against `__connection.json`'s location; absolute paths (`/__devframe_ws`) + * Path to the WS endpoint. Relative paths (the default, e.g. `__ws`) are + * resolved against `__connection.json`'s location; absolute paths (`/__ws`) * resolve against the page origin. */ path?: string diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 77dbde86..6b6bc77d 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -33,7 +33,7 @@ export type DevframeDuplicationStrategy = 'warn' | 'silent' | 'throw' | 'duplica * * 1. **Same server, different route** (default) — leave `port`/`url` unset. * The socket shares the HTTP server's port and binds to `route` - * (`__devframe_ws`). The client connects to its own origin, so the link + * (`__ws`). The client connects to its own origin, so the link * survives a reverse proxy that rewrites the host/port/subpath. * * 2. **Different port** — set `port`. The socket binds on its own port on the @@ -45,7 +45,7 @@ export type DevframeDuplicationStrategy = 'warn' | 'silent' | 'throw' | 'duplica export interface DevframeWsOptions { /** * Upgrade route segment the socket binds to and is advertised at, relative - * to the SPA base. Default: `__devframe_ws`. + * to the SPA base. Default: `__ws`. */ route?: string /** @@ -150,7 +150,7 @@ export interface DevframeCliOptions { distDir?: string /** * How the browser reaches the RPC WebSocket. Defaults to sharing the HTTP - * port on the `__devframe_ws` route. See {@link DevframeWsOptions} for the + * port on the `__ws` route. See {@link DevframeWsOptions} for the * different-port and remote-origin variants. */ ws?: DevframeWsOptions diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 57195d41..70c9ee92 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -106,6 +106,7 @@ const serverEntries = { 'adapters/dev': 'src/adapters/dev.ts', 'adapters/build': 'src/adapters/build.ts', 'adapters/embedded': 'src/adapters/embedded.ts', + 'adapters/initiate': 'src/adapters/initiate.ts', 'adapters/mcp': 'src/adapters/mcp/index.ts', 'cli/main': 'src/cli/main.ts', 'helpers/vite': 'src/helpers/vite.ts', diff --git a/packages/hub/src/client/remote.test.ts b/packages/hub/src/client/remote.test.ts index effe14c8..c52b8b28 100644 --- a/packages/hub/src/client/remote.test.ts +++ b/packages/hub/src/client/remote.test.ts @@ -8,7 +8,7 @@ import { } from './remote' const connection: DevframeConnection = { - connectionMeta: { backend: 'websocket', websocket: { path: '__devframe_ws' } }, + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } }, metaBaseUrl: 'http://localhost:5173/__devtools/__connection.json', authToken: 'secret', } @@ -19,7 +19,7 @@ describe('remote connection URLs', () => { expect(parseRemoteConnection(url)).toEqual({ v: 1, backend: 'websocket', - websocket: 'ws://localhost:5173/__devtools/__devframe_ws', + websocket: 'ws://localhost:5173/__devtools/__ws', authToken: 'secret', origin: 'http://localhost:5173', }) @@ -39,7 +39,7 @@ describe('remote connection URLs', () => { { v: 1, backend: 'websocket', - websocket: 'ws://localhost:5173/__devtools/__devframe_ws', + websocket: 'ws://localhost:5173/__devtools/__ws', authToken: 'secret', origin: 'http://localhost:5173', }, @@ -66,7 +66,7 @@ describe('remote connection URLs', () => { const query = buildRemoteConnectionUrl('https://viewer.example/?tab=state#section', { v: 1, backend: 'websocket', - websocket: 'ws://localhost:5173/__devtools/__devframe_ws', + websocket: 'ws://localhost:5173/__devtools/__ws', authToken: 'secret', origin: 'http://localhost:5173', }, 'query') diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts index 0ee5f47c..5d3f5b64 100644 --- a/packages/next/test/handler.test.ts +++ b/packages/next/test/handler.test.ts @@ -50,7 +50,7 @@ describe('createDevframeNextHandler', () => { const body = await meta.json() as { backend: string, websocket: { port: number, path: string } } expect(body.backend).toBe('websocket') expect(typeof body.websocket.port).toBe('number') - expect(body.websocket.path).toBe('/__devframe_ws') + expect(body.websocket.path).toBe('/__ws') // Unmounted base → bare 404. const miss = await handler.fetch(new Request(`${origin}/__other/x`)) diff --git a/plans/devframes-standard-middleware.md b/plans/devframes-standard-middleware.md new file mode 100644 index 00000000..9e05262f --- /dev/null +++ b/plans/devframes-standard-middleware.md @@ -0,0 +1,183 @@ +# Plan: `/__devframes/` framework-agnostic standard middleware + +> Plan of record settled in a design interview on 2026-08-05. Implementation lands as a +> 5-PR GitHub stack (bottom → top), each layer passing the full gauntlet +> (`pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build`). + +## Goal + +One web-standard handler (`(Request) => Response`, Comark-style — see +) that carries the +entire devtools surface — mounted devframes, WS RPC, auth, MCP, embedded floating mode — +mountable on any framework with a single catch-all route (Vite, Nitro, Hono, Next.js, Nuxt, +SvelteKit), running on Node ≥ 20 and Bun. The hub stays headless; UI is a composable slot. + +## Architecture + +### Two `createHandler` factories, one UI slot + +| Import | Serves | Default base | +|---|---|---| +| `devframe/initiate` | **One devframe**: its SPA (`distDir`; omitted → bridge mode serving only meta + WS), `__connection.json`, `__mcp`, WS RPC, auth. Own isolated context, one `def.setup(ctx)`. | `/__/` (hosted rule) | +| `@devframes/hub/initiate` | **Multi-frame, headless**: shared hub context (docks/terminals/messages/commands + hub built-in RPCs + shared-state slots); every frame's `setup(ctx)` runs against it → one merged RPC registry, one WS endpoint, **one hub Auth**, one aggregate MCP. Frames auto-registered as iframe docks. | `/__devframes/` | + +**`DevframeHubUi` slot** (type lives in `@devframes/hub`; data-first, zero policy): + +```ts +interface DevframeHubUi { + viewer?: { distDir: string } // standalone viewer SPA served at the namespace root + embedded?: { entry: string } // prebuilt bootstrap served at embedded.js +} +``` + +`@devframes/hub-ui` (new package) exports `createUi(options?)` — the *reference* implementation +(a port of Vite DevTools' web components). Vite DevTools / community supply their own `ui` +object to the same slot, reusing all infra. There is **no** `createHandler` in hub-ui. + +### Handler API (both factories) + +```ts +const h = createHandler(defOrOptions, { + base?, // mount base; hosted default /__/ (core) or /__devframes/ (hub) + server?, // sugar: node http server → shared WS upgrade at __ws + ws?: DevframeWsOptions, // explicit control — url > port > route (default '__ws') + auth?, // default TRUE (existing OTP/token machinery); explicit false to opt out + mcp?, // per-frame MCP (core) / aggregate MCP (hub) + key?, // globalThis memoization — HMR re-evaluation returns the live instance + origin?, // banner origin override; else derived lazily from first request + // hub only: + devframes?, context?, // declarative list OR pre-built hub context + configure?, // async (ctx) => {} for docks/commands/terminals/messages registration + ui?, // DevframeHubUi +}) +// → { fetch(request, runtimeCtx?), nodeMiddleware, websocket, ready, context, +// connectionMeta(), close() } +``` + +- Sync factory, **eager** async init; `fetch` awaits `ready` internally. +- `fetch` 404s inside its base; `nodeMiddleware` (connect-style) calls `next()` outside it. +- Bun: `fetch(req, server)` second arg + exposed `websocket` hooks (crossws Bun adapter). +- `key` memoization: a re-evaluation returns the live instance (closes/replaces it if the + options changed) — prevents eager side-car leaks under Next/Nitro/SvelteKit dev HMR. + +### WebSocket resolution (precedence) + +1. `ws.url` — advertise an external endpoint verbatim; the handler owns **no** transport. + Hosts that want the handler's RPC on their *own* WS server use the documented recipe: + `attachWsRpcTransport(handler.context RPC group, { server, path })` + a matching `ws`. +2. `ws.port` — explicit side-car port. +3. `server` — shared upgrade on the host's node http server at ``. +4. *(default)* — **eager** auto side-car on a free port, started at handler creation so + `__connection.json` is stable from the first request. + +All four advertised consistently in `__connection.json`. The WS route unifies on **`__ws`** +everywhere (breaking: was `__devframe_ws`), matching upstream Vite DevTools' `/__devtools/__ws`. + +### Path layout (hub, under base `/__devframes/`) + +| Path | Serves | Condition | +|---|---|---| +| `/` | `ui.viewer` dist, else the index document | — | +| `__index.json` | JSON index: frame ids/bases, endpoint paths | always | +| `embedded.js` | `ui.embedded.entry` | 404 without `ui.embedded` | +| `__connection.json` | hub connection meta | always | +| `__ws` | WS upgrade route (shared-server tier) | always | +| `__client-imports.js` | dock client-script import map | always | +| `__mcp` | **aggregate** MCP over the shared context registry | when `mcp` enabled | +| `/` | each frame's SPA + its per-frame `__connection.json` | reserved-name-validated ids | + +Per-frame `__mcp` exists only on the singular handler (the hub's shared context makes the +aggregate the meaningful endpoint; tool ids are already namespaced `devframes:plugin::*`). + +### Auth + +- Gated **by default** on both factories (existing `createInteractiveAuth` OTP + token + machinery; `anonymous:` pre-trust prefix; WS origin gate). +- **Hub: a single Auth.** One `DevframeAuthHandler` owned by the hub handler, one OTP + handshake, one trusted-token store, enforced at the one shared transport. Mounted frames + have no auth of their own — trust established once covers every frame, the aggregate MCP + origin gate, and the hub built-ins. Iframes may arrive pre-authorized via hub-served + `authToken` meta or reuse the parent page's connection (`__DEVFRAME_CONNECTION__`). +- Banner origin derived lazily from the first request (`origin` option overrides). + +### Embedded mode + +- `embedded.js` = prebuilt bundle: headless `createDevframeClientHost` + hub-ui's + `DockEmbedded`. **Always visible on load** — no view-mode model in hub-ui. Visibility + policy belongs to whoever authors the entry (Vite DevTools keeps its normal/passive/hidden + model in *its own* entry via its own `embedded: { entry }`). Dock-local state + (position/collapse) stays — component behavior, not visibility policy. +- Base discovery from `import.meta.url`; OTP/auth UI included. +- Injection = documented one-line `