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 `