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.
@@ -1,7 +1,7 @@
1
1
  version: 2
2
2
  updates:
3
- - package-ecosystem: npm
4
- directory: "/"
5
- schedule:
6
- interval: daily
7
- open-pull-requests-limit: 99
3
+ - package-ecosystem: npm
4
+ directory: "/"
5
+ schedule:
6
+ interval: daily
7
+ open-pull-requests-limit: 99
package/README.md CHANGED
@@ -10,7 +10,7 @@ A (primarily) zero-config bundler for HTML files. The idea is to use HTML as Sin
10
10
 
11
11
  - 🦾 TypeScript (reference it as .js or write inline TS)
12
12
  - 📦 Automatic Package Installation
13
- - 💨 HMR and automatic reconnect
13
+ - 💨 State-preserving HMR with automatic reconnect and full-reload fallback
14
14
  - ⚡ [ESBuild](https://github.com/evanw/esbuild)
15
15
  - 🦔 [Critical CSS](https://www.npmjs.com/package/beasties)
16
16
  - 🚋 Watcher on PostCSS and Tailwind CSS and TS Config
@@ -45,13 +45,45 @@ $ npm run build
45
45
 
46
46
  ## CLI
47
47
 
48
- `--hmr`: boots up a static server and enables Hot Module Replacement. **This generates a development build and works best when not triggered from the main index.html**<br>
48
+ `--hmr`: boots up a static server and enables Hot Module Replacement. See [HMR](#hmr) for what is hot-patched in place versus reloaded.<br>
49
49
  `--secure`: creates a secure HTTP2 over HTTPS instance. This requires the files `localhost.pem` and `localhost-key.pem` in the root folder. You can generate them with [mkcert](https://github.com/FiloSottile/mkcert) for instance.<br>
50
50
  `--isCritical`: uses critical to extract and inline critical-path CSS to HTML.<br>
51
51
  `--handler`: path to your custom handler. Here, you can handle all non-supported files. You can get the filename via `process.argv[2]`.
52
52
 
53
+ ## HMR
54
+
55
+ With `--hmr`, every change is pushed over a single Server-Sent-Events connection and applied without a full reload wherever that is safe:
56
+
57
+ - **HTML** (text, attributes, inline `<style>`) is diffed and patched in place. Only the `<script>`s whose code actually changed are re-executed, so unrelated state is preserved. Inserting or removing surrounding markup keeps dynamically rendered/fetched content intact.
58
+ - **CSS** (linked or inline) and **assets** (images, etc.) are swapped by cache-busting the matching element — no reload.
59
+ - **TS/JS modules** are re-bundled; the page(s) that inline them are re-emitted and hot-patched.
60
+ - **Dead ends** — a change with no owning page (e.g. a web-worker entry) or a deleted file — trigger a single debounced **full page reload**, so you never get stuck on a stale view.
61
+
62
+ Composed apps (pages that fetch and render other pages) are supported: every live page shares one connection and only its own region is patched. After HMR operations the `popstate` event is dispatched to nudge SPA routers.
63
+
64
+ ### Client API (optional)
65
+
66
+ Inside any HMR-managed page you can opt into lifecycle hooks — useful for side-effectful top-level code so a hot update does not duplicate its effects:
67
+
68
+ ```js
69
+ if (window.htmlBundleHMR) {
70
+ // run cleanup before this unit's changed scripts re-run
71
+ window.htmlBundleHMR.dispose(() => {
72
+ /* e.g. remove nodes this script appended */
73
+ });
74
+ // run after this page has been patched
75
+ window.htmlBundleHMR.accept(() => {
76
+ /* re-init if needed */
77
+ });
78
+ // a plain object that persists across hot updates
79
+ window.htmlBundleHMR.data.rendered = true;
80
+ }
81
+ ```
82
+
53
83
  ## import
84
+
54
85
  It is also possible to start the server by importing the package. This could be useful, if you intend to add routes for local development.
86
+
55
87
  ```js
56
88
  import router from "html-bundle";
57
89
 
@@ -60,7 +92,6 @@ router.get("/test", (_req, reply) => {
60
92
  });
61
93
  ```
62
94
 
63
-
64
95
  ## Optional Config
65
96
 
66
97
  _The CLI flags can also be set by the config. Flags set by the CLI will override the config._
package/dist/bundle.mjs CHANGED
@@ -92,6 +92,13 @@ async function build(files, firstRun = true) {
92
92
  console.log(`💻 Server listening on http${isSecure ? "s" : ""}://${bundleConfig.host === "::" ? "localhost" : bundleConfig.host}:${bundleConfig.port} and is shared in the local network.`);
93
93
  console.log(`⌛ Waiting for file changes ...`);
94
94
  const chokidarOptions = { awaitWriteFinish: false };
95
+ let rebuildQueue = Promise.resolve();
96
+ const enqueueRebuild = (file) => {
97
+ rebuildQueue = rebuildQueue
98
+ .catch(() => undefined)
99
+ .then(() => rebuild(file));
100
+ return rebuildQueue;
101
+ };
95
102
  if (postcssFile) {
96
103
  const postCSSWatcher = watch(postcssFile, chokidarOptions);
97
104
  const tailwindCSSWatcher = watch(postcssFile.replace("postcss", "tailwind"), chokidarOptions); // Assuming that the file ext is the same
@@ -111,7 +118,8 @@ async function build(files, firstRun = true) {
111
118
  return;
112
119
  }
113
120
  try {
114
- await rebuild(file);
121
+ files.push(file);
122
+ await enqueueRebuild(file);
115
123
  }
116
124
  catch { }
117
125
  console.log(`⚡ added ${file} to the build`);
@@ -121,7 +129,7 @@ async function build(files, firstRun = true) {
121
129
  return;
122
130
  }
123
131
  file = String.raw `${file}`.replace(/\\/g, "/");
124
- await rebuild(file);
132
+ await enqueueRebuild(file);
125
133
  console.log(`⚡ modified ${file} on the build`);
126
134
  });
127
135
  watcher.on("unlink", async (file) => {
@@ -129,6 +137,9 @@ async function build(files, firstRun = true) {
129
137
  return;
130
138
  }
131
139
  file = String.raw `${file}`.replace(/\\/g, "/");
140
+ const fileIndex = files.indexOf(file);
141
+ if (fileIndex !== -1)
142
+ files.splice(fileIndex, 1);
132
143
  inlineFiles.delete(file);
133
144
  const buildFile = getBuildPath(file)
134
145
  .replace(".ts", ".js")
@@ -141,46 +152,84 @@ async function build(files, firstRun = true) {
141
152
  await rm(bfDir);
142
153
  }
143
154
  catch { }
155
+ serverSentEvents?.({ type: "full-reload", file });
144
156
  console.log(`⚡ deleted ${file} from the build`);
145
157
  });
146
158
  async function rebuild(file) {
147
159
  // Rebuild all CSS because a change in any file might need to trigger PostCSS zu rebuild(e.g. Tailwind CSS)
148
160
  await rebuildCSS(files.filter((file) => file.endsWith(".css")));
149
- let html;
161
+ const htmlFiles = files.filter((f) => f.endsWith(".html"));
150
162
  if (file.endsWith(".html")) {
163
+ const previousHtml = builtHTMLCache.get(file);
151
164
  // To refill the inlineFiles needed to build JS
152
- for (const htmlFile of files.filter((file) => file.endsWith(".html"))) {
165
+ for (const htmlFile of htmlFiles) {
153
166
  await writeInlineScripts(htmlFile);
154
167
  }
155
168
  await minifyCode();
156
- for (const file of inlineFiles) {
157
- if (INLINE_BUNDLE_FILE.test(file)) {
158
- inlineFiles.delete(file);
159
- await rm(file);
169
+ for (const inline of inlineFiles) {
170
+ if (INLINE_BUNDLE_FILE.test(inline)) {
171
+ inlineFiles.delete(inline);
172
+ await rm(inline);
160
173
  }
161
174
  }
162
- html = await minifyHTML(file, getBuildPath(file));
175
+ const html = await minifyHTML(file, getBuildPath(file));
176
+ serverSentEvents?.({ type: "html", file, html, previousHtml });
163
177
  }
164
178
  else if (/\.(jsx?|tsx?)$/.test(file)) {
179
+ // A module change alters the inlined output of whichever page(s) import
180
+ // it. Rebuild every page, then emit only the pages whose HTML actually
181
+ // changed; the client diff re-runs just the scripts that differ, so
182
+ // unrelated pages keep their state.
165
183
  inlineFiles.add(file);
184
+ for (const htmlFile of htmlFiles) {
185
+ await writeInlineScripts(htmlFile);
186
+ }
166
187
  await minifyCode();
188
+ for (const inline of inlineFiles) {
189
+ if (INLINE_BUNDLE_FILE.test(inline)) {
190
+ inlineFiles.delete(inline);
191
+ await rm(inline);
192
+ }
193
+ }
194
+ let didEmit = false;
195
+ for (const htmlFile of htmlFiles) {
196
+ const previousHtml = builtHTMLCache.get(htmlFile);
197
+ const html = await minifyHTML(htmlFile, getBuildPath(htmlFile));
198
+ if (html !== previousHtml) {
199
+ didEmit = true;
200
+ serverSentEvents?.({
201
+ type: "html",
202
+ file: htmlFile,
203
+ html,
204
+ previousHtml,
205
+ });
206
+ }
207
+ }
208
+ if (!didEmit) {
209
+ serverSentEvents?.({ type: "full-reload", file });
210
+ }
211
+ }
212
+ else if (file.endsWith(".css")) {
213
+ serverSentEvents?.({ type: "css", file });
167
214
  }
168
- else if (!file.endsWith(".css")) {
215
+ else {
169
216
  if (handlerFile) {
170
- execFilePromise("node", [handlerFile, file]).then(({ stdout }) => {
217
+ try {
218
+ const { stdout } = await execFilePromise("node", [
219
+ handlerFile,
220
+ file,
221
+ ]);
171
222
  console.log("📋 Logging Handler: ", String(stdout));
172
- });
223
+ }
224
+ catch (err) {
225
+ console.error(err);
226
+ }
173
227
  }
174
228
  else {
175
229
  await fileCopy(file);
176
230
  }
231
+ serverSentEvents?.({ type: "asset", file });
177
232
  }
178
- else if (handlerFile) {
179
- execFilePromise("node", [handlerFile, file]).then(({ stdout }) => {
180
- console.log("📋 Logging Handler: ", String(stdout));
181
- });
182
- }
183
- serverSentEvents?.({ file, html });
184
233
  }
185
234
  }
186
235
  }
@@ -245,6 +294,7 @@ async function minifyCode() {
245
294
  }
246
295
  }
247
296
  const htmlFilesCache = new Map();
297
+ const builtHTMLCache = new Map();
248
298
  async function writeInlineScripts(file) {
249
299
  let fileText = await readFile(file, { encoding: "utf-8" });
250
300
  let DOM;
@@ -359,6 +409,7 @@ async function minifyHTML(file, buildFile) {
359
409
  }
360
410
  }
361
411
  await writeFile(buildFile, fileText);
412
+ builtHTMLCache.set(file, fileText);
362
413
  return fileText;
363
414
  }
364
415
  async function rebuildCSS(files, config) {
@@ -0,0 +1,40 @@
1
+ type HTMLMessage = {
2
+ type: "html";
3
+ file: string;
4
+ html: string;
5
+ previousHtml?: string;
6
+ };
7
+ type HMRMessage = HTMLMessage | {
8
+ type: "css";
9
+ file: string;
10
+ } | {
11
+ type: "asset";
12
+ file: string;
13
+ } | {
14
+ type: "full-reload";
15
+ file: string;
16
+ };
17
+ type Hub = {
18
+ currentUnit: string | null;
19
+ lastHTML: Map<string, string>;
20
+ register(file: string, id: string, handler: {
21
+ patch: (message: HTMLMessage) => void;
22
+ }): void;
23
+ addAccept(file: string, callback: () => void): void;
24
+ addDispose(file: string, callback: () => void): void;
25
+ dataFor(file: string): Record<string, unknown>;
26
+ dispatch(message: HMRMessage): void;
27
+ };
28
+ type HMRPublicAPI = {
29
+ accept(callback: () => void): void;
30
+ dispose(callback: () => void): void;
31
+ readonly data: Record<string, unknown>;
32
+ };
33
+ declare global {
34
+ interface Window {
35
+ isHMR?: boolean;
36
+ __htmlBundleHMR?: Hub;
37
+ htmlBundleHMR?: HMRPublicAPI;
38
+ }
39
+ }
40
+ export {};