Skip to content

Commit d54199c

Browse files
committed
feat(initiate)!: require base and expose it on the instance
Both initDevframe and initHub now take a required `base` option (the mount path is explicit at the call site — pass DEVFRAMES_HUB_BASE for the hub's conventional /__devframes/) and echo the normalized value back as `instance.base`, so route guards and middleware reference it instead of repeating the magic string. BREAKING CHANGE: `base` is no longer optional on initDevframe/initHub.
1 parent d465079 commit d54199c

14 files changed

Lines changed: 92 additions & 110 deletions

File tree

‎docs/adapters/initiate.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
# Initiate (standard middleware)
22

3-
Serve a devframe from inside any app that can mount a catch-all route: `initDevframe(def)` returns a live instance whose `.handler` — a web-standard `(request: Request) => Promise<Response>` — carries the whole surface (the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the auth gate, and the optional MCP route) under one mount base.
3+
Serve a devframe from inside any app that can mount a catch-all route: `initDevframe(def, { base })` returns a live instance whose `.handler` — a web-standard `(request: Request) => Promise<Response>` — carries the whole surface (the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the auth gate, and the optional MCP route) under one mount base.
44

55
```ts
66
import { initDevframe } from 'devframe/initiate'
77
import myDevframe from './devframe'
88

9-
const devtools = initDevframe(myDevframe, { key: 'my-tool' })
10-
// devtools.handler, devtools.nodeMiddleware, devtools.websocket,
9+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
10+
// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.websocket,
1111
// devtools.ready, devtools.context, devtools.connectionMeta(), devtools.close()
1212
```
1313

14-
The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot. The default base is the hosted rule — `def.basePath` or `/__<id>/`.
14+
`base` is required, so the mount path is explicit at the call site — pass the conventional `resolveBasePath(def, 'hosted')` (i.e. `def.basePath ?? /__<id>/`) if you don't want to pick one. The instance echoes the normalized value back as `devtools.base`, so route guards and middleware reference it instead of repeating the string. The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot.
1515

1616
## Mount the handler
1717

@@ -29,6 +29,7 @@ export default defineConfig({
2929
apply: 'serve',
3030
configureServer(server) {
3131
const devtools = initDevframe(myDevframe, {
32+
base: '/__my-tool/',
3233
key: 'my-tool',
3334
server: server.httpServer ?? undefined,
3435
})
@@ -65,7 +66,7 @@ import myDevframe from '@/devframe'
6566
export const runtime = 'nodejs'
6667
export const dynamic = 'force-dynamic'
6768

68-
const devtools = initDevframe(myDevframe, { key: 'my-tool' })
69+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
6970
export const GET = devtools.handler
7071
```
7172

@@ -75,7 +76,8 @@ import { devtools } from '../devtools'
7576

7677
export default defineEventHandler((event) => {
7778
const { pathname } = new URL(toWebRequest(event).url)
78-
if (pathname === '/__my-tool' || pathname.startsWith('/__my-tool/'))
79+
// `devtools.base` is the normalized mount base — no repeated string.
80+
if (pathname.startsWith(devtools.base) || pathname === devtools.base.slice(0, -1))
7981
return devtools.handler(toWebRequest(event))
8082
})
8183
```
@@ -85,7 +87,7 @@ export default defineEventHandler((event) => {
8587
import myDevframe from '$lib/devframe'
8688
import { initDevframe } from 'devframe/initiate'
8789

88-
const devtools = initDevframe(myDevframe, { key: 'my-tool' })
90+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
8991
export const GET = ({ request }) => devtools.handler(request)
9092
```
9193

‎docs/errors/DF0053.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ outline: deep
1818
import { initDevframe } from 'devframe/initiate'
1919

2020
// First evaluation:
21-
initDevframe(def, { key: 'devtools', ws: { port: 7811 } })
21+
initDevframe(def, { base: '/__my-tool/', key: 'devtools', ws: { port: 7811 } })
2222

2323
// A later reload with a different port replaces the live instance:
24-
initDevframe(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
24+
initDevframe(def, { base: '/__my-tool/', key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
2525
```
2626

2727
## Fix

‎docs/errors/DF0054.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ outline: deep
1717
```ts
1818
import { initDevframe } from 'devframe/initiate'
1919

20-
const devtools = initDevframe(def)
20+
const devtools = initDevframe(def, { base: '/__my-tool/' })
2121
devtools.connectionMeta() // ✗ throws DF0054 — init is still in flight
2222

2323
await devtools.ready

‎docs/errors/DF8000.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ outline: deep
1818
import { initHub } from '@devframes/hub/initiate'
1919

2020
initHub({
21+
base: '/__devframes/',
2122
devframes: [defineDevframe({ id: '__mcp', /* … */ })], // ✗ throws DF8000
2223
})
2324
```

‎docs/errors/DF8001.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ outline: deep
1818
import { initHub } from '@devframes/hub/initiate'
1919

2020
// First evaluation:
21-
initHub({ key: 'devtools', devframes: [git] })
21+
initHub({ base: '/__devframes/', key: 'devtools', devframes: [git] })
2222

2323
// A later reload with a different frame list replaces the live instance:
24-
initHub({ key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
24+
initHub({ base: '/__devframes/', key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
2525
```
2626

2727
## Fix

‎docs/errors/DF8002.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ outline: deep
1616

1717
```ts
1818
// ✗ Bad
19-
initHub({ devframes: [git], context: myCtx })
19+
initHub({ base: '/__devframes/', devframes: [git], context: myCtx })
2020

2121
// ✓ Good — declarative:
22-
initHub({ devframes: [git] })
22+
initHub({ base: '/__devframes/', devframes: [git] })
2323

2424
// ✓ Good — bring your own context:
2525
const ctx = await createHubContext({ host: myHost, cwd })
2626
await mountDevframe(ctx, git)
27-
initHub({ context: ctx })
27+
initHub({ base: '/__devframes/', context: ctx })
2828
```
2929

3030
## Fix

‎docs/errors/DF8003.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ outline: deep
1717
```ts
1818
import { initHub } from '@devframes/hub/initiate'
1919

20-
const hub = initHub({ devframes: [git] })
20+
const hub = initHub({ base: '/__devframes/', devframes: [git] })
2121
hub.connectionMeta() // ✗ throws DF8003 — init is still in flight
2222

2323
await hub.ready

‎docs/guide/hub-initiate.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# Serve a Hub Anywhere
22

3-
`initHub()` from `@devframes/hub/initiate` puts a whole multi-devframe devtools installation behind one web-standard handler: mount it on a single catch-all route and every frame, the shared RPC socket, the single auth gate, discovery, and the optional UI are live under one namespace (default `/__devframes/`).
3+
`initHub()` from `@devframes/hub/initiate` puts a whole multi-devframe devtools installation behind one web-standard handler: mount it on a single catch-all route and every frame, the shared RPC socket, the single auth gate, discovery, and the optional UI are live under one namespace.
44

55
```ts
66
import { createUi } from '@devframes/hub-ui'
7-
import { initHub } from '@devframes/hub/initiate'
7+
import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'
88
import { createInspectDevframe } from '@devframes/plugin-inspect'
99
import { createTerminalsDevframe } from '@devframes/plugin-terminals'
1010

1111
export const hub = initHub({
1212
key: 'devtools',
13+
base: DEVFRAMES_HUB_BASE, // required — the conventional `/__devframes/`
1314
devframes: [createInspectDevframe(), createTerminalsDevframe()],
1415
ui: createUi(),
1516
configure(ctx) {
@@ -18,7 +19,7 @@ export const hub = initHub({
1819
})
1920
```
2021

21-
Every mounted devframe runs its `setup()` against the **shared hub context**: one merged RPC registry (frames can call each other's functions), one shared-state store, one WebSocket transport, one Auth. The instance mirrors `initDevframe`'s surface — `handler`, `nodeMiddleware`, `websocket` (Bun), `ready`, `context`, `connectionMeta()`, `close()` — and the same mount snippets apply with the base swapped to `/__devframes/`; see [the initiate adapter](../adapters/initiate#mount-the-handler).
22+
`base` is required so the mount path is explicit; pass the exported `DEVFRAMES_HUB_BASE` for the conventional `/__devframes/`. The instance echoes the normalized value back as `hub.base`, so route guards and middleware reference it instead of repeating the string. Every mounted devframe runs its `setup()` against the **shared hub context**: one merged RPC registry (frames can call each other's functions), one shared-state store, one WebSocket transport, one Auth. The instance mirrors `initDevframe`'s surface — `base`, `handler`, `nodeMiddleware`, `websocket` (Bun), `ready`, `context`, `connectionMeta()`, `close()` — and the same mount snippets apply; see [the initiate adapter](../adapters/initiate#mount-the-handler).
2223

2324
## The namespace
2425

@@ -72,7 +73,7 @@ A devframe's SPA and RPC client code are byte-identical in both cases — that i
7273
Hosts that assemble `createHubContext` + `mountDevframe` themselves (with their own `DevframeHost` serving the frames) pass the finished context instead of a `devframes` list:
7374

7475
```ts
75-
const hub = initHub({ context: ctx })
76+
const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx })
7677
```
7778

78-
The instance then serves the hub-level endpoints and transport only; serve each frame's meta from `hub.connectionMeta()` yourself. The two reference examples — `examples/vite-devframe-hub` and `examples/next-devframe-hub` — use the declarative mode with their own hand-built viewer UIs, and `examples/nitro-devframe-hub` / `examples/hono-devframe-hub` show the minimal `createUi()` mounts (the Hono one on Node and Bun).
79+
The instance then serves the hub-level endpoints and transport only; serve each frame's meta from `hub.connectionMeta()` yourself. The two reference examples — `examples/hub-vite` and `examples/hub-next` — use the declarative mode with their own hand-built viewer UIs, while the `hub-*-minimal` family (`hub-vite-minimal`, `hub-next-minimal`, `hub-nitro-minimal`, `hub-hono-minimal`, `hub-rsbuild-minimal`) shows the minimal `createUi()` mount across frameworks (the Hono one on Node and Bun).

‎packages/devframe/src/adapters/__tests__/initiate.test.ts‎

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,15 @@ function defineTestDef(id: string) {
4343

4444
describe('adapters/handler', () => {
4545
it('connectionMeta() before ready throws DF0054', () => {
46-
const devtools = initDevframe(defineTestDef('handler-early'), { auth: false })
46+
const devtools = initDevframe(defineTestDef('handler-early'), { base: '/__handler-early/', auth: false })
4747
expect(() => devtools.connectionMeta()).toThrow(/DF0054|finished initializing/)
4848
return devtools.close()
4949
})
5050

5151
it('default tier: eager side-car — SPA, meta, and WS RPC through fetch', async () => {
5252
const distDir = makeTmpDist()
5353
const wsPort = await getPort({ port: 18110, host: '127.0.0.1' })
54-
const devtools = initDevframe(defineTestDef('handler-test'), {
55-
auth: false,
56-
distDir,
57-
host: '127.0.0.1',
58-
ws: { port: wsPort },
59-
})
54+
const devtools = initDevframe(defineTestDef('handler-test'), { base: '/__handler-test/', auth: false, distDir, host: '127.0.0.1', ws: { port: wsPort } })
6055

6156
try {
6257
await devtools.ready
@@ -102,10 +97,7 @@ describe('adapters/handler', () => {
10297
it('gates by default: untrusted calls reject until the OTP exchange', async () => {
10398
const wsPort = await getPort({ port: 18120, host: '127.0.0.1' })
10499
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
105-
const devtools = initDevframe(defineTestDef('handler-auth'), {
106-
host: '127.0.0.1',
107-
ws: { port: wsPort },
108-
})
100+
const devtools = initDevframe(defineTestDef('handler-auth'), { base: '/__handler-auth/', host: '127.0.0.1', ws: { port: wsPort } })
109101

110102
try {
111103
await devtools.ready
@@ -150,11 +142,7 @@ describe('adapters/handler', () => {
150142
res.end('host app')
151143
})
152144
})
153-
devtoolsRef = initDevframe(defineTestDef('handler-shared'), {
154-
auth: false,
155-
distDir,
156-
server,
157-
})
145+
devtoolsRef = initDevframe(defineTestDef('handler-shared'), { base: '/__handler-shared/', auth: false, distDir, server })
158146
await new Promise<void>(resolve => server.listen(port, host, resolve))
159147

160148
try {
@@ -212,9 +200,7 @@ describe('adapters/handler', () => {
212200
})
213201

214202
it('ws.url tier: advertises the external endpoint verbatim, owns no transport', async () => {
215-
const devtools = initDevframe(defineTestDef('handler-remote'), {
216-
ws: { url: 'wss://devtools.example.com/relay/__ws' },
217-
})
203+
const devtools = initDevframe(defineTestDef('handler-remote'), { base: '/__handler-remote/', ws: { url: 'wss://devtools.example.com/relay/__ws' } })
218204

219205
try {
220206
await devtools.ready
@@ -235,11 +221,7 @@ describe('adapters/handler', () => {
235221
const server = createServer((req, res) => {
236222
devtoolsRef.nodeMiddleware(req, res)
237223
})
238-
devtoolsRef = initDevframe(defineTestDef('handler-tunnel'), {
239-
auth: false,
240-
server,
241-
ws: { url: 'wss://devtools.example.com/relay/__ws' },
242-
})
224+
devtoolsRef = initDevframe(defineTestDef('handler-tunnel'), { base: '/__handler-tunnel/', auth: false, server, ws: { url: 'wss://devtools.example.com/relay/__ws' } })
243225
await new Promise<void>(resolve => server.listen(port, host, resolve))
244226

245227
try {
@@ -260,11 +242,7 @@ describe('adapters/handler', () => {
260242

261243
it('mcp: mounts <base>__mcp and advertises it in the meta', async () => {
262244
const wsPort = await getPort({ port: 18140, host: '127.0.0.1' })
263-
const devtools = initDevframe(defineTestDef('handler-mcp'), {
264-
auth: false,
265-
mcp: true,
266-
ws: { port: wsPort },
267-
})
245+
const devtools = initDevframe(defineTestDef('handler-mcp'), { base: '/__handler-mcp/', auth: false, mcp: true, ws: { port: wsPort } })
268246

269247
try {
270248
await devtools.ready
@@ -284,15 +262,15 @@ describe('adapters/handler', () => {
284262
it('key memoization: re-runs return the live instance; changed options replace it', async () => {
285263
const def = defineTestDef('handler-memo')
286264
const wsPort = await getPort({ port: 18150, host: '127.0.0.1' })
287-
const a = initDevframe(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort } })
288-
const b = initDevframe(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort } })
265+
const a = initDevframe(def, { base: '/__handler-memo/', auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort } })
266+
const b = initDevframe(def, { base: '/__handler-memo/', auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort } })
289267
expect(b).toBe(a)
290268

291269
try {
292270
await a.ready
293271
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
294272
const wsPort2 = await getPort({ port: 18151, host: '127.0.0.1' })
295-
const c = initDevframe(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort2 } })
273+
const c = initDevframe(def, { base: '/__handler-memo/', auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort2 } })
296274
try {
297275
expect(c).not.toBe(a)
298276
expect(String(warn.mock.calls)).toContain('DF0053')
@@ -323,10 +301,7 @@ describe('adapters/handler', () => {
323301

324302
it('bridge mode: without a distDir only meta + WS are served', async () => {
325303
const wsPort = await getPort({ port: 18160, host: '127.0.0.1' })
326-
const devtools = initDevframe(defineTestDef('handler-bridge'), {
327-
auth: false,
328-
ws: { port: wsPort },
329-
})
304+
const devtools = initDevframe(defineTestDef('handler-bridge'), { base: '/__handler-bridge/', auth: false, ws: { port: wsPort } })
330305

331306
try {
332307
await devtools.ready

‎packages/devframe/src/adapters/initiate.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@ import { diagnostics } from '../node/diagnostics'
1717
import { createH3DevframeHost } from '../node/host-h3'
1818
import { startHttpAndWs } from '../node/server'
1919
import { createInteractiveAuth } from '../recipes/interactive-auth'
20-
import { normalizeBasePath, resolveBasePath } from './_shared'
20+
import { normalizeBasePath } from './_shared'
2121
import { resolveDevServerPort, resolveMcpConnectionMeta } from './dev'
2222

2323
export interface InitDevframeOptions {
2424
/**
25-
* Mount base the handler answers under. Defaults to
26-
* `resolveBasePath(def, 'hosted')` (i.e. `def.basePath` or `/__<id>/`) —
27-
* a handler is by definition mounted *inside* a host app's origin.
25+
* Mount base the handler answers under (e.g. `/__my-tool/`) — required so
26+
* the mount path is explicit at the call site. A handler is by definition
27+
* mounted *inside* a host app's origin; the resolved value is echoed back
28+
* as {@link DevframeInstance.base} so route/middleware code references it
29+
* instead of repeating the string. `resolveBasePath(def, 'hosted')` gives
30+
* the conventional `def.basePath ?? /__<id>/`.
2831
*/
29-
base?: string
32+
base: string
3033
/**
3134
* Override `def.cli?.distDir`. When neither is set — or `false` is passed
3235
* to suppress the definition's own `distDir` — the handler runs in
@@ -154,9 +157,16 @@ export interface DevframeInstanceWebSocket {
154157
}
155158

156159
export interface DevframeInstance {
160+
/**
161+
* The normalized mount base this instance answers under (leading and
162+
* trailing slash, e.g. `/__my-tool/`). Reference it when wiring the mount
163+
* — route guards, middleware path checks — instead of repeating the
164+
* string literal.
165+
*/
166+
base: string
157167
/**
158168
* Web-standard request handler — mount it on a catch-all route under
159-
* {@link InitDevframeOptions.base} (Next.js route handler, SvelteKit
169+
* {@link DevframeInstance.base} (Next.js route handler, SvelteKit
160170
* `+server.ts`, Hono `c.req.raw`, Nitro `toWebRequest(event)`, …).
161171
* Requests outside the base 404. Under Bun, pass the `Bun.serve` server
162172
* as the second argument so WS upgrade requests can be completed (an
@@ -273,7 +283,7 @@ export function getInstanceInternals(handler: object): DevframeInstanceInternals
273283
*/
274284
export function initDevframe(
275285
def: DevframeDefinition,
276-
options: InitDevframeOptions = {},
286+
options: InitDevframeOptions,
277287
): DevframeInstance {
278288
if (options.key) {
279289
const registry = instanceRegistry()
@@ -296,7 +306,7 @@ function instantiateDevframe(
296306
def: DevframeDefinition,
297307
options: InitDevframeOptions,
298308
): DevframeInstance {
299-
const base = options.base ? normalizeBasePath(options.base) : resolveBasePath(def, 'hosted')
309+
const base = normalizeBasePath(options.base)
300310
const baseNoSlash = withoutTrailingSlash(base)
301311
const distDir = options.distDir === false ? undefined : options.distDir ?? def.cli?.distDir
302312
const app = options.app ?? new H3()
@@ -566,6 +576,7 @@ function instantiateDevframe(
566576
}
567577

568578
const handler: DevframeInstance = {
579+
base,
569580
handler: handleRequest,
570581
nodeMiddleware,
571582
websocket,

0 commit comments

Comments
 (0)