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.
@@ -0,0 +1,577 @@
1
+ // html-bundle HMR client runtime.
2
+ //
3
+ // This is a browser TypeScript module. The DOM lib is enabled in tsconfig.json,
4
+ // so the main `tsc` build type-checks it alongside the Node code and compiles it
5
+ // to dist/hmr-client.js next to this module's output. At inject time
6
+ // `buildHMRClient()` in utils.mts substitutes the `__HMR_*` tokens, then the
7
+ // result is injected as an inline `<script type="module">` into every built HTML
8
+ // page and bundled by esbuild so the `hydro-js` import resolves. Keeping it as a
9
+ // real file (instead of a generated template string) means backslashes/backticks
10
+ // are literal, avoiding the template-literal escape corruption a generated string
11
+ // is prone to.
12
+ import { render as hydroRender, html, setShouldSetReactivity } from "hydro-js";
13
+
14
+ // hydro-js's `render` is typed against its own `html()` output; this client
15
+ // feeds it arbitrary DOM nodes and uses `false` for `where` (a detached render),
16
+ // so widen the signature. The assertion is type-only — esbuild strips it and the
17
+ // runtime binding stays hydro-js's `render`.
18
+ const render = hydroRender as unknown as (
19
+ elem: Node,
20
+ where?: Node | string | false,
21
+ shouldSchedule?: boolean,
22
+ ) => void;
23
+
24
+ // HMR messages delivered over the shared EventSource; mirrors the server's
25
+ // HMREvent union (utils.mts). Kept local so the browser runtime never pulls the
26
+ // Node build's module graph into type-checking.
27
+ type HTMLMessage = {
28
+ type: "html";
29
+ file: string;
30
+ html: string;
31
+ previousHtml?: string;
32
+ };
33
+ type HMRMessage =
34
+ | HTMLMessage
35
+ | { type: "css"; file: string }
36
+ | { type: "asset"; file: string }
37
+ | { type: "full-reload"; file: string };
38
+
39
+ type PatchHandler = { id?: string; patch: (message: HTMLMessage) => void };
40
+
41
+ type Hub = {
42
+ currentUnit: string | null;
43
+ lastHTML: Map<string, string>;
44
+ register(
45
+ file: string,
46
+ id: string,
47
+ handler: { patch: (message: HTMLMessage) => void },
48
+ ): void;
49
+ addAccept(file: string, callback: () => void): void;
50
+ addDispose(file: string, callback: () => void): void;
51
+ dataFor(file: string): Record<string, unknown>;
52
+ dispatch(message: HMRMessage): void;
53
+ };
54
+
55
+ type HMRPublicAPI = {
56
+ accept(callback: () => void): void;
57
+ dispose(callback: () => void): void;
58
+ readonly data: Record<string, unknown>;
59
+ };
60
+
61
+ declare global {
62
+ interface Window {
63
+ isHMR?: boolean;
64
+ __htmlBundleHMR?: Hub;
65
+ htmlBundleHMR?: HMRPublicAPI;
66
+ }
67
+ }
68
+
69
+ const FILE = "__HMR_FILE__";
70
+ const ID = "__HMR_ID__";
71
+ const SRC = "__HMR_SRC__";
72
+ const REGION = '[data-hmr="__HMR_ID__"]';
73
+ const CLIENT = 'script[data-hmr-client="__HMR_ID__"]';
74
+
75
+ window.isHMR = true;
76
+
77
+ // One hub per browsing context, created by whichever page loads first. Every
78
+ // composed sub-page (index.html + fetched fragments) registers into it and stays
79
+ // live, so a single shared EventSource dispatches every file's changes to the
80
+ // right region — instead of each page fighting over one connection.
81
+ const hub = window.__htmlBundleHMR || (window.__htmlBundleHMR = createHub(SRC));
82
+ hub.currentUnit = FILE;
83
+
84
+ if (!window.htmlBundleHMR) {
85
+ // Public opt-in API for user code running inside HMR-managed pages.
86
+ // window.htmlBundleHMR.dispose(cb) — cleanup before this unit re-runs
87
+ // window.htmlBundleHMR.accept(cb) — hook after this unit is patched
88
+ // window.htmlBundleHMR.data — object persisted across hot updates
89
+ window.htmlBundleHMR = {
90
+ accept(callback) {
91
+ hub.addAccept(hub.currentUnit!, callback);
92
+ },
93
+ dispose(callback) {
94
+ hub.addDispose(hub.currentUnit!, callback);
95
+ },
96
+ get data() {
97
+ return hub.dataFor(hub.currentUnit!);
98
+ },
99
+ };
100
+ }
101
+
102
+ hub.register(FILE, ID, { patch });
103
+
104
+ // --- per-page patching (closes over this page's own hydro-js instance) --------
105
+
106
+ function patch(message: HTMLMessage): void {
107
+ const previousScroll = window.scrollY;
108
+ if (isFullDocument(message.html)) {
109
+ patchDocument(message.previousHtml, message.html);
110
+ if (FILE === SRC + "/index.html") {
111
+ dispatchEvent(new Event("popstate"));
112
+ }
113
+ } else {
114
+ patchFragment(message.html);
115
+ }
116
+ window.scrollTo(0, previousScroll);
117
+ }
118
+
119
+ function patchFragment(htmlText: string): void {
120
+ const incoming = parseHTML(htmlText);
121
+ // hydro-js returns a DocumentFragment for multi-root markup but the element
122
+ // itself for a single root — normalise to a flat list of top-level nodes.
123
+ const nextNodes: Node[] =
124
+ incoming.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
125
+ incoming.nodeType === Node.DOCUMENT_NODE
126
+ ? Array.from(incoming.childNodes)
127
+ : [incoming];
128
+ const regions = Array.from(document.querySelectorAll(REGION));
129
+
130
+ // Replace each existing region in place, drop the surplus, append the rest.
131
+ regions.forEach((where, index) => {
132
+ if (index < nextNodes.length) {
133
+ render(nextNodes[index], where, false);
134
+ } else {
135
+ where.remove();
136
+ }
137
+ });
138
+
139
+ for (let rest = regions.length; rest < nextNodes.length; rest++) {
140
+ if (regions.length) {
141
+ const template = document.createElement("template");
142
+ (nextNodes[regions.length - 1] as Element).after(template);
143
+ render(nextNodes[rest], template, false);
144
+ template.remove();
145
+ } else {
146
+ render(nextNodes[rest], false, false);
147
+ }
148
+ }
149
+ }
150
+
151
+ function isFullDocument(htmlText: string): boolean {
152
+ const trimmed = htmlText.trimStart().toLowerCase();
153
+ return trimmed.startsWith("<!doctype html") || trimmed.startsWith("<html");
154
+ }
155
+
156
+ function parseHTML(htmlText: string): Element | DocumentFragment | Text {
157
+ try {
158
+ return html`${htmlText}`;
159
+ } catch {
160
+ setShouldSetReactivity(false);
161
+ const parsed = html`${htmlText}`;
162
+ setShouldSetReactivity(true);
163
+ return parsed;
164
+ }
165
+ }
166
+
167
+ function patchDocument(
168
+ previousHtml: string | undefined,
169
+ nextHtml: string,
170
+ ): void {
171
+ const previousText =
172
+ previousHtml ||
173
+ hub.lastHTML.get(FILE) ||
174
+ document.documentElement.outerHTML;
175
+ const previousDocument = getDocumentParts(parseHTML(previousText));
176
+ const nextDocument = getDocumentParts(parseHTML(nextHtml));
177
+
178
+ if (!previousDocument.html || !nextDocument.html) {
179
+ render(parseHTML(nextHtml), document.documentElement, false);
180
+ hub.lastHTML.set(FILE, nextHtml);
181
+ return;
182
+ }
183
+
184
+ patchAttributes(document.documentElement, nextDocument.html);
185
+ if (previousDocument.head && nextDocument.head && document.head) {
186
+ patchChildren(previousDocument.head, nextDocument.head, document.head);
187
+ patchAttributes(document.head, nextDocument.head);
188
+ }
189
+ if (previousDocument.body && nextDocument.body && document.body) {
190
+ patchChildren(previousDocument.body, nextDocument.body, document.body);
191
+ patchAttributes(document.body, nextDocument.body);
192
+ }
193
+
194
+ hub.lastHTML.set(FILE, nextHtml);
195
+ }
196
+
197
+ function getDocumentParts(parsed: Element | DocumentFragment | Text): {
198
+ html: Element;
199
+ head: Element | null;
200
+ body: Element | null;
201
+ } {
202
+ const htmlNode = (
203
+ parsed instanceof HTMLHtmlElement
204
+ ? parsed
205
+ : (parsed as Element).querySelector?.("html") || parsed
206
+ ) as Element;
207
+ return {
208
+ html: htmlNode,
209
+ head: htmlNode.querySelector?.("head"),
210
+ body: htmlNode.querySelector?.("body"),
211
+ };
212
+ }
213
+
214
+ function patchChildren(
215
+ previousParent: Node,
216
+ nextParent: Node,
217
+ liveParent: Node,
218
+ ): void {
219
+ const previousNodes = comparableNodes(previousParent);
220
+ const nextNodes = comparableNodes(nextParent);
221
+ let previousIndex = 0;
222
+ let nextIndex = 0;
223
+ let liveIndex = 0;
224
+
225
+ while (nextIndex < nextNodes.length) {
226
+ const previousNode = previousNodes[previousIndex];
227
+ const nextNode = nextNodes[nextIndex];
228
+ const liveNode = nextLiveNode(liveParent, liveIndex);
229
+
230
+ if (!liveNode) {
231
+ (liveParent as Element).append(cloneForRender(nextNode));
232
+ nextIndex++;
233
+ liveIndex++;
234
+ continue;
235
+ }
236
+
237
+ if (!previousNode) {
238
+ render(cloneForRender(nextNode), liveNode, false);
239
+ nextIndex++;
240
+ liveIndex++;
241
+ continue;
242
+ }
243
+
244
+ const previousMatch = findStaticMatch(
245
+ previousNodes,
246
+ nextNode,
247
+ previousIndex + 1,
248
+ );
249
+ const nextMatch = findStaticMatch(nextNodes, previousNode, nextIndex + 1);
250
+
251
+ if (previousMatch !== -1 && nextMatch === -1) {
252
+ liveNode.remove();
253
+ previousIndex++;
254
+ continue;
255
+ }
256
+
257
+ if (nextMatch !== -1) {
258
+ liveNode.before(cloneForRender(nextNode));
259
+ nextIndex++;
260
+ liveIndex++;
261
+ continue;
262
+ }
263
+
264
+ if (sameNodeIdentity(previousNode, nextNode)) {
265
+ patchNode(previousNode, nextNode, liveNode);
266
+ previousIndex++;
267
+ nextIndex++;
268
+ liveIndex++;
269
+ continue;
270
+ }
271
+
272
+ render(cloneForRender(nextNode), liveNode, false);
273
+ previousIndex++;
274
+ nextIndex++;
275
+ liveIndex++;
276
+ }
277
+
278
+ while (
279
+ previousIndex < previousNodes.length &&
280
+ nextLiveNode(liveParent, liveIndex)
281
+ ) {
282
+ nextLiveNode(liveParent, liveIndex)!.remove();
283
+ previousIndex++;
284
+ }
285
+ }
286
+
287
+ function patchNode(previousNode: Node, nextNode: Node, liveNode: Node): void {
288
+ if (sameStaticNode(previousNode, nextNode)) return;
289
+ if (
290
+ previousNode.nodeType !== nextNode.nodeType ||
291
+ liveNode.nodeType !== nextNode.nodeType
292
+ ) {
293
+ render(cloneForRender(nextNode), liveNode, false);
294
+ return;
295
+ }
296
+ if (nextNode.nodeType === Node.TEXT_NODE) {
297
+ liveNode.nodeValue = nextNode.nodeValue;
298
+ return;
299
+ }
300
+ if (nextNode.nodeType !== Node.ELEMENT_NODE) {
301
+ render(cloneForRender(nextNode), liveNode, false);
302
+ return;
303
+ }
304
+ if ((nextNode as Element).localName === "script") {
305
+ patchScript(previousNode, nextNode as Element, liveNode);
306
+ return;
307
+ }
308
+ patchAttributes(liveNode as Element, nextNode as Element);
309
+ patchChildren(previousNode, nextNode, liveNode);
310
+ }
311
+
312
+ // Re-running only the scripts whose content actually changed is the "hot swap":
313
+ // unchanged scripts keep their state, changed ones re-execute with fresh code.
314
+ function patchScript(
315
+ previousScript: Node,
316
+ nextScript: Element,
317
+ liveScript: Node,
318
+ ): void {
319
+ if (previousScript.isEqualNode(nextScript)) return;
320
+ const clone = cloneScript(nextScript);
321
+ const source = clone.getAttribute("src");
322
+ if (source) clone.setAttribute("src", bust(source));
323
+ render(clone, liveScript, false);
324
+ }
325
+
326
+ function cloneForRender(node: Node): Node {
327
+ if (
328
+ node.nodeType === Node.ELEMENT_NODE &&
329
+ (node as Element).localName === "script"
330
+ ) {
331
+ return cloneScript(node as Element);
332
+ }
333
+ return node.cloneNode(true);
334
+ }
335
+
336
+ // Cloning via document.createElement forces the browser to (re-)execute the
337
+ // script; a plain cloneNode of a parsed <script> would not run.
338
+ function cloneScript(script: Element): HTMLScriptElement {
339
+ const clone = document.createElement("script");
340
+ for (const attr of Array.from(script.attributes)) {
341
+ clone.setAttribute(attr.name, attr.value);
342
+ }
343
+ clone.textContent = script.textContent;
344
+ return clone;
345
+ }
346
+
347
+ function patchAttributes(liveElement: Element, nextElement: Element): void {
348
+ for (const attr of Array.from(liveElement.attributes)) {
349
+ if (!nextElement.hasAttribute(attr.name))
350
+ liveElement.removeAttribute(attr.name);
351
+ }
352
+ for (const attr of Array.from(nextElement.attributes)) {
353
+ if (liveElement.getAttribute(attr.name) !== attr.value) {
354
+ liveElement.setAttribute(attr.name, attr.value);
355
+ }
356
+ }
357
+ }
358
+
359
+ function sameStaticNode(previousNode: Node, nextNode: Node): boolean {
360
+ return cloneComparable(previousNode).isEqualNode(cloneComparable(nextNode));
361
+ }
362
+
363
+ function sameNodeIdentity(previousNode: Node, nextNode: Node): boolean {
364
+ return (
365
+ previousNode.nodeType === nextNode.nodeType &&
366
+ previousNode.nodeName === nextNode.nodeName
367
+ );
368
+ }
369
+
370
+ function findStaticMatch(nodes: Node[], needle: Node, start: number): number {
371
+ for (let index = start; index < nodes.length; index++) {
372
+ if (sameStaticNode(nodes[index], needle)) return index;
373
+ }
374
+ return -1;
375
+ }
376
+
377
+ // Ignore the injected HMR client script when diffing so it never counts as a
378
+ // real change.
379
+ function cloneComparable(node: Node): Node {
380
+ const clone = node.cloneNode(true) as Element;
381
+ clone.querySelectorAll?.(CLIENT).forEach((script) => script.remove());
382
+ if (clone.matches?.(CLIENT)) clone.remove();
383
+ return clone;
384
+ }
385
+
386
+ function comparableNodes(parent: Node): ChildNode[] {
387
+ return Array.from(parent.childNodes).filter((node) => {
388
+ return !(
389
+ node.nodeType === Node.ELEMENT_NODE && (node as Element).matches?.(CLIENT)
390
+ );
391
+ });
392
+ }
393
+
394
+ function nextLiveNode(parent: Node, index: number): ChildNode | undefined {
395
+ return comparableNodes(parent)[index];
396
+ }
397
+
398
+ function bust(url: string): string {
399
+ return url.split("?")[0] + "?v=" + Date.now();
400
+ }
401
+
402
+ // --- shared hub (created once by the first page to load) ----------------------
403
+
404
+ function createHub(src: string): Hub {
405
+ const registry = new Map<string, PatchHandler>();
406
+ const accepts = new Map<string, Array<() => void>>();
407
+ const disposers = new Map<string, Array<() => void>>();
408
+ const store = new Map<string, Record<string, unknown>>();
409
+ const throttle = new Map<string, number>();
410
+ let source: EventSource | undefined;
411
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
412
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
413
+ let shouldReconnect = true;
414
+
415
+ const hub: Hub = {
416
+ currentUnit: null,
417
+ lastHTML: new Map(),
418
+ register(file, id, handler) {
419
+ registry.set(file, Object.assign({ id }, handler));
420
+ connect();
421
+ },
422
+ addAccept(file, callback) {
423
+ push(accepts, file, callback);
424
+ },
425
+ addDispose(file, callback) {
426
+ push(disposers, file, callback);
427
+ },
428
+ dataFor(file) {
429
+ if (!store.has(file)) store.set(file, {});
430
+ return store.get(file)!;
431
+ },
432
+ dispatch(message) {
433
+ if (message.type === "html") {
434
+ const entry = registry.get(message.file);
435
+ if (!entry) return;
436
+ hub.currentUnit = message.file;
437
+ run(disposers, message.file);
438
+ try {
439
+ entry.patch(message);
440
+ } catch (error) {
441
+ console.error("[html-bundle HMR] patch failed", error);
442
+ }
443
+ run(accepts, message.file);
444
+ } else if (message.type === "css") {
445
+ once("css", bustStylesheets);
446
+ } else if (message.type === "asset") {
447
+ once("asset:" + message.file, () => bustAsset(message.file));
448
+ } else if (message.type === "full-reload") {
449
+ reloadPage();
450
+ }
451
+ },
452
+ };
453
+
454
+ function push<T>(map: Map<string, T[]>, key: string, value: T): void {
455
+ if (!map.has(key)) map.set(key, []);
456
+ map.get(key)!.push(value);
457
+ }
458
+
459
+ function run(map: Map<string, Array<() => void>>, key: string): void {
460
+ const callbacks = map.get(key);
461
+ if (!callbacks || !callbacks.length) return;
462
+ map.set(key, []);
463
+ for (const callback of callbacks) {
464
+ try {
465
+ callback();
466
+ } catch (error) {
467
+ console.error("[html-bundle HMR] callback failed", error);
468
+ }
469
+ }
470
+ }
471
+
472
+ // Coalesce bursts of identical events (e.g. a save that touches many files).
473
+ function once(key: string, action: () => void): void {
474
+ const now = performance.now();
475
+ if (!throttle.has(key) || now - throttle.get(key)! > 100) {
476
+ throttle.set(key, now);
477
+ action();
478
+ }
479
+ }
480
+
481
+ function reloadPage(): void {
482
+ // Own timer (not reconnectTimer) so an SSE reconnect can't cancel a pending
483
+ // reload and vice versa.
484
+ clearTimeout(reloadTimer);
485
+ reloadTimer = setTimeout(() => window.location.reload(), 20);
486
+ }
487
+
488
+ function bustStylesheets(): void {
489
+ document
490
+ .querySelectorAll('link[rel="stylesheet"][href]')
491
+ .forEach((link) => {
492
+ const href = link.getAttribute("href")!.split("?")[0];
493
+ link.setAttribute("href", href + "?v=" + Date.now());
494
+ });
495
+ }
496
+
497
+ function bustAsset(file: string): void {
498
+ if (file.endsWith(".css")) {
499
+ bustStylesheets();
500
+ return;
501
+ }
502
+
503
+ const prefix = src + "/";
504
+ const relative =
505
+ file.indexOf(prefix) === 0 ? file.slice(prefix.length) : file;
506
+ const version = "?v=" + Date.now();
507
+
508
+ bustAttribute("img[src]", "src", relative, version);
509
+ bustAttribute("script[src]", "src", relative, version);
510
+ bustAttribute("link[href]", "href", relative, version);
511
+ bustContains("source[srcset]", "srcset", relative, version);
512
+ bustAttribute("img[data-src]", "data-src", relative, version);
513
+ }
514
+
515
+ function bustAttribute(
516
+ selector: string,
517
+ attribute: string,
518
+ relative: string,
519
+ version: string,
520
+ ): void {
521
+ document.querySelectorAll(selector).forEach((node) => {
522
+ const value = node.getAttribute(attribute);
523
+ if (value && value.split("?")[0] === relative) {
524
+ node.setAttribute(attribute, value.split("?")[0] + version);
525
+ }
526
+ });
527
+ }
528
+
529
+ function bustContains(
530
+ selector: string,
531
+ attribute: string,
532
+ relative: string,
533
+ version: string,
534
+ ): void {
535
+ document.querySelectorAll(selector).forEach((node) => {
536
+ const value = node.getAttribute(attribute);
537
+ if (value && value.split("?")[0].indexOf(relative) !== -1) {
538
+ node.setAttribute(attribute, value.split("?")[0] + version);
539
+ }
540
+ });
541
+ }
542
+
543
+ function connect(): void {
544
+ if (source) return;
545
+ shouldReconnect = true;
546
+ source = new EventSource("/hmr");
547
+ source.addEventListener("message", (event) => {
548
+ let message;
549
+ try {
550
+ message = JSON.parse(event.data);
551
+ } catch {
552
+ return;
553
+ }
554
+ hub.dispatch(message);
555
+ });
556
+ source.addEventListener("error", () => {
557
+ source!.close();
558
+ source = undefined;
559
+ if (shouldReconnect) {
560
+ clearTimeout(reconnectTimer);
561
+ reconnectTimer = setTimeout(connect, 1000);
562
+ }
563
+ });
564
+ window.addEventListener(
565
+ "pagehide",
566
+ () => {
567
+ shouldReconnect = false;
568
+ clearTimeout(reconnectTimer);
569
+ if (source) source.close();
570
+ source = undefined;
571
+ },
572
+ { once: true },
573
+ );
574
+ }
575
+
576
+ return hub;
577
+ }