Skip to content

Commit 567ad04

Browse files
committed
fix(proxy): improve HMR upgrade path handling and user proxy integration
1 parent 77edad8 commit 567ad04

1 file changed

Lines changed: 97 additions & 209 deletions

File tree

test/server/proxy-option.test.js

Lines changed: 97 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -859,134 +859,14 @@ describe("proxy option", () => {
859859
});
860860
});
861861

862-
describe("HMR upgrade path matching is exact when dispatching upgrades", () => {
862+
describe("HMR upgrade dispatching to user proxies", () => {
863863
let server;
864864
let backend;
865865
let backendUpgradeCount;
866866

867-
beforeAll(async () => {
868-
backendUpgradeCount = 0;
869-
870-
backend = http.createServer();
871-
new WebSocketServer({ server: backend }).on("connection", () => {
872-
backendUpgradeCount += 1;
873-
});
874-
875-
await new Promise((resolve) => {
876-
backend.listen(port5, resolve);
877-
});
878-
879-
const compiler = webpack(config);
880-
881-
server = new Server(
882-
{
883-
hot: true,
884-
allowedHosts: "all",
885-
webSocketServer: "ws",
886-
proxy: [
887-
{
888-
context: "/",
889-
target: `http://localhost:${port5}`,
890-
ws: true,
891-
},
892-
],
893-
port: port3,
894-
},
895-
compiler,
896-
);
897-
898-
await server.start();
899-
});
900-
901-
afterAll(async () => {
902-
backend.closeAllConnections();
903-
await server.stop();
904-
await new Promise((resolve) => {
905-
backend.close(resolve);
906-
});
907-
});
908-
909-
// The HMR server's default path is /ws. The dispatch matches the path
910-
// exactly, the same way the underlying `ws` server (`shouldHandle`) does, so
911-
// only the configured path is recognized as the HMR socket. The query
912-
// string is stripped before comparison (again like `ws`), so `/ws?token=1`
913-
// is still recognized as the HMR path.
914-
const hmrVariants = [
915-
["exact path", "/ws"],
916-
["path with query string", "/ws?token=1"],
917-
];
918-
919-
it.each(hmrVariants)(
920-
"treats %s (%s) as the HMR upgrade path (not forwarded to the proxy)",
921-
async (_label, path) => {
922-
const before = backendUpgradeCount;
923-
924-
const ws = new WebSocket(`ws://localhost:${port3}${path}`);
925-
ws.on("error", () => {});
926-
927-
await new Promise((resolve) => {
928-
setTimeout(resolve, 400);
929-
});
930-
931-
try {
932-
ws.close();
933-
} catch {
934-
// ignore close errors on already-failed sockets
935-
}
936-
937-
await new Promise((resolve) => {
938-
setTimeout(resolve, 200);
939-
});
940-
941-
expect(backendUpgradeCount).toBe(before);
942-
},
943-
);
944-
945-
// Leading double-slash, case, trailing slash and percent-encoding are all
946-
// significant: none equals the configured HMR path under the raw comparison
947-
// `ws` uses, so they are forwarded to the user proxy (which is what `ws`
948-
// would do — it refuses to serve them).
949-
const proxiedVariants = [
950-
["leading double slash", "//ws"],
951-
["trailing slash", "/ws/"],
952-
["uppercase", "/WS"],
953-
["mixed case", "/wS"],
954-
["percent-encoded path", "/%77%73"],
955-
];
956-
957-
it.each(proxiedVariants)(
958-
"forwards %s (%s) to the user proxy",
959-
async (_label, path) => {
960-
const before = backendUpgradeCount;
961-
962-
const ws = new WebSocket(`ws://localhost:${port3}${path}`);
963-
ws.on("error", () => {});
964-
965-
await new Promise((resolve) => {
966-
setTimeout(resolve, 400);
967-
});
968-
969-
try {
970-
ws.close();
971-
} catch {
972-
// ignore close errors on already-failed sockets
973-
}
974-
975-
await new Promise((resolve) => {
976-
setTimeout(resolve, 200);
977-
});
978-
979-
expect(backendUpgradeCount).toBe(before + 1);
980-
},
981-
);
982-
});
983-
984-
describe("HMR upgrade path matching honors a custom webSocketServer path", () => {
985-
let server;
986-
let backend;
987-
let backendUpgradeCount;
988-
989-
beforeAll(async () => {
867+
// Start a backend WebSocket server (the user proxy target) and a dev-server
868+
// proxying everything to it, with the given dev-server options merged in.
869+
const setup = async (devServerOptions) => {
990870
backendUpgradeCount = 0;
991871

992872
backend = http.createServer();
@@ -998,13 +878,10 @@ describe("proxy option", () => {
998878
backend.listen(port5, resolve);
999879
});
1000880

1001-
const compiler = webpack(config);
1002-
1003881
server = new Server(
1004882
{
1005883
hot: true,
1006884
allowedHosts: "all",
1007-
webSocketServer: { type: "ws", options: { path: "/custom-hmr" } },
1008885
proxy: [
1009886
{
1010887
context: "/",
@@ -1013,27 +890,33 @@ describe("proxy option", () => {
1013890
},
1014891
],
1015892
port: port3,
893+
...devServerOptions,
1016894
},
1017-
compiler,
895+
webpack(config),
1018896
);
1019897

1020898
await server.start();
1021-
});
899+
};
1022900

1023-
afterAll(async () => {
901+
const teardown = async () => {
1024902
backend.closeAllConnections();
1025903
await server.stop();
1026904
await new Promise((resolve) => {
1027905
backend.close(resolve);
1028906
});
1029-
});
907+
};
1030908

1031-
// The dispatch reads the HMR path from the configured `webSocketServer`
1032-
// options, not a hardcoded `/ws`.
1033-
it("treats the configured path (/custom-hmr) as the HMR upgrade path (not forwarded to the proxy)", async () => {
909+
// Open a WebSocket to `path` and report whether the dev-server completed the
910+
// handshake (`opened`) and whether the upgrade was forwarded to the backend
911+
// proxy (`forwarded`).
912+
const probe = async (path) => {
1034913
const before = backendUpgradeCount;
1035914

1036-
const ws = new WebSocket(`ws://localhost:${port3}/custom-hmr`);
915+
let opened = false;
916+
const ws = new WebSocket(`ws://localhost:${port3}${path}`);
917+
ws.on("open", () => {
918+
opened = true;
919+
});
1037920
ws.on("error", () => {});
1038921

1039922
await new Promise((resolve) => {
@@ -1050,106 +933,111 @@ describe("proxy option", () => {
1050933
setTimeout(resolve, 200);
1051934
});
1052935

1053-
expect(backendUpgradeCount).toBe(before);
1054-
});
936+
return { opened, forwarded: backendUpgradeCount > before };
937+
};
1055938

1056-
// The default `/ws` is no longer special once a custom path is configured,
1057-
// so it is forwarded to the user proxy like any other path.
1058-
it("forwards the default path (/ws) to the user proxy when it is not the configured HMR path", async () => {
1059-
const before = backendUpgradeCount;
939+
// Behavior shared by every WebSocket server implementation: the HMR socket
940+
// is served locally and never forwarded, while any path the HMR server does
941+
// not own falls through to the user proxy. SockJS serves its transport under
942+
// `/<prefix>/<server>/<session>/websocket`, not the bare `/ws`.
943+
const serverTypes = [
944+
{ type: "ws", hmrPath: "/ws", nonHmrPath: "/not-hmr" },
945+
{
946+
type: "sockjs",
947+
hmrPath: "/ws/000/abcd1234/websocket",
948+
nonHmrPath: "/not-hmr",
949+
},
950+
];
1060951

1061-
const ws = new WebSocket(`ws://localhost:${port3}/ws`);
1062-
ws.on("error", () => {});
952+
for (const { type, hmrPath, nonHmrPath } of serverTypes) {
953+
describe(`with webSocketServerType: ${type}`, () => {
954+
beforeAll(() => setup({ webSocketServer: type }));
1063955

1064-
await new Promise((resolve) => {
1065-
setTimeout(resolve, 400);
1066-
});
956+
afterAll(teardown);
1067957

1068-
try {
1069-
ws.close();
1070-
} catch {
1071-
// ignore close errors on already-failed sockets
1072-
}
958+
it("serves the HMR upgrade locally and does not forward it to the proxy", async () => {
959+
const { opened, forwarded } = await probe(hmrPath);
1073960

1074-
await new Promise((resolve) => {
1075-
setTimeout(resolve, 200);
961+
expect(opened).toBe(true);
962+
expect(forwarded).toBe(false);
963+
});
964+
965+
it("forwards a non-HMR upgrade to the user proxy", async () => {
966+
const { forwarded } = await probe(nonHmrPath);
967+
968+
expect(forwarded).toBe(true);
969+
});
1076970
});
971+
}
1077972

1078-
expect(backendUpgradeCount).toBe(before + 1);
1079-
});
1080-
});
973+
// `ws`-specific: the dispatch compares the path exactly the same way
974+
// `WebSocketServer#shouldHandle` does, so only the configured path (query
975+
// stripped) is the HMR socket; every other variant is forwarded.
976+
describe("with the `ws` server, path matching is exact", () => {
977+
beforeAll(() => setup({ webSocketServer: "ws" }));
1081978

1082-
describe("HMR upgrade path matching is skipped when no webSocketServer is configured", () => {
1083-
let server;
1084-
let backend;
1085-
let backendUpgradeCount;
979+
afterAll(teardown);
1086980

1087-
beforeAll(async () => {
1088-
backendUpgradeCount = 0;
981+
it.each([
982+
["exact path", "/ws"],
983+
["path with query string", "/ws?token=1"],
984+
])("treats %s (%s) as the HMR upgrade path", async (_label, path) => {
985+
const { forwarded } = await probe(path);
1089986

1090-
backend = http.createServer();
1091-
new WebSocketServer({ server: backend }).on("connection", () => {
1092-
backendUpgradeCount += 1;
987+
expect(forwarded).toBe(false);
1093988
});
1094989

1095-
await new Promise((resolve) => {
1096-
backend.listen(port5, resolve);
1097-
});
990+
it.each([
991+
["leading double slash", "//ws"],
992+
["trailing slash", "/ws/"],
993+
["uppercase", "/WS"],
994+
["mixed case", "/wS"],
995+
["percent-encoded path", "/%77%73"],
996+
])("forwards %s (%s) to the user proxy", async (_label, path) => {
997+
const { forwarded } = await probe(path);
1098998

1099-
const compiler = webpack(config);
999+
expect(forwarded).toBe(true);
1000+
});
1001+
});
11001002

1101-
server = new Server(
1102-
{
1103-
hot: false,
1104-
liveReload: false,
1105-
allowedHosts: "all",
1106-
webSocketServer: false,
1107-
proxy: [
1108-
{
1109-
context: "/",
1110-
target: `http://localhost:${port5}`,
1111-
ws: true,
1112-
},
1113-
],
1114-
port: port3,
1115-
},
1116-
compiler,
1003+
// The HMR path is read from the configured `webSocketServer` options, not a
1004+
// hardcoded `/ws`.
1005+
describe("with a custom `ws` path", () => {
1006+
beforeAll(() =>
1007+
setup({
1008+
webSocketServer: { type: "ws", options: { path: "/custom-hmr" } },
1009+
}),
11171010
);
11181011

1119-
await server.start();
1120-
});
1012+
afterAll(teardown);
11211013

1122-
afterAll(async () => {
1123-
backend.closeAllConnections();
1124-
await server.stop();
1125-
await new Promise((resolve) => {
1126-
backend.close(resolve);
1014+
it("treats the configured path (/custom-hmr) as the HMR upgrade path", async () => {
1015+
const { forwarded } = await probe("/custom-hmr");
1016+
1017+
expect(forwarded).toBe(false);
1018+
});
1019+
1020+
it("forwards the default path (/ws) once it is no longer the HMR path", async () => {
1021+
const { forwarded } = await probe("/ws");
1022+
1023+
expect(forwarded).toBe(true);
11271024
});
11281025
});
11291026

11301027
// With no HMR server there is no socket to protect, so the filter never
11311028
// engages and even `/ws` is forwarded to the user proxy.
1132-
it("forwards /ws to the user proxy because there is no HMR socket to protect", async () => {
1133-
const before = backendUpgradeCount;
1134-
1135-
const ws = new WebSocket(`ws://localhost:${port3}/ws`);
1136-
ws.on("error", () => {});
1029+
describe("without a webSocketServer", () => {
1030+
beforeAll(() =>
1031+
setup({ hot: false, liveReload: false, webSocketServer: false }),
1032+
);
11371033

1138-
await new Promise((resolve) => {
1139-
setTimeout(resolve, 400);
1140-
});
1034+
afterAll(teardown);
11411035

1142-
try {
1143-
ws.close();
1144-
} catch {
1145-
// ignore close errors on already-failed sockets
1146-
}
1036+
it("forwards /ws to the user proxy because there is no HMR socket to protect", async () => {
1037+
const { forwarded } = await probe("/ws");
11471038

1148-
await new Promise((resolve) => {
1149-
setTimeout(resolve, 200);
1039+
expect(forwarded).toBe(true);
11501040
});
1151-
1152-
expect(backendUpgradeCount).toBe(before + 1);
11531041
});
11541042
});
11551043

0 commit comments

Comments
 (0)