Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By normalizing this ourselves, how much would it affect performance? It might be worth running some benchmarks, just as a side note, since I don't remember whether decodeURIComponent is expensive performance-wise.

.toLowerCase()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the routes should be case-sensitive, just like Express handles them by default.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment also comes from how ws handles its internal server, and partly from how I remember Express handling routes.

https://github.com/websockets/ws/blob/2120f4c8c625a76316792680a231496e1b615252/lib/websocket-server.js#L224

.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;
}
}
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;
});

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 on lines +913 to +915
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these should go to the proxy if we implement case-sensitive routes.

["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