diff --git a/packages/devtools-onboard/package.json b/packages/devtools-onboard/package.json index 1b0ef596fb..ab809bc938 100644 --- a/packages/devtools-onboard/package.json +++ b/packages/devtools-onboard/package.json @@ -39,6 +39,7 @@ "devDependencies": { "@nuxt/kit": "catalog:prod", "@nuxt/schema": "catalog:types", - "nuxt": "catalog:buildtools" + "nuxt": "catalog:buildtools", + "vite": "catalog:buildtools" } } diff --git a/packages/devtools-onboard/src/module.ts b/packages/devtools-onboard/src/module.ts index 3e1b334427..c76bceb3a6 100644 --- a/packages/devtools-onboard/src/module.ts +++ b/packages/devtools-onboard/src/module.ts @@ -18,6 +18,12 @@ const DEVTOOLS_MODULES = new Set([DEVTOOLS, DEVTOOLS_NIGHTLY, `${DEVTOOLS}-edge` */ const DEVTOOLS_PACKAGES = [DEVTOOLS_NIGHTLY, DEVTOOLS] +/** + * Where Vite DevTools mounts the hub, so after the restart the real + * `embedded.js` answers at the URL the button was loaded from. + */ +const BASE = '/__devtools/' + function listsDevtools(modules: NuxtOptions['modules']): boolean { return modules.some((entry) => { const id = Array.isArray(entry) ? entry[0] : entry @@ -59,9 +65,7 @@ export default defineNuxtModule({ const onboarding = createOnboarding({ cwd: nuxt.options.rootDir, - // Where Vite DevTools mounts the hub, so after the restart the real - // `embedded.js` answers at the URL the button was loaded from. - base: '/__devtools/', + base: BASE, packages: [await devtoolsSpec()], branding: { productName: 'Nuxt DevTools', @@ -85,8 +89,16 @@ export default defineNuxtModule({ }) nuxt.hook('vite:serverCreated', (server, { isClient }) => { - if (isClient) - server.middlewares.use(onboarding.nodeMiddleware) + if (!isClient) + return + // Nuxt only hands a request to Vite when a middleware is mounted on a + // path other than `/`; otherwise it rewrites the URL to `/__skip_vite/…`. + // Connect strips the mount path, so restore the full URL the + // middleware matches against `base`. + server.middlewares.use(BASE.slice(0, -1), (req, res, next) => { + req.url = req.originalUrl || req.url + onboarding.nodeMiddleware(req, res, next) + }) }) if (!onboarding.disabled) (nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc, tagPosition: 'bodyClose' }) diff --git a/packages/devtools-onboard/test/module.test.ts b/packages/devtools-onboard/test/module.test.ts index b13ea94f12..975c4496e0 100644 --- a/packages/devtools-onboard/test/module.test.ts +++ b/packages/devtools-onboard/test/module.test.ts @@ -1,11 +1,12 @@ import type { Nuxt, NuxtConfig } from '@nuxt/schema' -import type { IncomingMessage, ServerResponse } from 'node:http' +import type { ViteDevServer } from 'vite' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { loadNuxt } from 'nuxt' +import { createServer as createViteServer } from 'vite' import { afterEach, describe, expect, it } from 'vitest' const onboardModule = fileURLToPath(new URL('../src/module.ts', import.meta.url)) @@ -56,26 +57,24 @@ function onboardScripts(nuxt: Nuxt) { return (nuxt.options.app.head.script ?? []).filter(script => typeof script === 'object' && script.src === '/__devtools/embedded.js') } -type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void - -/** Serves what the module mounts on the client Vite server; resolves to its origin. */ -async function serveViteMiddleware(nuxt: Nuxt): Promise { - const mounted: Middleware[] = [] - // Only `middlewares.use` is read; the rest of a real ViteDevServer is irrelevant here. - const viteServer = { middlewares: { use: (fn: Middleware) => mounted.push(fn) } } as never +/** Serves what the module mounts on a real client Vite server; resolves to its origin. */ +async function serveViteMiddleware(nuxt: Nuxt): Promise<{ origin: string, viteServer: ViteDevServer }> { + const viteServer = await createViteServer({ configFile: false, logLevel: 'silent', server: { middlewareMode: true, ws: false } }) + cleanups.push(() => viteServer.close()) + // Mount only what the module adds, not Vite's own transform middlewares. + viteServer.middlewares.stack.length = 0 await nuxt.callHook('vite:serverCreated', viteServer, { isClient: true, isServer: false }) - const [middleware] = mounted - if (!middleware) - throw new Error('onboarding middleware was not mounted') - - const server = createServer((req, res) => middleware(req, res, () => { - res.statusCode = 404 - res.end() - })) + + const server = createServer(viteServer.middlewares) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) cleanups.push(() => new Promise(resolve => server.close(() => resolve()))) const { port } = server.address() as { port: number } // listening on a TCP port, never a pipe - return `http://127.0.0.1:${port}` + return { origin: `http://127.0.0.1:${port}`, viteServer } +} + +/** Nuxt's dev handler only passes a URL to Vite when a middleware is mounted on a path other than `/`. */ +function nuxtRoutesToVite(viteServer: ViteDevServer, url: string) { + return viteServer.middlewares.stack.some(({ route }) => route.length > 1 && url.startsWith(route)) } const onboardStatus = (origin: string) => fetch(`${origin}/__devtools/__onboard/status`).then(r => r.json()) @@ -104,7 +103,12 @@ describe('@nuxt/devtools-onboard', () => { expect(nuxt.options._requiredModules['@nuxt/devtools']).toBeUndefined() expect(onboardScripts(nuxt)).toHaveLength(1) - const status = await onboardStatus(await serveViteMiddleware(nuxt)) + const { origin, viteServer } = await serveViteMiddleware(nuxt) + expect(nuxtRoutesToVite(viteServer, '/__devtools/embedded.js')).toBe(true) + const script = await fetch(`${origin}/__devtools/embedded.js`) + expect(script.headers.get('content-type')).toMatch(/javascript/) + + const status = await onboardStatus(origin) expect(status.state).toBe('idle') // No lockfile in the fixture, so the package manager falls back to npm. expect(status.command.slice(0, 3)).toEqual(['npm', 'i', '-D']) @@ -114,7 +118,7 @@ describe('@nuxt/devtools-onboard', () => { it('refuses to install when nobody can confirm it in the terminal', async () => { const nuxt = await bootNuxt({ dev: true }, { withoutDevtools: true }) - const origin = await serveViteMiddleware(nuxt) + const { origin } = await serveViteMiddleware(nuxt) // Vitest workers get a piped stdin, so no terminal can answer the question. const install = await fetch(`${origin}/__devtools/__onboard/install`, { method: 'POST' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfe3ec5cfb..75e4a703ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -924,6 +924,9 @@ importers: nuxt: specifier: npm:nuxt-nightly@5.0.0-2610052343-36eafab version: nuxt-nightly@5.0.0-2610052343-36eafab(@oxc-project/types@0.152.0)(@parcel/watcher@2.6.0)(@types/node@26.6.5)(@vitejs/devtools-kit@0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@1.0.6))(ocache@0.3.0)(srvx@1.0.6)(vite@8.3.4))(@vitejs/devtools@0.8.0)(@vue/compiler-sfc@3.5.43)(acorn@8.18.0)(autoprefixer@10.6.1(postcss@8.5.29))(confbox@0.2.4)(cssnano@7.1.9(postcss@8.5.29))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.13.0(jiti@2.7.0)(supports-color@10.2.2))(giget@3.3.1)(jiti@2.7.0)(lightningcss@1.33.0)(mlly@1.8.2)(nuxt-nightly@5.0.0-2610052343-36eafab)(optionator@0.9.4)(oxc-parser@0.153.0)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.64.10))(rollup@4.64.10)(terser@5.51.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.4)(vue-tsc@3.3.13(typescript@6.0.3))(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.29))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.29)(svgo@4.1.0))(yaml@2.9.1) + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@26.6.5)(@vitejs/devtools@0.8.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1) packages/devtools-ui-kit: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f2ae04df9e..12928f1587 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -174,6 +174,7 @@ catalogs: unocss: *unocss unplugin-vue: ^8.0.0 unplugin-vue-markdown: ^32.1.1 + vite: *vite cli: '@antfu/eslint-config': ^9.5.1 '@antfu/ni': ^30.6.0