Skip to content

Commit a7add36

Browse files
committed
fix(dev): harden listener edge cases
1 parent 5eee806 commit a7add36

4 files changed

Lines changed: 90 additions & 36 deletions

File tree

packages/nuxt-cli/src/commands/dev.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { satisfies } from 'verkit'
1515
import { initialize } from '../dev'
1616

1717
import { closeInspector, openInspector, resolveInspectOptions } from '../dev/inspect'
18-
import { isReusePortSupported } from '../dev/listen'
18+
import { isReusePortSupported, parsePort } from '../dev/listen'
1919
import { ForkPool } from '../dev/pool'
2020
import { formatRestartReason } from '../dev/reason'
2121
import { setupShortcuts } from '../dev/shortcuts'
@@ -158,7 +158,7 @@ const command = defineCommand({
158158
const listenOverrides = resolveListenOverrides(ctx.args)
159159

160160
const takeover = await takeOverDevServer(resolveDevBuildDir(cwd), {
161-
requestedPort: parseRequestedPort(listenOverrides.port),
161+
requestedPort: parsePort(listenOverrides.port),
162162
takeover: ctx.args.takeover,
163163
})
164164
if (takeover.action === 'refused') {
@@ -408,11 +408,6 @@ function resolveDevBuildDir(cwd: string): string {
408408
return resolve(cwd, '.nuxt')
409409
}
410410

411-
function parseRequestedPort(port: string | number | undefined): number | undefined {
412-
const parsed = Number(port)
413-
return port === undefined || port === '' || !Number.isInteger(parsed) || parsed <= 0 ? undefined : parsed
414-
}
415-
416411
function resolveForkPoolSize(): number | undefined {
417412
const raw = process.env.NUXT_DEV_FORK_POOL_SIZE
418413
if (!raw) {

packages/nuxt-cli/src/dev/listen.ts

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export async function listen(handler: RequestListener, options: ListenOptions =
155155
const isolatedEnvironment = options.hostname === undefined && !options.public && detectIsolatedEnvironment()
156156
const hostname = validateHostname(options.hostname, options.public) ?? (options.public || isolatedEnvironment ? '' : 'localhost')
157157

158-
const requestedPort = options.port === undefined || options.port === '' ? undefined : Number(options.port)
158+
const requestedPort = parsePort(options.port)
159159
const port = options.handover && requestedPort
160160
? requestedPort
161161
: await resolvePort(requestedPort, hostname, options.strictPort)
@@ -191,15 +191,16 @@ export async function listen(handler: RequestListener, options: ListenOptions =
191191
tunnel = await startTunnel(`${protocol}://localhost:${address.port}`, !!certificate)
192192
}
193193

194+
const tunnelURL = tunnel?.url && tunnel.url + baseURL
194195
const portless = resolvePortlessURLs()
195196
const portlessURL = portless.url && portless.url + baseURL
196197
const portlessShareURL = portless.shareURL && portless.shareURL + baseURL
197198
const stackblitzURL = resolveStackblitzURL()
198199

199200
function getURLs(): ListenURL[] {
200201
const urls: ListenURL[] = []
201-
if (tunnel) {
202-
urls.push({ url: tunnel.url, type: 'tunnel' })
202+
if (tunnelURL) {
203+
urls.push({ url: tunnelURL, type: 'tunnel' })
203204
}
204205
for (const portlessURL of portless.all) {
205206
urls.push({ url: portlessURL + baseURL, type: 'public' })
@@ -221,7 +222,7 @@ export async function listen(handler: RequestListener, options: ListenOptions =
221222

222223
// The StackBlitz URL points at the editor rather than at a host another
223224
// device can open, so it is not a QR code candidate.
224-
const shareableURL = options.publicURL || tunnel?.url || portlessShareURL || portlessURL
225+
const shareableURL = options.publicURL || tunnelURL || portlessShareURL || portlessURL
225226
const publicURL = shareableURL || stackblitzURL
226227

227228
const qrURL = options.qr === false
@@ -278,31 +279,35 @@ export async function listen(handler: RequestListener, options: ListenOptions =
278279
https: certificate,
279280
getURLs,
280281
showURLs,
281-
close: async () => {
282-
await tunnel?.close()
283-
return new Promise<void>((resolve, reject) => {
284-
let forceClose: NodeJS.Timeout | undefined
285-
server.close((error) => {
286-
if (forceClose) {
287-
clearTimeout(forceClose)
288-
}
289-
if (error) {
290-
reject(error)
291-
}
292-
else {
293-
resolve()
294-
}
295-
})
296-
// Sockets waiting on keep-alive are closed at once, so shutdown is only
297-
// delayed while a request is actually being served.
298-
server.closeIdleConnections?.()
299-
forceClose = setTimeout(() => server.closeAllConnections?.(), CONNECTION_DRAIN_TIMEOUT_MS)
300-
forceClose.unref()
301-
})
302-
},
282+
close: () => Promise.all([
283+
tunnel?.close(),
284+
closeServer(server),
285+
]).then(() => {}),
303286
}
304287
}
305288

289+
function closeServer(server: HttpServer): Promise<void> {
290+
return new Promise<void>((resolve, reject) => {
291+
let forceClose: NodeJS.Timeout | undefined
292+
server.close((error) => {
293+
if (forceClose) {
294+
clearTimeout(forceClose)
295+
}
296+
if (error) {
297+
reject(error)
298+
}
299+
else {
300+
resolve()
301+
}
302+
})
303+
// Sockets waiting on keep-alive are closed at once, so shutdown is only
304+
// delayed while a request is actually being served.
305+
server.closeIdleConnections?.()
306+
forceClose = setTimeout(() => server.closeAllConnections?.(), CONNECTION_DRAIN_TIMEOUT_MS)
307+
forceClose.unref()
308+
})
309+
}
310+
306311
function bindServer(server: HttpServer, port: number, hostname: string, reusePort: boolean): Promise<void> {
307312
return new Promise<void>((resolve, reject) => {
308313
const onError = (error: NodeJS.ErrnoException) => {
@@ -372,6 +377,17 @@ export function isReusePortSupported(): Promise<boolean> {
372377
return reusePortSupport
373378
}
374379

380+
export function parsePort(value: string | number | undefined): number | undefined {
381+
if (value === undefined || value === '') {
382+
return undefined
383+
}
384+
const port = Number(value)
385+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
386+
throw new Error(`Invalid port \`${value}\`; expected an integer between 0 and 65535.`)
387+
}
388+
return port
389+
}
390+
375391
async function resolvePort(requestedPort: number | undefined, hostname: string, strictPort?: boolean): Promise<number> {
376392
if (requestedPort === 0) {
377393
return getPort({ random: true, host: hostname || undefined })

packages/nuxt-cli/src/dev/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -899,8 +899,8 @@ function createConfigDirWatcher(cwd: string, onReload: (path: string) => void) {
899899

900900
fileWatcher.prime(configDir)
901901
const configDirWatcher = watch(configDir)
902-
configDirWatcher.on('change', (_event, file: string) => {
903-
if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) {
902+
configDirWatcher.on('change', (_event, file: string | null) => {
903+
if (!file || !fileWatcher.shouldEmitChange(resolve(configDir, file))) {
904904
return
905905
}
906906

packages/nuxt-cli/test/unit/listen.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { networkInterfaces } from 'node:os'
55

66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, openBrowser, resolveOpenCommand, validateHostname } from '../../src/dev/listen'
8+
import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, openBrowser, parsePort, resolveOpenCommand, validateHostname } from '../../src/dev/listen'
99

1010
const writeText = vi.hoisted(() => vi.fn())
1111
const isolatedEnvironment = vi.hoisted(() => ({ current: undefined as string | undefined }))
@@ -115,6 +115,22 @@ describe('validateHostname', () => {
115115
})
116116
})
117117

118+
describe('parsePort', () => {
119+
it.each([
120+
['0', 0],
121+
['3000', 3000],
122+
[65_535, 65_535],
123+
['', undefined],
124+
[undefined, undefined],
125+
])('should parse %j as %j', (value, expected) => {
126+
expect(parsePort(value)).toBe(expected)
127+
})
128+
129+
it.each(['nope', '-1', '1.5', '65536', 'Infinity'])('should reject %s', (value) => {
130+
expect(() => parsePort(value)).toThrow(`Invalid port \`${value}\``)
131+
})
132+
})
133+
118134
describe('resolveOpenCommand', () => {
119135
const url = 'http://localhost:3000/'
120136

@@ -180,6 +196,10 @@ describe('listen', () => {
180196
expect(listener.url).toBe(`http://localhost:${listener.address.port}/`)
181197
})
182198

199+
it('should reject an invalid port before binding', async () => {
200+
await expect(start({ port: 'nope' })).rejects.toThrow('Invalid port `nope`; expected an integer between 0 and 65535.')
201+
})
202+
183203
it('should fall back to another port by default', async () => {
184204
const first = await start({ port: 0 })
185205
const second = await start({ port: first.address.port })
@@ -242,6 +262,29 @@ describe('listen', () => {
242262
})
243263

244264
describe('listener.close', () => {
265+
it('should close the listener while a tunnel is still shutting down', async () => {
266+
let closeTunnel: (() => void) | undefined
267+
vi.doMock('../../src/dev/tunnel', () => ({
268+
startTunnel: async () => ({
269+
url: 'https://example.test',
270+
close: () => new Promise<void>((resolve) => {
271+
closeTunnel = resolve
272+
}),
273+
}),
274+
}))
275+
276+
const listener = await listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', baseURL: '/dashboard/', showURL: false, tunnel: true })
277+
expect(listener.publicURL).toBe('https://example.test/dashboard/')
278+
expect(listener.getURLs()).toContainEqual({ url: 'https://example.test/dashboard/', type: 'tunnel' })
279+
280+
const closed = listener.close()
281+
282+
await vi.waitFor(() => expect(listener.server.listening).toBe(false))
283+
closeTunnel!()
284+
await closed
285+
vi.doUnmock('../../src/dev/tunnel')
286+
})
287+
245288
it('should let an in-flight request finish', async () => {
246289
let respond: (() => void) | undefined
247290
const listener = await listen((_req, res) => {

0 commit comments

Comments
 (0)