Skip to content

Commit 4752813

Browse files
committed
feat: add SSE helper functions and tests for event streaming
1 parent ac2a61c commit 4752813

3 files changed

Lines changed: 210 additions & 24 deletions

File tree

test/helpers/sse.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import http from "node:http";
2+
3+
/**
4+
* @typedef {object} SseEvent
5+
* @property {string=} action event action (building/built/sync/custom)
6+
* @property {string=} name compilation name
7+
* @property {string=} hash compilation hash
8+
* @property {number=} time build time in ms
9+
* @property {string[]=} errors errors
10+
* @property {string[]=} warnings warnings
11+
* @property {Record<string, string>=} modules module id → name map
12+
*/
13+
14+
/**
15+
* Open the SSE endpoint just long enough to capture the response headers, then
16+
* force-close it (the stream never ends on its own).
17+
* @param {import("supertest").Test} pending pending supertest request
18+
* @returns {Promise<import("supertest").Response>} resolved response
19+
*/
20+
export async function readSseHandshake(pending) {
21+
return new Promise((resolve, reject) => {
22+
pending
23+
.buffer(false)
24+
.parse((res, cb) => {
25+
res.on("data", () => {});
26+
res.on("end", () => cb(null, ""));
27+
// SSE never closes on its own; force-close after we have headers.
28+
setTimeout(() => res.destroy(), 50);
29+
})
30+
.end((err, res) => {
31+
if (err && err.code !== "ECONNRESET") {
32+
reject(err);
33+
return;
34+
}
35+
resolve(res);
36+
});
37+
});
38+
}
39+
40+
/**
41+
* Read the SSE stream directly over HTTP (supertest buffers streaming bodies
42+
* differently per framework), then close it and return the parsed payloads
43+
* (heartbeats and the initial handshake newline are skipped).
44+
* @param {import("node:http").Server & { info?: { port: number }, listener?: import("node:http").Server }} listeningServer the running server
45+
* @param {string} requestPath SSE endpoint path
46+
* @param {number} waitMs how long to collect events before closing
47+
* @returns {Promise<SseEvent[]>} parsed event payloads
48+
*/
49+
export async function readSseEvents(
50+
listeningServer,
51+
requestPath,
52+
waitMs = 250,
53+
) {
54+
const httpServer = listeningServer.listener || listeningServer;
55+
const address =
56+
typeof httpServer.address === "function" ? httpServer.address() : null;
57+
const port =
58+
address && typeof address === "object" && address.port
59+
? address.port
60+
: listeningServer.info && listeningServer.info.port
61+
? listeningServer.info.port
62+
: 3000;
63+
64+
return new Promise((resolve, reject) => {
65+
let raw = "";
66+
const pending = http.get(
67+
{ host: "127.0.0.1", port, path: requestPath },
68+
(res) => {
69+
res.setEncoding("utf8");
70+
res.on("data", (chunk) => {
71+
raw += chunk;
72+
});
73+
},
74+
);
75+
pending.on("error", (err) => {
76+
if (err.code !== "ECONNRESET") reject(err);
77+
});
78+
79+
setTimeout(() => {
80+
pending.destroy();
81+
82+
/** @type {SseEvent[]} */
83+
const events = [];
84+
for (const block of raw.split("\n\n")) {
85+
const line = block.split("\n").find((item) => item.startsWith("data:"));
86+
if (!line) continue;
87+
const payload = line.slice("data:".length).trim();
88+
if (!payload || payload === "💓") continue;
89+
try {
90+
events.push(JSON.parse(payload));
91+
} catch {
92+
// Ignore non-JSON frames.
93+
}
94+
}
95+
resolve(events);
96+
}, waitMs);
97+
});
98+
}
99+
100+
/**
101+
* @param {{ waitUntilValid: (callback: () => void) => void }} devMiddleware middleware instance
102+
* @returns {Promise<void>} resolves once the first compilation is valid
103+
*/
104+
export async function waitUntilValid(devMiddleware) {
105+
return new Promise((resolve) => {
106+
devMiddleware.waitUntilValid(() => resolve());
107+
});
108+
}

test/hot.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ describe("hot middleware (unit)", () => {
105105
expect(pathMatch("/bundle.js", "/__webpack_hmr")).toBe(false);
106106
});
107107

108+
it("returns false when the path has a trailing segment", () => {
109+
expect(pathMatch("/__webpack_hmr/extra", "/__webpack_hmr")).toBe(false);
110+
});
111+
108112
it("returns false when url is undefined", () => {
109113
expect(pathMatch(undefined, "/__webpack_hmr")).toBe(false);
110114
});

test/middleware.test.js

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import getCompiler from "./helpers/getCompiler";
3232

3333
import getCompilerHooks from "./helpers/getCompilerHooks";
3434

