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/dist/utils.mjs CHANGED
@@ -6,8 +6,19 @@ import express from "express";
6
6
  import postcssrc from "postcss-load-config";
7
7
  import cssnano from "cssnano";
8
8
  import { parse, parseFragment, serialize } from "parse5";
9
- import { createScript, getTagName, findElement, appendChild, } from "@web/parse5-utils";
9
+ import { createScript, getTagName, findElement } from "@web/parse5-utils";
10
10
  export const bundleConfig = await getBundleConfig();
11
+ // The HMR client runtime is authored in src/hmr-client.ts and compiled by tsc to
12
+ // dist/hmr-client.js alongside this module (a real file, so its code is never
13
+ // mangled by template-literal escaping). Read it once and substitute the per-page
14
+ // tokens on demand in buildHMRClient().
15
+ let hmrClientTemplate = "";
16
+ try {
17
+ hmrClientTemplate = await readFile(new URL("./hmr-client.js", import.meta.url), "utf-8");
18
+ }
19
+ catch {
20
+ // Only needed when --hmr is active; addHMRCode tolerates an empty template.
21
+ }
11
22
  export function fileCopy(file) {
12
23
  return copyFile(file, getBuildPath(file));
13
24
  }
@@ -35,16 +46,13 @@ export async function createDefaultServer(isSecure) {
35
46
  req.on("close", () => {
36
47
  CONNECTIONS.delete(reply);
37
48
  });
38
- serverSentEvents = (data) => {
39
- if (/\.(jsx?|tsx?)$/.test(data.file)) {
40
- data.file = data.file.replace(".ts", ".js").replace(".jsx", ".js");
41
- }
49
+ serverSentEvents = (event) => {
42
50
  CONNECTIONS.forEach((rep) => {
43
51
  if (rep.destroyed || rep.writableEnded) {
44
52
  CONNECTIONS.delete(rep);
45
53
  return;
46
54
  }
47
- rep.write(`data: ${JSON.stringify(data)}\n\n`);
55
+ rep.write(`data: ${JSON.stringify(event)}\n\n`);
48
56
  });
49
57
  };
50
58
  });
