From 3c16eb6b19c8360057904665fdb1a42618f503e8 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 26 Sep 2026 13:14:26 +0000 Subject: [PATCH 1/2] fix(dev): open inspector in nitro dev worker --- docs/dev.md | 16 +++- packages/nuxt-cli/src/commands/dev.ts | 4 +- packages/nuxt-cli/src/dev/inspect.ts | 87 ++++++++++++++++++++- packages/nuxt-cli/test/unit/help.spec.ts | 4 +- packages/nuxt-cli/test/unit/inspect.spec.ts | 58 +++++++++++++- 5 files changed, 158 insertions(+), 11 deletions(-) diff --git a/docs/dev.md b/docs/dev.md index 2511b5851..aca9b3637 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -36,8 +36,8 @@ The `dev` command starts a development server with hot module replacement at [ht | `--dotenv=...` | | Path to `.env` file to load, relative to the root directory. Can be repeated, with later files taking precedence. | | `--envName=` | | The environment to use when resolving configuration overrides (default is `production` when building, and `development` when running the dev server) | | `-e, --extends=...` | | Extend from a Nuxt layer | -| `--inspect` | | Enable the Node.js inspector for the process serving your app (`--inspect=[host:]port`) | -| `--inspect-brk` | | Enable the Node.js inspector and wait for a debugger to attach (`--inspect-brk=[host:]port`) | +| `--inspect` | | Enable the Node.js inspector for server code (`--inspect=[host:]port`), and for the CLI process on the next port | +| `--inspect-brk` | | Like `--inspect`, and wait for a debugger to attach to the CLI process before loading Nuxt (`--inspect-brk=[host:]port`) | | `--tui` | `true` | Interactive terminal UI (pinned status panel, folded logs and single-key shortcuts) | | `--no-tui` | | Disable the interactive terminal UI and stream logs instead | | `--clear` | `false` | Clear console on restart | @@ -116,7 +116,17 @@ Node does not read your system trust store, so requests made from Node to a serv ## Debugging -`--inspect` opens the Node.js inspector on the process actually serving your app, and `--inspect-brk` waits for a debugger to attach before running. Both accept an optional `[host:]port`. +`--inspect` opens the Node.js inspector for your server code: server routes, middleware and server-side rendering, which run in a Nitro worker thread. It accepts an optional `[host:]port` and defaults to `127.0.0.1:9229`, so Chrome DevTools (`chrome://inspect`) and most editors find it without configuration. Your debugger reconnects automatically when the server reloads. + +### Debugging the CLI + +`nuxt.config`, modules and build hooks run in the CLI process instead, which gets its own inspector on the next port (`9230` by default). Add `localhost:9230` as a target in `chrome://inspect` (**Configure...**) or point your editor at it. + +To debug code that runs while Nuxt is loading, use `--inspect-brk`. The CLI process then waits for a debugger to attach to the next port before loading Nuxt: + +```bash +npx nuxt dev --inspect-brk +``` `--profile` writes a V8 CPU profile to `nuxt-dev.cpuprofile` in your project when the process exits. `--profile=verbose` also prints a full report to the console. diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 30508b65c..bdeb7bf28 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -48,11 +48,11 @@ const command = defineCommand({ ...extendsArgs, 'inspect': { type: 'boolean', - description: 'Enable the Node.js inspector for the process serving your app (`--inspect=[host:]port`)', + description: 'Enable the Node.js inspector for server code (`--inspect=[host:]port`), and for the CLI process on the next port', }, 'inspect-brk': { type: 'boolean', - description: 'Enable the Node.js inspector and wait for a debugger to attach (`--inspect-brk=[host:]port`)', + description: 'Like `--inspect`, and wait for a debugger to attach to the CLI process before loading Nuxt (`--inspect-brk=[host:]port`)', }, 'tui': { type: 'boolean', diff --git a/packages/nuxt-cli/src/dev/inspect.ts b/packages/nuxt-cli/src/dev/inspect.ts index eda625eaf..40612794a 100644 --- a/packages/nuxt-cli/src/dev/inspect.ts +++ b/packages/nuxt-cli/src/dev/inspect.ts @@ -1,3 +1,4 @@ +import type { Session } from 'node:inspector' import process from 'node:process' import { styleText } from 'node:util' import { debug, logger } from '../utils/logger' @@ -86,11 +87,13 @@ function toPort(value: string): number | undefined { } /** - * Open the inspector in the current process, or move it to the requested - * address if Node already opened one via `execArgv`. + * Open the inspector for the nitro dev server worker on the requested address, + * and the inspector for this process on the next port. Node's own inspector + * from `execArgv` is moved there too. */ -export async function openInspector(options: InspectOptions): Promise { +export async function openInspector(inspectOptions: InspectOptions): Promise { const inspector = await import('node:inspector') + const options = resolveProcessInspectOptions(inspectOptions) try { if (inspector.url()) { @@ -102,11 +105,89 @@ export async function openInspector(options: InspectOptions): Promise { catch (error) { logger.warn(`Could not start the inspector on ${styleText('cyan', `${options.host}:${options.port}`)}: ${error instanceof Error ? error.message : error}`) } + + inspectDevWorkers(inspector.Session, { ...inspectOptions, wait: false }) +} + +let workerSession: Session | undefined + +/** + * Inspector address for the CLI process itself, which loads `nuxt.config` and + * modules. Server code runs in a nitro worker thread on the requested port. + */ +export function resolveProcessInspectOptions(options: InspectOptions): InspectOptions { + return { ...options, port: options.port === 0 ? 0 : options.port + 1 } +} + +/** + * Runs inside a worker thread, so it must be self-contained. Waits for the + * port to be released by the worker it replaces before opening the inspector. + */ +function openWorkerInspector(host: string, port: number): void { + // eslint-disable-next-line node/prefer-global/process + const proc = (globalThis as any).process + const { workerData } = proc.getBuiltinModule('node:worker_threads') + if (!proc.env.NITRO_DEV_WORKER_ID && typeof workerData?.name !== 'string') { + return + } + const inspector = proc.getBuiltinModule('node:inspector') + const { createServer } = proc.getBuiltinModule('node:net') + const deadline = Date.now() + 10_000 + const retry = (): void => { + if (Date.now() < deadline) { + setTimeout(attempt, 100).unref() + } + } + const open = (): void => { + inspector.open(port, host, false) + if (!inspector.url()) { + retry() + } + } + function attempt(): void { + if (port === 0) { + return open() + } + const probe = createServer() + probe.unref() + probe.once('error', retry) + probe.listen(port, host, () => probe.close(open)) + } + attempt() +} + +/** Open an inspector inside each nitro dev worker thread this process starts. */ +export function inspectDevWorkers(SessionConstructor: typeof Session, options: InspectOptions): void { + try { + workerSession?.disconnect() + const session = new SessionConstructor() + session.connect() + const expression = `(${openWorkerInspector.toString()})(${JSON.stringify(options.host)}, ${options.port})` + session.on('NodeWorker.attachedToWorker', ({ params }) => { + const { sessionId } = params + const message = JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression } }) + session.post('NodeWorker.sendMessageToWorker', { sessionId, message }, (error) => { + if (error) { + debug(`Could not open the inspector in a worker: ${error.message}`) + } + }) + }) + session.on('NodeWorker.receivedMessageFromWorker', ({ params }) => { + session.post('NodeWorker.detach', { sessionId: params.sessionId }, () => {}) + }) + session.post('NodeWorker.enable', { waitForDebuggerOnStart: false }, () => {}) + workerSession = session + } + catch (error) { + debug(`Could not watch worker threads for the inspector: ${error}`) + } } /** Release the inspector port so another process (a fork) can bind to it. */ export async function closeInspector(): Promise { try { + workerSession?.disconnect() + workerSession = undefined const inspector = await import('node:inspector') if (inspector.url()) { inspector.close() diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index d0ac8131f..9cf69747a 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -215,8 +215,8 @@ describe('help', () => { --dotenv=... Path to \`.env\` file to load, relative to the root directory. Can be repeated, with later files taking precedence. --envName= The environment to use when resolving configuration overrides (default is \`production\` when building, and \`development\` when running the dev server) -e, --extends=... Extend from a Nuxt layer - --inspect Enable the Node.js inspector for the process serving your app (\`--inspect=[host:]port\`) - --inspect-brk Enable the Node.js inspector and wait for a debugger to attach (\`--inspect-brk=[host:]port\`) + --inspect Enable the Node.js inspector for server code (\`--inspect=[host:]port\`), and for the CLI process on the next port + --inspect-brk Like \`--inspect\`, and wait for a debugger to attach to the CLI process before loading Nuxt (\`--inspect-brk=[host:]port\`) --tui Interactive terminal UI (pinned status panel, folded logs and single-key shortcuts) (Default: true) --no-tui Disable the interactive terminal UI and stream logs instead --clear Clear console on restart (Default: false) diff --git a/packages/nuxt-cli/test/unit/inspect.spec.ts b/packages/nuxt-cli/test/unit/inspect.spec.ts index b88a5f38c..ea6593750 100644 --- a/packages/nuxt-cli/test/unit/inspect.spec.ts +++ b/packages/nuxt-cli/test/unit/inspect.spec.ts @@ -1,6 +1,10 @@ +import { Session } from 'node:inspector' +import { createServer } from 'node:net' +import process from 'node:process' +import { Worker } from 'node:worker_threads' import { describe, expect, it } from 'vitest' -import { parseInspectArgs } from '../../src/dev/inspect' +import { closeInspector, inspectDevWorkers, parseInspectArgs, resolveProcessInspectOptions } from '../../src/dev/inspect' describe('parseInspectArgs', () => { it('should return undefined when the inspector is not requested', () => { @@ -59,3 +63,55 @@ describe('parseInspectArgs', () => { expect(parseInspectArgs(['--inspect=99999'])).toStrictEqual({ host: '127.0.0.1', port: 9229, wait: false }) }) }) + +describe('resolveProcessInspectOptions', () => { + it('should use the next port', () => { + expect(resolveProcessInspectOptions({ host: '0.0.0.0', port: 9229, wait: true })).toStrictEqual({ host: '0.0.0.0', port: 9230, wait: true }) + }) + + it('should keep a random port random', () => { + expect(resolveProcessInspectOptions({ host: '127.0.0.1', port: 0, wait: false }).port).toBe(0) + }) +}) + +describe('inspectDevWorkers', () => { + const getFreePort = () => new Promise((resolve) => { + const server = createServer().listen(0, '127.0.0.1', () => { + const { port } = server.address() as { port: number } + server.close(() => resolve(port)) + }) + }) + + const workerInspectorURL = (env: Record) => { + const worker = new Worker( + `const { parentPort } = require('node:worker_threads') + parentPort.on('message', () => parentPort.postMessage(require('node:inspector').url() ?? null))`, + { eval: true, env: { ...process.env, ...env } }, + ) + const deadline = Date.now() + 2000 + return new Promise((resolve) => { + const poll = () => worker.postMessage('url') + worker.on('message', (url) => { + if (url || Date.now() > deadline) { + worker.terminate().then(() => resolve(url)) + } + else { + setTimeout(poll, 50) + } + }) + poll() + }) + } + + it('should open an inspector in nitro dev workers', async () => { + const port = await getFreePort() + inspectDevWorkers(Session, { host: '127.0.0.1', port, wait: false }) + try { + expect(await workerInspectorURL({ NITRO_DEV_WORKER_ID: '1' })).toMatch(`ws://127.0.0.1:${port}/`) + expect(await workerInspectorURL({})).toBeNull() + } + finally { + await closeInspector() + } + }) +}) From e39a12b55a47a0b95539229193e031981b7d779e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 26 Sep 2026 15:23:24 +0000 Subject: [PATCH 2/2] fix(dev): handle worker inspector bind errors --- packages/nuxt-cli/src/dev/inspect.ts | 12 ++++++++++-- packages/nuxt-cli/test/unit/inspect.spec.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/dev/inspect.ts b/packages/nuxt-cli/src/dev/inspect.ts index 40612794a..72a7f347e 100644 --- a/packages/nuxt-cli/src/dev/inspect.ts +++ b/packages/nuxt-cli/src/dev/inspect.ts @@ -139,7 +139,10 @@ function openWorkerInspector(host: string, port: number): void { } } const open = (): void => { - inspector.open(port, host, false) + try { + inspector.open(port, host, false) + } + catch {} if (!inspector.url()) { retry() } @@ -150,7 +153,12 @@ function openWorkerInspector(host: string, port: number): void { } const probe = createServer() probe.unref() - probe.once('error', retry) + probe.once('error', (error: any) => { + if (error?.code === 'EADDRINUSE') { + return retry() + } + proc.stderr.write(`Could not start the inspector on ${host}:${port}: ${error?.message ?? error}\n`) + }) probe.listen(port, host, () => probe.close(open)) } attempt() diff --git a/packages/nuxt-cli/test/unit/inspect.spec.ts b/packages/nuxt-cli/test/unit/inspect.spec.ts index ea6593750..221cede65 100644 --- a/packages/nuxt-cli/test/unit/inspect.spec.ts +++ b/packages/nuxt-cli/test/unit/inspect.spec.ts @@ -114,4 +114,18 @@ describe('inspectDevWorkers', () => { await closeInspector() } }) + + it('should wait for the port to be released', async () => { + const port = await getFreePort() + const blocker = createServer() + await new Promise(resolve => blocker.listen(port, '127.0.0.1', resolve)) + setTimeout(() => blocker.close(), 300) + inspectDevWorkers(Session, { host: '127.0.0.1', port, wait: false }) + try { + expect(await workerInspectorURL({ NITRO_DEV_WORKER_ID: '1' })).toMatch(`ws://127.0.0.1:${port}/`) + } + finally { + await closeInspector() + } + }) })