html-bundle 6.3.1 → 6.4.1

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