From eb9f4b02bbcbfda290988b67f2177188655832e6 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Tue, 26 May 2026 15:59:46 +0200 Subject: [PATCH 1/7] test(proxy-option): extend HMR upgrade coverage to URL variants of the configured path Adds trailing slash, case variations, percent-encoded path, and leading double slash to the existing HMR/proxy isolation tests so that all URL shapes recognized as the HMR upgrade are treated consistently. --- test/server/proxy-option.test.js | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index c82e56d13e..4c0f9daf35 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -859,6 +859,90 @@ describe("proxy option", () => { }); }); + describe("HMR upgrade path matching tolerates URL variants of the configured path", () => { + let server; + let backend; + let backendUpgradeCount; + + beforeAll(async () => { + backendUpgradeCount = 0; + + backend = http.createServer(); + new WebSocketServer({ server: backend }).on("connection", () => { + backendUpgradeCount += 1; + }); + + await new Promise((resolve) => { + backend.listen(port5, resolve); + }); + + const compiler = webpack(config); + + server = new Server( + { + hot: true, + allowedHosts: "all", + webSocketServer: "ws", + proxy: [ + { + context: "/", + target: `http://localhost:${port5}`, + ws: true, + }, + ], + port: port3, + }, + compiler, + ); + + await server.start(); + }); + + afterAll(async () => { + backend.closeAllConnections(); + await server.stop(); + await new Promise((resolve) => { + backend.close(resolve); + }); + }); + + // The HMR server's default path is /ws. These variants should all be + // recognized as the HMR socket and not forwarded through the user proxy. + const variants = [ + ["trailing slash", "/ws/"], + ["uppercase", "/WS"], + ["mixed case", "/wS"], + ["percent-encoded path", "/%77%73"], + ["leading double slash", "//ws"], + ]; + + it.each(variants)( + "treats %s (%s) as the HMR upgrade path", + async (_label, path) => { + const before = backendUpgradeCount; + + const ws = new WebSocket(`ws://localhost:${port3}${path}`); + ws.on("error", () => {}); + + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + try { + ws.close(); + } catch { + // ignore close errors on already-failed sockets + } + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + expect(backendUpgradeCount).toBe(before); + }, + ); + }); + describe("should supports http methods", () => { let server; let req; From 50c2572746fbea35e5c747c92bc552762fdc5ce7 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Tue, 26 May 2026 16:00:07 +0200 Subject: [PATCH 2/7] fix(proxy): normalize HMR upgrade path comparison Tighten the HMR-vs-proxy upgrade dispatch so URL variants of the configured HMR path are consistently recognized as the HMR socket: - lowercase comparison (case-insensitive) - decode percent-encoding before comparison - strip trailing slashes - collapse leading multiple slashes so //foo is treated as /foo instead of being parsed as a scheme-relative URL - wrap URL parsing in try/catch so a malformed req.url does not raise an uncaught exception --- lib/Server.js | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 19631099ab..47c3c58877 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1860,6 +1860,25 @@ class Server { (this.options.webSocketServer).options ).path; + // Normalize a URL path for HMR-path comparison: lowercase, decode + // percent-encoding (so `/%77%73` matches `/ws`), and strip trailing + // slashes. Returns null when decoding fails on malformed percent escapes. + /** + * @param {string} pathToNormalize URL path to normalize for HMR comparison. + * @returns {string | null} Normalized path, or null on decode failure. + */ + const normalizeHmrPath = (pathToNormalize) => { + try { + return decodeURIComponent(pathToNormalize) + .toLowerCase() + .replace(/\/+$/, ""); + } catch { + return null; + } + }; + + const normalizedHmrPath = hmrPath ? normalizeHmrPath(hmrPath) : null; + for (const webSocketProxy of webSocketProxies) { const proxyUpgrade = /** @type {RequestHandler & { upgrade: NonNullable }} */ @@ -1867,9 +1886,24 @@ class Server { /** @type {S} */ (this.server).on("upgrade", (req, socket, head) => { - if (hmrPath && req.url) { - const { pathname } = new URL(req.url, "http://0.0.0.0"); - if (pathname === hmrPath) { + if ( + normalizedHmrPath && + typeof req.url === "string" && + req.url.startsWith("/") + ) { + // Collapse leading multiple-slashes so `//ws` is not parsed as a + // scheme-relative URL (which would yield `pathname === "/"` and + // bypass the HMR-path filter). + const cleanUrl = req.url.replace(/^\/+/, "/"); + let pathname; + try { + ({ pathname } = new URL(cleanUrl, "http://0.0.0.0")); + } catch { + // Malformed URL: fail closed (do not forward to proxy). + return; + } + const normalized = normalizeHmrPath(pathname); + if (normalized !== null && normalized === normalizedHmrPath) { return; } } From 77edad80297aeea1c35feb4b1facb6d8892c17f7 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 10 Jun 2026 12:18:20 -0500 Subject: [PATCH 3/7] fix(proxy): enforce exact HMR upgrade path matching for websocket connections --- lib/Server.js | 48 ++----- test/server/proxy-option.test.js | 226 +++++++++++++++++++++++++++++-- 2 files changed, 229 insertions(+), 45 deletions(-) diff --git a/lib/Server.js b/lib/Server.js index 47c3c58877..28f1687fab 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1860,25 +1860,6 @@ class Server { (this.options.webSocketServer).options ).path; - // Normalize a URL path for HMR-path comparison: lowercase, decode - // percent-encoding (so `/%77%73` matches `/ws`), and strip trailing - // slashes. Returns null when decoding fails on malformed percent escapes. - /** - * @param {string} pathToNormalize URL path to normalize for HMR comparison. - * @returns {string | null} Normalized path, or null on decode failure. - */ - const normalizeHmrPath = (pathToNormalize) => { - try { - return decodeURIComponent(pathToNormalize) - .toLowerCase() - .replace(/\/+$/, ""); - } catch { - return null; - } - }; - - const normalizedHmrPath = hmrPath ? normalizeHmrPath(hmrPath) : null; - for (const webSocketProxy of webSocketProxies) { const proxyUpgrade = /** @type {RequestHandler & { upgrade: NonNullable }} */ @@ -1886,24 +1867,17 @@ class Server { /** @type {S} */ (this.server).on("upgrade", (req, socket, head) => { - if ( - normalizedHmrPath && - typeof req.url === "string" && - req.url.startsWith("/") - ) { - // Collapse leading multiple-slashes so `//ws` is not parsed as a - // scheme-relative URL (which would yield `pathname === "/"` and - // bypass the HMR-path filter). - const cleanUrl = req.url.replace(/^\/+/, "/"); - let pathname; - try { - ({ pathname } = new URL(cleanUrl, "http://0.0.0.0")); - } catch { - // Malformed URL: fail closed (do not forward to proxy). - return; - } - const normalized = normalizeHmrPath(pathname); - if (normalized !== null && normalized === normalizedHmrPath) { + if (hmrPath && typeof req.url === "string") { + // Match the configured HMR path exactly the same way the underlying + // WebSocket server (`ws`) does in `WebSocketServer#shouldHandle`: a + // raw, case-sensitive comparison of the request target with the query + // string stripped. Any normalization here would classify URL variants + // (`//ws`, `/WS`, …) as the HMR socket even though `ws` refuses them. + // https://github.com/websockets/ws/blob/8.18.3/lib/websocket-server.js#L214 + const queryIndex = req.url.indexOf("?"); + const pathname = + queryIndex !== -1 ? req.url.slice(0, queryIndex) : req.url; + if (pathname === hmrPath) { return; } } diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index 4c0f9daf35..4f7f9afe83 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -859,7 +859,7 @@ describe("proxy option", () => { }); }); - describe("HMR upgrade path matching tolerates URL variants of the configured path", () => { + describe("HMR upgrade path matching is exact when dispatching upgrades", () => { let server; let backend; let backendUpgradeCount; @@ -906,18 +906,56 @@ describe("proxy option", () => { }); }); - // The HMR server's default path is /ws. These variants should all be - // recognized as the HMR socket and not forwarded through the user proxy. - const variants = [ + // The HMR server's default path is /ws. The dispatch matches the path + // exactly, the same way the underlying `ws` server (`shouldHandle`) does, so + // only the configured path is recognized as the HMR socket. The query + // string is stripped before comparison (again like `ws`), so `/ws?token=1` + // is still recognized as the HMR path. + const hmrVariants = [ + ["exact path", "/ws"], + ["path with query string", "/ws?token=1"], + ]; + + it.each(hmrVariants)( + "treats %s (%s) as the HMR upgrade path (not forwarded to the proxy)", + async (_label, path) => { + const before = backendUpgradeCount; + + const ws = new WebSocket(`ws://localhost:${port3}${path}`); + ws.on("error", () => {}); + + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + try { + ws.close(); + } catch { + // ignore close errors on already-failed sockets + } + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + expect(backendUpgradeCount).toBe(before); + }, + ); + + // Leading double-slash, case, trailing slash and percent-encoding are all + // significant: none equals the configured HMR path under the raw comparison + // `ws` uses, so they are forwarded to the user proxy (which is what `ws` + // would do — it refuses to serve them). + const proxiedVariants = [ + ["leading double slash", "//ws"], ["trailing slash", "/ws/"], ["uppercase", "/WS"], ["mixed case", "/wS"], ["percent-encoded path", "/%77%73"], - ["leading double slash", "//ws"], ]; - it.each(variants)( - "treats %s (%s) as the HMR upgrade path", + it.each(proxiedVariants)( + "forwards %s (%s) to the user proxy", async (_label, path) => { const before = backendUpgradeCount; @@ -938,11 +976,183 @@ describe("proxy option", () => { setTimeout(resolve, 200); }); - expect(backendUpgradeCount).toBe(before); + expect(backendUpgradeCount).toBe(before + 1); }, ); }); + describe("HMR upgrade path matching honors a custom webSocketServer path", () => { + let server; + let backend; + let backendUpgradeCount; + + beforeAll(async () => { + backendUpgradeCount = 0; + + backend = http.createServer(); + new WebSocketServer({ server: backend }).on("connection", () => { + backendUpgradeCount += 1; + }); + + await new Promise((resolve) => { + backend.listen(port5, resolve); + }); + + const compiler = webpack(config); + + server = new Server( + { + hot: true, + allowedHosts: "all", + webSocketServer: { type: "ws", options: { path: "/custom-hmr" } }, + proxy: [ + { + context: "/", + target: `http://localhost:${port5}`, + ws: true, + }, + ], + port: port3, + }, + compiler, + ); + + await server.start(); + }); + + afterAll(async () => { + backend.closeAllConnections(); + await server.stop(); + await new Promise((resolve) => { + backend.close(resolve); + }); + }); + + // The dispatch reads the HMR path from the configured `webSocketServer` + // options, not a hardcoded `/ws`. + it("treats the configured path (/custom-hmr) as the HMR upgrade path (not forwarded to the proxy)", async () => { + const before = backendUpgradeCount; + + const ws = new WebSocket(`ws://localhost:${port3}/custom-hmr`); + ws.on("error", () => {}); + + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + try { + ws.close(); + } catch { + // ignore close errors on already-failed sockets + } + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + expect(backendUpgradeCount).toBe(before); + }); + + // The default `/ws` is no longer special once a custom path is configured, + // so it is forwarded to the user proxy like any other path. + it("forwards the default path (/ws) to the user proxy when it is not the configured HMR path", async () => { + const before = backendUpgradeCount; + + const ws = new WebSocket(`ws://localhost:${port3}/ws`); + ws.on("error", () => {}); + + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + try { + ws.close(); + } catch { + // ignore close errors on already-failed sockets + } + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + expect(backendUpgradeCount).toBe(before + 1); + }); + }); + + describe("HMR upgrade path matching is skipped when no webSocketServer is configured", () => { + let server; + let backend; + let backendUpgradeCount; + + beforeAll(async () => { + backendUpgradeCount = 0; + + backend = http.createServer(); + new WebSocketServer({ server: backend }).on("connection", () => { + backendUpgradeCount += 1; + }); + + await new Promise((resolve) => { + backend.listen(port5, resolve); + }); + + const compiler = webpack(config); + + server = new Server( + { + hot: false, + liveReload: false, + allowedHosts: "all", + webSocketServer: false, + proxy: [ + { + context: "/", + target: `http://localhost:${port5}`, + ws: true, + }, + ], + port: port3, + }, + compiler, + ); + + await server.start(); + }); + + afterAll(async () => { + backend.closeAllConnections(); + await server.stop(); + await new Promise((resolve) => { + backend.close(resolve); + }); + }); + + // With no HMR server there is no socket to protect, so the filter never + // engages and even `/ws` is forwarded to the user proxy. + it("forwards /ws to the user proxy because there is no HMR socket to protect", async () => { + const before = backendUpgradeCount; + + const ws = new WebSocket(`ws://localhost:${port3}/ws`); + ws.on("error", () => {}); + + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + try { + ws.close(); + } catch { + // ignore close errors on already-failed sockets + } + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + expect(backendUpgradeCount).toBe(before + 1); + }); + }); + describe("should supports http methods", () => { let server; let req; From 567ad044ad827fcab37ffcd3f6283938a4d979b4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 10 Jun 2026 12:34:26 -0500 Subject: [PATCH 4/7] fix(proxy): improve HMR upgrade path handling and user proxy integration --- test/server/proxy-option.test.js | 306 ++++++++++--------------------- 1 file changed, 97 insertions(+), 209 deletions(-) diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index 4f7f9afe83..c83b6cfd9b 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -859,134 +859,14 @@ describe("proxy option", () => { }); }); - describe("HMR upgrade path matching is exact when dispatching upgrades", () => { + describe("HMR upgrade dispatching to user proxies", () => { let server; let backend; let backendUpgradeCount; - beforeAll(async () => { - backendUpgradeCount = 0; - - backend = http.createServer(); - new WebSocketServer({ server: backend }).on("connection", () => { - backendUpgradeCount += 1; - }); - - await new Promise((resolve) => { - backend.listen(port5, resolve); - }); - - const compiler = webpack(config); - - server = new Server( - { - hot: true, - allowedHosts: "all", - webSocketServer: "ws", - proxy: [ - { - context: "/", - target: `http://localhost:${port5}`, - ws: true, - }, - ], - port: port3, - }, - compiler, - ); - - await server.start(); - }); - - afterAll(async () => { - backend.closeAllConnections(); - await server.stop(); - await new Promise((resolve) => { - backend.close(resolve); - }); - }); - - // The HMR server's default path is /ws. The dispatch matches the path - // exactly, the same way the underlying `ws` server (`shouldHandle`) does, so - // only the configured path is recognized as the HMR socket. The query - // string is stripped before comparison (again like `ws`), so `/ws?token=1` - // is still recognized as the HMR path. - const hmrVariants = [ - ["exact path", "/ws"], - ["path with query string", "/ws?token=1"], - ]; - - it.each(hmrVariants)( - "treats %s (%s) as the HMR upgrade path (not forwarded to the proxy)", - async (_label, path) => { - const before = backendUpgradeCount; - - const ws = new WebSocket(`ws://localhost:${port3}${path}`); - ws.on("error", () => {}); - - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); - - try { - ws.close(); - } catch { - // ignore close errors on already-failed sockets - } - - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - expect(backendUpgradeCount).toBe(before); - }, - ); - - // Leading double-slash, case, trailing slash and percent-encoding are all - // significant: none equals the configured HMR path under the raw comparison - // `ws` uses, so they are forwarded to the user proxy (which is what `ws` - // would do — it refuses to serve them). - const proxiedVariants = [ - ["leading double slash", "//ws"], - ["trailing slash", "/ws/"], - ["uppercase", "/WS"], - ["mixed case", "/wS"], - ["percent-encoded path", "/%77%73"], - ]; - - it.each(proxiedVariants)( - "forwards %s (%s) to the user proxy", - async (_label, path) => { - const before = backendUpgradeCount; - - const ws = new WebSocket(`ws://localhost:${port3}${path}`); - ws.on("error", () => {}); - - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); - - try { - ws.close(); - } catch { - // ignore close errors on already-failed sockets - } - - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - expect(backendUpgradeCount).toBe(before + 1); - }, - ); - }); - - describe("HMR upgrade path matching honors a custom webSocketServer path", () => { - let server; - let backend; - let backendUpgradeCount; - - beforeAll(async () => { + // Start a backend WebSocket server (the user proxy target) and a dev-server + // proxying everything to it, with the given dev-server options merged in. + const setup = async (devServerOptions) => { backendUpgradeCount = 0; backend = http.createServer(); @@ -998,13 +878,10 @@ describe("proxy option", () => { backend.listen(port5, resolve); }); - const compiler = webpack(config); - server = new Server( { hot: true, allowedHosts: "all", - webSocketServer: { type: "ws", options: { path: "/custom-hmr" } }, proxy: [ { context: "/", @@ -1013,27 +890,33 @@ describe("proxy option", () => { }, ], port: port3, + ...devServerOptions, }, - compiler, + webpack(config), ); await server.start(); - }); + }; - afterAll(async () => { + const teardown = async () => { backend.closeAllConnections(); await server.stop(); await new Promise((resolve) => { backend.close(resolve); }); - }); + }; - // The dispatch reads the HMR path from the configured `webSocketServer` - // options, not a hardcoded `/ws`. - it("treats the configured path (/custom-hmr) as the HMR upgrade path (not forwarded to the proxy)", async () => { + // Open a WebSocket to `path` and report whether the dev-server completed the + // handshake (`opened`) and whether the upgrade was forwarded to the backend + // proxy (`forwarded`). + const probe = async (path) => { const before = backendUpgradeCount; - const ws = new WebSocket(`ws://localhost:${port3}/custom-hmr`); + let opened = false; + const ws = new WebSocket(`ws://localhost:${port3}${path}`); + ws.on("open", () => { + opened = true; + }); ws.on("error", () => {}); await new Promise((resolve) => { @@ -1050,106 +933,111 @@ describe("proxy option", () => { setTimeout(resolve, 200); }); - expect(backendUpgradeCount).toBe(before); - }); + return { opened, forwarded: backendUpgradeCount > before }; + }; - // The default `/ws` is no longer special once a custom path is configured, - // so it is forwarded to the user proxy like any other path. - it("forwards the default path (/ws) to the user proxy when it is not the configured HMR path", async () => { - const before = backendUpgradeCount; + // Behavior shared by every WebSocket server implementation: the HMR socket + // is served locally and never forwarded, while any path the HMR server does + // not own falls through to the user proxy. SockJS serves its transport under + // `////websocket`, not the bare `/ws`. + const serverTypes = [ + { type: "ws", hmrPath: "/ws", nonHmrPath: "/not-hmr" }, + { + type: "sockjs", + hmrPath: "/ws/000/abcd1234/websocket", + nonHmrPath: "/not-hmr", + }, + ]; - const ws = new WebSocket(`ws://localhost:${port3}/ws`); - ws.on("error", () => {}); + for (const { type, hmrPath, nonHmrPath } of serverTypes) { + describe(`with webSocketServerType: ${type}`, () => { + beforeAll(() => setup({ webSocketServer: type })); - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); + afterAll(teardown); - try { - ws.close(); - } catch { - // ignore close errors on already-failed sockets - } + it("serves the HMR upgrade locally and does not forward it to the proxy", async () => { + const { opened, forwarded } = await probe(hmrPath); - await new Promise((resolve) => { - setTimeout(resolve, 200); + expect(opened).toBe(true); + expect(forwarded).toBe(false); + }); + + it("forwards a non-HMR upgrade to the user proxy", async () => { + const { forwarded } = await probe(nonHmrPath); + + expect(forwarded).toBe(true); + }); }); + } - expect(backendUpgradeCount).toBe(before + 1); - }); - }); + // `ws`-specific: the dispatch compares the path exactly the same way + // `WebSocketServer#shouldHandle` does, so only the configured path (query + // stripped) is the HMR socket; every other variant is forwarded. + describe("with the `ws` server, path matching is exact", () => { + beforeAll(() => setup({ webSocketServer: "ws" })); - describe("HMR upgrade path matching is skipped when no webSocketServer is configured", () => { - let server; - let backend; - let backendUpgradeCount; + afterAll(teardown); - beforeAll(async () => { - backendUpgradeCount = 0; + it.each([ + ["exact path", "/ws"], + ["path with query string", "/ws?token=1"], + ])("treats %s (%s) as the HMR upgrade path", async (_label, path) => { + const { forwarded } = await probe(path); - backend = http.createServer(); - new WebSocketServer({ server: backend }).on("connection", () => { - backendUpgradeCount += 1; + expect(forwarded).toBe(false); }); - await new Promise((resolve) => { - backend.listen(port5, resolve); - }); + it.each([ + ["leading double slash", "//ws"], + ["trailing slash", "/ws/"], + ["uppercase", "/WS"], + ["mixed case", "/wS"], + ["percent-encoded path", "/%77%73"], + ])("forwards %s (%s) to the user proxy", async (_label, path) => { + const { forwarded } = await probe(path); - const compiler = webpack(config); + expect(forwarded).toBe(true); + }); + }); - server = new Server( - { - hot: false, - liveReload: false, - allowedHosts: "all", - webSocketServer: false, - proxy: [ - { - context: "/", - target: `http://localhost:${port5}`, - ws: true, - }, - ], - port: port3, - }, - compiler, + // The HMR path is read from the configured `webSocketServer` options, not a + // hardcoded `/ws`. + describe("with a custom `ws` path", () => { + beforeAll(() => + setup({ + webSocketServer: { type: "ws", options: { path: "/custom-hmr" } }, + }), ); - await server.start(); - }); + afterAll(teardown); - afterAll(async () => { - backend.closeAllConnections(); - await server.stop(); - await new Promise((resolve) => { - backend.close(resolve); + it("treats the configured path (/custom-hmr) as the HMR upgrade path", async () => { + const { forwarded } = await probe("/custom-hmr"); + + expect(forwarded).toBe(false); + }); + + it("forwards the default path (/ws) once it is no longer the HMR path", async () => { + const { forwarded } = await probe("/ws"); + + expect(forwarded).toBe(true); }); }); // With no HMR server there is no socket to protect, so the filter never // engages and even `/ws` is forwarded to the user proxy. - it("forwards /ws to the user proxy because there is no HMR socket to protect", async () => { - const before = backendUpgradeCount; - - const ws = new WebSocket(`ws://localhost:${port3}/ws`); - ws.on("error", () => {}); + describe("without a webSocketServer", () => { + beforeAll(() => + setup({ hot: false, liveReload: false, webSocketServer: false }), + ); - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); + afterAll(teardown); - try { - ws.close(); - } catch { - // ignore close errors on already-failed sockets - } + it("forwards /ws to the user proxy because there is no HMR socket to protect", async () => { + const { forwarded } = await probe("/ws"); - await new Promise((resolve) => { - setTimeout(resolve, 200); + expect(forwarded).toBe(true); }); - - expect(backendUpgradeCount).toBe(before + 1); }); }); From 5375de38185d07c4fe2936530ea9d662c47435f3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 12 Jun 2026 10:23:59 -0500 Subject: [PATCH 5/7] ci: pin node 24 --- .github/workflows/nodejs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 74945cdeb6..0591508471 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -69,7 +69,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [18.x, 20.x, 22.x, 24.15] shard: ["1/4", "2/4", "3/4", "4/4"] webpack-version: [latest] From f61096ca87e5f9ca2c68818d3790c4d4c7446a15 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 12 Jun 2026 10:32:16 -0500 Subject: [PATCH 6/7] fix(test): update Node.js version matrix and adjust test naming for clarity --- .github/workflows/nodejs.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 0591508471..bc18a54293 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -64,11 +64,12 @@ jobs: run: npx commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose test: - name: Test - ${{ matrix.os }} - Node v${{ matrix.node-version }}, Webpack ${{ matrix.webpack-version }} (${{ matrix.shard }}) + name: Test - ${{ matrix.os }} - Node v${{ matrix.node-version == '24.15' && '24.x' || matrix.node-version }}, Webpack ${{ matrix.webpack-version }} (${{ matrix.shard }}) strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] + # 24.15 is pinned on purpose: Node 24.16 is broken for Puppeteer. Do not bump to 24.x. node-version: [18.x, 20.x, 22.x, 24.15] shard: ["1/4", "2/4", "3/4", "4/4"] webpack-version: [latest] From 0a1eddb08f59b8e1b64cdc22fc20211e72887f77 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 12 Jun 2026 10:53:39 -0500 Subject: [PATCH 7/7] fix(proxy): enhance HMR upgrade handling by improving WebSocket server management --- test/server/proxy-option.test.js | 34 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index c83b6cfd9b..1dc9964fe8 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -862,6 +862,7 @@ describe("proxy option", () => { describe("HMR upgrade dispatching to user proxies", () => { let server; let backend; + let backendWss; let backendUpgradeCount; // Start a backend WebSocket server (the user proxy target) and a dev-server @@ -870,7 +871,8 @@ describe("proxy option", () => { backendUpgradeCount = 0; backend = http.createServer(); - new WebSocketServer({ server: backend }).on("connection", () => { + backendWss = new WebSocketServer({ server: backend }); + backendWss.on("connection", () => { backendUpgradeCount += 1; }); @@ -899,6 +901,10 @@ describe("proxy option", () => { }; const teardown = async () => { + for (const client of backendWss.clients) { + client.terminate(); + } + backendWss.close(); backend.closeAllConnections(); await server.stop(); await new Promise((resolve) => { @@ -912,15 +918,23 @@ describe("proxy option", () => { const probe = async (path) => { const before = backendUpgradeCount; - let opened = false; const ws = new WebSocket(`ws://localhost:${port3}${path}`); - ws.on("open", () => { - opened = true; - }); - ws.on("error", () => {}); - await new Promise((resolve) => { - setTimeout(resolve, 400); + // Resolve as soon as the socket reaches a terminal state instead of + // waiting a fixed delay: `open` means the handshake completed, `error` + // means it was rejected. The timeout is only a fallback in case neither + // event ever fires, so it can be generous without slowing the happy path. + const opened = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), 2000); + + ws.once("open", () => { + clearTimeout(timer); + resolve(true); + }); + ws.once("error", () => { + clearTimeout(timer); + resolve(false); + }); }); try { @@ -929,10 +943,6 @@ describe("proxy option", () => { // ignore close errors on already-failed sockets } - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - return { opened, forwarded: backendUpgradeCount > before }; };