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
package/src/utils.mts
CHANGED
|
@@ -9,15 +9,24 @@ import express from "express";
|
|
|
9
9
|
import postcssrc from "postcss-load-config";
|
|
10
10
|
import cssnano from "cssnano";
|
|
11
11
|
import { parse, parseFragment, serialize } from "parse5";
|
|
12
|
-
import {
|
|
13
|
-
createScript,
|
|
14
|
-
getTagName,
|
|
15
|
-
findElement,
|
|
16
|
-
appendChild,
|
|
17
|
-
} from "@web/parse5-utils";
|
|
12
|
+
import { createScript, getTagName, findElement } from "@web/parse5-utils";
|
|
18
13
|
|
|
19
14
|
export const bundleConfig = await getBundleConfig();
|
|
20
15
|
|
|
16
|
+
// The HMR client runtime is authored in src/hmr-client.ts and compiled by tsc to
|
|
17
|
+
// dist/hmr-client.js alongside this module (a real file, so its code is never
|
|
18
|
+
// mangled by template-literal escaping). Read it once and substitute the per-page
|
|
19
|
+
// tokens on demand in buildHMRClient().
|
|
20
|
+
let hmrClientTemplate = "";
|
|
21
|
+
try {
|
|
22
|
+
hmrClientTemplate = await readFile(
|
|
23
|
+
new URL("./hmr-client.js", import.meta.url),
|
|
24
|
+
"utf-8",
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
// Only needed when --hmr is active; addHMRCode tolerates an empty template.
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export function fileCopy(file: string) {
|
|
22
31
|
return copyFile(file, getBuildPath(file));
|
|
23
32
|
}
|
|
@@ -32,10 +41,17 @@ export function getBuildPath(file: string) {
|
|
|
32
41
|
return file.replace(`${bundleConfig.src}/`, `${bundleConfig.build}/`);
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
// Every change the watcher detects is normalised into one of these events. The
|
|
45
|
+
// client dispatches on `type`, so the server never needs the old .ts->.js file
|
|
46
|
+
// renaming: module edits are delivered as "html" updates for the owning page(s).
|
|
47
|
+
export type HMREvent =
|
|
48
|
+
| { type: "html"; file: string; html?: string; previousHtml?: string }
|
|
49
|
+
| { type: "css"; file: string }
|
|
50
|
+
| { type: "asset"; file: string }
|
|
51
|
+
| { type: "full-reload"; file: string };
|
|
52
|
+
|
|
35
53
|
const CONNECTIONS = new Set<any>(); // In order to send the HMR information
|
|
36
|
-
export let serverSentEvents:
|
|
37
|
-
| undefined
|
|
38
|
-
| (({ file, html }: { file: string; html?: string }) => void);
|
|
54
|
+
export let serverSentEvents: undefined | ((event: HMREvent) => void);
|
|
39
55
|
export async function createDefaultServer(
|
|
40
56
|
isSecure: boolean,
|
|
41
57
|
): Promise<[Router, Server | HTTPSServer]> {
|
|
@@ -55,17 +71,14 @@ export async function createDefaultServer(
|
|
|
55
71
|
CONNECTIONS.delete(reply);
|
|
56
72
|
});
|
|
57
73
|
|
|
58
|
-
serverSentEvents = (
|
|
59
|
-
if (/\.(jsx?|tsx?)$/.test(data.file)) {
|
|
60
|
-
data.file = data.file.replace(".ts", ".js").replace(".jsx", ".js");
|
|
61
|
-
}
|
|
74
|
+
serverSentEvents = (event) => {
|
|
62
75
|
CONNECTIONS.forEach((rep) => {
|
|
63
76
|
if (rep.destroyed || rep.writableEnded) {
|
|
64
77
|
CONNECTIONS.delete(rep);
|
|
65
78
|
return;
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
rep.write(`data: ${JSON.stringify(
|
|
81
|
+
rep.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
69
82
|
});
|
|
70
83
|
};
|
|
71
84
|
});
|
|
@@ -102,7 +115,17 @@ export async function createDefaultServer(
|
|
|
102
115
|
export async function getPostCSSConfig() {
|
|
103
116
|
try {
|
|
104
117
|
return await postcssrc({});
|
|
105
|
-
} catch {
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
// postcssrc throws "No PostCSS Config found" when the project is zero-config;
|
|
121
|
+
// that is expected, so stay silent. Any other failure (e.g. a broken config
|
|
122
|
+
// or a TypeScript config that cannot be loaded) would otherwise silently
|
|
123
|
+
// degrade the build to cssnano-only, so surface it.
|
|
124
|
+
if (!/No PostCSS Config found/i.test(message)) {
|
|
125
|
+
console.error(
|
|
126
|
+
`\u26A0\uFE0F Could not load your PostCSS config \u2013 falling back to cssnano only. ${message}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
106
129
|
return { plugins: [cssnano], options: {}, file: "" };
|
|
107
130
|
}
|
|
108
131
|
}
|
|
@@ -141,25 +164,27 @@ export function addHMRCode(
|
|
|
141
164
|
if (!htmlIdMap.has(file)) {
|
|
142
165
|
htmlIdMap.set(file, randomText());
|
|
143
166
|
}
|
|
167
|
+
const id = htmlIdMap.get(file);
|
|
144
168
|
|
|
145
169
|
const script = createScript(
|
|
146
|
-
{ type: "module" },
|
|
147
|
-
|
|
170
|
+
{ type: "module", "data-hmr-client": id },
|
|
171
|
+
buildHMRClient(file, id, bundleConfig.src),
|
|
148
172
|
);
|
|
149
173
|
|
|
150
174
|
let DOM;
|
|
151
175
|
if (html.includes("<!DOCTYPE html>") || html.includes("<html")) {
|
|
152
176
|
DOM = ast || parse(html);
|
|
153
177
|
const headNode = findElement(DOM as Node, (e) => getTagName(e) === "head");
|
|
154
|
-
|
|
178
|
+
// Inject first in <head> so the client runs before the page's own scripts.
|
|
179
|
+
prependChild(headNode as Node, script);
|
|
155
180
|
} else {
|
|
156
181
|
DOM = ast || parseFragment(html);
|
|
157
|
-
|
|
182
|
+
prependChild(DOM as Node, script);
|
|
158
183
|
}
|
|
159
184
|
|
|
160
185
|
//@ts-ignore
|
|
161
186
|
DOM.childNodes.forEach((node) =>
|
|
162
|
-
node.attrs?.push({ name: "data-hmr", value:
|
|
187
|
+
node.attrs?.push({ name: "data-hmr", value: id }),
|
|
163
188
|
);
|
|
164
189
|
|
|
165
190
|
return serialize(DOM as any);
|
|
@@ -169,171 +194,21 @@ function randomText() {
|
|
|
169
194
|
return Math.random().toString(32).slice(2);
|
|
170
195
|
}
|
|
171
196
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (hmrId !== "${id}") {
|
|
183
|
-
eventSource.close();
|
|
184
|
-
window.htmlBundleHMRConnections.delete(hmrId);
|
|
185
|
-
delete window["eventsource" + hmrId];
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function closeEventSource() {
|
|
190
|
-
const eventSource = window.eventsource${id};
|
|
191
|
-
if (eventSource) {
|
|
192
|
-
eventSource.close();
|
|
193
|
-
window.htmlBundleHMRConnections.delete("${id}");
|
|
194
|
-
delete window.eventsource${id};
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function connectEventSource() {
|
|
199
|
-
closeEventSource();
|
|
200
|
-
shouldReconnect = true;
|
|
201
|
-
|
|
202
|
-
const eventSource = new EventSource("/hmr");
|
|
203
|
-
window.eventsource${id} = eventSource;
|
|
204
|
-
window.htmlBundleHMRConnections.set("${id}", eventSource);
|
|
205
|
-
|
|
206
|
-
eventSource.addEventListener('error', () => {
|
|
207
|
-
eventSource.close();
|
|
208
|
-
if (window.eventsource${id} === eventSource) {
|
|
209
|
-
window.htmlBundleHMRConnections.delete("${id}");
|
|
210
|
-
delete window.eventsource${id};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (shouldReconnect && window.htmlBundleHMRActiveId === "${id}") {
|
|
214
|
-
clearTimeout(reconnectTimer);
|
|
215
|
-
reconnectTimer = setTimeout(connectEventSource, 1000);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
eventSource.addEventListener("message", ({ data }) => {
|
|
220
|
-
if (window.lastScroll == null) {
|
|
221
|
-
window.lastScroll = window.scrollY;
|
|
222
|
-
}
|
|
223
|
-
const dataObj = JSON.parse(data);
|
|
224
|
-
const file = "${file}";
|
|
225
|
-
|
|
226
|
-
if (file === dataObj.file && "html" in dataObj) {
|
|
227
|
-
let newHTML;
|
|
228
|
-
try {
|
|
229
|
-
newHTML = html\`\${dataObj.html}\`
|
|
230
|
-
} catch {
|
|
231
|
-
setShouldSetReactivity(false);
|
|
232
|
-
newHTML = html\`\${dataObj.html}\`
|
|
233
|
-
setShouldSetReactivity(true);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (dataObj.html.startsWith('<!DOCTYPE html>') || dataObj.html.startsWith('<html')) {
|
|
237
|
-
document.head.remove(); // Don't try to diff the head – just re-run the scripts
|
|
238
|
-
|
|
239
|
-
// Restore Scroll
|
|
240
|
-
window.addEventListener("afterRouting", () => {
|
|
241
|
-
window.scrollTo(0, window.lastScroll);
|
|
242
|
-
delete window.lastScroll;
|
|
243
|
-
}, { once: true })
|
|
244
|
-
|
|
245
|
-
render(newHTML, document.documentElement, false);
|
|
246
|
-
} else {
|
|
247
|
-
const hmrID = "${id}";
|
|
248
|
-
const hmrElems = Array.from(newHTML.childNodes);
|
|
249
|
-
const hmrWheres = Array.from($$(\`[data-hmr="\${hmrID}"]\`))
|
|
250
|
-
// render new elements for old elements. Then, remove rest old elements and add add new elements after the last old one
|
|
251
|
-
hmrWheres.forEach((where, index) => {
|
|
252
|
-
if (index < hmrElems.length) {
|
|
253
|
-
render(hmrElems[index], where, false);
|
|
254
|
-
} else {
|
|
255
|
-
where.remove();
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
for (let rest = hmrWheres.length; rest < hmrElems.length; rest++) {
|
|
259
|
-
if (hmrWheres.length) {
|
|
260
|
-
const template = document.createElement('template');
|
|
261
|
-
hmrElems[hmrWheres.length - 1].after(template);
|
|
262
|
-
render(hmrElems[rest], template, false);
|
|
263
|
-
template.remove();
|
|
264
|
-
} else {
|
|
265
|
-
render(hmrElems[rest], false, false)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
$$('link[rel="stylesheet"][href]').forEach(link => {
|
|
271
|
-
link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
|
|
272
|
-
})
|
|
273
|
-
if (dataObj.html.includes("<script")) updateElem("script");
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (dataObj.file === \`${src}/index.html\`) {
|
|
277
|
-
dispatchEvent(new Event("popstate"));
|
|
278
|
-
}
|
|
279
|
-
} else if (dataObj.file.endsWith(".css")) {
|
|
280
|
-
const now = performance.now();
|
|
281
|
-
if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
|
|
282
|
-
$$('link[rel="stylesheet"][href]').forEach(link => {
|
|
283
|
-
link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
|
|
284
|
-
})
|
|
285
|
-
window.lastCalled.set(dataObj.file, now)
|
|
286
|
-
}
|
|
287
|
-
} else if (dataObj.file.endsWith(".js")) {
|
|
288
|
-
const now = performance.now();
|
|
289
|
-
if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
|
|
290
|
-
$$('link[rel="stylesheet"][href]').forEach(link => {
|
|
291
|
-
link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
|
|
292
|
-
})
|
|
293
|
-
updateElem("script");
|
|
294
|
-
window.lastCalled.set(dataObj.file, now)
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
function updateElem(type) {
|
|
300
|
-
const hmrId = "${id}";
|
|
301
|
-
const noSrcFile = dataObj.file.replace(\`${src}/\`, '');
|
|
302
|
-
const attr = type === "script" ? "src" : "href";
|
|
303
|
-
const elem = $(\`[data-hmr="\${hmrId}"] \${type}[\${attr}^="\${noSrcFile}"]\`); // could be $(\`\${type}[data-hmr="\${hmrId}"][\${attr}^="\${noSrcFile}"]\`) ?
|
|
304
|
-
|
|
305
|
-
if (elem) {
|
|
306
|
-
updateOne(type, attr, elem)
|
|
307
|
-
} else {
|
|
308
|
-
for(const e of $$(\`[data-hmr="\${hmrId}"] \${type}\`)) {
|
|
309
|
-
updateOne(type, attr, e);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function updateOne(type, attr, elem) {
|
|
315
|
-
const clone = document.createElement(type);
|
|
316
|
-
for (const key of elem.getAttributeNames()) {
|
|
317
|
-
clone.setAttribute(key, elem.getAttribute(key));
|
|
318
|
-
}
|
|
319
|
-
const attrVal = elem.getAttribute(attr);
|
|
320
|
-
if (attrVal) clone.setAttribute(attr, attrVal + "?v=" + String(Math.random().toFixed(4)).slice(2));
|
|
321
|
-
render(clone, elem, false);
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
window.addEventListener("pagehide", () => {
|
|
327
|
-
shouldReconnect = false;
|
|
328
|
-
clearTimeout(reconnectTimer);
|
|
329
|
-
if (window.htmlBundleHMRActiveId === "${id}") {
|
|
330
|
-
delete window.htmlBundleHMRActiveId;
|
|
331
|
-
}
|
|
332
|
-
closeEventSource();
|
|
333
|
-
}, { once: true });
|
|
197
|
+
// Produce the per-page HMR client by substituting tokens into the shared runtime
|
|
198
|
+
// template (src/hmr-client.ts). The runtime is injected as an inline module so
|
|
199
|
+
// esbuild bundles hydro-js for it, but it coordinates through a single global hub
|
|
200
|
+
// so every composed page shares one EventSource and patches its own region.
|
|
201
|
+
function buildHMRClient(file: string, id: string, src: string) {
|
|
202
|
+
return hmrClientTemplate
|
|
203
|
+
.replaceAll("__HMR_FILE__", file)
|
|
204
|
+
.replaceAll("__HMR_ID__", id)
|
|
205
|
+
.replaceAll("__HMR_SRC__", src);
|
|
206
|
+
}
|
|
334
207
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
208
|
+
function prependChild(parent: Node, node: unknown) {
|
|
209
|
+
// Insert as the first child so the HMR client runs before the page's own
|
|
210
|
+
// scripts — required for window.htmlBundleHMR.dispose()/data to be usable on
|
|
211
|
+
// initial load.
|
|
212
|
+
(node as { parentNode?: unknown }).parentNode = parent;
|
|
213
|
+
(parent as unknown as { childNodes: unknown[] }).childNodes.unshift(node);
|
|
339
214
|
}
|
package/tests/bundle.test.mjs
CHANGED
|
@@ -89,23 +89,75 @@ test("addHMRCode injects stable HMR wiring", async () => {
|
|
|
89
89
|
);
|
|
90
90
|
const fragment = addHMRCode("<main>Hi</main>", "src/fragment.html");
|
|
91
91
|
|
|
92
|
-
assert.match(fullDocument, /<script type="module">/);
|
|
92
|
+
assert.match(fullDocument, /<script type="module" data-hmr-client="[^\"]+">/);
|
|
93
93
|
assert.match(fullDocument, /new EventSource\("\/hmr"\)/);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
98
|
-
assert.match(fullDocument, /
|
|
99
|
-
assert.match(fullDocument,
|
|
94
|
+
// Single shared hub + public opt-in API replace the old per-page globals.
|
|
95
|
+
assert.match(fullDocument, /window\.__htmlBundleHMR/);
|
|
96
|
+
assert.match(fullDocument, /window\.htmlBundleHMR = \{/);
|
|
97
|
+
assert.match(fullDocument, /hub\.register\(FILE, ID/);
|
|
98
|
+
assert.match(fullDocument, /"pagehide"/);
|
|
99
|
+
assert.match(fullDocument, /\.close\(\)/);
|
|
100
100
|
assert.match(fullDocument, /data-hmr="[^"]+"/);
|
|
101
|
+
assert.match(fullDocument, /function patchDocument\(/);
|
|
102
|
+
assert.match(fullDocument, /function patchScript\(/);
|
|
103
|
+
// Single-root fragments must be normalised (hydro-js returns the element, not
|
|
104
|
+
// a DocumentFragment) so patching targets the right nodes.
|
|
105
|
+
assert.match(fullDocument, /Node\.DOCUMENT_FRAGMENT_NODE/);
|
|
106
|
+
// tsc formats the tagged template with a space (`html `...``), so allow it.
|
|
107
|
+
assert.match(fullDocument, /html\s*`\$\{htmlText\}`/);
|
|
108
|
+
assert.doesNotMatch(fullDocument, /document\.head\.remove\(\)/);
|
|
109
|
+
assert.doesNotMatch(fullDocument, /DOMParser/);
|
|
110
|
+
// The client is injected first in <head> so it runs before the page's own
|
|
111
|
+
// scripts (needed for window.htmlBundleHMR.dispose()/data on initial load).
|
|
101
112
|
assert.ok(
|
|
102
113
|
fullDocument.indexOf("<head>") <
|
|
103
|
-
fullDocument.indexOf('<script type="module"
|
|
114
|
+
fullDocument.indexOf('<script type="module" data-hmr-client='),
|
|
104
115
|
);
|
|
105
116
|
|
|
106
|
-
assert.match(fragment,
|
|
117
|
+
assert.match(fragment, /<main data-hmr="[^"]+">Hi<\/main>/);
|
|
107
118
|
assert.match(fragment, /new EventSource\("\/hmr"\)/);
|
|
108
|
-
assert.match(fragment, /
|
|
119
|
+
assert.match(fragment, /hub\.register\(FILE, ID/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("addHMRCode injects fragment client before fragment scripts", async () => {
|
|
123
|
+
const { addHMRCode } = await import(pathToFileURL(utilsPath).href);
|
|
124
|
+
|
|
125
|
+
const fragment = addHMRCode(
|
|
126
|
+
'<script type="module">window.htmlBundleHMR.dispose(() => {});</script><main>Hi</main>',
|
|
127
|
+
"src/scripted-fragment.html",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
assert.ok(
|
|
131
|
+
fragment.indexOf("data-hmr-client") <
|
|
132
|
+
fragment.indexOf("window.htmlBundleHMR.dispose"),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("HMR full-document detection survives template-literal escaping", async () => {
|
|
137
|
+
const { addHMRCode } = await import(pathToFileURL(utilsPath).href);
|
|
138
|
+
|
|
139
|
+
const doc = addHMRCode(
|
|
140
|
+
"<!DOCTYPE html><html><head></head><body>x</body></html>",
|
|
141
|
+
"src/detect.html",
|
|
142
|
+
);
|
|
143
|
+
const code = doc.match(/data-hmr-client="[^"]+">([\s\S]*?)<\/script>/)[1];
|
|
144
|
+
const start = code.indexOf("function isFullDocument");
|
|
145
|
+
const end = code.indexOf("function parseHTML");
|
|
146
|
+
const fnText = code.slice(start, end);
|
|
147
|
+
const isFullDocument = new Function(`${fnText}\n return isFullDocument;`)();
|
|
148
|
+
|
|
149
|
+
// Real payloads emitted by the server (parse5 + html-minifier-terser).
|
|
150
|
+
assert.equal(
|
|
151
|
+
isFullDocument("<!DOCTYPE html><html><body></body></html>"),
|
|
152
|
+
true,
|
|
153
|
+
);
|
|
154
|
+
assert.equal(
|
|
155
|
+
isFullDocument("<!doctype html><html><body></body></html>"),
|
|
156
|
+
true,
|
|
157
|
+
);
|
|
158
|
+
assert.equal(isFullDocument('<html lang="en"><body></body></html>'), true);
|
|
159
|
+
// Fragments must still take the fragment path.
|
|
160
|
+
assert.equal(isFullDocument("<main>fragment</main>"), false);
|
|
109
161
|
});
|
|
110
162
|
|
|
111
163
|
test("getBuildPath maps src paths into the build directory", async () => {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Client-runtime tests for the compiled HMR client (dist/hmr-client.js, built
|
|
2
|
+
// from src/hmr-client.ts).
|
|
3
|
+
//
|
|
4
|
+
// The runtime is a real browser module, so we exercise it under happy-dom with
|
|
5
|
+
// the real hydro-js: replace its `hydro-js` import with injected deps, load it
|
|
6
|
+
// per page via `new Function`, then drive it through the shared hub the same way
|
|
7
|
+
// the browser's EventSource would.
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { Window } from "happy-dom";
|
|
13
|
+
|
|
14
|
+
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
|
|
15
|
+
const clientPath = path.join(repoRoot, "dist", "hmr-client.js");
|
|
16
|
+
const clientSrc = await readFile(clientPath, "utf8");
|
|
17
|
+
|
|
18
|
+
// Fresh DOM, globals and hub per test so state never leaks between cases.
|
|
19
|
+
async function setup() {
|
|
20
|
+
const w = new Window({ url: "http://localhost:5000/" });
|
|
21
|
+
globalThis.window = w;
|
|
22
|
+
globalThis.document = w.document;
|
|
23
|
+
globalThis.Node = w.Node;
|
|
24
|
+
globalThis.HTMLHtmlElement = w.HTMLHtmlElement;
|
|
25
|
+
globalThis.HTMLElement = w.HTMLElement;
|
|
26
|
+
globalThis.Event = w.Event;
|
|
27
|
+
globalThis.performance = w.performance ?? { now: () => Date.now() };
|
|
28
|
+
globalThis.dispatchEvent = w.dispatchEvent
|
|
29
|
+
? w.dispatchEvent.bind(w)
|
|
30
|
+
: () => {};
|
|
31
|
+
w.scrollTo = () => {};
|
|
32
|
+
|
|
33
|
+
let eventSourceCount = 0;
|
|
34
|
+
globalThis.EventSource = class {
|
|
35
|
+
constructor() {
|
|
36
|
+
eventSourceCount++;
|
|
37
|
+
}
|
|
38
|
+
addEventListener() {}
|
|
39
|
+
close() {}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let reloadCount = 0;
|
|
43
|
+
w.location.reload = () => {
|
|
44
|
+
reloadCount++;
|
|
45
|
+
};
|
|
46
|
+
globalThis.__hydro = await import("hydro-js");
|
|
47
|
+
|
|
48
|
+
function loadPage(file, id, src = "src") {
|
|
49
|
+
const code = clientSrc
|
|
50
|
+
.replace(
|
|
51
|
+
'import { render as hydroRender, html, setShouldSetReactivity } from "hydro-js";',
|
|
52
|
+
"const { render: hydroRender, html, setShouldSetReactivity } = globalThis.__hydro;",
|
|
53
|
+
)
|
|
54
|
+
.replaceAll("__HMR_FILE__", file)
|
|
55
|
+
.replaceAll("__HMR_ID__", id)
|
|
56
|
+
.replaceAll("__HMR_SRC__", src);
|
|
57
|
+
// eslint-disable-next-line no-new-func
|
|
58
|
+
new Function(code)();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
window: w,
|
|
63
|
+
document: w.document,
|
|
64
|
+
loadPage,
|
|
65
|
+
eventSourceCount: () => eventSourceCount,
|
|
66
|
+
reloadCount: () => reloadCount,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
+
|
|
72
|
+
const FULL_BEFORE =
|
|
73
|
+
"<!DOCTYPE html><html><head><title>Before</title></head><body><main class='center'></main></body></html>";
|
|
74
|
+
const FULL_AFTER =
|
|
75
|
+
"<!DOCTYPE html><html><head><title>After</title></head><body><main class='center'></main></body></html>";
|
|
76
|
+
|
|
77
|
+
test("shared hub patches a full-document title change in place", async () => {
|
|
78
|
+
const { document, loadPage } = await setup();
|
|
79
|
+
document.documentElement.innerHTML =
|
|
80
|
+
"<head><title>Before</title></head><body><main class='center'></main></body>";
|
|
81
|
+
|
|
82
|
+
loadPage("src/index.html", "idx1");
|
|
83
|
+
assert.ok(window.__htmlBundleHMR, "hub is created");
|
|
84
|
+
assert.equal(typeof window.htmlBundleHMR.dispose, "function");
|
|
85
|
+
|
|
86
|
+
window.__htmlBundleHMR.dispatch({
|
|
87
|
+
type: "html",
|
|
88
|
+
file: "src/index.html",
|
|
89
|
+
previousHtml: FULL_BEFORE,
|
|
90
|
+
html: FULL_AFTER,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.equal(document.querySelector("title").textContent, "After");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("full-document insertion before a composed mount preserves fetched content", async () => {
|
|
97
|
+
const { document, loadPage, reloadCount } = await setup();
|
|
98
|
+
const before =
|
|
99
|
+
"<!DOCTYPE html><html><head><title>Fixture</title></head><body><noscript>Enable JS</noscript><main class='center'></main></body></html>";
|
|
100
|
+
const after =
|
|
101
|
+
"<!DOCTYPE html><html><head><title>Fixture</title></head><body><p id='new-copy'>New copy</p><noscript>Enable JS</noscript><main class='center'></main></body></html>";
|
|
102
|
+
document.documentElement.innerHTML =
|
|
103
|
+
"<head><title>Fixture</title></head><body><noscript>Enable JS</noscript><main class='center'><picture><img src='@public/splash.webp'></picture></main></body>";
|
|
104
|
+
|
|
105
|
+
loadPage("src/index.html", "idx1");
|
|
106
|
+
window.__htmlBundleHMR.dispatch({
|
|
107
|
+
type: "html",
|
|
108
|
+
file: "src/index.html",
|
|
109
|
+
previousHtml: before,
|
|
110
|
+
html: after,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.equal(document.querySelector("#new-copy")?.textContent, "New copy");
|
|
114
|
+
assert.ok(document.querySelector("main.center img"));
|
|
115
|
+
assert.equal(reloadCount(), 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("same-tag insertion before a composed mount preserves fetched content", async () => {
|
|
119
|
+
const { document, loadPage } = await setup();
|
|
120
|
+
const before =
|
|
121
|
+
"<!DOCTYPE html><html><head><title>Fixture</title></head><body><main class='center'></main></body></html>";
|
|
122
|
+
const after =
|
|
123
|
+
"<!DOCTYPE html><html><head><title>Fixture</title></head><body><main id='banner'>New copy</main><main class='center'></main></body></html>";
|
|
124
|
+
document.documentElement.innerHTML =
|
|
125
|
+
"<head><title>Fixture</title></head><body><main class='center'><picture><img src='@public/splash.webp'></picture></main></body>";
|
|
126
|
+
|
|
127
|
+
loadPage("src/index.html", "idx1");
|
|
128
|
+
window.__htmlBundleHMR.dispatch({
|
|
129
|
+
type: "html",
|
|
130
|
+
file: "src/index.html",
|
|
131
|
+
previousHtml: before,
|
|
132
|
+
html: after,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
assert.equal(document.querySelector("#banner")?.textContent, "New copy");
|
|
136
|
+
assert.ok(document.querySelector("main.center img"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("full-reload events trigger a debounced page reload", async () => {
|
|
140
|
+
const { loadPage, reloadCount } = await setup();
|
|
141
|
+
loadPage("src/index.html", "idx1");
|
|
142
|
+
|
|
143
|
+
window.__htmlBundleHMR.dispatch({
|
|
144
|
+
type: "full-reload",
|
|
145
|
+
file: "src/@shared/workerCode.ts",
|
|
146
|
+
});
|
|
147
|
+
window.__htmlBundleHMR.dispatch({
|
|
148
|
+
type: "full-reload",
|
|
149
|
+
file: "src/@shared/workerCode.ts",
|
|
150
|
+
});
|
|
151
|
+
await wait(30);
|
|
152
|
+
|
|
153
|
+
assert.equal(reloadCount(), 1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("single-root fragment update patches only its own region", async () => {
|
|
157
|
+
const { document, loadPage } = await setup();
|
|
158
|
+
document.body.innerHTML =
|
|
159
|
+
"<main class='center'><div data-hmr='app1'>Old</div></main>";
|
|
160
|
+
|
|
161
|
+
loadPage("src/app/index.html", "app1");
|
|
162
|
+
window.__htmlBundleHMR.dispatch({
|
|
163
|
+
type: "html",
|
|
164
|
+
file: "src/app/index.html",
|
|
165
|
+
html: "<main data-hmr='app1'>New</main>",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const region = document.querySelector('[data-hmr="app1"]');
|
|
169
|
+
assert.equal(region.textContent, "New");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("dispose runs before re-patch and data persists across updates", async () => {
|
|
173
|
+
const { document, loadPage } = await setup();
|
|
174
|
+
document.body.innerHTML = "<div data-hmr='app1'>Old</div>";
|
|
175
|
+
loadPage("src/app/index.html", "app1");
|
|
176
|
+
|
|
177
|
+
let disposed = false;
|
|
178
|
+
window.htmlBundleHMR.dispose(() => {
|
|
179
|
+
disposed = true;
|
|
180
|
+
});
|
|
181
|
+
window.htmlBundleHMR.data.persisted = 42;
|
|
182
|
+
|
|
183
|
+
window.__htmlBundleHMR.dispatch({
|
|
184
|
+
type: "html",
|
|
185
|
+
file: "src/app/index.html",
|
|
186
|
+
html: "<div data-hmr='app1'>New</div>",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
assert.equal(disposed, true);
|
|
190
|
+
assert.equal(window.htmlBundleHMR.data.persisted, 42);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("css event cache-busts stylesheet links", async () => {
|
|
194
|
+
const { document, loadPage } = await setup();
|
|
195
|
+
document.documentElement.innerHTML =
|
|
196
|
+
'<head><link rel="stylesheet" href="@public/base.css"></head><body></body>';
|
|
197
|
+
loadPage("src/index.html", "idx1");
|
|
198
|
+
|
|
199
|
+
window.__htmlBundleHMR.dispatch({
|
|
200
|
+
type: "css",
|
|
201
|
+
file: "src/@public/base.css",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const href = document.querySelector("link").getAttribute("href");
|
|
205
|
+
assert.match(href, /^@public\/base\.css\?v=\d+$/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("asset event cache-busts the matching element", async () => {
|
|
209
|
+
const { document, loadPage } = await setup();
|
|
210
|
+
document.body.innerHTML = '<img src="@public/splash.webp">';
|
|
211
|
+
loadPage("src/index.html", "idx1");
|
|
212
|
+
|
|
213
|
+
window.__htmlBundleHMR.dispatch({
|
|
214
|
+
type: "asset",
|
|
215
|
+
file: "src/@public/splash.webp",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const src = document.querySelector("img").getAttribute("src");
|
|
219
|
+
assert.match(src, /^@public\/splash\.webp\?v=\d+$/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a change to an unregistered page is a safe no-op", async () => {
|
|
223
|
+
const { document, loadPage } = await setup();
|
|
224
|
+
document.documentElement.innerHTML =
|
|
225
|
+
"<head><title>Before</title></head><body></body>";
|
|
226
|
+
loadPage("src/index.html", "idx1");
|
|
227
|
+
|
|
228
|
+
assert.doesNotThrow(() =>
|
|
229
|
+
window.__htmlBundleHMR.dispatch({
|
|
230
|
+
type: "html",
|
|
231
|
+
file: "src/never.html",
|
|
232
|
+
html: "<div></div>",
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
assert.equal(document.querySelector("title").textContent, "Before");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("all composed pages share exactly one EventSource", async () => {
|
|
239
|
+
const { loadPage, eventSourceCount } = await setup();
|
|
240
|
+
loadPage("src/index.html", "idx1");
|
|
241
|
+
loadPage("src/app/index.html", "app1");
|
|
242
|
+
loadPage("src/link/index.html", "lnk1");
|
|
243
|
+
|
|
244
|
+
assert.equal(eventSourceCount(), 1);
|
|
245
|
+
});
|