Skip to content

Commit fdf12a5

Browse files
committed
feat(handler): add devframe/handler — framework-agnostic web-standard middleware
createHandler(def) serves a devframe's whole surface — SPA, __connection.json discovery, WebSocket RPC, auth gate (on by default), and the optional MCP route — through one fetch handler mountable on any framework's catch-all route, plus a connect-style nodeMiddleware and Bun fetch-upgrade websocket hooks. WebSocket tiers resolve by precedence: ws.url (external, advertise-only) > ws.port (explicit side-car) > server (shared upgrade at <base>__ws) > Bun fetch-upgrade > eager auto side-car. A key option memoizes the handler on globalThis so HMR module re-evaluation can't leak side-cars. BREAKING CHANGE: the WS route unifies on `__ws` (was `__devframe_ws`) across every adapter, and the unused DEVFRAME_MOUNT_PATH / DEVFRAME_DIRNAME constants are removed.
1 parent 715fcd0 commit fdf12a5

34 files changed

Lines changed: 1105 additions & 53 deletions

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const alias = {
4040
'devframe/adapters/build': r('devframe/src/adapters/build.ts'),
4141
'devframe/helpers/vite': r('devframe/src/helpers/vite.ts'),
4242
'devframe/adapters/embedded': r('devframe/src/adapters/embedded.ts'),
43+
'devframe/handler': r('devframe/src/adapters/handler.ts'),
4344
'devframe/adapters/mcp': r('devframe/src/adapters/mcp/index.ts'),
4445
'@devframes/hub/client': r('hub/src/client/index.ts'),
4546
'@devframes/hub/constants': r('hub/src/constants.ts'),

docs/adapters/dev.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,18 @@ process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
3535

3636
## WebSocket endpoint
3737

38-
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):
38+
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):
3939

4040
```ts
4141
defineDevframe({
42-
// 1. Same server, a custom route (default route is `__devframe_ws`):
42+
// 1. Same server, a custom route (default route is `__ws`):
4343
cli: { ws: { route: '__sockets' } },
4444

4545
// 2. A dedicated port on the same host:
4646
cli: { ws: { port: 9788 } },
4747

4848
// 3. A remote, fully-qualified endpoint (e.g. a tunnel/relay):
49-
cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } },
49+
cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } },
5050
})
5151
```
5252

docs/errors/DF0052.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0052: Conflicting WebSocket Bindings on createHandler
6+
7+
## Message
8+
9+
> createHandler("`{id}`") received \`ws.url\` alongside \`server\`/\`ws.port\` — the external URL wins and no local WebSocket transport is started.
10+
11+
## Cause
12+
13+
`createHandler` resolves its WebSocket tier in precedence order — `ws.url` (advertise an external endpoint verbatim) > `ws.port` (explicit side-car port) > `server` (shared upgrade on the host's HTTP server) > the eager auto side-car. Passing `ws.url` together with `server` or `ws.port` is contradictory: the external URL is advertised, and the other bindings are ignored — the handler starts no transport of its own in that tier.
14+
15+
## Example
16+
17+
```ts
18+
import { createHandler } from 'devframe/handler'
19+
20+
// ✗ Bad — the server is never used for devframe's socket:
21+
const handler = createHandler(def, {
22+
server: httpServer,
23+
ws: { url: 'wss://relay.example.com/__ws' },
24+
})
25+
26+
// ✓ Good — pick exactly one binding:
27+
const shared = createHandler(def, { server: httpServer })
28+
const external = createHandler(def, { ws: { url: 'wss://relay.example.com/__ws' } })
29+
```
30+
31+
## Fix
32+
33+
Pass exactly one WebSocket binding: `ws.url` when a server you run yourself owns the RPC endpoint (wire the handler's `context` into it via `startHttpAndWs`), `ws.port` for an explicit side-car port, or `server` to share the host HTTP server's port. Drop the extras.
34+
35+
## Source
36+
37+
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler`'s WebSocket tier resolution warns this when `ws.url` shadows another binding.

docs/errors/DF0053.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0053: Memoized Handler Replaced
6+
7+
## Message
8+
9+
> createHandler("`{id}`") replaced the live handler memoized under key "`{key}`": its options changed since the previous call.
10+
11+
## Cause
12+
13+
`createHandler` was called with a `key` that already maps to a live handler, but the option fingerprint differs from the memoized instance's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `createHandler` on every reload; the `key` memoization normally returns the live instance, but when the options genuinely changed the old instance — including its side-car WebSocket server — is closed and a fresh one starts.
14+
15+
## Example
16+
17+
```ts
18+
import { createHandler } from 'devframe/handler'
19+
20+
// First evaluation:
21+
createHandler(def, { key: 'devtools', ws: { port: 7811 } })
22+
23+
// A later reload with a different port replaces the live instance:
24+
createHandler(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
25+
```
26+
27+
## Fix
28+
29+
This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, make the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different handlers distinct keys.
30+
31+
## Source
32+
33+
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler` warns this before closing and replacing a memoized instance whose options fingerprint changed.

docs/errors/DF0054.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0054: connectionMeta() Before Handler Ready
6+
7+
## Message
8+
9+
> connectionMeta() was called before createHandler("`{id}`") finished initializing.
10+
11+
## Cause
12+
13+
`createHandler` 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.
14+
15+
## Example
16+
17+
```ts
18+
import { createHandler } from 'devframe/handler'
19+
20+
const handler = createHandler(def)
21+
handler.connectionMeta() // ✗ throws DF0054 — init is still in flight
22+
23+
await handler.ready
24+
handler.connectionMeta() // ✓ { backend: 'websocket', websocket: { … } }
25+
```
26+
27+
## Fix
28+
29+
Await `handler.ready` (or any `handler.fetch` call — it awaits readiness internally) before reading `connectionMeta()`.
30+
31+
## Source
32+
33+
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler`'s `connectionMeta()` throws this while initialization is still pending.

