Skip to content

Commit 60b9653

Browse files
committed
feat: add SSE helper functions and tests for event streaming
1 parent 730d0d0 commit 60b9653

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

@@ -6952,30 +6954,6 @@ describe.each([
69526954
let server;
69536955
let req;
69546956

6955-
/**
6956-
* @param {import("supertest").Test} pending pending supertest request
6957-
* @returns {Promise<import("supertest").Response>} resolved response
6958-
*/
6959-
async function readSseHandshake(pending) {
6960-
return new Promise((resolve, reject) => {
6961-
pending
6962-
.buffer(false)
6963-
.parse((res, cb) => {
6964-
res.on("data", () => {});
6965-
res.on("end", () => cb(null, ""));
6966-
// SSE never closes on its own; force-close after we have headers.
6967-
setTimeout(() => res.destroy(), 50);
6968-
})
6969-
.end((err, res) => {
6970-
if (err && err.code !== "ECONNRESET") {
6971-
reject(err);
6972-
return;
6973-
}
6974-
resolve(res);
6975-
});
6976-
});
6977-
}
6978-
69796957
afterEach(async () => {
69806958
await close(server, instance);
69816959
});
@@ -7061,5 +7039,101 @@ describe.each([
70617039
expect(res.status).toBe(200);
70627040
expect(res.headers["content-type"]).toMatch(/text\/event-stream/);
70637041
});
7042+
7043+
it("sends a permissive CORS header", async () => {
7044+
const compiler = getCompiler(webpackConfig);
7045+
[server, req, instance] = await frameworkFactory(
7046+
name,
7047+
framework,
7048+
compiler,
7049+
{ hot: true },
7050+
);
7051+
7052+
const res = await readSseHandshake(req.get("/__webpack_hmr"));
7053+
7054+
expect(res.headers["access-control-allow-origin"]).toBe("*");
7055+
});
7056+
7057+
describe("SSE payload", () => {
7058+
it("sends a sync event with the build hash to a client connecting after a build", async () => {
7059+
const compiler = getCompiler(webpackConfig);
7060+
[server, req, instance] = await frameworkFactory(
7061+
name,
7062+
framework,
7063+
compiler,
7064+
{ hot: true },
7065+
);
7066+
7067+
await waitUntilValid(instance);
7068+
7069+
const events = await readSseEvents(server, "/__webpack_hmr");
7070+
const sync = events.find((event) => event.action === "sync");
7071+
7072+
expect(sync).toBeDefined();
7073+
expect(typeof sync.hash).toBe("string");
7074+
expect(sync.errors).toEqual([]);
7075+
});
7076+
7077+
it("sends a sync event per compilation for a MultiCompiler", async () => {
7078+
const compiler = getCompiler(webpackMultiConfig);
7079+
[server, req, instance] = await frameworkFactory(
7080+
name,
7081+
framework,
7082+
compiler,
7083+
{ hot: true },
7084+
);
7085+
7086+
await waitUntilValid(instance);
7087+
7088+
const events = await readSseEvents(server, "/__webpack_hmr");
7089+
const syncs = events.filter((event) => event.action === "sync");
7090+
7091+
expect(syncs).toHaveLength(webpackMultiConfig.length);
7092+
for (const sync of syncs) {
7093+
expect(typeof sync.hash).toBe("string");
7094+
}
7095+
});
7096+
7097+
it("streams building and built events on recompilation", async () => {
7098+
const compiler = getCompiler({ ...webpackConfig, watch: true });
7099+
[server, req, instance] = await frameworkFactory(
7100+
name,
7101+
framework,
7102+
compiler,
7103+
{ hot: true },
7104+
);
7105+
7106+
await waitUntilValid(instance);
7107+
7108+
const pending = readSseEvents(server, "/__webpack_hmr", 2000);
7109+
// Trigger a rebuild once the client is listening.
7110+
setTimeout(() => instance.invalidate(), 150);
7111+
const events = await pending;
7112+
const actions = events.map((event) => event.action);
7113+
7114+
expect(actions).toContain("building");
7115+
expect(actions).toContain("built");
7116+
});
7117+
7118+
it("broadcasts to every connected client", async () => {
7119+
const compiler = getCompiler(webpackConfig);
7120+
[server, req, instance] = await frameworkFactory(
7121+
name,
7122+
framework,
7123+
compiler,
7124+
{ hot: true },
7125+
);
7126+
7127+
await waitUntilValid(instance);
7128+
7129+
const [first, second] = await Promise.all([
7130+
readSseEvents(server, "/__webpack_hmr"),
7131+
readSseEvents(server, "/__webpack_hmr"),
7132+
]);
7133+
7134+
expect(first.some((event) => event.action === "sync")).toBe(true);
7135+
expect(second.some((event) => event.action === "sync")).toBe(true);
7136+
});
7137+
});
70647138
});
70657139
});

0 commit comments

Comments
 (0)