@@ -71,7 +79,15 @@ export async function getPostCSSConfig() {
71
79
  try {
72
80
  return await postcssrc({});
73
81
  }
74
- catch {
82
+ catch (err) {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ // postcssrc throws "No PostCSS Config found" when the project is zero-config;
85
+ // that is expected, so stay silent. Any other failure (e.g. a broken config
86
+ // or a TypeScript config that cannot be loaded) would otherwise silently
87
+ // degrade the build to cssnano-only, so surface it.
88
+ if (!/No PostCSS Config found/i.test(message)) {
89
+ console.error(`\u26A0\uFE0F Could not load your PostCSS config \u2013 falling back to cssnano only. ${message}`);
90
+ }
75
91
  return { plugins: [cssnano], options: {}, file: "" };
76
92
  }
77
93
  }
@@ -104,189 +120,40 @@ export function addHMRCode(html, file, ast) {
104
120
  if (!htmlIdMap.has(file)) {
105
121
  htmlIdMap.set(file, randomText());
106
122
  }
107
- const script = createScript({ type: "module" }, getHMRCode(file, htmlIdMap.get(file), bundleConfig.src));
123
+ const id = htmlIdMap.get(file);
124
+ const script = createScript({ type: "module", "data-hmr-client": id }, buildHMRClient(file, id, bundleConfig.src));
108
125
  let DOM;
109
126
  if (html.includes("<!DOCTYPE html>") || html.includes("<html")) {
110
127
  DOM = ast || parse(html);
111
128
  const headNode = findElement(DOM, (e) => getTagName(e) === "head");
112
- appendChild(headNode, script);
129
+ // Inject first in <head> so the client runs before the page's own scripts.
130
+ prependChild(headNode, script);
113
131
  }
114
132
  else {
115
133
  DOM = ast || parseFragment(html);
116
- appendChild(DOM, script);
134
+ prependChild(DOM, script);
117
135
  }
118
136
  //@ts-ignore
119
- DOM.childNodes.forEach((node) => node.attrs?.push({ name: "data-hmr", value: htmlIdMap.get(file) }));
137
+ DOM.childNodes.forEach((node) => node.attrs?.push({ name: "data-hmr", value: id }));
120
138
  return serialize(DOM);
121
139
  }
122
140
  function randomText() {
123
141
  return Math.random().toString(32).slice(2);
124
142
  }
125
- function getHMRCode(file, id, src) {
126
- return `import { render, html, $, $$, setShouldSetReactivity } from "hydro-js";
127
- window.isHMR = true;
128
- window.lastCalled = new Map();
129
- window.htmlBundleHMRConnections ||= new Map();
130
- window.htmlBundleHMRActiveId = "${id}";
131
- let shouldReconnect = true;
132
- let reconnectTimer;
133
-
134
- for (const [hmrId, eventSource] of window.htmlBundleHMRConnections) {
135
- if (hmrId !== "${id}") {
136
- eventSource.close();
137
- window.htmlBundleHMRConnections.delete(hmrId);
138
- delete window["eventsource" + hmrId];
139
- }
140
- }
141
-
142
- function closeEventSource() {
143
- const eventSource = window.eventsource${id};
144
- if (eventSource) {
145
- eventSource.close();
146
- window.htmlBundleHMRConnections.delete("${id}");
147
- delete window.eventsource${id};
148
- }
149
- }
150
-
151
- function connectEventSource() {
152
- closeEventSource();
153
- shouldReconnect = true;
154
-
155
- const eventSource = new EventSource("/hmr");
156
- window.eventsource${id} = eventSource;
157
- window.htmlBundleHMRConnections.set("${id}", eventSource);
158
-
159
- eventSource.addEventListener('error', () => {
160
- eventSource.close();
161
- if (window.eventsource${id} === eventSource) {
162
- window.htmlBundleHMRConnections.delete("${id}");
163
- delete window.eventsource${id};
164
- }
165
-
166
- if (shouldReconnect && window.htmlBundleHMRActiveId === "${id}") {
167
- clearTimeout(reconnectTimer);
168
- reconnectTimer = setTimeout(connectEventSource, 1000);
169
- }
170
- });
171
-
172
- eventSource.addEventListener("message", ({ data }) => {
173
- if (window.lastScroll == null) {
174
- window.lastScroll = window.scrollY;
175
- }
176
- const dataObj = JSON.parse(data);
177
- const file = "${file}";
178
-
179
- if (file === dataObj.file && "html" in dataObj) {
180
- let newHTML;
181
- try {
182
- newHTML = html\`\${dataObj.html}\`
183
- } catch {
184
- setShouldSetReactivity(false);
185
- newHTML = html\`\${dataObj.html}\`
186
- setShouldSetReactivity(true);
187
- }
188
-
189
- if (dataObj.html.startsWith('<!DOCTYPE html>') || dataObj.html.startsWith('<html')) {
190
- document.head.remove(); // Don't try to diff the head – just re-run the scripts
191
-
192
- // Restore Scroll
193
- window.addEventListener("afterRouting", () => {
194
- window.scrollTo(0, window.lastScroll);
195
- delete window.lastScroll;
196
- }, { once: true })
197
-
198
- render(newHTML, document.documentElement, false);
199
- } else {
200
- const hmrID = "${id}";
201
- const hmrElems = Array.from(newHTML.childNodes);
202
- const hmrWheres = Array.from($$(\`[data-hmr="\${hmrID}"]\`))
203
- // render new elements for old elements. Then, remove rest old elements and add add new elements after the last old one
204
- hmrWheres.forEach((where, index) => {
205
- if (index < hmrElems.length) {
206
- render(hmrElems[index], where, false);
207
- } else {
208
- where.remove();
209
- }
210
- });
211
- for (let rest = hmrWheres.length; rest < hmrElems.length; rest++) {
212
- if (hmrWheres.length) {
213
- const template = document.createElement('template');
214
- hmrElems[hmrWheres.length - 1].after(template);
215
- render(hmrElems[rest], template, false);
216
- template.remove();
217
- } else {
218
- render(hmrElems[rest], false, false)
219
- }
220
- }
221
- }
222
-
223
- $$('link[rel="stylesheet"][href]').forEach(link => {
224
- link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
225
- })
226
- if (dataObj.html.includes("<script")) updateElem("script");
227
-
228
-
229
- if (dataObj.file === \`${src}/index.html\`) {
230
- dispatchEvent(new Event("popstate"));
231
- }
232
- } else if (dataObj.file.endsWith(".css")) {
233
- const now = performance.now();
234
- if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
235
- $$('link[rel="stylesheet"][href]').forEach(link => {
236
- link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
237
- })
238
- window.lastCalled.set(dataObj.file, now)
239
- }
240
- } else if (dataObj.file.endsWith(".js")) {
241
- const now = performance.now();
242
- if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
243
- $$('link[rel="stylesheet"][href]').forEach(link => {
244
- link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
245
- })
246
- updateElem("script");
247
- window.lastCalled.set(dataObj.file, now)
248
- }
249
- }
250
-
251
-
252
- function updateElem(type) {
253
- const hmrId = "${id}";
254
- const noSrcFile = dataObj.file.replace(\`${src}/\`, '');
255
- const attr = type === "script" ? "src" : "href";
256
- const elem = $(\`[data-hmr="\${hmrId}"] \${type}[\${attr}^="\${noSrcFile}"]\`); // could be $(\`\${type}[data-hmr="\${hmrId}"][\${attr}^="\${noSrcFile}"]\`) ?
257
-
258
- if (elem) {
259
- updateOne(type, attr, elem)
260
- } else {
261
- for(const e of $$(\`[data-hmr="\${hmrId}"] \${type}\`)) {
262
- updateOne(type, attr, e);
263
- }
264
- }
265
- }
266
-
267
- function updateOne(type, attr, elem) {
268
- const clone = document.createElement(type);
269
- for (const key of elem.getAttributeNames()) {
270
- clone.setAttribute(key, elem.getAttribute(key));
271
- }
272
- const attrVal = elem.getAttribute(attr);
273
- if (attrVal) clone.setAttribute(attr, attrVal + "?v=" + String(Math.random().toFixed(4)).slice(2));
274
- render(clone, elem, false);
275
- }
276
- });
277
- }
278
-
279
- window.addEventListener("pagehide", () => {
280
- shouldReconnect = false;
281
- clearTimeout(reconnectTimer);
282
- if (window.htmlBundleHMRActiveId === "${id}") {
283
- delete window.htmlBundleHMRActiveId;
284
- }
285
- closeEventSource();
286
- }, { once: true });
287
-
288
- if (!window.eventsource${id}) {
289
- connectEventSource();
290
- }
291
- `;
143
+ // Produce the per-page HMR client by substituting tokens into the shared runtime
144
+ // template (src/hmr-client.ts). The runtime is injected as an inline module so
145
+ // esbuild bundles hydro-js for it, but it coordinates through a single global hub
146
+ // so every composed page shares one EventSource and patches its own region.
147
+ function buildHMRClient(file, id, src) {
148
+ return hmrClientTemplate
149
+ .replaceAll("__HMR_FILE__", file)
150
+ .replaceAll("__HMR_ID__", id)
151
+ .replaceAll("__HMR_SRC__", src);
152
+ }
153
+ function prependChild(parent, node) {
154
+ // Insert as the first child so the HMR client runs before the page's own
155
+ // scripts — required for window.htmlBundleHMR.dispose()/data to be usable on
156
+ // initial load.
157
+ node.parentNode = parent;
158
+ parent.childNodes.unshift(node);
292
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html-bundle",
3
- "version": "6.3.1",
3
+ "version": "6.4.0",
4
4
  "description": "A very simple bundler for HTML SFC",
5
5
  "bin": "./dist/bundle.mjs",
6
6
  "main": "./dist/bundle.mjs",
@@ -32,6 +32,7 @@
32
32
  "@types/html-minifier-terser": "^7.0.2",
33
33
  "@types/parse5": "^7.0.0",
34
34
  "@types/postcss-load-config": "^3.0.1",
35
+ "happy-dom": "^20.10.6",
35
36
  "typescript": "^6.0.3"
36
37
  },
37
38
  "repository": {
@@ -50,6 +51,7 @@
50
51
  "glob": "^13.0.6",
51
52
  "html-minifier-terser": "^7.2.0",
52
53
  "hydro-js": "^1.9.0",
54
+ "jiti": "^2.7.0",
53
55
  "parse5": "^8.0.1",
54
56
  "postcss": "^8.5.16",
55
57
  "postcss-load-config": "^6.0.1"
package/src/bundle.mts CHANGED
@@ -122,6 +122,13 @@ async function build(files: string[], firstRun = true) {
122
122
  console.log(`⌛ Waiting for file changes ...`);
123
123
 
124
124
  const chokidarOptions = { awaitWriteFinish: false };
125
+ let rebuildQueue = Promise.resolve();
126
+ const enqueueRebuild = (file: string) => {
127
+ rebuildQueue = rebuildQueue
128
+ .catch(() => undefined)
129
+ .then(() => rebuild(file));
130
+ return rebuildQueue;
131
+ };
125
132
  if (postcssFile) {
126
133
  const postCSSWatcher = watch(postcssFile, chokidarOptions);
127
134
  const tailwindCSSWatcher = watch(
@@ -156,7 +163,8 @@ async function build(files: string[], firstRun = true) {
156
163
  }
157
164
 
158
165
  try {
159
- await rebuild(file);
166
+ files.push(file);
167
+ await enqueueRebuild(file);
160
168
  } catch {}
161
169
 
162
170
  console.log(`⚡ added ${file} to the build`);
@@ -167,7 +175,7 @@ async function build(files: string[], firstRun = true) {
167
175
  }
168
176
  file = String.raw`${file}`.replace(/\\/g, "/");
169
177
 
170
- await rebuild(file);
178
+ await enqueueRebuild(file);
171
179
 
172
180
  console.log(`⚡ modified ${file} on the build`);
173
181
  });
@@ -177,6 +185,8 @@ async function build(files: string[], firstRun = true) {
177
185
  }
178
186
  file = String.raw`${file}`.replace(/\\/g, "/");
179
187
 
188
+ const fileIndex = files.indexOf(file);
189
+ if (fileIndex !== -1) files.splice(fileIndex, 1);
180
190
  inlineFiles.delete(file);
181
191
  const buildFile = getBuildPath(file)
182
192
  .replace(".ts", ".js")
@@ -189,45 +199,82 @@ async function build(files: string[], firstRun = true) {
189
199
  if (!stats.length) await rm(bfDir);
190
200
  } catch {}
191
201
 
202
+ serverSentEvents?.({ type: "full-reload", file });
203
+
192
204
  console.log(`⚡ deleted ${file} from the build`);
193
205
  });
194
206
 
195
207
  async function rebuild(file: string) {
196
208
  // Rebuild all CSS because a change in any file might need to trigger PostCSS zu rebuild(e.g. Tailwind CSS)
197
209
  await rebuildCSS(files.filter((file) => file.endsWith(".css")));
210
+ const htmlFiles = files.filter((f) => f.endsWith(".html"));
198
211
 
199
- let html;
200
212
  if (file.endsWith(".html")) {
213
+ const previousHtml = builtHTMLCache.get(file);
201
214
  // To refill the inlineFiles needed to build JS
202
- for (const htmlFile of files.filter((file) => file.endsWith(".html"))) {
215
+ for (const htmlFile of htmlFiles) {
203
216
  await writeInlineScripts(htmlFile);
204
217
  }
205
218
  await minifyCode();
206
- for (const file of inlineFiles) {
207
- if (INLINE_BUNDLE_FILE.test(file)) {
208
- inlineFiles.delete(file);
209
- await rm(file);
219
+ for (const inline of inlineFiles) {
220
+ if (INLINE_BUNDLE_FILE.test(inline)) {
221
+ inlineFiles.delete(inline);
222
+ await rm(inline);
210
223
  }
211
224
  }
212
- html = await minifyHTML(file, getBuildPath(file));
225
+ const html = await minifyHTML(file, getBuildPath(file));
226
+ serverSentEvents?.({ type: "html", file, html, previousHtml });
213
227
  } else if (/\.(jsx?|tsx?)$/.test(file)) {
228
+ // A module change alters the inlined output of whichever page(s) import
229
+ // it. Rebuild every page, then emit only the pages whose HTML actually
230
+ // changed; the client diff re-runs just the scripts that differ, so
231
+ // unrelated pages keep their state.
214
232
  inlineFiles.add(file);
233
+ for (const htmlFile of htmlFiles) {
234
+ await writeInlineScripts(htmlFile);
235
+ }
215
236
  await minifyCode();
216
- } else if (!file.endsWith(".css")) {
237
+ for (const inline of inlineFiles) {
238
+ if (INLINE_BUNDLE_FILE.test(inline)) {
239
+ inlineFiles.delete(inline);
240
+ await rm(inline);
241
+ }
242
+ }
243
+ let didEmit = false;
244
+ for (const htmlFile of htmlFiles) {
245
+ const previousHtml = builtHTMLCache.get(htmlFile);
246
+ const html = await minifyHTML(htmlFile, getBuildPath(htmlFile));
247
+ if (html !== previousHtml) {
248
+ didEmit = true;
249
+ serverSentEvents?.({
250
+ type: "html",
251
+ file: htmlFile,
252
+ html,
253
+ previousHtml,
254
+ });
255
+ }
256
+ }
257
+ if (!didEmit) {
258
+ serverSentEvents?.({ type: "full-reload", file });
259
+ }
260
+ } else if (file.endsWith(".css")) {
261
+ serverSentEvents?.({ type: "css", file });
262
+ } else {
217
263
  if (handlerFile) {
218
- execFilePromise("node", [handlerFile, file]).then(({ stdout }) => {
264
+ try {
265
+ const { stdout } = await execFilePromise("node", [
266
+ handlerFile,
267
+ file,
268
+ ]);
219
269
  console.log("📋 Logging Handler: ", String(stdout));
220
- });
270
+ } catch (err) {
271
+ console.error(err);
272
+ }
221
273
  } else {
222
274
  await fileCopy(file);
223
275
  }
224
- } else if (handlerFile) {
225
- execFilePromise("node", [handlerFile, file]).then(({ stdout }) => {
226
- console.log("📋 Logging Handler: ", String(stdout));
227
- });
276
+ serverSentEvents?.({ type: "asset", file });
228
277
  }
229
-
230
- serverSentEvents?.({ file, html });
231
278
  }
232
279
  }
233
280
  }
@@ -297,6 +344,7 @@ async function minifyCode(): Promise<unknown> {
297
344
  }
298
345
 
299
346
  const htmlFilesCache = new Map();
347
+ const builtHTMLCache = new Map<string, string>();
300
348
  async function writeInlineScripts(file: string) {
301
349
  let fileText = await readFile(file, { encoding: "utf-8" });
302
350
 
@@ -430,6 +478,7 @@ async function minifyHTML(file: string, buildFile: string) {
430
478
  }
431
479
 
432
480
  await writeFile(buildFile, fileText);
481
+ builtHTMLCache.set(file, fileText);
433
482
  return fileText;
434
483
  }
435
484