Skip to content

Commit f255885

Browse files
antfuclaude
andauthored
feat(devframe): dev-time RPC bridge via createVitePlugin({ devMiddleware }) and Nuxt module (#329)
Adds `devMiddleware` to `createVitePlugin` so a host Vite/Nuxt/Astro app can own the SPA while devframe owns the RPC + WS backend. The plugin starts `createDevServer` in bridge mode (no sirv mount) on a resolved port and serves `<base>__connection.json` via Vite middleware so the host-served SPA can discover the WS endpoint. The `@devframes/nuxt` module gains a matching `devframe` + `devMiddleware` pair. Passing `devframe` is the only step required — the bridge defaults on, auto-reads `nuxt.options.devServer.host` so `nuxt dev --host` propagates, and tears down cleanly on Vite restart / Nuxt close. `createDevServer`'s `distDir` is now truly optional (bridge mode). New `DF0033` (warn) covers bridge startup failures. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1aed403 commit f255885

10 files changed

Lines changed: 329 additions & 50 deletions

File tree

devframe/docs/errors/DF0033.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0033: Dev RPC Bridge Failed to Start
6+
7+
## Message
8+
9+
> Failed to start dev RPC bridge for "`{id}`": `{reason}`
10+
11+
## Cause
12+
13+
`createVitePlugin({ devMiddleware })` could not bring up the bridge dev server that pairs a host-served SPA (Vite, Nuxt, Astro, etc.) with devframe's RPC backend. Common reasons:
14+
15+
- The preferred port is in use and no fallback range was configured.
16+
- Calling `def.setup(ctx)` threw — the devframe's own setup logic surfaced an error.
17+
- A required peer (e.g. `get-port-please` or `h3`) is missing or mismatched.
18+
19+
This is a soft warning — the surrounding Vite dev server keeps running, but the host-served SPA will fail its `__connection.json` lookup until the bridge starts.
20+
21+
## Fix
22+
23+
- Pin a port via `cli.port` / `cli.portRange` on the devframe definition, or via `devMiddleware.port` on `createVitePlugin`.
24+
- Inspect the `reason` (or the attached `cause`) for the underlying error — fix the setup function or free the port.
25+
- For Nuxt: pass `devMiddleware: { port: <free-port> }` to the `@devframes/nuxt` module.
26+
27+
## Source
28+
29+
- [`packages/devframe/src/adapters/vite.ts`](https://github.com/vitejs/devtools/blob/main/devframe/packages/devframe/src/adapters/vite.ts)`createVitePlugin({ devMiddleware })` logs `DF0033` when port resolution or `createDevServer` throws during `configureServer`.

devframe/docs/errors/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ Emitted by `devframe` — framework-neutral host / shared-state / auth surface.
4141
| [DF0030](./DF0030) | error | Unknown Stream ID |
4242
| [DF0031](./DF0031) | error | Write to Closed Stream |
4343
| [DF0032](./DF0032) | error | Streaming Channel Already Registered |
44+
| [DF0033](./DF0033) | warn | Dev RPC Bridge Failed to Start |

devframe/docs/guide/nuxt.md

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ outline: deep
44

55
# Nuxt Helper
66

7-
The `@devframes/nuxt` module wires a Nuxt-built SPA as a devframe client. It runs inside the Nuxt app that consumes your devframe — separate from the CLI that serves it.
7+
The `@devframes/nuxt` module wires a Nuxt-built SPA as a devframe client, and optionally serves the dev-time RPC bridge alongside `nuxt dev`. It runs inside the Nuxt app that consumes your devframe.
88

9-
It handles the three things every Nuxt-powered standalone devtool needs:
9+
It handles the four things every Nuxt-powered standalone devtool needs:
1010

1111
1. **Base-agnostic assets.** Sets `app.baseURL: './'` and `vite.base: './'` so the same production build works at `/`, `/tool/`, and any other deployment path without build-time URL rewriting.
1212
2. **Runtime RPC connection.** Adds a client plugin that calls [`connectDevframe()`](./client) once on page load and provides the result as `$rpc` on the Nuxt app.
13-
3. **TypeScript augmentation.** `useNuxtApp().$rpc` is typed as `DevToolsRpcClient` out of the box.
13+
3. **Dev-time RPC bridge.** When you pass `devframe`, `nuxt dev` spins up a separate WebSocket RPC server and serves `__connection.json` so the SPA can reach it — no hand-rolled Vite plugin required.
14+
4. **TypeScript augmentation.** `useNuxtApp().$rpc` is typed as `DevToolsRpcClient` out of the box.
1415

1516
## Install
1617

@@ -55,40 +56,77 @@ export default defineNuxtConfig({
5556
- **`baseURL`** defaults to `'./'`, which resolves against `document.baseURI` at runtime. The connection meta and dump shards sit next to `index.html`, so the same build works at any deployment path.
5657
- **`skipAppDefaults: true`** disables the `app.baseURL: './'` / `vite.base: './'` defaults. Use this when you're shipping with absolute asset paths and have your own base-URL story.
5758

58-
## How it works
59+
## Dev-time RPC bridge
5960

60-
At build time the module:
61+
Pass your devframe definition to wire `nuxt dev` up to the RPC backend:
6162

62-
- Sets `nuxt.options.app.baseURL` to `'./'` (unless already set)
63-
- Sets `nuxt.options.vite.base` to `'./'` (unless already set)
64-
- Merges `{ devframe: { baseURL } }` into `runtimeConfig.public`
65-
- Injects a client-only plugin (`helpers/nuxt/runtime/plugin.client`) that:
66-
```ts
67-
const rpc = await connectDevframe({ baseURL: config.public.devframe.baseURL })
68-
return { provide: { rpc } }
69-
```
63+
```ts [nuxt.config.ts]
64+
import devframe from './src/devframe' // defineDevframe(...) export
7065
71-
At runtime the built SPA fetches `./__connection.json` (resolved against `document.baseURI`) and branches on the `backend` field — `websocket` in dev, `static` from a `createBuild` snapshot.
66+
export default defineNuxtConfig({
67+
modules: [['@devframes/nuxt', { devframe }]],
68+
})
69+
```
7270

73-
## Relationship to `createCli`
71+
That's the full setup. Behind the scenes, `nuxt dev` now:
7472

75-
The Nuxt helper is a client-side integration — `createCli` runs the server side. The typical shape is:
73+
- Starts a separate WebSocket RPC server on a port resolved via [`get-port-please`](https://github.com/unjs/get-port-please) (respects `devframe.cli.port` / `portRange` / `random`).
74+
- Registers Vite middleware at `${baseURL}__connection.json` so the SPA reads it on load.
75+
- Runs `devframe.setup(ctx, { flags })` once the bridge is up, registering your RPC functions.
76+
- Cleans up the bridge on Vite restart, `nuxt dev` shutdown, and bundle close.
77+
78+
The bridge is **on by default** whenever `devframe` is set. Skip it (back to client-only) with `devMiddleware: false`.
79+
80+
### Customizing the bridge
81+
82+
```ts [nuxt.config.ts]
83+
export default defineNuxtConfig({
84+
modules: [['@devframes/nuxt', {
85+
devframe,
86+
devMiddleware: {
87+
port: 7777,
88+
host: '0.0.0.0',
89+
flags: { config: process.env.MY_CONFIG },
90+
},
91+
}]],
92+
})
93+
```
94+
95+
- **`port`** pins the bridge port. Skip it to let `get-port-please` pick a free port.
96+
- **`host`** controls the bridge bind host. Defaults to `nuxt.options.devServer.host ?? devframe.cli?.host ?? 'localhost'`, so `nuxt dev --host` propagates automatically. Set this manually when your Nuxt server config doesn't surface `host` (e.g. custom listen options).
97+
- **`flags`** is forwarded to `devframe.setup(ctx, { flags })`. Use it to pass env-derived configuration into the RPC layer.
98+
99+
### Relationship to `createCli`
100+
101+
The bridge handles the **dev workflow**. Production deploys still go through `createCli` (or `createBuild`), which produces a static `__connection.json` + `__rpc-dump/` snapshot from `cli.distDir`:
76102

77103
```
78104
my-tool/
79-
├── bin.mjs # createCli(devtool).parse()
105+
├── bin.mjs # createCli(devframe).parse()
80106
├── src/
81-
│ ├── cli.ts # defineDevframe + setup(ctx) { ctx.rpc.register(...) }
107+
│ ├── devframe.ts # defineDevframe + setup(ctx) { ctx.rpc.register(...) }
82108
│ └── app/ # Nuxt SPA — uses `@devframes/nuxt`
83109
└── dist/
84110
├── cli.mjs # bundled Node entry
85111
└── public/ # Nuxt build output, pointed at by cli.distDir
86112
```
87113

88-
- `createCli` (from `devframe/adapters/cli`) runs the Node side — HTTP + WS + static build + MCP.
89-
- `@devframes/nuxt` handles the client side — RPC connection + base-URL plumbing.
114+
In dev (`nuxt dev`) the bridge is live. In production (`<your-cli> build` then `<your-cli> spa`) the SPA loads the static dump.
90115

91-
They're decoupled: swap Nuxt for any other SPA framework that calls `connectDevframe()` in the browser.
116+
## How it works
117+
118+
At build time the module:
119+
120+
- Sets `nuxt.options.app.baseURL` to `'./'` (unless already set)
121+
- Sets `nuxt.options.vite.base` to `'./'` (unless already set)
122+
- Merges `{ devframe: { baseURL } }` into `runtimeConfig.public`
123+
- Injects a client-only plugin (`helpers/nuxt/runtime/plugin.client`) that:
124+
```ts
125+
const rpc = await connectDevframe({ baseURL: config.public.devframe.baseURL })
126+
return { provide: { rpc } }
127+
```
128+
129+
At runtime the built SPA fetches `./__connection.json` (resolved against `document.baseURI`) and branches on the `backend` field — `websocket` in dev, `static` from a `createBuild` snapshot.
92130

93131
## See also
94132

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,36 @@ describe('adapters/dev', () => {
4444
}
4545
})
4646

47-
it('createDevServer throws when no distDir is configured', async () => {
47+
it('createDevServer runs in bridge mode when no distDir is configured', async () => {
4848
const devframe = defineDevframe({
4949
id: 'devframe-test-nodist',
5050
name: 'No Dist',
5151
setup: () => {},
5252
})
53-
await expect(createDevServer(devframe, { openBrowser: false }))
54-
.rejects
55-
.toThrow(/no distDir/)
53+
const host = '127.0.0.1'
54+
const port = await getPort({ port: 19990, host })
55+
const handle = await createDevServer(devframe, {
56+
host,
57+
port,
58+
openBrowser: false,
59+
})
60+
61+
try {
62+
// Connection meta is still served — the bridge endpoint that lets
63+
// a host-served SPA discover the WS backend.
64+
const res = await fetch(`http://${host}:${port}/__connection.json`)
65+
expect(res.ok).toBe(true)
66+
const meta = await res.json()
67+
expect(meta).toEqual({ backend: 'websocket', websocket: port })
68+
69+
// The SPA mount is absent — without a distDir, sirv isn't wired,
70+
// so the basePath returns a 404 from h3 instead of an index.html.
71+
const spa = await fetch(`http://${host}:${port}/`)
72+
expect(spa.status).toBe(404)
73+
}
74+
finally {
75+
await handle.close()
76+
}
5677
})
5778

5879
it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ export interface CreateDevServerOptions {
3030
*/
3131
flags?: Record<string, unknown>
3232
/**
33-
* Override `def.cli?.distDir`. Throws when neither is set — the dev
34-
* server has nothing to mount otherwise.
33+
* Override `def.cli?.distDir`. When neither this option nor
34+
* `def.cli?.distDir` is set, the dev server runs in **bridge mode** —
35+
* only `__connection.json` and the WS endpoint are mounted; the SPA
36+
* is expected to be hosted elsewhere (e.g. by a parent Vite/Nuxt
37+
* dev server via `createVitePlugin({ devMiddleware })`).
3538
*/
3639
distDir?: string
3740
/**
@@ -93,8 +96,14 @@ export async function resolveDevServerPort(
9396

9497
/**
9598
* Start a devframe dev server for a {@link DevframeDefinition} —
96-
* h3 + WebSocket RPC + the author's SPA mounted at the resolved base
97-
* path.
99+
* h3 + WebSocket RPC + (optionally) the author's SPA mounted at the
100+
* resolved base path.
101+
*
102+
* When `distDir` is omitted (and `def.cli?.distDir` is unset) the
103+
* server runs in **bridge mode**: only `__connection.json` and the WS
104+
* endpoint are mounted, with no sirv-served SPA. The SPA is expected to
105+
* be hosted elsewhere (e.g. by a parent Vite/Nuxt dev server) — see
106+
* `createVitePlugin({ devMiddleware })`.
98107
*
99108
* Returns the underlying {@link StartedServer} handle so callers can
100109
* close it gracefully (SIGINT, hot-reload, test teardown).
@@ -108,8 +117,6 @@ export async function createDevServer(
108117
options: CreateDevServerOptions = {},
109118
): Promise<StartedServer> {
110119
const distDir = options.distDir ?? def.cli?.distDir
111-
if (!distDir)
112-
throw new Error(`[devframe] createDevServer: no distDir for "${def.id}". Set \`cli.distDir\` on the definition or pass it as an option.`)
113120

114121
const host = options.host ?? def.cli?.host ?? 'localhost'
115122
const port = options.port ?? await resolveDevServerPort(def, { host })
@@ -145,7 +152,8 @@ export async function createDevServer(
145152
return event.node.res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
146153
}))
147154

148-
app.use(basePath, fromNodeMiddleware(sirv(resolve(distDir), { dev: true, single: true })))
155+
if (distDir)
156+
app.use(basePath, fromNodeMiddleware(sirv(resolve(distDir), { dev: true, single: true })))
149157

150158
return startHttpAndWs({
151159
context: ctx,
Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,145 @@
11
import type { DevframeDefinition } from '../types/devframe'
22
import { resolve } from 'pathe'
33
import sirv from 'sirv'
4+
import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants'
5+
import { logger } from '../node/diagnostics'
46
import { resolveBasePath } from './_shared'
7+
import { createDevServer, resolveDevServerPort } from './dev'
58

69
export interface CreateVitePluginOptions {
710
/**
811
* Mount base. Defaults to `def.basePath ?? '/__<id>/'` for this hosted
912
* adapter — the devframe shares the origin with the host Vite app.
13+
*
14+
* Relative spellings like `'./'` (common for base-agnostic Nuxt builds)
15+
* are normalized to absolute paths so they compose with Vite's connect
16+
* router.
1017
*/
1118
base?: string
19+
/**
20+
* Dev-time middleware mode. When set, the host app owns the SPA and
21+
* devframe spins up a separate RPC + WS server on a resolved port,
22+
* registering Vite middleware at `<base>__connection.json` so the
23+
* host-served SPA can discover the WS endpoint.
24+
*
25+
* - `false` (default) — sirv-mount the SPA at `base` (today's
26+
* behavior). No RPC server is started.
27+
* - `true` — bridge mode with all defaults (port from
28+
* {@link resolveDevServerPort}, host from `def.cli?.host`).
29+
* - object — bridge mode with explicit overrides.
30+
*/
31+
devMiddleware?: boolean | {
32+
/** Override the bridge port. Default: {@link resolveDevServerPort}. */
33+
port?: number
34+
/** Override the bridge bind host. Default: `def.cli?.host ?? 'localhost'`. */
35+
host?: string
36+
/** Flag bag forwarded to `def.setup(ctx, { flags })`. */
37+
flags?: Record<string, unknown>
38+
}
1239
}
1340

1441
export interface DevframeVitePlugin {
1542
name: string
1643
apply: 'serve'
1744
configureServer: (server: {
1845
middlewares: { use: (path: string, handler: any) => void }
19-
}) => void
46+
httpServer?: { once: (event: 'close', cb: () => void) => void } | null
47+
}) => void | Promise<void>
48+
closeBundle?: () => void | Promise<void>
2049
}
2150

2251
/**
23-
* Plain Vite plugin — mounts a devframe's SPA into a user's
24-
* Vite dev server at `options.base` (default: `def.basePath ?? '/__<id>/'`).
25-
* Use this for tools that want the Vite dev experience without
26-
* pulling the full Vite DevTools Kit.
52+
* Vite plugin for hosting a devframe inside a Vite dev server.
53+
*
54+
* Two modes, picked via `options.devMiddleware`:
55+
*
56+
* - **sirv mode** (default) — mounts `def.cli.distDir` at `options.base`
57+
* via sirv. Single-page fallback enabled. No RPC server is started.
2758
*
28-
* Note: this does not yet spin up the RPC WS server — for the full
29-
* RPC path, use `createPluginFromDevframe` from
30-
* `@vitejs/devtools-kit/node` alongside `@vitejs/devtools`, or the
31-
* standalone `createCli`.
59+
* - **bridge mode** (`devMiddleware: true | {…}`) — skips the sirv
60+
* mount; the host app owns the SPA. Devframe starts a separate
61+
* RPC + WS dev server (via {@link createDevServer} in bridge mode,
62+
* i.e. without sirv) and registers Vite middleware at
63+
* `<base>__connection.json` so the host-served SPA can discover
64+
* the WS endpoint via {@link connectDevframe}.
65+
*
66+
* Use bridge mode when integrating with frameworks that own the SPA
67+
* (Nuxt, Astro, SolidStart, plain Vite apps). For the all-in-one
68+
* `dev` / `build` / `mcp` shell, reach for {@link createCli} instead.
3269
*/
3370
export function createVitePlugin(d: DevframeDefinition, options: CreateVitePluginOptions = {}): DevframeVitePlugin {
34-
const base = options.base ?? resolveBasePath(d, 'hosted')
35-
const distDir = d.cli?.distDir
71+
const base = normalizeMountBase(options.base ?? resolveBasePath(d, 'hosted'))
72+
73+
if (!options.devMiddleware) {
74+
const distDir = d.cli?.distDir
75+
return {
76+
name: `devframe:${d.id}`,
77+
apply: 'serve',
78+
configureServer(server) {
79+
if (!distDir)
80+
return
81+
server.middlewares.use(base, sirv(resolve(distDir), { dev: true, single: true }))
82+
},
83+
}
84+
}
85+
86+
const mw = options.devMiddleware === true ? {} : options.devMiddleware
87+
let started: Awaited<ReturnType<typeof createDevServer>> | undefined
88+
3689
return {
3790
name: `devframe:${d.id}`,
3891
apply: 'serve',
39-
configureServer(server) {
40-
if (!distDir)
92+
async configureServer(server) {
93+
// Vite re-invokes `configureServer` on each restart cycle; close
94+
// the prior handle so we don't leak the WS server. Silent catch —
95+
// a stale handle's close failure shouldn't block a fresh start.
96+
await started?.close().catch(() => {})
97+
started = undefined
98+
99+
let port: number
100+
try {
101+
port = mw.port ?? await resolveDevServerPort(d, { host: mw.host })
102+
started = await createDevServer(d, {
103+
host: mw.host,
104+
port,
105+
flags: mw.flags,
106+
openBrowser: false,
107+
})
108+
}
109+
catch (e) {
110+
logger.DF0033({ id: d.id, reason: String(e) }, { cause: e as Error }).log()
41111
return
42-
server.middlewares.use(base, sirv(resolve(distDir), { dev: true, single: true }))
112+
}
113+
114+
const metaPath = `${base}${DEVTOOLS_CONNECTION_META_FILENAME}`
115+
server.middlewares.use(metaPath, (_req: unknown, res: any) => {
116+
res.setHeader('Content-Type', 'application/json')
117+
res.end(JSON.stringify({ backend: 'websocket', websocket: port }))
118+
})
119+
120+
server.httpServer?.once('close', () => {
121+
void started?.close().catch(() => {})
122+
})
123+
},
124+
125+
async closeBundle() {
126+
await started?.close().catch(() => {})
127+
started = undefined
43128
},
44129
}
45130
}
131+
132+
/**
133+
* Make `base` safe for `server.middlewares.use(path, …)`. Vite's connect
134+
* router matches by absolute URL prefix, so relative spellings like
135+
* `'./'` (commonly used for base-agnostic Nuxt builds) need to be
136+
* converted to `/` first.
137+
*/
138+
function normalizeMountBase(base: string): string {
139+
let out = base.replace(/^\.\/?/, '/')
140+
if (!out.startsWith('/'))
141+
out = `/${out}`
142+
if (!out.endsWith('/'))
143+
out = `${out}/`
144+
return out.replace(/\/+/g, '/')
145+
}

0 commit comments

Comments
 (0)