35+
import { readSseEvents, readSseHandshake, waitUntilValid } from "./helpers/sse";
36+
3537
// Suppress unnecessary stats output
3638
jest.spyOn(globalThis.console, "log").mockImplementation();
3739

@@ -6975,30 +6977,6 @@ describe.each([
69756977
let server;
69766978
let req;
69776979

6978-
/**
6979-
* @param {import("supertest").Test} pending pending supertest request
6980-
* @returns {Promise<import("supertest").Response>} resolved response
6981-
*/
6982-
async function readSseHandshake(pending) {
6983-
return new Promise((resolve, reject) => {
6984-
pending
6985-
.buffer(false)
6986-
.parse((res, cb) => {
6987-
res.on("data", () => {});
6988-
res.on("end", () => cb(null, ""));
6989-
// SSE never closes on its own; force-close after we have headers.
6990-
setTimeout(() => res.destroy(), 50);
6991-
})
6992-
.end((err, res) => {
6993-
if (err && err.code !== "ECONNRESET") {
6994-
reject(err);
6995-
return;
6996-
}
6997-
resolve(res);
6998-
});
6999-
});
7000-
}
7001-
70026980
afterEach(async () => {
70036981
await close(server, instance);
70046982
});
@@ -7084,5 +7062,101 @@ describe.each([
70847062
expect(res.status).toBe(200);
70857063
expect(res.headers["content-type"]).toMatch(/text\/event-stream/);
70867064
});
7065+
7066+
it("sends a permissive CORS header", async () => {
7067+
const compiler = getCompiler(webpackConfig);
7068+
[server, req, instance] = await frameworkFactory(
7069+
name,
7070+
framework,
7071+
compiler,
7072+
{ hot: true },
7073+
);
7074+
7075+
const res = await readSseHandshake(req.get("/__webpack_hmr"));
7076+
7077+
expect(res.headers["access-control-allow-origin"]).toBe("*");
7078+
});
7079+
7080+
describe("SSE payload", () => {
7081+
it("sends a sync event with the build hash to a client connecting after a build", async () => {
7082+
const compiler = getCompiler(webpackConfig);
7083+
[server, req, instance] = await frameworkFactory(
7084+
name,
7085+
framework,
7086+
compiler,
7087+
{ hot: true },
7088+
);
7089+
7090+
await waitUntilValid(instance);
7091+
7092+
const events = await readSseEvents(server, "/__webpack_hmr");
7093+
const sync = events.find((event) => event.action === "sync");
7094+
7095+
expect(sync).toBeDefined();
7096+
expect(typeof sync.hash).toBe("string");
7097+
expect(sync.errors).toEqual([]);
7098+
});
7099+
7100+
it("sends a sync event per compilation for a MultiCompiler", async () => {
7101+
const compiler = getCompiler(webpackMultiConfig);
7102+
[server, req, instance] = await frameworkFactory(
7103+
name,
7104+
framework,
7105+
compiler,
7106+
{ hot: true },
7107+
);
7108+
7109+
await waitUntilValid(instance);
7110+
7111+
const events = await readSseEvents(server, "/__webpack_hmr");
7112+
const syncs = events.filter((event) => event.action === "sync");
7113+
7114+
expect(syncs).toHaveLength(webpackMultiConfig.length);
7115+
for (const sync of syncs) {
7116+
expect(typeof sync.hash).toBe("string");
7117+
}
7118+
});
7119+
7120+
it("streams building and built events on recompilation", async () => {
7121+
const compiler = getCompiler({ ...webpackConfig, watch: true });
7122+
[server, req, instance] = await frameworkFactory(
7123+
name,
7124+
framework,
7125+
compiler,
7126+
{ hot: true },
7127+
);
7128+
7129+
await waitUntilValid(instance);
7130+
7131+
const pending = readSseEvents(server, "/__webpack_hmr", 2000);
7132+
// Trigger a rebuild once the client is listening.
7133+
setTimeout(() => instance.invalidate(), 150);
7134+
const events = await pending;
7135+
const actions = events.map((event) => event.action);
7136+
7137+
expect(actions).toContain("building");
7138+
expect(actions).toContain("built");
7139+
});
7140+
7141+
it("broadcasts to every connected client", async () => {
7142+
const compiler = getCompiler(webpackConfig);
7143+
[server, req, instance] = await frameworkFactory(
7144+
name,
7145+
framework,
7146+
compiler,
7147+
{ hot: true },
7148+
);
7149+
7150+
await waitUntilValid(instance);
7151+
7152+
const [first, second] = await Promise.all([
7153+
readSseEvents(server, "/__webpack_hmr"),
7154+
readSseEvents(server, "/__webpack_hmr"),
7155+
]);
7156+
7157+
expect(first.some((event) => event.action === "sync")).toBe(true);
7158+
expect(second.some((event) => event.action === "sync")).toBe(true);
7159+
});
7160+
});
70877161
});
70887162
});

0 commit comments

Comments
 (0)