|
1 | 1 | import type { DevframeDefinition } from '../types/devframe' |
2 | 2 | import { resolve } from 'pathe' |
3 | 3 | import sirv from 'sirv' |
| 4 | +import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' |
| 5 | +import { logger } from '../node/diagnostics' |
4 | 6 | import { resolveBasePath } from './_shared' |
| 7 | +import { createDevServer, resolveDevServerPort } from './dev' |
5 | 8 |
|
6 | 9 | export interface CreateVitePluginOptions { |
7 | 10 | /** |
8 | 11 | * Mount base. Defaults to `def.basePath ?? '/__<id>/'` for this hosted |
9 | 12 | * 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. |
10 | 17 | */ |
11 | 18 | 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 | + } |
12 | 39 | } |
13 | 40 |
|
14 | 41 | export interface DevframeVitePlugin { |
15 | 42 | name: string |
16 | 43 | apply: 'serve' |
17 | 44 | configureServer: (server: { |
18 | 45 | 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> |
20 | 49 | } |
21 | 50 |
|
22 | 51 | /** |
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. |
27 | 58 | * |
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. |
32 | 69 | */ |
33 | 70 | 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 | + |
36 | 89 | return { |
37 | 90 | name: `devframe:${d.id}`, |
38 | 91 | 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() |
41 | 111 | 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 |
43 | 128 | }, |
44 | 129 | } |
45 | 130 | } |
| 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