Skip to content
40 changes: 37 additions & 3 deletions lib/Server.js
Original file line number Diff line number Diff line change
Expand Up @@ -1860,16 +1860,50 @@ 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)
Comment thread
bjohansebas marked this conversation as resolved.
Outdated
.toLowerCase()
Comment thread
bjohansebas marked this conversation as resolved.
Outdated
.replace(/\/+$/, "");
} catch {
return null;
}
};

const normalizedHmrPath = hmrPath ? normalizeHmrPath(hmrPath) : null;

for (const webSocketProxy of webSocketProxies) {
const proxyUpgrade =
/** @type {RequestHandler & { upgrade: NonNullable<RequestHandler["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) {
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;
Comment thread
bjohansebas marked this conversation as resolved.
}
}
Expand Down
84 changes: 84 additions & 0 deletions test/server/proxy-option.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Comment thread
bjohansebas marked this conversation as resolved.

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"],
Comment thread
bjohansebas marked this conversation as resolved.
Outdated
["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;
Expand Down
Loading