From 54ff56e1f300a82b394f1fa0d5af391b8ed84a41 Mon Sep 17 00:00:00 2001 From: elijahjunaid Date: Wed, 12 Nov 2025 13:56:59 -0500 Subject: [PATCH 1/6] fix: handle ECONNRESET errors in Node.js 24.x - Replace wait-port dependency with custom implementation - Add retry logic with ECONNRESET error handling - Add HTTP agent with ECONNRESET handling in proxy - Enable framework host detection for Node 24+ - Update tests to use new wait-port API Fixes Node.js 24.x dev server startup failures --- package-lock.json | 76 ------------------- package.json | 1 - src/lib/http-agent.ts | 15 ++-- src/lib/wait-port.ts | 60 +++++++++++++++ src/utils/framework-server.ts | 16 ++-- src/utils/proxy.ts | 20 +++++ src/utils/run-build.ts | 4 +- .../functions-serve/functions-serve.test.ts | 10 +-- 8 files changed, 100 insertions(+), 102 deletions(-) create mode 100644 src/lib/wait-port.ts diff --git a/package-lock.json b/package-lock.json index 87a1ce15a65..e6bacdb0837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,6 @@ "ulid": "3.0.1", "update-notifier": "7.3.1", "uuid": "11.1.0", - "wait-port": "1.1.0", "write-file-atomic": "5.0.1", "ws": "8.18.3" }, @@ -18022,81 +18021,6 @@ } } }, - "node_modules/wait-port": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", - "integrity": "sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==", - "dependencies": { - "chalk": "^4.1.2", - "commander": "^9.3.0", - "debug": "^4.3.4" - }, - "bin": { - "wait-port": "bin/wait-port.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/wait-port/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wait-port/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/wait-port/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wait-port/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/wait-port/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/package.json b/package.json index 9a1d0024d1f..730ab98cf80 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ "ulid": "3.0.1", "update-notifier": "7.3.1", "uuid": "11.1.0", - "wait-port": "1.1.0", "write-file-atomic": "5.0.1", "ws": "8.18.3" }, diff --git a/src/lib/http-agent.ts b/src/lib/http-agent.ts index fd1b0f7312f..d9f44df9b65 100644 --- a/src/lib/http-agent.ts +++ b/src/lib/http-agent.ts @@ -1,9 +1,9 @@ import { readFile } from 'fs/promises' import { HttpsProxyAgent } from 'https-proxy-agent' -import waitPort from 'wait-port' import { NETLIFYDEVERR, NETLIFYDEVWARN, exit, log } from '../utils/command-helpers.js' +import { waitPort } from './wait-port.js' // https://github.com/TooTallNate/node-https-proxy-agent/issues/89 // Maybe replace with https://github.com/delvedor/hpagent @@ -29,7 +29,7 @@ class HttpsProxyAgentWithCA extends HttpsProxyAgent { const DEFAULT_HTTP_PORT = 80 const DEFAULT_HTTPS_PORT = 443 // 50 seconds -const AGENT_PORT_TIMEOUT = 50 +const AGENT_PORT_TIMEOUT = 50_000 export const tryGetAgent = async ({ certificateFile, @@ -66,12 +66,11 @@ export const tryGetAgent = async ({ let port try { - port = await waitPort({ - port: Number.parseInt(proxyUrl.port) || (scheme === 'http' ? DEFAULT_HTTP_PORT : DEFAULT_HTTPS_PORT), - host: proxyUrl.hostname, - timeout: AGENT_PORT_TIMEOUT, - output: 'silent', - }) + port = await waitPort( + Number.parseInt(proxyUrl.port) || (scheme === 'http' ? DEFAULT_HTTP_PORT : DEFAULT_HTTPS_PORT), + proxyUrl.hostname, + AGENT_PORT_TIMEOUT, + ) } catch (error) { // unknown error // @ts-expect-error TS(2571) FIXME: Object is of type 'unknown'. diff --git a/src/lib/wait-port.ts b/src/lib/wait-port.ts new file mode 100644 index 00000000000..44741de4a60 --- /dev/null +++ b/src/lib/wait-port.ts @@ -0,0 +1,60 @@ +import net from 'net' + +export const waitPort = async ( + port: number, + host: string, + timeout: number, + maxRetries = 10, +): Promise<{ open: boolean; ipVersion?: 4 | 6 }> => { + const startTime = Date.now() + + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (Date.now() - startTime > timeout) { + return { open: false } + } + + try { + await new Promise((resolve, reject) => { + const socket = new net.Socket() + let isResolved = false + + const cleanup = () => { + if (!isResolved) { + socket.destroy() + } + } + + socket.on('connect', () => { + isResolved = true + socket.end() + resolve() + }) + + socket.on('error', (error) => { + isResolved = true + cleanup() + if ('code' in error && error.code === 'ECONNRESET') { + resolve() + } else { + reject(error) + } + }) + + socket.setTimeout(1000, () => { + isResolved = true + cleanup() + reject(new Error('Socket timeout')) + }) + + socket.connect(port, host) + }) + + return { open: true, ipVersion: net.isIPv6(host) ? 6 : 4 } + } catch { + await new Promise((resolve) => setTimeout(resolve, Math.min(100 * (attempt + 1), 1000))) + continue + } + } + + return { open: false } +} diff --git a/src/utils/framework-server.ts b/src/utils/framework-server.ts index 594096af4f6..bb7f11e83b7 100644 --- a/src/utils/framework-server.ts +++ b/src/utils/framework-server.ts @@ -1,8 +1,7 @@ import { rm } from 'node:fs/promises' -import waitPort from 'wait-port' - import { startSpinner, stopSpinner } from '../lib/spinner.js' +import { waitPort } from '../lib/wait-port.js' import { logAndThrowError, log, NETLIFYDEVERR, NETLIFYDEVLOG, chalk } from './command-helpers.js' import { runCommand } from './shell.js' @@ -56,14 +55,13 @@ export const startFrameworkServer = async function ({ const ipVersion = parseInt(process.versions.node.split('.')[0]) >= 18 ? 6 : 4 port = { open: true, ipVersion } } else { - const waitPortPromise = waitPort({ + const waitPortPromise = waitPort( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - port: settings.frameworkPort!, - host: 'localhost', - output: 'silent', - timeout: FRAMEWORK_PORT_TIMEOUT_MS, - ...(settings.pollingStrategies?.includes('HTTP') && { protocol: 'http' }), - }) + settings.frameworkPort!, + 'localhost', + FRAMEWORK_PORT_TIMEOUT_MS, + 20, + ) const timerId = setTimeout(() => { if (!port?.open) { diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 256d7833cc0..9713fba9a08 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -492,12 +492,32 @@ const initializeProxy = async function ({ projectDir, siteInfo, }: { config: NormalizedCachedConfigConfig } & Record) { + const agent = new http.Agent({ + keepAlive: false, + maxSockets: Infinity, + }) + const originalCreateConnection = agent.createConnection.bind(agent) + agent.createConnection = function (options, callback) { + const socket = originalCreateConnection(options, callback) + + if (socket) { + socket.on('error', (error) => { + if ('code' in error && error.code === 'ECONNRESET') { + socket.destroy() + } + }) + } + + return socket + } + const proxy = httpProxy.createProxyServer({ selfHandleResponse: true, target: { host, port, }, + agent, }) const headersFiles = [...new Set([path.resolve(projectDir, '_headers'), path.resolve(distDir, '_headers')])] diff --git a/src/utils/run-build.ts b/src/utils/run-build.ts index f59be04bff6..525bd79ef1e 100644 --- a/src/utils/run-build.ts +++ b/src/utils/run-build.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'fs' import path, { join } from 'path' import { NetlifyConfig, type GeneratedFunction } from '@netlify/build' +import semver from 'semver' import BaseCommand from '../commands/base-command.js' import { $TSFixMe } from '../commands/types.js' @@ -126,7 +127,8 @@ export async function runNetlifyBuild({ }) settings.frameworkHost = ipVersion === 6 ? '::1' : '127.0.0.1' - settings.detectFrameworkHost = options.skipWaitPort + const nodeVersion = process.versions.node + settings.detectFrameworkHost = options.skipWaitPort || semver.gte(nodeVersion, '24.0.0') } if (timeline === 'build') { diff --git a/tests/integration/commands/functions-serve/functions-serve.test.ts b/tests/integration/commands/functions-serve/functions-serve.test.ts index 07cada07d02..09a8c1ae326 100644 --- a/tests/integration/commands/functions-serve/functions-serve.test.ts +++ b/tests/integration/commands/functions-serve/functions-serve.test.ts @@ -5,8 +5,8 @@ import getPort from 'get-port' import fetch from 'node-fetch' import semver from 'semver' import { describe, test } from 'vitest' -import waitPort from 'wait-port' +import { waitPort } from '../../../../src/lib/wait-port.js' import { cliPath } from '../../utils/cli-path.js' import { withMockApi } from '../../utils/mock-api.js' import { type SiteBuilder, withSiteBuilder } from '../../utils/site-builder.js' @@ -48,12 +48,8 @@ const withFunctionsServer = async ( console.log(data.toString()) }) - const { open } = await waitPort({ - port, - output: 'silent', - timeout: SERVE_TIMEOUT, - }) - if (!open) { + const result = await waitPort(port, 'localhost', SERVE_TIMEOUT) + if (!result.open) { throw new Error('Timed out waiting for functions server') } return await testHandler() From 9ad20f1f7c8ffc140e46eb3ad3ca4adc3ae0bd0d Mon Sep 17 00:00:00 2001 From: elijahjunaid Date: Wed, 12 Nov 2025 16:59:47 -0500 Subject: [PATCH 2/6] fix: revert detectFrameworkHost change to avoid breaking env injection --- src/utils/run-build.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/run-build.ts b/src/utils/run-build.ts index 525bd79ef1e..f59be04bff6 100644 --- a/src/utils/run-build.ts +++ b/src/utils/run-build.ts @@ -2,7 +2,6 @@ import { promises as fs } from 'fs' import path, { join } from 'path' import { NetlifyConfig, type GeneratedFunction } from '@netlify/build' -import semver from 'semver' import BaseCommand from '../commands/base-command.js' import { $TSFixMe } from '../commands/types.js' @@ -127,8 +126,7 @@ export async function runNetlifyBuild({ }) settings.frameworkHost = ipVersion === 6 ? '::1' : '127.0.0.1' - const nodeVersion = process.versions.node - settings.detectFrameworkHost = options.skipWaitPort || semver.gte(nodeVersion, '24.0.0') + settings.detectFrameworkHost = options.skipWaitPort } if (timeline === 'build') { From d861bb5a948cc5c4d49efe286fd855efc0a9c8a2 Mon Sep 17 00:00:00 2001 From: elijahjunaid Date: Thu, 13 Nov 2025 10:37:52 -0500 Subject: [PATCH 3/6] fix: correct ECONNRESET handling in waitPort - reject to trigger retry --- src/lib/wait-port.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/wait-port.ts b/src/lib/wait-port.ts index 44741de4a60..d5932374f5a 100644 --- a/src/lib/wait-port.ts +++ b/src/lib/wait-port.ts @@ -33,11 +33,7 @@ export const waitPort = async ( socket.on('error', (error) => { isResolved = true cleanup() - if ('code' in error && error.code === 'ECONNRESET') { - resolve() - } else { - reject(error) - } + reject(error) }) socket.setTimeout(1000, () => { From e74d888ff59901bc7e41f33c495ee1f5050dd88c Mon Sep 17 00:00:00 2001 From: elijahjunaid Date: Thu, 13 Nov 2025 10:52:58 -0500 Subject: [PATCH 4/6] refactor: remove redundant cleanup function per reviewer feedback --- src/lib/wait-port.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lib/wait-port.ts b/src/lib/wait-port.ts index d5932374f5a..f90d45f016d 100644 --- a/src/lib/wait-port.ts +++ b/src/lib/wait-port.ts @@ -18,12 +18,6 @@ export const waitPort = async ( const socket = new net.Socket() let isResolved = false - const cleanup = () => { - if (!isResolved) { - socket.destroy() - } - } - socket.on('connect', () => { isResolved = true socket.end() @@ -31,15 +25,19 @@ export const waitPort = async ( }) socket.on('error', (error) => { - isResolved = true - cleanup() - reject(error) + if (!isResolved) { + isResolved = true + socket.destroy() + reject(error) + } }) socket.setTimeout(1000, () => { - isResolved = true - cleanup() - reject(new Error('Socket timeout')) + if (!isResolved) { + isResolved = true + socket.destroy() + reject(new Error('Socket timeout')) + } }) socket.connect(port, host) From 30c7f48d6a484dd26553c2fffa9f5935b978a9a1 Mon Sep 17 00:00:00 2001 From: elijahjunaid Date: Thu, 13 Nov 2025 13:33:13 -0500 Subject: [PATCH 5/6] fix: removed proxy.ts handling, setting up for full wait-port.ts handling for econnreset errors --- src/lib/wait-port.ts | 8 +++++--- src/utils/proxy.ts | 20 -------------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/lib/wait-port.ts b/src/lib/wait-port.ts index f90d45f016d..775865e3081 100644 --- a/src/lib/wait-port.ts +++ b/src/lib/wait-port.ts @@ -14,14 +14,16 @@ export const waitPort = async ( } try { - await new Promise((resolve, reject) => { + const ipVersion = await new Promise<4 | 6>((resolve, reject) => { const socket = new net.Socket() let isResolved = false socket.on('connect', () => { isResolved = true + // Detect actual IP version from the connection + const detectedVersion = socket.remoteFamily === 'IPv6' ? 6 : 4 socket.end() - resolve() + resolve(detectedVersion) }) socket.on('error', (error) => { @@ -43,7 +45,7 @@ export const waitPort = async ( socket.connect(port, host) }) - return { open: true, ipVersion: net.isIPv6(host) ? 6 : 4 } + return { open: true, ipVersion } } catch { await new Promise((resolve) => setTimeout(resolve, Math.min(100 * (attempt + 1), 1000))) continue diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 9713fba9a08..256d7833cc0 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -492,32 +492,12 @@ const initializeProxy = async function ({ projectDir, siteInfo, }: { config: NormalizedCachedConfigConfig } & Record) { - const agent = new http.Agent({ - keepAlive: false, - maxSockets: Infinity, - }) - const originalCreateConnection = agent.createConnection.bind(agent) - agent.createConnection = function (options, callback) { - const socket = originalCreateConnection(options, callback) - - if (socket) { - socket.on('error', (error) => { - if ('code' in error && error.code === 'ECONNRESET') { - socket.destroy() - } - }) - } - - return socket - } - const proxy = httpProxy.createProxyServer({ selfHandleResponse: true, target: { host, port, }, - agent, }) const headersFiles = [...new Set([path.resolve(projectDir, '_headers'), path.resolve(distDir, '_headers')])] From fd07eb9d636d9524bfa74c622ce0cb7e239829af Mon Sep 17 00:00:00 2001 From: elijahjunaid Date: Fri, 14 Nov 2025 15:24:14 -0500 Subject: [PATCH 6/6] fix: readded prior retry count to see if it works --- src/lib/wait-port.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/wait-port.ts b/src/lib/wait-port.ts index 775865e3081..c3aa66e6b03 100644 --- a/src/lib/wait-port.ts +++ b/src/lib/wait-port.ts @@ -4,11 +4,12 @@ export const waitPort = async ( port: number, host: string, timeout: number, - maxRetries = 10, + maxRetries?: number, ): Promise<{ open: boolean; ipVersion?: 4 | 6 }> => { const startTime = Date.now() + const retries = maxRetries ?? Math.ceil(timeout / 2000) - for (let attempt = 0; attempt < maxRetries; attempt++) { + for (let attempt = 0; attempt < retries; attempt++) { if (Date.now() - startTime > timeout) { return { open: false } }