From 34012916e3b7bda936f2e52c7f450a7d0dfa2138 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 29 Oct 2025 21:04:49 +0000 Subject: [PATCH 1/6] refactor(dev): remove proxy server -> direct listening --- knip.json | 1 - packages/nuxi/package.json | 2 - packages/nuxi/src/commands/dev.ts | 430 +++++++----------------------- packages/nuxi/src/dev/error.ts | 3 + packages/nuxi/src/dev/index.ts | 66 +---- packages/nuxi/src/dev/pool.ts | 215 +++++++++++++++ packages/nuxi/src/dev/socket.ts | 58 ---- packages/nuxi/src/dev/utils.ts | 337 ++++++++++++++--------- packages/nuxt-cli/package.json | 3 +- pnpm-lock.yaml | 144 +++++----- 10 files changed, 625 insertions(+), 634 deletions(-) create mode 100644 packages/nuxi/src/dev/pool.ts delete mode 100644 packages/nuxi/src/dev/socket.ts diff --git a/knip.json b/knip.json index 4b14a1e92..b9b81125a 100644 --- a/knip.json +++ b/knip.json @@ -33,7 +33,6 @@ "fuse.js", "giget", "h3-next", - "http-proxy-3", "jiti", "nitro", "nitropack", diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index 9aefa9ffa..288062e6f 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -46,11 +46,9 @@ "defu": "^6.1.4", "exsolve": "^1.0.7", "fuse.js": "^7.1.0", - "get-port-please": "^3.2.0", "giget": "^2.0.0", "h3": "^1.15.4", "h3-next": "npm:h3@^2.0.1-rc.5", - "http-proxy-3": "^1.22.0", "jiti": "^2.6.1", "listhen": "^1.9.0", "magicast": "^0.4.0", diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index e96c95f58..c53cbd72a 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -1,31 +1,18 @@ -import type { NuxtOptions } from '@nuxt/schema' import type { ParsedArgs } from 'citty' -import type { ProxyTargetDetailed } from 'http-proxy-3/dist/lib/http-proxy' -import type { HTTPSOptions, ListenOptions } from 'listhen' -import type { ChildProcess } from 'node:child_process' -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { TLSSocket } from 'node:tls' -import type { NuxtDevContext, NuxtDevIPCMessage } from '../dev/utils' +import type { ListenOptions } from 'listhen' +import type { NuxtDevContext } from '../dev/utils' -import { fork } from 'node:child_process' import process from 'node:process' import { defineCommand } from 'citty' -import { isSocketSupported } from 'get-port-please' -import { createProxyServer } from 'http-proxy-3' -import { listen } from 'listhen' -import { getArgs as getListhenArgs, parseArgs as parseListhenArgs } from 'listhen/cli' +import { getArgs as getListhenArgs } from 'listhen/cli' import { resolve } from 'pathe' import { satisfies } from 'semver' -import { isBun, isDeno, isTest } from 'std-env' +import { isBun, isTest } from 'std-env' import { initialize } from '../dev' -import { renderError } from '../dev/error' -import { isSocketURL, parseSocketURL } from '../dev/socket' -import { resolveLoadingTemplate } from '../dev/utils' -import { showVersionsFromConfig } from '../utils/banner' +import { ForkPool } from '../dev/pool' import { overrideEnv } from '../utils/env' -import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { cwdArgs, dotEnvArgs, envNameArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared' @@ -94,35 +81,18 @@ const command = defineCommand({ overrideEnv('development') const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) - // Load Nuxt Config - const { loadNuxtConfig } = await loadKit(cwd) - const nuxtOptions = await loadNuxtConfig({ - cwd, - dotenv: { cwd, fileName: ctx.args.dotenv }, - envName: ctx.args.envName, // c12 will fall back to NODE_ENV - overrides: { - dev: true, - logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose', - ...(ctx.args.extends && { extends: ctx.args.extends }), - ...ctx.data?.overrides, - }, - }) - - showVersionsFromConfig(cwd, nuxtOptions) + const listenOverrides = resolveListenOverrides(ctx.args) - const listenOptions = resolveListenOptions(nuxtOptions, ctx.args) if (!ctx.args.fork) { - // Directly start Nuxt dev + // No-fork mode: everything runs in-process with direct listening const { listener, close } = await initialize({ cwd, args: ctx.args, - hostname: listenOptions.hostname, - public: listenOptions.public, - publicURLs: undefined, - proxy: { - https: listenOptions.https, - }, - }, { data: ctx.data }, listenOptions) + }, { + data: ctx.data, + listenOverrides, + showBanner: true, + }) return { listener, @@ -133,335 +103,135 @@ const command = defineCommand({ } } - // Start proxy Listener - const devProxy = await createDevProxy(cwd, nuxtOptions, listenOptions) - - const nuxtSocketEnv = process.env.NUXT_SOCKET ? process.env.NUXT_SOCKET === '1' : undefined - - const useSocket = nuxtSocketEnv ?? (nuxtOptions._majorVersion === 4 && await isSocketSupported()) + // Fork mode: use pool of pre-warmed forks + const pool = new ForkPool({ + rawArgs: ctx.rawArgs, + poolSize: 2, + listenOverrides, + }) - const urls = await devProxy.listener.getURLs() - // run initially in in no-fork mode - const { onRestart, onReady, close } = await initialize({ + // Start the initial dev server in-process with listener + const { listener, close, onRestart, onReady } = await initialize({ cwd, args: ctx.args, - hostname: listenOptions.hostname, - public: listenOptions.public, - publicURLs: urls.map(r => r.url), - proxy: { - url: devProxy.listener.url, - urls, - https: devProxy.listener.https, - addr: devProxy.listener.address, - }, - // if running with nuxt v4 or `NUXT_SOCKET=1`, we use the socket listener - // otherwise pass 'true' to listen on a random port instead - }, {}, useSocket ? undefined : true) - - onReady(address => devProxy.setAddress(address)) - - // ... then fall back to pre-warmed fork if a hard restart is required - const fork = startSubprocess(cwd, ctx.args, ctx.rawArgs, listenOptions) - onRestart(async (devServer) => { - const [subprocess] = await Promise.all([ - fork, - devServer.close().catch(() => {}), - ]) - await subprocess.initialize(devProxy, useSocket) + }, { + data: ctx.data, + listenOverrides, + showBanner: true, }) - return { - listener: devProxy.listener, - async close() { - await close() - const subprocess = await fork - subprocess.kill(0) - await devProxy.listener.close() - }, - } - }, -}) - -export default command - -// --- Internal --- - -type ArgsT = Exclude< - Awaited, - undefined | ((...args: unknown[]) => unknown) -> - -type DevProxy = Awaited> - -async function createDevProxy(cwd: string, nuxtOptions: NuxtOptions, listenOptions: Partial) { - let loadingMessage = 'Nuxt dev server is starting...' - let error: Error | undefined - let address: string | undefined - - let loadingTemplate = nuxtOptions.devServer.loadingTemplate - - const proxy = createProxyServer({}) - - proxy.on('proxyReq', (proxyReq, req) => { - if (!proxyReq.hasHeader('x-forwarded-for')) { - const address = req.socket.remoteAddress - if (address) { - proxyReq.appendHeader('x-forwarded-for', address) - } - } - if (!proxyReq.hasHeader('x-forwarded-port')) { - const localPort = req?.socket?.localPort - if (localPort) { - proxyReq.setHeader('x-forwarded-port', localPort) - } - } - if (!proxyReq.hasHeader('x-forwarded-Proto')) { - const encrypted = (req?.connection as TLSSocket)?.encrypted - proxyReq.setHeader('x-forwarded-proto', encrypted ? 'https' : 'http') - } - }) - - const listener = await listen((req: IncomingMessage, res: ServerResponse) => { - if (error) { - renderError(req, res, error) - return - } - if (!address) { - res.statusCode = 503 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Cache-Control', 'no-store') - if (loadingTemplate) { - res.end(loadingTemplate({ loading: loadingMessage })) - return + // When ready, start warming up the fork pool + onReady((_address) => { + pool.startWarming() + if (startTime) { + logger.debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) } + }) - // Nuxt <3.6 did not have the loading template defined in the schema - async function resolveLoadingMessage() { - loadingTemplate = await resolveLoadingTemplate(cwd) - res.end(loadingTemplate({ loading: loadingMessage })) - } - return resolveLoadingMessage() - } - const target = isSocketURL(address) ? parseSocketURL(address) as ProxyTargetDetailed : address - proxy.web(req, res, { target }) - }, listenOptions) - - listener.server.on('upgrade', (req, socket, head) => { - if (!address) { - if (!socket.destroyed) { - socket.end() - } - return - } - const target = isSocketURL(address) ? parseSocketURL(address) as ProxyTargetDetailed : address - return proxy.ws(req, socket, head, { target, xfwd: true }) - }) - - return { - listener, - setAddress: (_addr: string | undefined) => { - address = _addr - }, - setLoadingMessage: (_msg: string) => { - loadingMessage = _msg - }, - setError: (_error: Error) => { - error = _error - }, - clearError() { - error = undefined - }, - } -} - -async function startSubprocess(cwd: string, args: { logLevel: string, clear: boolean, dotenv: string, envName: string, extends?: string }, rawArgs: string[], listenOptions: Partial) { - let childProc: ChildProcess | undefined - let devProxy: DevProxy - let ready: Promise | undefined - const kill = (signal: NodeJS.Signals | number) => { - if (childProc) { - childProc.kill(signal === 0 && isDeno ? 'SIGTERM' : signal) - childProc = undefined - } - } + // On hard restart, use a fork from the pool + let cleanupCurrentFork: (() => void) | undefined - async function initialize(proxy: DevProxy, socket: boolean) { - devProxy = proxy - const urls = await devProxy.listener.getURLs() - await ready - childProc!.send({ - type: 'nuxt:internal:dev:context', - socket, - context: { + async function restartWithFork() { + // Get a fork from the pool (warm if available, cold otherwise) + const context: NuxtDevContext = { cwd, - args, - hostname: listenOptions.hostname, - public: listenOptions.public, - publicURLs: urls.map(r => r.url), - proxy: { - url: devProxy.listener.url, - urls, - https: devProxy.listener.https, - }, - } satisfies NuxtDevContext, - }) - } - - async function restart() { - devProxy?.clearError() - if (!globalThis.__nuxt_cli__) { - return - } - // Kill previous process with restart signal (not supported on Windows) - if (process.platform === 'win32') { - kill('SIGTERM') - } - else { - kill('SIGHUP') - } - // Start new process - childProc = fork(globalThis.__nuxt_cli__.devEntry!, rawArgs, { - execArgv: ['--enable-source-maps', process.argv.find((a: string) => a.includes('--inspect'))].filter(Boolean) as string[], - env: { - ...process.env, - __NUXT__FORK: 'true', - }, - }) + args: ctx.args, + } - // Close main process on child exit with error - childProc.on('close', (errorCode) => { - if (errorCode) { - process.exit(errorCode) + // Clean up previous fork if any + if (cleanupCurrentFork) { + cleanupCurrentFork() } - }) - // Listen for IPC messages - ready = new Promise((resolve, reject) => { - childProc!.on('error', reject) - childProc!.on('message', (message: NuxtDevIPCMessage) => { - if (message.type === 'nuxt:internal:dev:fork-ready') { - resolve() - } - else if (message.type === 'nuxt:internal:dev:ready') { - devProxy.setAddress(message.address) + cleanupCurrentFork = await pool.getFork(context, (message) => { + // Handle IPC messages from the fork + if (message.type === 'nuxt:internal:dev:ready') { if (startTime) { logger.debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) } } - else if (message.type === 'nuxt:internal:dev:loading') { - devProxy.setAddress(undefined) - devProxy.setLoadingMessage(message.message) - devProxy.clearError() - } - else if (message.type === 'nuxt:internal:dev:loading:error') { - devProxy.setAddress(undefined) - devProxy.setError(message.error) - } else if (message.type === 'nuxt:internal:dev:restart') { - restart() + // Fork is requesting another restart + void restartWithFork() } else if (message.type === 'nuxt:internal:dev:rejection') { logger.info(`Restarting Nuxt due to error: \`${message.message}\``) - restart() + void restartWithFork() } }) - }) - } + } - // Graceful shutdown - for (const signal of [ - 'exit', - 'SIGTERM' /* Graceful shutdown */, - 'SIGINT' /* Ctrl-C */, - 'SIGQUIT' /* Ctrl-\ */, - ] as const) { - process.once(signal, () => { - kill(signal === 'exit' ? 0 : signal) + onRestart(async (devServer) => { + // Close the in-process dev server + await Promise.all([ + listener.close(), + devServer.close().catch(() => {}), + close(), + ]) + + await restartWithFork() }) - } - await restart() + return { + listener, + async close() { + cleanupCurrentFork?.() + await close() + await listener.close() + }, + } + }, +}) - return { - initialize, - restart, - kill, - } -} +export default command -function resolveListenOptions( - nuxtOptions: { devServer: NuxtOptions['devServer'], app: NuxtOptions['app'] }, - args: ParsedArgs, -): Partial { - const _port = args.port - ?? args.p - ?? process.env.NUXT_PORT - ?? process.env.NITRO_PORT - ?? process.env.PORT - ?? nuxtOptions.devServer.port +// --- Internal --- - const _hostname = typeof args.host === 'string' - ? args.host - : args.host === true - ? '' - : process.env.NUXT_HOST - ?? process.env.NITRO_HOST - ?? process.env.HOST - ?? (nuxtOptions.devServer?.host || undefined /* for backwards compatibility with previous '' default */) - ?? undefined +type ArgsT = Exclude< + Awaited, + undefined | ((...args: unknown[]) => unknown) +> - const _public: boolean | undefined = args.public - ?? (_hostname && !['localhost', '127.0.0.1', '::1'].includes(_hostname)) - ? true - : undefined +function resolveListenOverrides(args: ParsedArgs): Partial { + const httpsEnv = resolveHttpsFromEnv() const _httpsCert = args['https.cert'] || (args.sslCert as string) - || process.env.NUXT_SSL_CERT - || process.env.NITRO_SSL_CERT - || (typeof nuxtOptions.devServer.https !== 'boolean' && nuxtOptions.devServer.https && 'cert' in nuxtOptions.devServer.https && nuxtOptions.devServer.https.cert) - || '' + || httpsEnv.cert const _httpsKey = args['https.key'] || (args.sslKey as string) - || process.env.NUXT_SSL_KEY - || process.env.NITRO_SSL_KEY - || (typeof nuxtOptions.devServer.https !== 'boolean' && nuxtOptions.devServer.https && 'key' in nuxtOptions.devServer.https && nuxtOptions.devServer.https.key) - || '' - - const _httpsPfx = args['https.pfx'] - || (typeof nuxtOptions.devServer.https !== 'boolean' && nuxtOptions.devServer.https && 'pfx' in nuxtOptions.devServer.https && nuxtOptions.devServer.https.pfx) - || '' - - const _httpsPassphrase = args['https.passphrase'] - || (typeof nuxtOptions.devServer.https !== 'boolean' && nuxtOptions.devServer.https && 'passphrase' in nuxtOptions.devServer.https && nuxtOptions.devServer.https.passphrase) - || '' - - const httpsEnabled = !!(args.https ?? nuxtOptions.devServer.https) + || httpsEnv.key - const _listhenOptions = parseListhenArgs({ + const overrides = { ...args, 'open': (args.o as boolean) || args.open, - 'https': httpsEnabled, - 'https.cert': _httpsCert, - 'https.key': _httpsKey, - 'https.pfx': _httpsPfx, - 'https.passphrase': _httpsPassphrase, - }) - - const httpsOptions = httpsEnabled && { - ...(nuxtOptions.devServer.https as HTTPSOptions), - ...(_listhenOptions.https as HTTPSOptions), + 'https': args.https, + 'https.cert': _httpsCert || '', + 'https.key': _httpsKey || '', + 'https.pfx': args['https.pfx'] || '', + 'https.passphrase': args['https.passphrase'] || '', } - return { - ..._listhenOptions, - port: _port, - hostname: _hostname, - public: _public, - https: httpsOptions, - baseURL: nuxtOptions.app.baseURL.startsWith('./') ? nuxtOptions.app.baseURL.slice(1) : nuxtOptions.app.baseURL, + // _PORT is used by `@nuxt/test-utils` to launch the dev server on a specific port + // It takes highest priority over all other port sources + if (process.env._PORT) { + return { + ...overrides, + port: process.env._PORT || 0, + hostname: '127.0.0.1', + showURL: false, + } } + + return overrides +} + +// Helper to resolve HTTPS certificate and key from environment variables +function resolveHttpsFromEnv() { + const cert = process.env.NUXT_SSL_CERT || process.env.NITRO_SSL_CERT || '' + const key = process.env.NUXT_SSL_KEY || process.env.NITRO_SSL_KEY || '' + return { cert, key } } function isBunForkSupported() { diff --git a/packages/nuxi/src/dev/error.ts b/packages/nuxi/src/dev/error.ts index 3dc1a9e5e..e6b9cbe81 100644 --- a/packages/nuxi/src/dev/error.ts +++ b/packages/nuxi/src/dev/error.ts @@ -12,6 +12,9 @@ export async function renderError(req: IncomingMessage, res: ServerResponse, err const youch = new Youch() res.statusCode = 500 res.setHeader('Content-Type', 'text/html') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Refresh', '3') + const html = await youch.toHTML(error, { request: { url: req.url, diff --git a/packages/nuxi/src/dev/index.ts b/packages/nuxi/src/dev/index.ts index 195df8198..5677d1241 100644 --- a/packages/nuxi/src/dev/index.ts +++ b/packages/nuxi/src/dev/index.ts @@ -1,13 +1,10 @@ import type { NuxtConfig } from '@nuxt/schema' import type { Listener, ListenOptions } from 'listhen' -import type { AddressInfo } from 'node:net' import type { NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './utils' import process from 'node:process' import defu from 'defu' -import { listen } from 'listhen' -import { createSocketListener } from './socket' -import { NuxtDevServer, resolveDevServerDefaults, resolveDevServerOverrides } from './utils' +import { NuxtDevServer } from './utils' const start = Date.now() @@ -18,6 +15,8 @@ interface InitializeOptions { data?: { overrides?: NuxtConfig } + listenOverrides?: Partial + showBanner?: boolean } // IPC Hooks @@ -33,7 +32,7 @@ class IPC { } process.on('message', (message: NuxtParentIPCMessage) => { if (message.type === 'nuxt:internal:dev:context') { - initialize(message.context, {}, message.socket ? undefined : true) + initialize(message.context, { listenOverrides: message.listenOverrides }) } }) this.send({ type: 'nuxt:internal:dev:fork-ready' }) @@ -49,71 +48,27 @@ class IPC { const ipc = new IPC() interface InitializeReturn { - listener: Pick & { - _url?: string - address: (Omit & { - socketPath: string - }) | AddressInfo - } + listener: Listener close: () => Promise onReady: (callback: (address: string) => void) => void onRestart: (callback: (devServer: NuxtDevServer) => void) => void - } -export async function initialize(devContext: NuxtDevContext, ctx: InitializeOptions = {}, _listenOptions?: true | Partial): Promise { - const devServerOverrides = resolveDevServerOverrides({ - public: devContext.public, - }) - - const devServerDefaults = resolveDevServerDefaults({ - hostname: devContext.hostname, - https: devContext.proxy?.https, - }, devContext.publicURLs) - - // Initialize dev server +export async function initialize(devContext: NuxtDevContext, ctx: InitializeOptions = {}): Promise { const devServer = new NuxtDevServer({ cwd: devContext.cwd, overrides: defu( ctx.data?.overrides, ({ extends: devContext.args.extends } satisfies NuxtConfig) as NuxtConfig, - devServerOverrides, ), - defaults: devServerDefaults, logLevel: devContext.args.logLevel as 'silent' | 'info' | 'verbose', clear: devContext.args.clear, dotenv: { cwd: devContext.cwd, fileName: devContext.args.dotenv }, envName: devContext.args.envName, - devContext: { - proxy: devContext.proxy, - }, + showBanner: ctx.showBanner !== false && !ipc.enabled, + listenOverrides: ctx.listenOverrides, }) - // _PORT is used by `@nuxt/test-utils` to launch the dev server on a specific port - const listenOptions = _listenOptions === true || process.env._PORT - ? { port: process.env._PORT ?? 0, hostname: '127.0.0.1', showURL: false } - : _listenOptions - - // Attach internal listener - devServer.listener = listenOptions - ? await listen(devServer.handler, listenOptions) - : await createSocketListener(devServer.handler, devContext.proxy?.addr) - - if (process.env.DEBUG) { - // eslint-disable-next-line no-console - console.debug(`Using ${listenOptions ? 'network' : 'socket'} listener for Nuxt dev server.`) - } - - // Merge interface with public context - devServer.listener._url = devServer.listener.url - if (devContext.proxy?.url) { - devServer.listener.url = devContext.proxy.url - } - if (devContext.proxy?.urls) { - const _getURLs = devServer.listener.getURLs.bind(devServer.listener) - devServer.listener.getURLs = async () => Array.from(new Set([...devContext.proxy?.urls || [], ...(await _getURLs())])) - } - let address: string if (ipc.enabled) { @@ -156,7 +111,10 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti listener: devServer.listener, close: async () => { devServer.closeWatchers() - await devServer.close() + await Promise.all([ + devServer.listener.close(), + devServer.close(), + ]) }, onReady: (callback: (address: string) => void) => { if (address) { diff --git a/packages/nuxi/src/dev/pool.ts b/packages/nuxi/src/dev/pool.ts new file mode 100644 index 000000000..d58331d76 --- /dev/null +++ b/packages/nuxi/src/dev/pool.ts @@ -0,0 +1,215 @@ +import type { ListenOptions } from 'listhen' +import type { ChildProcess } from 'node:child_process' +import type { NuxtDevContext, NuxtDevIPCMessage } from './utils' + +import { fork } from 'node:child_process' +import process from 'node:process' +import { isDeno } from 'std-env' +import { logger } from '../utils/logger' + +interface ForkPoolOptions { + rawArgs: string[] + poolSize?: number + listenOverrides: Partial +} + +interface PooledFork { + process: ChildProcess + ready: Promise + state: 'warming' | 'ready' | 'active' | 'dead' +} + +export class ForkPool { + private pool: PooledFork[] = [] + private poolSize: number + private rawArgs: string[] + private listenOverrides: Partial + private warming = false + + constructor(options: ForkPoolOptions) { + this.rawArgs = options.rawArgs + this.poolSize = options.poolSize ?? 2 + this.listenOverrides = options.listenOverrides + + // Graceful shutdown + for (const signal of [ + 'exit', + 'SIGTERM' /* Graceful shutdown */, + 'SIGINT' /* Ctrl-C */, + 'SIGQUIT' /* Ctrl-\ */, + ] as const) { + process.once(signal, () => { + this.killAll(signal === 'exit' ? 0 : signal) + }) + } + } + + startWarming(): void { + if (this.warming) { + return + } + this.warming = true + + // Start warming forks up to pool size + for (let i = 0; i < this.poolSize; i++) { + this.warmFork() + } + } + + async getFork(context: NuxtDevContext, onMessage?: (message: NuxtDevIPCMessage) => void): Promise<() => void> { + // Try to get a ready fork from the pool + const readyFork = this.pool.find(f => f.state === 'ready') + + if (readyFork) { + readyFork.state = 'active' + if (onMessage) { + this.attachMessageHandler(readyFork.process, onMessage) + } + await this.sendContext(readyFork.process, context) + + // Start warming a replacement fork + if (this.warming) { + this.warmFork() + } + + return () => this.killFork(readyFork) + } + + // No ready fork available, try a warming fork + const warmingFork = this.pool.find(f => f.state === 'warming') + if (warmingFork) { + await warmingFork.ready + warmingFork.state = 'active' + if (onMessage) { + this.attachMessageHandler(warmingFork.process, onMessage) + } + await this.sendContext(warmingFork.process, context) + + // Start warming a replacement fork + if (this.warming) { + this.warmFork() + } + + return () => this.killFork(warmingFork) + } + + // No forks in pool, create a cold fork + logger.debug('No pre-warmed forks available, starting cold fork') + const coldFork = this.createFork() + await coldFork.ready + coldFork.state = 'active' + if (onMessage) { + this.attachMessageHandler(coldFork.process, onMessage) + } + await this.sendContext(coldFork.process, context) + + return () => this.killFork(coldFork) + } + + private attachMessageHandler(childProc: ChildProcess, onMessage: (message: NuxtDevIPCMessage) => void): void { + childProc.on('message', (message: NuxtDevIPCMessage) => { + // Don't forward fork-ready messages as those are internal + if (message.type !== 'nuxt:internal:dev:fork-ready') { + onMessage(message) + } + }) + } + + private warmFork(): void { + const fork = this.createFork() + fork.ready.then(() => { + if (fork.state === 'warming') { + fork.state = 'ready' + } + }).catch(() => { + // Fork failed to warm, remove from pool + this.removeFork(fork) + }) + this.pool.push(fork) + } + + private createFork(): PooledFork { + const childProc = fork(globalThis.__nuxt_cli__.devEntry!, this.rawArgs, { + execArgv: ['--enable-source-maps', process.argv.find((a: string) => a.includes('--inspect'))].filter(Boolean) as string[], + env: { + ...process.env, + __NUXT__FORK: 'true', + }, + }) + + let readyResolve: () => void + let readyReject: (err: Error) => void + const ready = new Promise((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) + + const pooledFork: PooledFork = { + process: childProc, + ready, + state: 'warming', + } + + // Listen for fork-ready message + childProc.on('message', (message: NuxtDevIPCMessage) => { + if (message.type === 'nuxt:internal:dev:fork-ready') { + readyResolve() + } + }) + + // Handle errors + childProc.on('error', (err) => { + readyReject(err) + this.removeFork(pooledFork) + }) + + // Handle unexpected exit + childProc.on('close', (errorCode) => { + if (pooledFork.state === 'active' && errorCode) { + // Active fork crashed + process.exit(errorCode) + } + this.removeFork(pooledFork) + }) + + return pooledFork + } + + private async sendContext(childProc: ChildProcess, context: NuxtDevContext): Promise { + childProc.send({ + type: 'nuxt:internal:dev:context', + listenOverrides: this.listenOverrides, + context, + }) + } + + private killFork(fork: PooledFork, signal: NodeJS.Signals | number = 'SIGTERM'): void { + fork.state = 'dead' + if (fork.process) { + fork.process.kill(signal === 0 && isDeno ? 'SIGTERM' : signal) + } + this.removeFork(fork) + } + + private removeFork(fork: PooledFork): void { + const index = this.pool.indexOf(fork) + if (index > -1) { + this.pool.splice(index, 1) + } + } + + private killAll(signal: NodeJS.Signals | number): void { + for (const fork of this.pool) { + this.killFork(fork, signal) + } + } + + getStats() { + return { + total: this.pool.length, + warming: this.pool.filter(f => f.state === 'warming').length, + ready: this.pool.filter(f => f.state === 'ready').length, + active: this.pool.filter(f => f.state === 'active').length, + } + } +} diff --git a/packages/nuxi/src/dev/socket.ts b/packages/nuxi/src/dev/socket.ts deleted file mode 100644 index 0e333d77d..000000000 --- a/packages/nuxi/src/dev/socket.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { RequestListener } from 'node:http' -import type { AddressInfo } from 'node:net' -import { Server } from 'node:http' -import process from 'node:process' - -import { cleanSocket, getSocketAddress } from 'get-port-please' - -export function formatSocketURL(socketPath: string, ssl = false): string { - const protocol = ssl ? 'https' : 'http' - // Windows named pipes need special encoding - const encodedPath = process.platform === 'win32' - ? encodeURIComponent(socketPath) - : socketPath.replace(/\//g, '%2F') - return `${protocol}+unix://${encodedPath}` -} - -export function isSocketURL(url: string): boolean { - return url.startsWith('http+unix://') || url.startsWith('https+unix://') -} - -export function parseSocketURL(url: string): { socketPath: string, protocol: 'https' | 'http' } { - if (!isSocketURL(url)) { - throw new Error(`Invalid socket URL: ${url}`) - } - - const ssl = url.startsWith('https+unix://') - const path = url.slice(ssl ? 'https+unix://'.length : 'http+unix://'.length) - const socketPath = decodeURIComponent(path.replace(/%2F/g, '/')) - - return { socketPath, protocol: ssl ? 'https' : 'http' } -} - -export async function createSocketListener(handler: RequestListener, proxyAddress?: AddressInfo) { - const socketPath = getSocketAddress({ - name: 'nuxt-dev', - random: true, - }) - const server = new Server(handler) - await cleanSocket(socketPath) - await new Promise(resolve => server.listen({ path: socketPath }, resolve)) - const url = formatSocketURL(socketPath) - return { - url, - address: { address: 'localhost', port: 3000, ...proxyAddress, socketPath }, - async close() { - try { - server.removeAllListeners() - await new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())) - } - finally { - await cleanSocket(socketPath) - } - }, - getURLs: async () => [{ url, type: 'network' as const }], - https: false as const, - server, - } -} diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index be068ce5b..956ead699 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -1,11 +1,10 @@ import type { Nuxt, NuxtConfig } from '@nuxt/schema' import type { DotenvOptions } from 'c12' -import type { HTTPSOptions, Listener, ListenOptions, ListenURL } from 'listhen' +import type { Listener, ListenOptions } from 'listhen' import type { createDevServer } from 'nitro' import type { NitroDevServer } from 'nitropack' import type { FSWatcher } from 'node:fs' import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http' -import type { AddressInfo } from 'node:net' import EventEmitter from 'node:events' import { existsSync, statSync, watch } from 'node:fs' @@ -16,21 +15,22 @@ import { pathToFileURL } from 'node:url' import defu from 'defu' import { resolveModulePath } from 'exsolve' import { toNodeListener } from 'h3' +import { listen } from 'listhen' import { resolve } from 'pathe' import { debounce } from 'perfect-debounce' import { toNodeHandler } from 'srvx/node' import { provider } from 'std-env' import { joinURL } from 'ufo' +import { showVersionsFromConfig } from '../utils/banner' import { clearBuildDir } from '../utils/fs' import { loadKit } from '../utils/kit' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { renderError } from './error' -import { formatSocketURL, isSocketURL } from './socket' export type NuxtParentIPCMessage - = | { type: 'nuxt:internal:dev:context', context: NuxtDevContext, socket?: boolean } + = | { type: 'nuxt:internal:dev:context', context: NuxtDevContext, listenOverrides: Partial } export type NuxtDevIPCMessage = | { type: 'nuxt:internal:dev:fork-ready' } @@ -42,9 +42,6 @@ export type NuxtDevIPCMessage export interface NuxtDevContext { cwd: string - public?: boolean - hostname?: string - publicURLs?: string[] args: { clear: boolean logLevel: string @@ -52,12 +49,6 @@ export interface NuxtDevContext { envName: string extends?: string } - proxy?: { - url?: string - urls?: ListenURL[] - https?: boolean | HTTPSOptions - addr?: AddressInfo - } } interface NuxtDevServerOptions { @@ -66,10 +57,10 @@ interface NuxtDevServerOptions { dotenv: DotenvOptions envName?: string clear?: boolean - defaults: NuxtConfig overrides: NuxtConfig loadingTemplate?: ({ loading }: { loading: string }) => string - devContext: Pick + showBanner?: boolean + listenOverrides?: Partial } // https://regex101.com/r/7HkR5c/1 @@ -107,21 +98,18 @@ interface DevServerEventMap { } export class NuxtDevServer extends EventEmitter { - private _handler?: RequestListener - private _distWatcher?: FSWatcher - private _configWatcher?: () => void - private _currentNuxt?: NuxtWithServer - private _loadingMessage?: string - private _loadingError?: Error - private _fileChangeTracker = new FileChangeTracker() - private cwd: string + #handler?: RequestListener + #distWatcher?: FSWatcher + #configWatcher?: () => void + #currentNuxt?: NuxtWithServer + #loadingMessage?: string + #loadingError?: Error + #fileChangeTracker = new FileChangeTracker() + #cwd: string loadDebounced: (reload?: boolean, reason?: string) => void handler: RequestListener - listener: Pick & { - _url?: string - address: Omit & { socketPath: string } | AddressInfo - } + listener!: Listener constructor(private options: NuxtDevServerOptions) { super() @@ -136,31 +124,24 @@ export class NuxtDevServer extends EventEmitter { _initResolve() }) - this.cwd = options.cwd + this.#cwd = options.cwd this.handler = async (req, res) => { - if (this._loadingError) { - this._renderError(req, res) + if (this.#loadingError) { + renderError(req, res, this.#loadingError) return } await _initPromise - if (this._handler) { - this._handler(req, res) + if (this.#handler) { + this.#handler(req, res) } else { - this._renderLoadingScreen(req, res) + this.#renderLoadingScreen(req, res) } } - - // @ts-expect-error we set it in wrapper function - this.listener = undefined } - _renderError(req: IncomingMessage, res: ServerResponse): void { - renderError(req, res, this._loadingError) - } - - async _renderLoadingScreen(req: IncomingMessage, res: ServerResponse): Promise { + async #renderLoadingScreen(req: IncomingMessage, res: ServerResponse): Promise { if (res.headersSent) { if (!res.writableEnded) { res.end() @@ -171,64 +152,60 @@ export class NuxtDevServer extends EventEmitter { res.statusCode = 503 res.setHeader('Content-Type', 'text/html') const loadingTemplate = this.options.loadingTemplate - || this._currentNuxt?.options.devServer.loadingTemplate - || await resolveLoadingTemplate(this.cwd) + || this.#currentNuxt?.options.devServer.loadingTemplate + || await resolveLoadingTemplate(this.#cwd) res.end( loadingTemplate({ - loading: this._loadingMessage || 'Loading...', + loading: this.#loadingMessage || 'Loading...', }), ) } async init(): Promise { - await this.load() - this._watchConfig() + const action = 'Starting' + this.#loadingMessage = `${action} Nuxt...` + this.#handler = undefined + this.emit('loading', this.#loadingMessage) + + await this.#loadNuxtInstance() + + if (this.options.showBanner) { + showVersionsFromConfig(this.options.cwd, this.#currentNuxt!.options) + } + + await this.#createListener() + await this.#initializeNuxt(false) + this.#watchConfig() } closeWatchers(): void { - this._distWatcher?.close() - this._configWatcher?.() + this.#distWatcher?.close() + this.#configWatcher?.() } async load(reload?: boolean, reason?: string): Promise { try { this.closeWatchers() - await this._load(reload, reason) - this._watchConfig() - this._loadingError = undefined + + // For reloads, we already have a listener, so use the existing flow + await this.#load(reload, reason) + + this.#loadingError = undefined } catch (error) { console.error(`Cannot ${reload ? 'restart' : 'start'} nuxt: `, error) - this._handler = undefined - this._loadingError = error as Error - this._loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' + this.#handler = undefined + this.#loadingError = error as Error + this.#loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' this.emit('loading:error', error as Error) } + this.#watchConfig() } - async close(): Promise { - if (this._currentNuxt) { - await this._currentNuxt.close() - } - } - - async _load(reload?: boolean, reason?: string): Promise { - const action = reload ? 'Restarting' : 'Starting' - this._loadingMessage = `${reason ? `${reason}. ` : ''}${action} Nuxt...` - this._handler = undefined - this.emit('loading', this._loadingMessage) - if (reload) { - // eslint-disable-next-line no-console - console.info(this._loadingMessage) - } - - await this.close() - + async #loadNuxtInstance(urls?: string[]): Promise { const kit = await loadKit(this.options.cwd) - const devServerDefaults = resolveDevServerDefaults({}, await this.listener.getURLs().then(r => r.map(r => r.url))) - - this._currentNuxt = await kit.loadNuxt({ + const loadOptions: Parameters[0] = { cwd: this.options.cwd, dev: true, ready: false, @@ -237,7 +214,6 @@ export class NuxtDevServer extends EventEmitter { cwd: this.options.cwd, fileName: this.options.dotenv.fileName, }, - defaults: defu(this.options.defaults, devServerDefaults), overrides: { logLevel: this.options.logLevel as 'silent' | 'info' | 'verbose', ...this.options.overrides, @@ -246,11 +222,99 @@ export class NuxtDevServer extends EventEmitter { ...this.options.overrides.vite, }, }, - }) + } + + if (urls) { + // Pass hostname and https info for proper CORS and allowedHosts setup + const overrides = this.options.listenOverrides || {} + const hostname = overrides.hostname ?? (overrides as any).host + const https = overrides.https + + loadOptions.defaults = resolveDevServerDefaults({ hostname, https }, urls) + } + + this.#currentNuxt = await kit.loadNuxt(loadOptions) + } + + async #createListener(): Promise { + if (!this.#currentNuxt) { + throw new Error('Nuxt must be loaded before creating listener') + } + + // Merge config values with CLI overrides + const listenOptions = this.#resolveListenOptions() + this.listener = await listen(this.handler, listenOptions) + + // Apply devServer overrides based on whether listener is public + if (listenOptions.public) { + this.#currentNuxt.options.devServer.cors = { origin: '*' } + if (this.#currentNuxt.options.vite?.server) { + this.#currentNuxt.options.vite.server.allowedHosts = true + } + return + } + + // Get listener URLs for configuring allowed hosts + const urls = await this.listener.getURLs().then(r => r.map(r => r.url)) + if (urls && urls.length > 0) { + this.#currentNuxt.options.vite = defu(this.#currentNuxt.options.vite, { + server: { + allowedHosts: urls.map(u => new URL(u).hostname), + }, + }) + } + } + + #resolveListenOptions(): Partial { + if (!this.#currentNuxt) { + throw new Error('Nuxt must be loaded before resolving listen options') + } + + const nuxtConfig = this.#currentNuxt.options + const overrides = this.options.listenOverrides || {} + + const port = overrides.port ?? nuxtConfig.devServer?.port + // CLI args use 'host', but ListenOptions uses 'hostname' + const hostname = overrides.hostname ?? (overrides as any).host ?? nuxtConfig.devServer?.host + + // Resolve public flag + const isPublic = provider === 'codesandbox' || (overrides.public ?? (isPublicHostname(hostname) ? true : undefined)) + + // Resolve HTTPS options + const httpsFromConfig = typeof nuxtConfig.devServer?.https !== 'boolean' && nuxtConfig.devServer?.https + ? nuxtConfig.devServer.https + : {} + + const httpsEnabled = !!(overrides.https ?? nuxtConfig.devServer?.https) + + const httpsOptions = httpsEnabled && { + ...httpsFromConfig, + ...(typeof overrides.https === 'object' ? overrides.https : {}), + } + + // Resolve baseURL + const baseURL = nuxtConfig.app?.baseURL?.startsWith?.('./') + ? nuxtConfig.app.baseURL.slice(1) + : nuxtConfig.app?.baseURL + + return { + ...overrides, + port, + hostname, + public: isPublic, + https: httpsOptions || undefined, + baseURL, + } + } + + async #initializeNuxt(reload: boolean): Promise { + if (!this.#currentNuxt) { + throw new Error('Nuxt must be loaded before configuration') + } // Connect Vite HMR if (!process.env.NUXI_DISABLE_VITE_HMR) { - this._currentNuxt.hooks.hook('vite:extend', ({ config }) => { + this.#currentNuxt.hooks.hook('vite:extend', ({ config }) => { if (config.server) { config.server.hmr = { protocol: undefined, @@ -264,18 +328,18 @@ export class NuxtDevServer extends EventEmitter { } // Remove websocket handlers on close - this._currentNuxt.hooks.hookOnce('close', () => { + this.#currentNuxt.hooks.hookOnce('close', () => { this.listener.server.removeAllListeners('upgrade') }) // Write manifest and also check if we need cache invalidation if (!reload) { - const previousManifest = await loadNuxtManifest(this._currentNuxt.options.buildDir) - const newManifest = resolveNuxtManifest(this._currentNuxt) + const previousManifest = await loadNuxtManifest(this.#currentNuxt.options.buildDir) + const newManifest = resolveNuxtManifest(this.#currentNuxt) // we deliberately do not block initialising Nuxt on creation of the manifest - const promise = writeNuxtManifest(this._currentNuxt, newManifest) - this._currentNuxt.hooks.hookOnce('ready', async () => { + const promise = writeNuxtManifest(this.#currentNuxt, newManifest) + this.#currentNuxt.hooks.hookOnce('ready', async () => { await promise }) @@ -284,13 +348,13 @@ export class NuxtDevServer extends EventEmitter { && newManifest && previousManifest._hash !== newManifest._hash ) { - await clearBuildDir(this._currentNuxt.options.buildDir) + await clearBuildDir(this.#currentNuxt.options.buildDir) } } - await this._currentNuxt.ready() + await this.#currentNuxt.ready() - const unsub = this._currentNuxt.hooks.hook('restart', async (options) => { + const unsub = this.#currentNuxt.hooks.hook('restart', async (options) => { unsub() // We use this instead of `hookOnce` for Nuxt Bridge support if (options?.hard) { this.emit('restart') @@ -299,9 +363,9 @@ export class NuxtDevServer extends EventEmitter { await this.load(true) }) - if (this._currentNuxt.server && 'upgrade' in this._currentNuxt.server) { + if (this.#currentNuxt.server && 'upgrade' in this.#currentNuxt.server) { this.listener.server.on('upgrade', (req, socket, head) => { - const nuxt = this._currentNuxt + const nuxt = this.#currentNuxt if (!nuxt || !nuxt.server) return const viteHmrPath = joinURL( @@ -315,53 +379,83 @@ export class NuxtDevServer extends EventEmitter { }) } - await this._currentNuxt.hooks.callHook('listen', this.listener.server, this.listener) + await this.#currentNuxt.hooks.callHook('listen', this.listener.server, this.listener) - // Sync internal server info to the internals - // It is important for vite-node to use the internal URL but public proto + // Sync internal server info to the internals BEFORE building + // This prevents Nitro from trying to create its own listener const addr = this.listener.address - this._currentNuxt.options.devServer.host = addr.address - this._currentNuxt.options.devServer.port = addr.port - this._currentNuxt.options.devServer.url = getAddressURL(addr, !!this.listener.https) - this._currentNuxt.options.devServer.https = this.options.devContext.proxy?.https as boolean | { key: string, cert: string } + this.#currentNuxt.options.devServer.host = addr.address + this.#currentNuxt.options.devServer.port = addr.port + this.#currentNuxt.options.devServer.url = getAddressURL(addr, !!this.listener.https) + this.#currentNuxt.options.devServer.https = this.listener.https as boolean | { key: string, cert: string } if (this.listener.https && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { console.warn('You might need `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable to make https work.') } + const kit = await loadKit(this.options.cwd) await Promise.all([ - kit.writeTypes(this._currentNuxt).catch(console.error), - kit.buildNuxt(this._currentNuxt), + kit.writeTypes(this.#currentNuxt).catch(console.error), + kit.buildNuxt(this.#currentNuxt), ]) - if (!this._currentNuxt.server) { + if (!this.#currentNuxt.server) { throw new Error('Nitro server has not been initialized.') } // Watch dist directory - const distDir = resolve(this._currentNuxt.options.buildDir, 'dist') + const distDir = resolve(this.#currentNuxt.options.buildDir, 'dist') await mkdir(distDir, { recursive: true }) - this._distWatcher = watch(distDir) - this._distWatcher.on('change', (_event, file: string) => { - if (!this._fileChangeTracker.shouldEmitChange(resolve(distDir, file || ''))) { + this.#distWatcher = watch(distDir) + this.#distWatcher.on('change', (_event, file: string) => { + if (!this.#fileChangeTracker.shouldEmitChange(resolve(distDir, file || ''))) { return } this.loadDebounced(true, '.nuxt/dist directory has been removed') }) - if ('fetch' in this._currentNuxt.server) { - this._handler = toNodeHandler(this._currentNuxt.server.fetch) + if ('fetch' in this.#currentNuxt.server) { + this.#handler = toNodeHandler(this.#currentNuxt.server.fetch) } else { - this._handler = toNodeListener(this._currentNuxt.server.app) + this.#handler = toNodeListener(this.#currentNuxt.server.app) } - this.emit('ready', 'socketPath' in addr ? formatSocketURL(addr.socketPath, !!this.listener.https) : `http://127.0.0.1:${addr.port}`) + + // Emit ready with the server URL + const proto = this.listener.https ? 'https' : 'http' + this.emit('ready', `${proto}://127.0.0.1:${addr.port}`) } - _watchConfig(): void { - this._configWatcher = createConfigWatcher( - this.cwd, + async close(): Promise { + if (this.#currentNuxt) { + await this.#currentNuxt.close() + } + } + + async #load(reload?: boolean, reason?: string): Promise { + const action = reload ? 'Restarting' : 'Starting' + this.#loadingMessage = `${reason ? `${reason}. ` : ''}${action} Nuxt...` + this.#handler = undefined + this.emit('loading', this.#loadingMessage) + if (reload) { + // eslint-disable-next-line no-console + console.info(this.#loadingMessage) + } + + await this.close() + + const urls = await this.listener.getURLs().then(r => r.map(r => r.url)) + + await this.#loadNuxtInstance(urls) + + // Configure the Nuxt instance (shared logic with initial load) + await this.#initializeNuxt(!!reload) + } + + #watchConfig(): void { + this.#configWatcher = createConfigWatcher( + this.#cwd, this.options.dotenv.fileName, () => this.emit('restart'), file => this.loadDebounced(true, `${file} updated`), @@ -369,7 +463,7 @@ export class NuxtDevServer extends EventEmitter { } } -function getAddressURL(addr: Pick, https: boolean) { +function getAddressURL(addr: { address: string, port: number }, https: boolean) { const proto = https ? 'https' : 'http' let host = addr.address.includes(':') ? `[${addr.address}]` : addr.address if (host === '[::]') { @@ -379,24 +473,13 @@ function getAddressURL(addr: Pick, https: boole return `${proto}://${host}:${port}/` } -export function resolveDevServerOverrides(listenOptions: Partial>): Partial> { - if (listenOptions.public || provider === 'codesandbox') { - return { - devServer: { cors: { origin: '*' } }, - vite: { server: { allowedHosts: true } }, - } as const - } - - return {} -} - -export function resolveDevServerDefaults(listenOptions: Partial>, urls: string[] = []): Partial { +function resolveDevServerDefaults(listenOptions: Partial>, urls: string[] = []): Partial { const defaultConfig: Partial = {} - if (urls) { + if (urls && urls.length > 0) { defaultConfig.vite = { server: { - allowedHosts: urls.filter(u => !isSocketURL(u)).map(u => new URL(u).hostname), + allowedHosts: urls.map(u => new URL(u).hostname), }, } } @@ -460,10 +543,14 @@ function createConfigDirWatcher(cwd: string, onReload: (file: string) => void) { } // Nuxt <3.6 did not have the loading template defined in the schema -export async function resolveLoadingTemplate(cwd: string): Promise<({ loading }: { loading?: string }) => string> { +async function resolveLoadingTemplate(cwd: string): Promise<({ loading }: { loading?: string }) => string> { const nuxtPath = resolveModulePath('nuxt', { from: cwd, try: true }) const uiTemplatesPath = resolveModulePath('@nuxt/ui-templates', { from: nuxtPath || cwd }) const r: { loading: (opts?: { loading?: string }) => string } = await import(pathToFileURL(uiTemplatesPath).href) return r.loading || ((params: { loading: string }) => `

${params.loading}

`) } + +function isPublicHostname(hostname: string | undefined): boolean { + return !!hostname && !['localhost', '127.0.0.1', '::1'].includes(hostname) +} diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 57d6ebfe0..20605c314 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -41,9 +41,7 @@ "defu": "^6.1.4", "exsolve": "^1.0.7", "fuse.js": "^7.1.0", - "get-port-please": "^3.2.0", "giget": "^2.0.0", - "http-proxy-3": "^1.22.0", "jiti": "^2.6.1", "listhen": "^1.9.0", "nypm": "^0.6.2", @@ -64,6 +62,7 @@ "@nuxt/kit": "^4.1.3", "@nuxt/schema": "^4.1.3", "@types/node": "^22.18.12", + "get-port-please": "^3.2.0", "h3": "^1.15.4", "h3-next": "npm:h3@^2.0.1-rc.5", "nitro": "^3.0.1-alpha.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 899d6ea64..61920f006 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,9 +149,6 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 - get-port-please: - specifier: ^3.2.0 - version: 3.2.0 giget: specifier: ^2.0.0 version: 2.0.0 @@ -161,9 +158,6 @@ importers: h3-next: specifier: npm:h3@^2.0.1-rc.5 version: h3@2.0.1-rc.5(crossws@0.4.1(srvx@0.9.1)) - http-proxy-3: - specifier: ^1.22.0 - version: 1.22.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -178,7 +172,7 @@ importers: version: 3.0.1-alpha.0(chokidar@4.0.3)(ioredis@5.8.2)(rolldown@1.0.0-beta.45)(vite@7.1.9(@types/node@22.18.12)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) nitropack: specifier: latest - version: 2.12.8(rolldown@1.0.0-beta.45) + version: 2.12.9(rolldown@1.0.0-beta.45) nypm: specifier: ^0.6.2 version: 0.6.2 @@ -263,15 +257,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 - get-port-please: - specifier: ^3.2.0 - version: 3.2.0 giget: specifier: ^2.0.0 version: 2.0.0 - http-proxy-3: - specifier: ^1.22.0 - version: 1.22.0 jiti: specifier: ^2.6.1 version: 2.6.1 @@ -327,6 +315,9 @@ importers: '@types/node': specifier: ^22.18.12 version: 22.18.12 + get-port-please: + specifier: ^3.2.0 + version: 3.2.0 h3: specifier: ^1.15.4 version: 1.15.4 @@ -338,7 +329,7 @@ importers: version: 3.0.1-alpha.0(chokidar@4.0.3)(ioredis@5.8.2)(rolldown@1.0.0-beta.45)(vite@7.1.9(@types/node@22.18.12)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1)) nitropack: specifier: latest - version: 2.12.8(rolldown@1.0.0-beta.45) + version: 2.12.9(rolldown@1.0.0-beta.45) rollup: specifier: ^4.52.5 version: 4.52.5 @@ -3563,10 +3554,6 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} - http-proxy-3@1.22.0: - resolution: {integrity: sha512-qyYYKjmPW7kDiRBGzydmD5f5ckuniL9fY45EWP05YVDoR/02JjrVMGqdrO5O+OURU7imhF3WyiKwp++4A3KEbw==} - engines: {node: '>=18'} - http-shutdown@1.2.2: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3902,6 +3889,9 @@ packages: magicast@0.4.0: resolution: {integrity: sha512-2SCG6Qg6i0srING+lgghlpBl3klNKBudpPzOHDCQCjzWzEf6VnIxJMoiyaNUjbqeSG2zpwRsu9M9D8visuiQFQ==} + magicast@0.5.0: + resolution: {integrity: sha512-D0cxqnb8DpO66P4LkD9ME6a4AhRK6A+xprXksD5vtsJN6G4zbzdI10vDaWCIyj3eLwjNZrQxUYB20FDhKrMEKQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -4176,8 +4166,8 @@ packages: xml2js: optional: true - nitropack@2.12.8: - resolution: {integrity: sha512-k4KT/6CMiX+aAI2LWEdVhvI4PPPWt6NTz70TcxrGUgvMpt8Pv4/iG0KTwBJ58KdwFp59p3Mlp8QyGVmIVP6GvQ==} + nitropack@2.12.9: + resolution: {integrity: sha512-t6qqNBn2UDGMWogQuORjbL2UPevB8PvIPsPHmqvWpeGOlPr4P8Oc5oA8t3wFwGmaolM2M/s2SwT23nx9yARmOg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4263,6 +4253,9 @@ packages: ofetch@1.4.1: resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} + ofetch@1.5.0: + resolution: {integrity: sha512-A7llJ7eZyziA5xq9//3ZurA8OhFqtS99K5/V1sLBJ5j137CM/OAjlbA/TEJXBuOWwOfLqih+oH5U3ran4za1FQ==} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -5205,6 +5198,9 @@ packages: unenv@2.0.0-rc.21: resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unhead@2.0.17: resolution: {integrity: sha512-xX3PCtxaE80khRZobyWCVxeFF88/Tg9eJDcJWY9us727nsTC7C449B8BUfVBmiF2+3LjPcmqeoB2iuMs0U4oJQ==} @@ -5830,8 +5826,8 @@ snapshots: '@babel/generator@7.28.3': dependencies: - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -5846,7 +5842,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -5874,14 +5870,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -5896,7 +5892,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 '@babel/helper-plugin-utils@7.27.1': {} @@ -5912,7 +5908,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -5927,7 +5923,7 @@ snapshots: '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 '@babel/parser@7.28.4': dependencies: @@ -5961,17 +5957,17 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@babel/traverse@7.28.4': dependencies: '@babel/code-frame': 7.27.1 '@babel/generator': 7.28.3 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.4 + '@babel/parser': 7.28.5 '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/types': 7.28.5 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7167,7 +7163,7 @@ snapshots: estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) is-reference: 1.2.1 - magic-string: 0.30.19 + magic-string: 0.30.21 picomatch: 4.0.3 optionalDependencies: rollup: 4.52.5 @@ -7176,7 +7172,7 @@ snapshots: dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) estree-walker: 2.0.2 - magic-string: 0.30.19 + magic-string: 0.30.21 optionalDependencies: rollup: 4.52.5 @@ -7199,7 +7195,7 @@ snapshots: '@rollup/plugin-replace@6.0.2(rollup@4.52.5)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) - magic-string: 0.30.19 + magic-string: 0.30.21 optionalDependencies: rollup: 4.52.5 @@ -7591,7 +7587,7 @@ snapshots: dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 - magic-string: 0.30.19 + magic-string: 0.30.21 optionalDependencies: vite: 7.1.9(@types/node@22.18.12)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) @@ -7608,7 +7604,7 @@ snapshots: '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 '@vitest/spy@3.2.4': @@ -7661,7 +7657,7 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - '@babel/parser': 7.28.4 + '@babel/parser': 7.28.5 '@vue/compiler-sfc': 3.5.22 transitivePeerDependencies: - supports-color @@ -7687,7 +7683,7 @@ snapshots: '@vue/compiler-ssr': 3.5.22 '@vue/shared': 3.5.22 estree-walker: 2.0.2 - magic-string: 0.30.19 + magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 @@ -7866,7 +7862,7 @@ snapshots: axios@1.12.2: dependencies: - follow-redirects: 1.15.11(debug@4.4.3) + follow-redirects: 1.15.11 form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -7962,6 +7958,23 @@ snapshots: optionalDependencies: magicast: 0.4.0 + c12@3.3.1(magicast@0.5.0): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 17.2.3 + exsolve: 1.0.7 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.0 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -8840,9 +8853,7 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.11(debug@4.4.3): - optionalDependencies: - debug: 4.4.3 + follow-redirects@1.15.11: {} foreground-child@3.3.1: dependencies: @@ -9021,13 +9032,6 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 - http-proxy-3@1.22.0: - dependencies: - debug: 4.4.3 - follow-redirects: 1.15.11(debug@4.4.3) - transitivePeerDependencies: - - supports-color - http-shutdown@1.2.2: {} https-proxy-agent@7.0.6: @@ -9326,7 +9330,7 @@ snapshots: magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 regexp-tree: 0.1.27 type-level-regexp: 0.1.17 @@ -9335,7 +9339,7 @@ snapshots: magic-string-ast@1.0.2: dependencies: - magic-string: 0.30.19 + magic-string: 0.30.21 magic-string@0.30.19: dependencies: @@ -9357,6 +9361,12 @@ snapshots: '@babel/types': 7.28.5 source-map-js: 1.2.1 + magicast@0.5.0: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.7.3 @@ -9815,7 +9825,7 @@ snapshots: - sqlite3 - uploadthing - nitropack@2.12.8(rolldown@1.0.0-beta.45): + nitropack@2.12.9(rolldown@1.0.0-beta.45): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 '@rollup/plugin-alias': 5.1.1(rollup@4.52.5) @@ -9827,7 +9837,7 @@ snapshots: '@rollup/plugin-terser': 0.4.4(rollup@4.52.5) '@vercel/nft': 0.30.3(rollup@4.52.5) archiver: 7.0.1 - c12: 3.3.1(magicast@0.3.5) + c12: 3.3.1(magicast@0.5.0) chokidar: 4.0.3 citty: 0.1.6 compatx: 0.2.0 @@ -9854,13 +9864,13 @@ snapshots: klona: 2.0.6 knitwork: 1.2.0 listhen: 1.9.0 - magic-string: 0.30.19 - magicast: 0.3.5 + magic-string: 0.30.21 + magicast: 0.5.0 mime: 4.1.0 mlly: 1.8.0 node-fetch-native: 1.6.7 node-mock-http: 1.0.3 - ofetch: 1.4.1 + ofetch: 1.5.0 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 2.0.0 @@ -9879,7 +9889,7 @@ snapshots: ultrahtml: 1.6.0 uncrypto: 0.1.3 unctx: 2.4.1 - unenv: 2.0.0-rc.21 + unenv: 2.0.0-rc.24 unimport: 5.5.0 unplugin-utils: 0.3.1 unstorage: 1.17.1(db0@0.3.4)(ioredis@5.8.2) @@ -9988,7 +9998,7 @@ snapshots: mlly: 1.8.0 mocked-exports: 0.1.1 nanotar: 0.2.0 - nitropack: 2.12.8(rolldown@1.0.0-beta.45) + nitropack: 2.12.9(rolldown@1.0.0-beta.45) nypm: 0.6.2 ofetch: 1.4.1 ohash: 2.0.11 @@ -10094,6 +10104,12 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.1 + ofetch@1.5.0: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.1 + ohash@2.0.11: {} on-change@6.0.0: {} @@ -11082,6 +11098,10 @@ snapshots: pathe: 2.0.3 ufo: 1.6.1 + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unhead@2.0.17: dependencies: hookable: 5.5.3 @@ -11111,7 +11131,7 @@ snapshots: escape-string-regexp: 5.0.0 estree-walker: 3.0.3 local-pkg: 1.1.2 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 pathe: 2.0.3 picomatch: 4.0.3 @@ -11260,7 +11280,7 @@ snapshots: unwasm@0.3.11: dependencies: knitwork: 1.2.0 - magic-string: 0.30.19 + magic-string: 0.30.21 mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 @@ -11352,7 +11372,7 @@ snapshots: dependencies: estree-walker: 3.0.3 exsolve: 1.0.7 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 vite: 7.1.9(@types/node@22.18.12)(jiti@2.6.1)(terser@5.44.0)(yaml@2.8.1) From fc272fd7d61dd9af43bd36b5e940ba3f9f60ab48 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 29 Oct 2025 21:09:07 +0000 Subject: [PATCH 2/6] test: mark deno websockets as working on mac --- packages/nuxt-cli/test/e2e/runtimes.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index e743d7d41..0dbdb1896 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -49,7 +49,7 @@ function createIt(runtimeName: typeof runtimes[number], _socketsEnabled: boolean deno: { start: !platform.windows, fetching: !platform.windows, - websockets: !platform.windows && !platform.macos, + websockets: !platform.windows, }, } const status = supportMatrix[runtimeName] From 346781922c56cef7d6f8184265e05b1cefa924d4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 30 Oct 2025 10:43:44 +0000 Subject: [PATCH 3/6] refactor: simplify listen option overrides --- packages/nuxi/src/commands/dev.ts | 49 +++++++++++-------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index c53cbd72a..fdb00134b 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -1,5 +1,4 @@ import type { ParsedArgs } from 'citty' -import type { ListenOptions } from 'listhen' import type { NuxtDevContext } from '../dev/utils' import process from 'node:process' @@ -192,46 +191,32 @@ type ArgsT = Exclude< undefined | ((...args: unknown[]) => unknown) > -function resolveListenOverrides(args: ParsedArgs): Partial { - const httpsEnv = resolveHttpsFromEnv() - - const _httpsCert = args['https.cert'] - || (args.sslCert as string) - || httpsEnv.cert - - const _httpsKey = args['https.key'] - || (args.sslKey as string) - || httpsEnv.key - - const overrides = { - ...args, - 'open': (args.o as boolean) || args.open, - 'https': args.https, - 'https.cert': _httpsCert || '', - 'https.key': _httpsKey || '', - 'https.pfx': args['https.pfx'] || '', - 'https.passphrase': args['https.passphrase'] || '', - } - +function resolveListenOverrides(args: ParsedArgs) { // _PORT is used by `@nuxt/test-utils` to launch the dev server on a specific port - // It takes highest priority over all other port sources if (process.env._PORT) { return { - ...overrides, port: process.env._PORT || 0, hostname: '127.0.0.1', showURL: false, - } + } as const } - return overrides -} + const _httpsCert = args['https.cert'] + || args.sslCert + || process.env.NUXT_SSL_CERT + || process.env.NITRO_SSL_CERT + + const _httpsKey = args['https.key'] + || args.sslKey + || process.env.NUXT_SSL_KEY + || process.env.NITRO_SSL_KEY -// Helper to resolve HTTPS certificate and key from environment variables -function resolveHttpsFromEnv() { - const cert = process.env.NUXT_SSL_CERT || process.env.NITRO_SSL_CERT || '' - const key = process.env.NUXT_SSL_KEY || process.env.NITRO_SSL_KEY || '' - return { cert, key } + return { + ...args, + 'open': (args.o as boolean) || args.open, + 'https.cert': _httpsCert || '', + 'https.key': _httpsKey || '', + } as const } function isBunForkSupported() { From e3701373a7950d1764ac91cbd3ccec042d659ac7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 30 Oct 2025 10:53:36 +0000 Subject: [PATCH 4/6] wip --- packages/nuxi/src/commands/dev.ts | 58 +++++++++---------------------- 1 file changed, 16 insertions(+), 42 deletions(-) diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index fdb00134b..c22fe06b0 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -82,43 +82,26 @@ const command = defineCommand({ const listenOverrides = resolveListenOverrides(ctx.args) - if (!ctx.args.fork) { - // No-fork mode: everything runs in-process with direct listening - const { listener, close } = await initialize({ - cwd, - args: ctx.args, - }, { - data: ctx.data, - listenOverrides, - showBanner: true, - }) + // Start the initial dev server in-process with listener + const { listener, close, onRestart, onReady } = await initialize({ cwd, args: ctx.args }, { + data: ctx.data, + listenOverrides, + showBanner: true, + }) + if (!ctx.args.fork) { return { listener, - async close() { - await close() - await listener.close() - }, + close, } } - // Fork mode: use pool of pre-warmed forks const pool = new ForkPool({ rawArgs: ctx.rawArgs, poolSize: 2, listenOverrides, }) - // Start the initial dev server in-process with listener - const { listener, close, onRestart, onReady } = await initialize({ - cwd, - args: ctx.args, - }, { - data: ctx.data, - listenOverrides, - showBanner: true, - }) - // When ready, start warming up the fork pool onReady((_address) => { pool.startWarming() @@ -132,15 +115,10 @@ const command = defineCommand({ async function restartWithFork() { // Get a fork from the pool (warm if available, cold otherwise) - const context: NuxtDevContext = { - cwd, - args: ctx.args, - } + const context: NuxtDevContext = { cwd, args: ctx.args } // Clean up previous fork if any - if (cleanupCurrentFork) { - cleanupCurrentFork() - } + cleanupCurrentFork?.() cleanupCurrentFork = await pool.getFork(context, (message) => { // Handle IPC messages from the fork @@ -160,23 +138,19 @@ const command = defineCommand({ }) } - onRestart(async (devServer) => { + onRestart(async () => { // Close the in-process dev server - await Promise.all([ - listener.close(), - devServer.close().catch(() => {}), - close(), - ]) - + await close() await restartWithFork() }) return { - listener, async close() { cleanupCurrentFork?.() - await close() - await listener.close() + await Promise.all([ + listener.close(), + close(), + ]) }, } }, From e919c2bcff2e5d183882f77bb67fd85c75942379 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 30 Oct 2025 11:04:53 +0000 Subject: [PATCH 5/6] fix: track + clear websocket connections --- packages/nuxi/src/dev/utils.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 956ead699..2af723d38 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -106,6 +106,7 @@ export class NuxtDevServer extends EventEmitter { #loadingError?: Error #fileChangeTracker = new FileChangeTracker() #cwd: string + #websocketConnections = new Set() loadDebounced: (reload?: boolean, reason?: string) => void handler: RequestListener @@ -329,6 +330,7 @@ export class NuxtDevServer extends EventEmitter { // Remove websocket handlers on close this.#currentNuxt.hooks.hookOnce('close', () => { + this.#closeWebSocketConnections() this.listener.server.removeAllListeners('upgrade') }) @@ -376,6 +378,12 @@ export class NuxtDevServer extends EventEmitter { return // Skip for Vite HMR } nuxt.server.upgrade(req, socket, head) + + // Track WebSocket connections + this.#websocketConnections.add(socket) + socket.on('close', () => { + this.#websocketConnections.delete(socket) + }) }) } @@ -433,6 +441,13 @@ export class NuxtDevServer extends EventEmitter { } } + #closeWebSocketConnections(): void { + for (const socket of this.#websocketConnections) { + socket.destroy() + } + this.#websocketConnections.clear() + } + async #load(reload?: boolean, reason?: string): Promise { const action = reload ? 'Restarting' : 'Starting' this.#loadingMessage = `${reason ? `${reason}. ` : ''}${action} Nuxt...` From f2925d13a97d1981c27d3ff484a5e364ea238e10 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 30 Oct 2025 11:13:50 +0000 Subject: [PATCH 6/6] chore: add link to nitro issue --- packages/nuxt-cli/test/e2e/runtimes.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index 0dbdb1896..94f476052 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -44,6 +44,7 @@ function createIt(runtimeName: typeof runtimes[number], _socketsEnabled: boolean bun: { start: !platform.windows, fetching: !platform.windows, + // https://github.com/nitrojs/nitro/issues/2721 websockets: false, }, deno: {