html-bundle 6.3.1 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +5 -5
- package/README.md +34 -3
- package/dist/bundle.mjs +69 -18
- package/dist/hmr-client.d.ts +40 -0
- package/dist/hmr-client.js +446 -0
- package/dist/utils.d.mts +14 -2
- package/dist/utils.mjs +45 -178
- package/package.json +3 -1
- package/src/bundle.mts +67 -18
- package/src/hmr-client.ts +577 -0
- package/src/utils.mts +61 -186
- package/tests/bundle.test.mjs +62 -10
- package/tests/hmr-client.test.mjs +245 -0
- package/tests/hmr-server.test.mjs +175 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Server-side HMR tests: spawn the CLI in --hmr mode against a throwaway
|
|
2
|
+
// project, subscribe to the /hmr event stream, edit files, and assert the
|
|
3
|
+
// normalised events the client depends on.
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
|
|
13
|
+
const bundlePath = path.join(repoRoot, "dist", "bundle.mjs");
|
|
14
|
+
const PORT = 5323;
|
|
15
|
+
|
|
16
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
function waitForListening(child, ms = 15000) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const timer = setTimeout(
|
|
21
|
+
() => reject(new Error("server did not start in time")),
|
|
22
|
+
ms,
|
|
23
|
+
);
|
|
24
|
+
let out = "";
|
|
25
|
+
child.stdout.on("data", (chunk) => {
|
|
26
|
+
out += chunk;
|
|
27
|
+
if (out.includes("Server listening")) {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
resolve();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
child.on("exit", (code) => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
reject(new Error(`server exited early (code ${code})`));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function subscribe(events) {
|
|
40
|
+
// `listen()` logs "Server listening" before the socket is actually bound, so
|
|
41
|
+
// the first connect can be refused — retry until the stream is open.
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let attempts = 0;
|
|
44
|
+
const tryConnect = () => {
|
|
45
|
+
const req = http.get(`http://127.0.0.1:${PORT}/hmr`, (res) => {
|
|
46
|
+
res.setEncoding("utf8");
|
|
47
|
+
let buffer = "";
|
|
48
|
+
res.on("data", (chunk) => {
|
|
49
|
+
buffer += chunk;
|
|
50
|
+
let index;
|
|
51
|
+
while ((index = buffer.indexOf("\n\n")) !== -1) {
|
|
52
|
+
const line = buffer.slice(0, index).trim();
|
|
53
|
+
buffer = buffer.slice(index + 2);
|
|
54
|
+
if (line.startsWith("data:")) {
|
|
55
|
+
try {
|
|
56
|
+
events.push(JSON.parse(line.slice(5).trim()));
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore keep-alive noise */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
resolve(req);
|
|
64
|
+
});
|
|
65
|
+
req.on("error", (err) => {
|
|
66
|
+
if (++attempts < 40) {
|
|
67
|
+
setTimeout(tryConnect, 100);
|
|
68
|
+
} else {
|
|
69
|
+
reject(err);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
tryConnect();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function until(predicate, ms = 6000) {
|
|
78
|
+
const deadline = Date.now() + ms;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
const hit = predicate();
|
|
81
|
+
if (hit) return hit;
|
|
82
|
+
await wait(50);
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("HMR server emits typed events and funnels module edits to owning pages", async (t) => {
|
|
88
|
+
const cwd = await mkdtemp(path.join(tmpdir(), "html-bundle-hmr-"));
|
|
89
|
+
t.after(() => rm(cwd, { force: true, recursive: true }));
|
|
90
|
+
await mkdir(path.join(cwd, "src"), { recursive: true });
|
|
91
|
+
|
|
92
|
+
await writeFile(
|
|
93
|
+
path.join(cwd, "bundle.config.js"),
|
|
94
|
+
`export default { port: ${PORT}, host: "127.0.0.1", deletePrev: true };\n`,
|
|
95
|
+
);
|
|
96
|
+
await writeFile(
|
|
97
|
+
path.join(cwd, "src", "index.html"),
|
|
98
|
+
`<!DOCTYPE html>
|
|
99
|
+
<html>
|
|
100
|
+
<head>
|
|
101
|
+
<title>Fixture</title>
|
|
102
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
103
|
+
<script type="module">
|
|
104
|
+
import { value } from "./mod.js";
|
|
105
|
+
window.value = value;
|
|
106
|
+
</script>
|
|
107
|
+
</head>
|
|
108
|
+
<body><main>Fixture</main></body>
|
|
109
|
+
</html>`,
|
|
110
|
+
);
|
|
111
|
+
const modPath = path.join(cwd, "src", "mod.ts");
|
|
112
|
+
const workerCodePath = path.join(cwd, "src", "workerCode.ts");
|
|
113
|
+
const cssPath = path.join(cwd, "src", "styles.css");
|
|
114
|
+
await writeFile(modPath, `export const value: number = 1;\n`);
|
|
115
|
+
await writeFile(
|
|
116
|
+
workerCodePath,
|
|
117
|
+
`globalThis.postMessage?.({ value: 1 });\nexport const value = 1;\n`,
|
|
118
|
+
);
|
|
119
|
+
await writeFile(cssPath, `body { color: red; }\n`);
|
|
120
|
+
|
|
121
|
+
const server = spawn(process.execPath, [bundlePath, "--hmr"], { cwd });
|
|
122
|
+
t.after(() => server.kill("SIGKILL"));
|
|
123
|
+
server.stderr.on("data", () => {});
|
|
124
|
+
|
|
125
|
+
await waitForListening(server);
|
|
126
|
+
const events = [];
|
|
127
|
+
const req = await subscribe(events);
|
|
128
|
+
t.after(() => req.destroy());
|
|
129
|
+
await wait(300);
|
|
130
|
+
|
|
131
|
+
// 1. Module edit funnels into an "html" update for the owning page.
|
|
132
|
+
const beforeModule = events.length;
|
|
133
|
+
await writeFile(modPath, `export const value: number = 2;\n`);
|
|
134
|
+
const moduleEvent = await until(() =>
|
|
135
|
+
events
|
|
136
|
+
.slice(beforeModule)
|
|
137
|
+
.find((e) => e.type === "html" && e.file === "src/index.html"),
|
|
138
|
+
);
|
|
139
|
+
assert.ok(
|
|
140
|
+
moduleEvent,
|
|
141
|
+
"module change should emit an html event for index.html",
|
|
142
|
+
);
|
|
143
|
+
assert.equal(typeof moduleEvent.html, "string");
|
|
144
|
+
|
|
145
|
+
// 2. CSS edit emits a css event (client busts stylesheets).
|
|
146
|
+
const beforeCss = events.length;
|
|
147
|
+
await writeFile(cssPath, `body { color: blue; }\n`);
|
|
148
|
+
const cssEvent = await until(() =>
|
|
149
|
+
events.slice(beforeCss).find((e) => e.type === "css"),
|
|
150
|
+
);
|
|
151
|
+
assert.ok(cssEvent, "css change should emit a css event");
|
|
152
|
+
assert.match(cssEvent.file, /styles\.css$/);
|
|
153
|
+
|
|
154
|
+
// 3. A module entry with no changed owning page is an HMR dead end, so the
|
|
155
|
+
// client should reload automatically instead of leaving the user stuck.
|
|
156
|
+
const beforeWorker = events.length;
|
|
157
|
+
await writeFile(
|
|
158
|
+
workerCodePath,
|
|
159
|
+
`globalThis.postMessage?.({ value: 2 });\nexport const value = 2;\n`,
|
|
160
|
+
);
|
|
161
|
+
const workerEvent = await until(() =>
|
|
162
|
+
events
|
|
163
|
+
.slice(beforeWorker)
|
|
164
|
+
.find(
|
|
165
|
+
(e) => e.type === "full-reload" && /workerCode\.ts$/.test(e.file || ""),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
assert.ok(workerEvent, "dead-end module change should emit full-reload");
|
|
169
|
+
|
|
170
|
+
// 4. Legacy untyped fields are gone (client dispatches on `type`).
|
|
171
|
+
assert.ok(
|
|
172
|
+
events.every((e) => typeof e.type === "string"),
|
|
173
|
+
"every event carries a type",
|
|
174
|
+
);
|
|
175
|
+
});
|