diff --git a/lib/Server.js b/lib/Server.js index 87c4edb347..9ba378d933 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1850,13 +1850,31 @@ class Server { /** @type {RequestHandler[]} */ (this.webSocketProxies); + const hmrPath = + this.options.webSocketServer && + /** @type {WebSocketServerConfiguration} */ + (this.options.webSocketServer).options && + /** @type {NonNullable} */ + ( + /** @type {WebSocketServerConfiguration} */ + (this.options.webSocketServer).options + ).path; + for (const webSocketProxy of webSocketProxies) { - /** @type {S} */ - (this.server).on( - "upgrade", + const proxyUpgrade = /** @type {RequestHandler & { upgrade: NonNullable }} */ - (webSocketProxy).upgrade, - ); + (webSocketProxy).upgrade; + + /** @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) { + return; + } + } + proxyUpgrade(req, socket, head); + }); } } diff --git a/test/e2e/__snapshots__/api.test.js.snap.webpack5 b/test/e2e/__snapshots__/api.test.js.snap.webpack5 index 0d6367ba1f..65995bb1b8 100644 --- a/test/e2e/__snapshots__/api.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/api.test.js.snap.webpack5 @@ -29,7 +29,7 @@ exports[`API Server.checkHostHeader should allow URLs with scheme for checking o "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", "Hey.", - "WebSocket connection to 'ws://test.host:8158/ws' failed: Error in connection establishment: net::ERR_NAME_NOT_RESOLVED", + "WebSocket connection to 'ws://test.host:8159/ws' failed: Error in connection establishment: net::ERR_NAME_NOT_RESOLVED", "[webpack-dev-server] JSHandle@object", "[webpack-dev-server] Disconnected!", "[webpack-dev-server] Trying to reconnect...", @@ -40,7 +40,7 @@ exports[`API Server.checkHostHeader should allow URLs with scheme for checking o exports[`API Server.checkHostHeader should allow URLs with scheme for checking origin when the "option.client.webSocketURL" is object: response status 1`] = `200`; -exports[`API Server.checkHostHeader should allow URLs with scheme for checking origin when the "option.client.webSocketURL" is object: web socket URL 1`] = `"ws://test.host:8158/ws"`; +exports[`API Server.checkHostHeader should allow URLs with scheme for checking origin when the "option.client.webSocketURL" is object: web socket URL 1`] = `"ws://test.host:8159/ws"`; exports[`API Server.getFreePort should retry finding the port for up to defaultPortRetry times (number): console messages 1`] = ` [ diff --git a/test/e2e/__snapshots__/client-reconnect.test.js.snap.webpack5 b/test/e2e/__snapshots__/client-reconnect.test.js.snap.webpack5 index 6beb3163b8..4f92588825 100644 --- a/test/e2e/__snapshots__/client-reconnect.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/client-reconnect.test.js.snap.webpack5 @@ -20,10 +20,10 @@ exports[`client.reconnect option specified as number should try to reconnect 2 t "Hey.", "[webpack-dev-server] Disconnected!", "[webpack-dev-server] Trying to reconnect...", - "WebSocket connection to 'ws://localhost:8163/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED", + "WebSocket connection to 'ws://localhost:8164/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED", "[webpack-dev-server] JSHandle@object", "[webpack-dev-server] Trying to reconnect...", - "WebSocket connection to 'ws://localhost:8163/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED", + "WebSocket connection to 'ws://localhost:8164/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED", "[webpack-dev-server] JSHandle@object", ] `; diff --git a/test/e2e/__snapshots__/port.test.js.snap.webpack5 b/test/e2e/__snapshots__/port.test.js.snap.webpack5 index b88dae9b1a..68ad8c61b3 100644 --- a/test/e2e/__snapshots__/port.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/port.test.js.snap.webpack5 @@ -20,7 +20,7 @@ exports[`port should work using "0" port : console messages 1`] = ` exports[`port should work using "0" port : page errors 1`] = `[]`; -exports[`port should work using "8161" port : console messages 1`] = ` +exports[`port should work using "8162" port : console messages 1`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", @@ -28,7 +28,7 @@ exports[`port should work using "8161" port : console messages 1`] = ` ] `; -exports[`port should work using "8161" port : console messages 2`] = ` +exports[`port should work using "8162" port : console messages 2`] = ` [ "[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading enabled, Progress disabled, Overlay enabled.", "[HMR] Waiting for update signal from WDS...", @@ -36,9 +36,9 @@ exports[`port should work using "8161" port : console messages 2`] = ` ] `; -exports[`port should work using "8161" port : page errors 1`] = `[]`; +exports[`port should work using "8162" port : page errors 1`] = `[]`; -exports[`port should work using "8161" port : page errors 2`] = `[]`; +exports[`port should work using "8162" port : page errors 2`] = `[]`; exports[`port should work using "auto" port : console messages 1`] = ` [ diff --git a/test/ports-map.js b/test/ports-map.js index d95dc62af9..347f3aeead 100644 --- a/test/ports-map.js +++ b/test/ports-map.js @@ -35,7 +35,7 @@ const listOfTests = { "on-listening-option": 1, "open-option": 1, "port-option": 1, - "proxy-option": 4, + "proxy-option": 5, server: 1, "setup-exit-signals-option": 1, "static-directory-option": 1, diff --git a/test/server/proxy-option.test.js b/test/server/proxy-option.test.js index d6c209e055..c82e56d13e 100644 --- a/test/server/proxy-option.test.js +++ b/test/server/proxy-option.test.js @@ -1,5 +1,6 @@ "use strict"; +const http = require("node:http"); const path = require("node:path"); const util = require("node:util"); const express = require("express"); @@ -8,7 +9,8 @@ const webpack = require("webpack"); const WebSocket = require("ws"); const Server = require("../../lib/Server"); const config = require("../fixtures/proxy-config/webpack.config"); -const [port1, port2, port3, port4] = require("../ports-map")["proxy-option"]; +const [port1, port2, port3, port4, port5] = + require("../ports-map")["proxy-option"]; const WebSocketServer = WebSocket.Server; const staticDirectory = path.resolve(__dirname, "../fixtures/proxy-config"); @@ -659,6 +661,204 @@ describe("proxy option", () => { } }); + describe("should not silently proxy dev-server HMR websocket to a permissive backend", () => { + let server; + let backend; + let backendWss; + let backendUpgradeCount; + + const BACKEND_MESSAGE_TYPE = "backend-message"; + + beforeAll(async () => { + backendUpgradeCount = 0; + + backend = http.createServer(); + backendWss = new WebSocketServer({ server: backend }); + backendWss.on("connection", (connection) => { + backendUpgradeCount += 1; + connection.send(JSON.stringify({ type: BACKEND_MESSAGE_TYPE })); + }); + + 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 () => { + for (const client of backendWss.clients) { + client.terminate(); + } + backendWss.close(); + // Force-drop any lingering proxy-opened sockets so backend.close() does + // not hang when the fix is missing and the proxy is mid-upgrade. + backend.closeAllConnections(); + await server.stop(); + await new Promise((resolve) => { + backend.close(resolve); + }); + }); + + it("delivers the HMR control messages and never reaches the proxy target", async () => { + const messages = []; + + const ws = new WebSocket(`ws://localhost:${port3}/ws`); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `Timed out waiting for HMR message. Got: ${JSON.stringify(messages)}`, + ), + ); + }, 3000); + + ws.on("message", (raw) => { + const parsed = JSON.parse(raw.toString()); + messages.push(parsed); + if (parsed.type === "hot") { + clearTimeout(timer); + resolve(); + } + }); + + ws.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + ws.close(); + + // Let the proxy finish its async forwarding so the assertion below sees + // the upgrade attempt deterministically. + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + + expect(messages.some((m) => m.type === "hot")).toBe(true); + expect(messages.some((m) => m.type === BACKEND_MESSAGE_TYPE)).toBe(false); + expect(backendUpgradeCount).toBe(0); + }); + }); + + describe("should not log proxy errors for the dev-server HMR upgrade", () => { + let server; + let backend; + let stderrSpy; + + beforeAll(async () => { + stderrSpy = jest + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + backend = http.createServer(); + backend.on("upgrade", (req, socket) => { + socket.destroy(); + }); + 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 () => { + stderrSpy.mockRestore(); + backend.closeAllConnections(); + await server.stop(); + await new Promise((resolve) => { + backend.close(resolve); + }); + }); + + it("does not surface any [HPM] error when the HMR client connects", async () => { + const messages = []; + + const ws = new WebSocket(`ws://localhost:${port3}/ws`); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `Timed out waiting for HMR message. Got: ${JSON.stringify(messages)}`, + ), + ); + }, 3000); + + ws.on("message", (raw) => { + const parsed = JSON.parse(raw.toString()); + messages.push(parsed); + if (parsed.type === "hot") { + clearTimeout(timer); + resolve(); + } + }); + + ws.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); + + ws.close(); + + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + + const hpmLines = stderrSpy.mock.calls + .map((c) => c[0]) + .join("") + .split("\n") + .filter((line) => line.includes("[HPM]")) + .map((line) => line.replaceAll(/localhost:\d+/g, "localhost:")) + .join("\n"); + + expect(hpmLines).toBe(""); + expect(messages.some((m) => m.type === "hot")).toBe(true); + }); + }); + describe("should supports http methods", () => { let server; let req;