Skip to content
123 changes: 98 additions & 25 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ParsedArgs } from 'citty'
import type { HTTPSOptions } from '../dev/cert'
import type { DevListenOverrides } from '../dev/listen'
import type { ActiveFork } from '../dev/pool'
import type { DevRestartReason } from '../dev/reason'
import type { NuxtDevContext } from '../dev/utils'

Expand All @@ -13,6 +14,7 @@ import { satisfies } from 'verkit'
import { initialize } from '../dev'

import { closeInspector, openInspector, resolveInspectOptions } from '../dev/inspect'
import { isReusePortSupported } from '../dev/listen'
import { ForkPool } from '../dev/pool'
import { formatRestartReason } from '../dev/reason'
import { setupShortcuts } from '../dev/shortcuts'
Expand Down Expand Up @@ -146,6 +148,11 @@ const command = defineCommand({

const listenOverrides = resolveListenOverrides(ctx.args)

// With `SO_REUSEPORT` an incoming fork can bind the port before this process
// releases it, so a hard restart never leaves the port unserved.
const reusePort = ctx.args.fork && !ctx.args.profile && await isReusePortSupported()
listenOverrides.reusePort = reusePort

// The inspector belongs to whichever process is currently serving the app:
// this one until a hard restart hands over to a fork.
const inspect = resolveInspectOptions(ctx.rawArgs)
Expand Down Expand Up @@ -191,39 +198,105 @@ const command = defineCommand({

// On hard restart, use a fork from the pool
let cleanupCurrentFork: (() => Promise<void>) | undefined
// Whatever is serving the app right now: this process, then each fork in turn.
let closeCurrent = close
let currentPid = process.pid

// A hard restart can be asked for from several places at once (the config
// watcher, the `r` shortcut, and the fork that is currently serving), and two
// overlapping handovers would both bind the port and then race over which one
// is recorded as current.
let inFlight: Promise<void> | undefined
let pendingReason: { reason?: DevRestartReason } | undefined

function restartWithFork(reason?: DevRestartReason): Promise<void> {
if (inFlight) {
pendingReason = { reason }
return inFlight
}
inFlight = replaceWithFork(reason).finally(() => {
inFlight = undefined
const pending = pendingReason
pendingReason = undefined
if (pending) {
void restartWithFork(pending.reason)
}
})
return inFlight
}

async function restartWithFork(reason?: DevRestartReason) {
async function replaceWithFork(reason?: DevRestartReason) {
logger.info(formatRestartReason(reason, { rootDir: cwd, hard: true }))

// The inspector port cannot be shared, so the handover has to stay
// serialised whenever the inspector is open.
const handover = reusePort && !inspect

// Get a fork from the pool (warm if available, cold otherwise)
const context: NuxtDevContext = { cwd, args: ctx.args }

// Release the inspector port before the incoming fork tries to bind it
await Promise.all([
cleanupCurrentFork?.(),
inspect ? closeInspector() : undefined,
])

cleanupCurrentFork = await pool.getFork(context, (message) => {
// Handle IPC messages from the fork
if (message.type === 'nuxt:internal:dev:ready') {
if (startTime) {
debug(`Dev server ready for connections in ${Date.now() - startTime}ms`)
}
}
else if (message.type === 'nuxt:internal:dev:restart') {
// Fork is requesting another restart
void restartWithFork(message.reason)
}
else if (message.type === 'nuxt:internal:dev:rejection') {
void restartWithFork({ type: 'error', message: message.message })
const context: NuxtDevContext = {
cwd,
args: ctx.args,
handoverFrom: handover ? currentPid : undefined,
}

if (!handover) {
await Promise.all([
closeCurrent(),
inspect ? closeInspector() : undefined,
])
}

let serving = false
let fork: ActiveFork | undefined
try {
fork = await pool.getFork(context, {
listenOverrides: handover
? { port: listener.address.port, handover: true }
: undefined,
onMessage: (message) => {
// Handle IPC messages from the fork
if (message.type === 'nuxt:internal:dev:ready') {
if (startTime) {
debug(`Dev server ready for connections in ${Date.now() - startTime}ms`)
}
}
else if (!serving) {
// Failures before the fork serves anything are handled below, which
// leaves the outgoing server in place.
}
else if (message.type === 'nuxt:internal:dev:restart') {
// Fork is requesting another restart
void restartWithFork(message.reason)
}
else if (message.type === 'nuxt:internal:dev:rejection') {
void restartWithFork({ type: 'error', message: message.message })
}
},
})
await fork.serving
}
catch (error) {
await fork?.close()
const detail = error instanceof Error ? error.message : String(error)
logger.error(handover
? `Could not restart the dev server, keeping the current one: ${detail}`
: `Could not restart the dev server: ${detail}`)
if (handover) {
onRestart(restart)
}
})
return
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

serving = true
fork.promote()
const closePrevious = handover ? closeCurrent : undefined
cleanupCurrentFork = fork.close
closeCurrent = fork.close
currentPid = fork.pid ?? currentPid
await closePrevious?.()
}

async function restart(reason?: DevRestartReason) {
// Close the in-process dev server
await close()
await restartWithFork(reason)
}

Expand Down
76 changes: 55 additions & 21 deletions packages/nuxt-cli/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti
envName: devContext.args.envName,
showBanner: ctx.showBanner !== false && !ipc.enabled,
listenOverrides: ctx.listenOverrides,
handoverFrom: devContext.handoverFrom,
})

let address: string
Expand Down Expand Up @@ -152,6 +153,8 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti

let closePromise: Promise<void> | undefined

const armRestart = createRestartHook(devServer)

return {
listener: devServer.listener,
reload: (reason?: DevRestartReason) => devServer.load(true, reason),
Expand Down Expand Up @@ -181,26 +184,57 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti
onFileChange: (callback: () => void) => {
devServer.once('change', callback)
},
onRestart: (callback: (reason?: DevRestartReason) => void) => {
let restarted = false
function restart(reason?: DevRestartReason) {
if (!restarted) {
restarted = true
process.off('uncaughtException', restartOnError)
process.off('unhandledRejection', restartOnError)
callback(reason)
}
}
function restartOnError(error: unknown) {
if (isBrokenPipe(error)) {
debug('Ignoring broken pipe:', error)
return
}
restart({ type: 'error', message: formatErrorMessage(error) })
}
devServer.once('restart', restart)
process.on('uncaughtException', restartOnError)
process.on('unhandledRejection', restartOnError)
},
onRestart: armRestart,
}
}

interface RestartSource {
once: (event: 'restart', handler: (reason?: DevRestartReason) => void) => void
off: (event: 'restart', handler: (reason?: DevRestartReason) => void) => void
}

/**
* Connect the triggers for a hard restart (an explicit `restart` event, and
* errors that leave this process unable to serve) to a single callback, which
* fires at most once per arming. Re-arming after a restart that could not be
* completed swaps the callback in without stacking another set of listeners.
*/
export function createRestartHook(source: RestartSource): (callback: (reason?: DevRestartReason) => void) => void {
let callback: ((reason?: DevRestartReason) => void) | undefined
let fired = false
let armed = false

function restart(reason?: DevRestartReason) {
if (fired) {
return
}
fired = true
armed = false
// An error-triggered restart leaves the `restart` listener in place, since
// `once` only removes it when the event itself fires.
source.off('restart', restart)
process.off('uncaughtException', restartOnError)
process.off('unhandledRejection', restartOnError)
callback?.(reason)
}

function restartOnError(error: unknown) {
if (isBrokenPipe(error)) {
debug('Ignoring broken pipe:', error)
return
}
restart({ type: 'error', message: formatErrorMessage(error) })
}

return (next: (reason?: DevRestartReason) => void) => {
callback = next
fired = false
if (armed) {
return
}
armed = true
source.once('restart', restart)
process.on('uncaughtException', restartOnError)
process.on('unhandledRejection', restartOnError)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
85 changes: 74 additions & 11 deletions packages/nuxt-cli/src/dev/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface ListenOptions {
port?: string | number
/** Fail instead of falling back to another port when `port` is unavailable. */
strictPort?: boolean
/** Bind with `SO_REUSEPORT` so a successor can bind the same port before this one is released. */
reusePort?: boolean
/**
* Bind `port` as given, without checking whether it is free. Used together with
* `reusePort` when taking over from a process that still holds the port.
*/
handover?: boolean
hostname?: string
baseURL?: string
showURL?: boolean
Expand Down Expand Up @@ -150,25 +157,22 @@ export async function listen(handler: RequestListener, options: ListenOptions =
const hostname = validateHostname(options.hostname, options.public) ?? (options.public ? '' : 'localhost')

const requestedPort = options.port === undefined || options.port === '' ? undefined : Number(options.port)
const port = await resolvePort(requestedPort, hostname, options.strictPort)
const port = options.handover && requestedPort
? requestedPort
: await resolvePort(requestedPort, hostname, options.strictPort)

const httpsOptions = options.https === true ? {} : options.https
const certificate = httpsOptions ? await resolveCertificate(httpsOptions) : false
const server = certificate
? createSecureServer(certificate, handler)
: createHttpServer(handler)

await new Promise<void>((resolve, reject) => {
const onError = (error: NodeJS.ErrnoException) => reject(describeBindError(error, port, hostname, options.strictPort))
server.once('error', onError)
server.listen(port, hostname || undefined, () => {
server.removeListener('error', onError)
// Without a persistent handler, any later `error` event is unhandled and
// takes the whole dev process down.
server.on('error', error => logger.error(`Dev server error: ${error.message}`))
resolve()
})
await bindServer(server, port, hostname, !!options.reusePort).catch((error) => {
throw describeBindError(error, port, hostname, options.strictPort)
})
// Without a persistent handler, any later `error` event is unhandled and
// takes the whole dev process down.
server.on('error', error => logger.error(`Dev server error: ${error.message}`))

const address = server.address() as AddressInfo
const protocol = certificate ? 'https' : 'http'
Expand Down Expand Up @@ -295,6 +299,65 @@ export async function listen(handler: RequestListener, options: ListenOptions =
}
}

function bindServer(server: HttpServer, port: number, hostname: string, reusePort: boolean): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onError = (error: NodeJS.ErrnoException) => {
if (reusePort && isUnsupportedOptionError(error)) {
debug('`reusePort` is unsupported on this platform, binding without it:', error)
bindServer(server, port, hostname, false).then(resolve, reject)
return
}
reject(error)
}
server.once('error', onError)
server.listen({ port, host: hostname || undefined, reusePort, exclusive: !reusePort }, () => {
server.removeListener('error', onError)
resolve()
})
})
}

/**
* Whether the platform rejected the `reusePort` listen option itself, as opposed
* to refusing this particular bind. Linux reports `ENOTSUP`, Windows `EINVAL`,
* and Node validates the option before the bind on runtimes without support.
*/
function isUnsupportedOptionError(error: NodeJS.ErrnoException): boolean {
return error.code === 'ENOTSUP' || error.code === 'EINVAL' || error.code === 'ERR_INVALID_ARG_VALUE'
}

let reusePortSupport: Promise<boolean> | undefined

/**
* Whether two sockets can bind the same port at once via `SO_REUSEPORT`, probed
* on an ephemeral port. A single successful bind is not enough: some platforms
* accept the option and still reject the second socket. Cached per process.
*/
export function isReusePortSupported(): Promise<boolean> {
reusePortSupport ??= (async () => {
const first = createHttpServer()
const second = createHttpServer()
try {
await bindServer(first, 0, '127.0.0.1', true)
const { port } = first.address() as AddressInfo
await bindServer(second, port, '127.0.0.1', true)
return true
}
catch (error) {
debug('`reusePort` is not available:', error)
return false
}
finally {
for (const server of [first, second]) {
if (server.listening) {
server.close()
}
}
}
})()
return reusePortSupport
}

async function resolvePort(requestedPort: number | undefined, hostname: string, strictPort?: boolean): Promise<number> {
if (requestedPort === 0) {
return getPort({ random: true, host: hostname || undefined })
Expand Down
Loading
Loading