docs/guide/client.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,12 @@ With caching on, `query` / `static` function responses are memoized per argument
229229

230230
## Discovery (`__connection.json`)
231231

232-
Devframe writes a JSON descriptor at `<base>/__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 (`<base>__devframe_ws`) next to the meta file — and advertises it as a relative path:
232+
Devframe writes a JSON descriptor at `<base>/__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 (`<base>__ws`) next to the meta file — and advertises it as a relative path:
233233

234234
```json
235235
{
236236
"backend": "websocket",
237-
"websocket": { "path": "__devframe_ws" }
237+
"websocket": { "path": "__ws" }
238238
}
239239
```
240240

docs/helpers/vite-bridge.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default defineConfig({
2121
## Modes
2222

2323
- **Static mount** (default) — mounts `def.cli.distDir` at `options.base` (`/__<id>/` by default). No RPC server. Useful when you only need the SPA bundle served from a known path.
24-
- **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 `<base>__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.
24+
- **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 `<base>__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.
2525

2626
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.
2727

knip.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"entry": [
7474
"src/{index,constants}.ts",
7575
"src/helpers/vite.ts",
76-
"src/adapters/{build,cac,cli,dev,embedded}.ts",
76+
"src/adapters/{build,cac,cli,dev,embedded,handler}.ts",
7777
"src/adapters/mcp/index.ts",
7878
"src/client/index.ts",
7979
"src/node/index.ts",

packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"./adapters/mcp": "./dist/adapters/mcp.mjs",
2929
"./client": "./dist/client/index.mjs",
3030
"./constants": "./dist/constants.mjs",
31+
"./handler": "./dist/adapters/handler.mjs",
3132
"./helpers/vite": "./dist/helpers/vite.mjs",
3233
"./node": "./dist/node/index.mjs",
3334
"./node/auth": "./dist/node/auth.mjs",

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) }))
1919
function connectWsClient(host: string, port: number, authToken?: string) {
2020
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
2121
{} as DevframeRpcClientFunctions,
22-
{ channel: createWsRpcChannel({ url: `ws://${host}:${port}/__devframe_ws`, authToken }) },
22+
{ channel: createWsRpcChannel({ url: `ws://${host}:${port}/__ws`, authToken }) },
2323
)
2424
}
2525

@@ -64,7 +64,7 @@ describe('adapters/dev', () => {
6464
const meta = await res.json()
6565
// Proxy-safe: the WS endpoint is advertised as a same-origin route
6666
// relative to `__connection.json`, never a baked-in host/port.
67-
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } })
67+
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } })
6868
}
6969
finally {
7070
await handle.close()
@@ -129,7 +129,7 @@ describe('adapters/dev', () => {
129129

130130
try {
131131
// Connects on the bound route.
132-
const ok = new WebSocket(`ws://${host}:${port}/__devframe_ws`)
132+
const ok = new WebSocket(`ws://${host}:${port}/__ws`)
133133
await expect(new Promise((resolve, reject) => {
134134
ok.on('open', () => resolve('open'))
135135
ok.on('error', reject)
@@ -205,11 +205,11 @@ describe('adapters/dev', () => {
205205
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
206206
expect(meta).toEqual({
207207
backend: 'websocket',
208-
websocket: { port: wsPort, path: '__devframe_ws' },
208+
websocket: { port: wsPort, path: '__ws' },
209209
})
210210

211211
// The socket is reachable on its own port, rooted at `/<route>`.
212-
const ok = new WebSocket(`ws://${host}:${wsPort}/__devframe_ws`)
212+
const ok = new WebSocket(`ws://${host}:${wsPort}/__ws`)
213213
await expect(new Promise((resolve, reject) => {
214214
ok.on('open', () => resolve('open'))
215215
ok.on('error', reject)
@@ -234,7 +234,7 @@ describe('adapters/dev', () => {
234234
homepage: 'https://example.test',
235235
description: 'Test devframe.',
236236
setup: () => {},
237-
cli: { ws: { url: 'wss://devtools.example.com/relay/__devframe_ws' } },
237+
cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } },
238238
})
239239
const host = '127.0.0.1'
240240
const port = await getPort({ port: 19860, host })
@@ -244,7 +244,7 @@ describe('adapters/dev', () => {
244244
const meta = await (await fetch(`http://${host}:${port}/__connection.json`)).json()
245245
expect(meta).toEqual({
246246
backend: 'websocket',
247-
websocket: 'wss://devtools.example.com/relay/__devframe_ws',
247+
websocket: 'wss://devtools.example.com/relay/__ws',
248248
})
249249
}
250250
finally {
@@ -276,7 +276,7 @@ describe('adapters/dev', () => {
276276
const res = await fetch(`http://${host}:${port}/__connection.json`)
277277
expect(res.ok).toBe(true)
278278
const meta = await res.json()
279-
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__devframe_ws' } })
279+
expect(meta).toEqual({ backend: 'websocket', websocket: { path: '__ws' } })
280280

281281
// The SPA mount is absent — without a distDir, no static handler
282282
// is wired, so the basePath returns a 404 from h3 instead of an

0 commit comments

Comments
 (0)