html-bundle 6.4.0 → 6.5.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/README.md CHANGED
@@ -46,9 +46,10 @@ $ npm run build
46
46
  ## CLI
47
47
 
48
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
- `--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>
49
+ `--secure`: creates a secure HTTP2 over HTTPS instance. Plain HTTP requests to the same host and port redirect to HTTPS. 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
- `--handler`: path to your custom handler. Here, you can handle all non-supported files. You can get the filename via `process.argv[2]`.
51
+ `--handler`: path to your custom handler. Here, you can handle all non-supported files. You can get the filename via `process.argv[2]`.<br>
52
+ `--handlerConcurrency`: maximum number of handler processes running at once. Defaults to the available CPU count. For image-heavy handlers such as Sharp resizing, try 2, 4, 8, and the default value, then keep the fastest value that does not spike memory.
52
53
 
53
54
  ## HMR
54
55
 
@@ -112,6 +113,7 @@ Example:
112
113
  export default {
113
114
  secure: true,
114
115
  handler: "utils/staticFiles.js",
116
+ handlerConcurrency: 4,
115
117
  esbuild: {
116
118
  external: ["images"],
117
119
  },
package/dist/bundle.d.mts CHANGED
@@ -17,6 +17,8 @@ export type Config = {
17
17
  isCritical?: boolean;
18
18
  hmr?: boolean;
19
19
  handler?: string;
20
+ handlerConcurrency?: number;
21
+ maxHandlerConcurrency?: number;
20
22
  host?: string;
21
23
  key?: Buffer;
22
24
  cert?: Buffer;
package/dist/bundle.mjs CHANGED
@@ -4,9 +4,11 @@ import { readFile, rm, writeFile, readdir, lstat } from "fs/promises";
4
4
  import { execFile } from "child_process";
5
5
  import { promisify } from "util";
6
6
  import { sep } from "path";
7
+ import { availableParallelism } from "os";
7
8
  import { glob } from "glob";
8
9
  import postcss from "postcss";
9
10
  import esbuild from "esbuild";
11
+ import pLimit from "p-limit";
10
12
  import Beasties from "beasties";
11
13
  import { minify } from "html-minifier-terser";
12
14
  import { watch } from "chokidar";
@@ -25,6 +27,9 @@ const isSecure = process.argv.includes("--secure") || bundleConfig.secure; // us
25
27
  const handlerFile = process.argv.includes("--handler")
26
28
  ? process.argv[process.argv.indexOf("--handler") + 1]
27
29
  : bundleConfig.handler;
30
+ const defaultHandlerConcurrency = availableParallelism();
31
+ const handlerConcurrency = getHandlerConcurrency();
32
+ const limitHandler = pLimit(handlerConcurrency);
28
33
  process.env.NODE_ENV = isHMR ? "development" : "production"; // just in case other tools are using it
29
34
  let timer = performance.now();
30
35
  let { plugins, options, file: postcssFile } = await getPostCSSConfig();
@@ -43,20 +48,19 @@ async function cleanupStaleInlineBundleFiles() {
43
48
  await Promise.all(staleFiles.map((file) => rm(file.replaceAll(sep, "/"), { force: true })));
44
49
  }
45
50
  async function build(files, firstRun = true) {
51
+ const handlerTasks = [];
46
52
  for (const file of files) {
47
53
  if (INLINE_BUNDLE_FILE.test(file)) {
48
54
  continue;
49
55
  }
50
56
  await createDir(file);
51
57
  if (!SUPPORTED_FILES.test(file)) {
58
+ if ((await lstat(file)).isDirectory())
59
+ continue;
52
60
  if (handlerFile) {
53
- execFilePromise("node", [handlerFile, file]).then(({ stdout }) => {
54
- console.log("📋 Logging Handler: ", String(stdout));
55
- });
61
+ handlerTasks.push(limitHandler(() => runHandler(file)));
56
62
  }
57
63
  else {
58
- if ((await lstat(file)).isDirectory())
59
- continue;
60
64
  await fileCopy(file);
61
65
  }
62
66
  }
@@ -84,6 +88,7 @@ async function build(files, firstRun = true) {
84
88
  await minifyHTML(file, getBuildPath(file));
85
89
  }
86
90
  }
91
+ await Promise.all(handlerTasks);
87
92
  console.log(`🚀 Build finished in ${(performance.now() - timer).toFixed(2)}ms ✨`);
88
93
  if (isHMR && firstRun) {
89
94
  const [dynamicRouter, server] = await createDefaultServer(isSecure);
@@ -215,11 +220,7 @@ async function build(files, firstRun = true) {
215
220
  else {
216
221
  if (handlerFile) {
217
222
  try {
218
- const { stdout } = await execFilePromise("node", [
219
- handlerFile,
220
- file,
221
- ]);
222
- console.log("📋 Logging Handler: ", String(stdout));
223
+ await limitHandler(() => runHandler(file));
223
224
  }
224
225
  catch (err) {
225
226
  console.error(err);
@@ -233,6 +234,30 @@ async function build(files, firstRun = true) {
233
234
  }
234
235
  }
235
236
  }
237
+ function getHandlerConcurrency() {
238
+ const value = getArgValue("--handlerConcurrency") ??
239
+ getArgValue("--maxHandlerConcurrency") ??
240
+ bundleConfig.handlerConcurrency ??
241
+ bundleConfig.maxHandlerConcurrency ??
242
+ defaultHandlerConcurrency;
243
+ const parsed = Number(value);
244
+ if (!Number.isInteger(parsed) || parsed < 1) {
245
+ return defaultHandlerConcurrency;
246
+ }
247
+ return parsed;
248
+ }
249
+ function getArgValue(name) {
250
+ const index = process.argv.indexOf(name);
251
+ return index === -1 ? undefined : process.argv[index + 1];
252
+ }
253
+ async function runHandler(file) {
254
+ if (!handlerFile)
255
+ return;
256
+ const { stdout } = await execFilePromise("node", [handlerFile, file]);
257
+ const output = String(stdout).trim();
258
+ if (output)
259
+ console.log("📋 Logging Handler: ", output);
260
+ }
236
261
  async function minifyCSS(file, buildFile) {
237
262
  try {
238
263
  const fileText = await readFile(file, { encoding: "utf-8" });
@@ -20,7 +20,27 @@ const ID = "__HMR_ID__";
20
20
  const SRC = "__HMR_SRC__";
21
21
  const REGION = '[data-hmr="__HMR_ID__"]';
22
22
  const CLIENT = 'script[data-hmr-client="__HMR_ID__"]';
23
- window.isHMR = true;
23
+ // The client is injected as the first <head> module so its hub and public API
24
+ // exist before the page's own scripts run. But `window.isHMR` must NOT be
25
+ // observable while those scripts execute for the first time: user code commonly
26
+ // branches on it to run one-time setup exactly once (e.g.
27
+ // `if (!window.isHMR) createRouter()`), relying on the flag being false on the
28
+ // pristine load and true on every hot re-run afterwards. Assigning it eagerly
29
+ // here runs before the page scripts and breaks that contract, leaving one-time
30
+ // init skipped (e.g. an SPA router never mounts, so its outlet stays empty).
31
+ // Flag it only after the initial module scripts have executed:
32
+ // - initial load: readyState is "interactive"; set it on DOMContentLoaded,
33
+ // which fires after the page's own deferred/module scripts.
34
+ // - hot re-render / composed page registered post-load: readyState is
35
+ // "complete"; set it immediately, since we are already past the first load.
36
+ if (document.readyState === "complete") {
37
+ window.isHMR = true;
38
+ }
39
+ else {
40
+ document.addEventListener("DOMContentLoaded", () => (window.isHMR = true), {
41
+ once: true,
42
+ });
43
+ }
24
44
  // One hub per browsing context, created by whichever page loads first. Every
25
45
  // composed sub-page (index.html + fetched fragments) registers into it and stays
26
46
  // live, so a single shared EventSource dispatches every file's changes to the
@@ -55,39 +75,55 @@ function patch(message) {
55
75
  }
56
76
  }
57
77
  else {
58
- patchFragment(message.html);
78
+ patchFragment(message);
59
79
  }
60
80
  window.scrollTo(0, previousScroll);
61
81
  }
62
- function patchFragment(htmlText) {
63
- const incoming = parseHTML(htmlText);
82
+ function patchFragment(message) {
83
+ const incoming = parseHTML(message.html);
64
84
  // hydro-js returns a DocumentFragment for multi-root markup but the element
65
85
  // itself for a single root — normalise to a flat list of top-level nodes.
66
- const nextNodes = incoming.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
67
- incoming.nodeType === Node.DOCUMENT_NODE
68
- ? Array.from(incoming.childNodes)
69
- : [incoming];
86
+ const nextNodes = topLevelNodes(incoming);
70
87
  const regions = Array.from(document.querySelectorAll(REGION));
88
+ if (!regions.length) {
89
+ hub.lastHTML.set(FILE, message.html);
90
+ return;
91
+ }
92
+ const previousText = message.previousHtml || hub.lastHTML.get(FILE);
93
+ const previousNodes = previousText
94
+ ? topLevelNodes(parseHTML(previousText))
95
+ : [];
71
96
  // Replace each existing region in place, drop the surplus, append the rest.
72
97
  regions.forEach((where, index) => {
73
98
  if (index < nextNodes.length) {
74
- render(nextNodes[index], where, false);
99
+ const previousNode = previousNodes[index];
100
+ const nextNode = nextNodes[index];
101
+ if (previousNode && sameNodeIdentity(previousNode, nextNode)) {
102
+ patchNode(previousNode, nextNode, where);
103
+ }
104
+ else {
105
+ render(cloneForRender(nextNode), where, false);
106
+ }
75
107
  }
76
108
  else {
77
109
  where.remove();
78
110
  }
79
111
  });
80
112
  for (let rest = regions.length; rest < nextNodes.length; rest++) {
81
- if (regions.length) {
82
- const template = document.createElement("template");
83
- nextNodes[regions.length - 1].after(template);
84
- render(nextNodes[rest], template, false);
85
- template.remove();
86
- }
87
- else {
88
- render(nextNodes[rest], false, false);
89
- }
113
+ const template = document.createElement("template");
114
+ const regionList = Array.from(document.querySelectorAll(REGION));
115
+ const lastRegion = regionList[regionList.length - 1];
116
+ lastRegion.after(template);
117
+ render(cloneForRender(nextNodes[rest]), template, false);
118
+ template.remove();
90
119
  }
120
+ hub.lastHTML.set(FILE, message.html);
121
+ }
122
+ function topLevelNodes(parsed) {
123
+ return parsed.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
124
+ parsed.nodeType === Node.DOCUMENT_NODE
125
+ ? Array.from(parsed.childNodes)
126
+ : [parsed];
91
127
  }
92
128
  function isFullDocument(htmlText) {
93
129
  const trimmed = htmlText.trimStart().toLowerCase();
package/dist/utils.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Config } from "./bundle.mjs";
2
2
  import type { Router } from "express-serve-static-core";
3
3
  import { type Server } from "http";
4
- import { type Server as HTTPSServer } from "https";
4
+ import type { Server as HTTPSServer } from "https";
5
5
  import postcssrc from "postcss-load-config";
6
6
  import cssnano from "cssnano";
7
7
  import { parse, parseFragment } from "parse5";
package/dist/utils.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { copyFile, mkdir, readFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import http from "http";
4
- import https from "https";
5
4
  import express from "express";
5
+ import httpolyglot from "httpolyglot";
6
6
  import postcssrc from "postcss-load-config";
7
7
  import cssnano from "cssnano";
8
8
  import { parse, parseFragment, serialize } from "parse5";
@@ -35,6 +35,17 @@ export let serverSentEvents;
35
35
  export async function createDefaultServer(isSecure) {
36
36
  const router = express.Router();
37
37
  const app = express();
38
+ if (isSecure) {
39
+ app.use((req, res, next) => {
40
+ const socket = req.socket;
41
+ if (socket.encrypted) {
42
+ next();
43
+ return;
44
+ }
45
+ const host = req.headers.host || getDefaultHost();
46
+ res.redirect(307, `https://${host}${req.originalUrl || req.url}`);
47
+ });
48
+ }
38
49
  app.use(router);
39
50
  app.use(express.static(path.join(process.cwd(), bundleConfig.build)));
40
51
  router.get("/hmr", (req, reply) => {
@@ -63,18 +74,25 @@ export async function createDefaultServer(isSecure) {
63
74
  });
64
75
  res.send(file);
65
76
  });
77
+ const secureOptions = isSecure
78
+ ? {
79
+ key: bundleConfig.key ||
80
+ (await readFile(path.join(process.cwd(), "localhost-key.pem"))),
81
+ cert: bundleConfig.cert ||
82
+ (await readFile(path.join(process.cwd(), "localhost.pem"))),
83
+ }
84
+ : undefined;
66
85
  return [
67
86
  router,
68
87
  isSecure
69
- ? https.createServer({
70
- key: bundleConfig.key ||
71
- (await readFile(path.join(process.cwd(), "localhost-key.pem"))),
72
- cert: bundleConfig.cert ||
73
- (await readFile(path.join(process.cwd(), "localhost.pem"))),
74
- }, app)
88
+ ? httpolyglot.createServer(secureOptions, app)
75
89
  : http.createServer({}, app),
76
90
  ];
77
91
  }
92
+ function getDefaultHost() {
93
+ const host = bundleConfig.host === "::" ? "localhost" : bundleConfig.host;
94
+ return `${host}:${bundleConfig.port}`;
95
+ }
78
96
  export async function getPostCSSConfig() {
79
97
  try {
80
98
  return await postcssrc({});
@@ -126,8 +144,7 @@ export function addHMRCode(html, file, ast) {
126
144
  if (html.includes("<!DOCTYPE html>") || html.includes("<html")) {
127
145
  DOM = ast || parse(html);
128
146
  const headNode = findElement(DOM, (e) => getTagName(e) === "head");
129
- // Inject first in <head> so the client runs before the page's own scripts.
130
- prependChild(headNode, script);
147
+ insertHeadClient(headNode, script);
131
148
  }
132
149
  else {
133
150
  DOM = ast || parseFragment(html);
@@ -157,3 +174,9 @@ function prependChild(parent, node) {
157
174
  node.parentNode = parent;
158
175
  parent.childNodes.unshift(node);
159
176
  }
177
+ function insertHeadClient(parent, node) {
178
+ const children = parent.childNodes;
179
+ const lastBaseIndex = children.findLastIndex((child) => getTagName(child) === "base");
180
+ node.parentNode = parent;
181
+ children.splice(lastBaseIndex + 1, 0, node);
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html-bundle",
3
- "version": "6.4.0",
3
+ "version": "6.5.0",
4
4
  "description": "A very simple bundler for HTML SFC",
5
5
  "bin": "./dist/bundle.mjs",
6
6
  "main": "./dist/bundle.mjs",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "bugs": "https://github.com/Krutsch/html-bundle/issues",
43
43
  "dependencies": {
44
- "@web/parse5-utils": "^2.1.1",
44
+ "@web/parse5-utils": "^3.0.0",
45
45
  "await-spawn": "^4.0.2",
46
46
  "beasties": "^0.4.2",
47
47
  "chokidar": "^5.0.0",
@@ -50,8 +50,10 @@
50
50
  "express": "^5.2.1",
51
51
  "glob": "^13.0.6",
52
52
  "html-minifier-terser": "^7.2.0",
53
+ "httpolyglot": "^0.1.2",
53
54
  "hydro-js": "^1.9.0",
54
55
  "jiti": "^2.7.0",
56
+ "p-limit": "^7.3.0",
55
57
  "parse5": "^8.0.1",
56
58
  "postcss": "^8.5.16",
57
59
  "postcss-load-config": "^6.0.1"
package/src/bundle.mts CHANGED
@@ -8,10 +8,12 @@ import { readFile, rm, writeFile, readdir, lstat } from "fs/promises";
8
8
  import { execFile } from "child_process";
9
9
  import { promisify } from "util";
10
10
  import { sep } from "path";
11
+ import { availableParallelism } from "os";
11
12
  import { glob } from "glob";
12
13
  import postcss from "postcss";
13
14
  import express from "express";
14
15
  import esbuild, { type BuildOptions } from "esbuild";
16
+ import pLimit from "p-limit";
15
17
  import Beasties, { type Options } from "beasties";
16
18
  import { minify, type Options as MinifyOptions } from "html-minifier-terser";
17
19
  import { watch } from "chokidar";
@@ -41,6 +43,9 @@ const isSecure = process.argv.includes("--secure") || bundleConfig.secure; // us
41
43
  const handlerFile = process.argv.includes("--handler")
42
44
  ? process.argv[process.argv.indexOf("--handler") + 1]
43
45
  : bundleConfig.handler;
46
+ const defaultHandlerConcurrency = availableParallelism();
47
+ const handlerConcurrency = getHandlerConcurrency();
48
+ const limitHandler = pLimit(handlerConcurrency);
44
49
 
45
50
  process.env.NODE_ENV = isHMR ? "development" : "production"; // just in case other tools are using it
46
51
  let timer = performance.now();
@@ -66,6 +71,8 @@ async function cleanupStaleInlineBundleFiles() {
66
71
  }
67
72
 
68
73
  async function build(files: string[], firstRun = true) {
74
+ const handlerTasks: Promise<void>[] = [];
75
+
69
76
  for (const file of files) {
70
77
  if (INLINE_BUNDLE_FILE.test(file)) {
71
78
  continue;
@@ -74,12 +81,11 @@ async function build(files: string[], firstRun = true) {
74
81
  await createDir(file);
75
82
 
76
83
  if (!SUPPORTED_FILES.test(file)) {
84
+ if ((await lstat(file)).isDirectory()) continue;
85
+
77
86
  if (handlerFile) {
78
- execFilePromise("node", [handlerFile, file]).then(({ stdout }) => {
79
- console.log("📋 Logging Handler: ", String(stdout));
80
- });
87
+ handlerTasks.push(limitHandler(() => runHandler(file)));
81
88
  } else {
82
- if ((await lstat(file)).isDirectory()) continue;
83
89
  await fileCopy(file);
84
90
  }
85
91
  } else {
@@ -104,6 +110,7 @@ async function build(files: string[], firstRun = true) {
104
110
  await minifyHTML(file, getBuildPath(file));
105
111
  }
106
112
  }
113
+ await Promise.all(handlerTasks);
107
114
 
108
115
  console.log(
109
116
  `🚀 Build finished in ${(performance.now() - timer).toFixed(2)}ms ✨`,
@@ -262,11 +269,7 @@ async function build(files: string[], firstRun = true) {
262
269
  } else {
263
270
  if (handlerFile) {
264
271
  try {
265
- const { stdout } = await execFilePromise("node", [
266
- handlerFile,
267
- file,
268
- ]);
269
- console.log("📋 Logging Handler: ", String(stdout));
272
+ await limitHandler(() => runHandler(file));
270
273
  } catch (err) {
271
274
  console.error(err);
272
275
  }
@@ -279,6 +282,35 @@ async function build(files: string[], firstRun = true) {
279
282
  }
280
283
  }
281
284
 
285
+ function getHandlerConcurrency() {
286
+ const value =
287
+ getArgValue("--handlerConcurrency") ??
288
+ getArgValue("--maxHandlerConcurrency") ??
289
+ bundleConfig.handlerConcurrency ??
290
+ bundleConfig.maxHandlerConcurrency ??
291
+ defaultHandlerConcurrency;
292
+ const parsed = Number(value);
293
+
294
+ if (!Number.isInteger(parsed) || parsed < 1) {
295
+ return defaultHandlerConcurrency;
296
+ }
297
+
298
+ return parsed;
299
+ }
300
+
301
+ function getArgValue(name: string) {
302
+ const index = process.argv.indexOf(name);
303
+ return index === -1 ? undefined : process.argv[index + 1];
304
+ }
305
+
306
+ async function runHandler(file: string) {
307
+ if (!handlerFile) return;
308
+
309
+ const { stdout } = await execFilePromise("node", [handlerFile, file]);
310
+ const output = String(stdout).trim();
311
+ if (output) console.log("📋 Logging Handler: ", output);
312
+ }
313
+
282
314
  async function minifyCSS(file: string, buildFile: string) {
283
315
  try {
284
316
  const fileText = await readFile(file, { encoding: "utf-8" });
@@ -521,6 +553,8 @@ export type Config = {
521
553
  isCritical?: boolean;
522
554
  hmr?: boolean;
523
555
  handler?: string;
556
+ handlerConcurrency?: number;
557
+ maxHandlerConcurrency?: number;
524
558
  host?: string;
525
559
  key?: Buffer;
526
560
  cert?: Buffer;
package/src/hmr-client.ts CHANGED
@@ -72,7 +72,26 @@ const SRC = "__HMR_SRC__";
72
72
  const REGION = '[data-hmr="__HMR_ID__"]';
73
73
  const CLIENT = 'script[data-hmr-client="__HMR_ID__"]';
74
74
 
75
- window.isHMR = true;
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
+ }
76
95
 
77
96
  // One hub per browsing context, created by whichever page loads first. Every
78
97
  // composed sub-page (index.html + fetched fragments) registers into it and stays
@@ -111,41 +130,60 @@ function patch(message: HTMLMessage): void {
111
130
  dispatchEvent(new Event("popstate"));
112
131
  }
113
132
  } else {
114
- patchFragment(message.html);
133
+ patchFragment(message);
115
134
  }
116
135
  window.scrollTo(0, previousScroll);
117
136
  }
118
137
 
119
- function patchFragment(htmlText: string): void {
120
- const incoming = parseHTML(htmlText);
138
+ function patchFragment(message: HTMLMessage): void {
139
+ const incoming = parseHTML(message.html);
121
140
  // hydro-js returns a DocumentFragment for multi-root markup but the element
122
141
  // 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];
142
+ const nextNodes = topLevelNodes(incoming);
128
143
  const regions = Array.from(document.querySelectorAll(REGION));
129
144
 
145
+ if (!regions.length) {
146
+ hub.lastHTML.set(FILE, message.html);
147
+ return;
148
+ }
149
+
150
+ const previousText = message.previousHtml || hub.lastHTML.get(FILE);
151
+ const previousNodes = previousText
152
+ ? topLevelNodes(parseHTML(previousText))
153
+ : [];
154
+
130
155
  // Replace each existing region in place, drop the surplus, append the rest.
131
156
  regions.forEach((where, index) => {
132
157
  if (index < nextNodes.length) {
133
- render(nextNodes[index], where, false);
158
+ const previousNode = previousNodes[index];
159
+ const nextNode = nextNodes[index];
160
+ if (previousNode && sameNodeIdentity(previousNode, nextNode)) {
161
+ patchNode(previousNode, nextNode, where);
162
+ } else {
163
+ render(cloneForRender(nextNode), where, false);
164
+ }
134
165
  } else {
135
166
  where.remove();
136
167
  }
137
168
  });
138
169
 
139
170
  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
- }
171
+ const template = document.createElement("template");
172
+ const regionList = Array.from(document.querySelectorAll(REGION));
173
+ const lastRegion = regionList[regionList.length - 1];
174
+ lastRegion.after(template);
175
+ render(cloneForRender(nextNodes[rest]), template, false);
176
+ template.remove();
148
177
  }
178
+
179
+ hub.lastHTML.set(FILE, message.html);
180
+ }
181
+
182
+ function topLevelNodes(parsed: Element | DocumentFragment | Text): Node[] {
183
+ return parsed.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
184
+ parsed.nodeType === Node.DOCUMENT_NODE
185
+ ? Array.from(parsed.childNodes)
186
+ : [parsed];
149
187
  }
150
188
 
151
189
  function isFullDocument(htmlText: string): boolean {
package/src/utils.mts CHANGED
@@ -4,8 +4,9 @@ import type { Router } from "express-serve-static-core";
4
4
  import { copyFile, mkdir, readFile } from "fs/promises";
5
5
  import path from "path";
6
6
  import http, { type Server } from "http";
7
- import https, { type Server as HTTPSServer } from "https";
7
+ import type { Server as HTTPSServer } from "https";
8
8
  import express from "express";
9
+ import httpolyglot from "httpolyglot";
9
10
  import postcssrc from "postcss-load-config";
10
11
  import cssnano from "cssnano";
11
12
  import { parse, parseFragment, serialize } from "parse5";
@@ -57,6 +58,20 @@ export async function createDefaultServer(
57
58
  ): Promise<[Router, Server | HTTPSServer]> {
58
59
  const router = express.Router();
59
60
  const app = express();
61
+
62
+ if (isSecure) {
63
+ app.use((req, res, next) => {
64
+ const socket = req.socket as typeof req.socket & { encrypted?: boolean };
65
+ if (socket.encrypted) {
66
+ next();
67
+ return;
68
+ }
69
+
70
+ const host = req.headers.host || getDefaultHost();
71
+ res.redirect(307, `https://${host}${req.originalUrl || req.url}`);
72
+ });
73
+ }
74
+
60
75
  app.use(router);
61
76
  app.use(express.static(path.join(process.cwd(), bundleConfig.build)));
62
77
 
@@ -94,24 +109,30 @@ export async function createDefaultServer(
94
109
  res.send(file);
95
110
  });
96
111
 
112
+ const secureOptions = isSecure
113
+ ? {
114
+ key:
115
+ bundleConfig.key ||
116
+ (await readFile(path.join(process.cwd(), "localhost-key.pem"))),
117
+ cert:
118
+ bundleConfig.cert ||
119
+ (await readFile(path.join(process.cwd(), "localhost.pem"))),
120
+ }
121
+ : undefined;
122
+
97
123
  return [
98
124
  router,
99
125
  isSecure
100
- ? https.createServer(
101
- {
102
- key:
103
- bundleConfig.key ||
104
- (await readFile(path.join(process.cwd(), "localhost-key.pem"))),
105
- cert:
106
- bundleConfig.cert ||
107
- (await readFile(path.join(process.cwd(), "localhost.pem"))),
108
- },
109
- app,
110
- )
126
+ ? httpolyglot.createServer(secureOptions!, app)
111
127
  : http.createServer({}, app),
112
128
  ];
113
129
  }
114
130
 
131
+ function getDefaultHost() {
132
+ const host = bundleConfig.host === "::" ? "localhost" : bundleConfig.host;
133
+ return `${host}:${bundleConfig.port}`;
134
+ }
135
+
115
136
  export async function getPostCSSConfig() {
116
137
  try {
117
138
  return await postcssrc({});
@@ -175,8 +196,7 @@ export function addHMRCode(
175
196
  if (html.includes("<!DOCTYPE html>") || html.includes("<html")) {
176
197
  DOM = ast || parse(html);
177
198
  const headNode = findElement(DOM as Node, (e) => getTagName(e) === "head");
178
- // Inject first in <head> so the client runs before the page's own scripts.
179
- prependChild(headNode as Node, script);
199
+ insertHeadClient(headNode as Node, script);
180
200
  } else {
181
201
  DOM = ast || parseFragment(html);
182
202
  prependChild(DOM as Node, script);
@@ -212,3 +232,13 @@ function prependChild(parent: Node, node: unknown) {
212
232
  (node as { parentNode?: unknown }).parentNode = parent;
213
233
  (parent as unknown as { childNodes: unknown[] }).childNodes.unshift(node);
214
234
  }
235
+
236
+ function insertHeadClient(parent: Node, node: unknown) {
237
+ const children = (parent as unknown as { childNodes: Node[] }).childNodes;
238
+ const lastBaseIndex = children.findLastIndex(
239
+ (child) => getTagName(child) === "base",
240
+ );
241
+
242
+ (node as { parentNode?: unknown }).parentNode = parent;
243
+ children.splice(lastBaseIndex + 1, 0, node as Node);
244
+ }
@@ -133,6 +133,18 @@ test("addHMRCode injects fragment client before fragment scripts", async () => {
133
133
  );
134
134
  });
135
135
 
136
+ test("addHMRCode injects full-document client after base and before scripts", async () => {
137
+ const { addHMRCode } = await import(pathToFileURL(utilsPath).href);
138
+
139
+ const html = addHMRCode(
140
+ '<!DOCTYPE html><html><head><base href="/"><script type="module">window.started = true;</script></head><body><main>Hi</main></body></html>',
141
+ "src/index.html",
142
+ );
143
+
144
+ assert.ok(html.indexOf('<base href="/">') < html.indexOf("data-hmr-client"));
145
+ assert.ok(html.indexOf("data-hmr-client") < html.indexOf("window.started"));
146
+ });
147
+
136
148
  test("HMR full-document detection survives template-literal escaping", async () => {
137
149
  const { addHMRCode } = await import(pathToFileURL(utilsPath).href);
138
150
 
@@ -276,3 +288,110 @@ test("CLI honors a custom bundle.config.js src and build directories", async (t)
276
288
  { code: "ENOENT" },
277
289
  );
278
290
  });
291
+
292
+ test("CLI removes development-only process.env.NODE_ENV branches in production", async (t) => {
293
+ const cwd = await mkdtemp(path.join(tmpdir(), "html-bundle-"));
294
+ t.after(() => rm(cwd, { force: true, recursive: true }));
295
+
296
+ await mkdir(path.join(cwd, "src"), { recursive: true });
297
+ await writeFile(
298
+ path.join(cwd, "src", "index.html"),
299
+ `<!DOCTYPE html>
300
+ <html lang="en">
301
+ <head>
302
+ <title>Fixture</title>
303
+ <script type="module">
304
+ if (process.env.NODE_ENV !== "production") {
305
+ window.__devOnly = "dev-only-hmr-hook";
306
+ }
307
+ window.__env = process.env.NODE_ENV;
308
+ </script>
309
+ </head>
310
+ <body><main>Hi</main></body>
311
+ </html>`,
312
+ );
313
+
314
+ await execFilePromise(process.execPath, [bundlePath], { cwd });
315
+
316
+ const html = await readFile(path.join(cwd, "build", "index.html"), "utf8");
317
+ assert.doesNotMatch(html, /dev-only-hmr-hook/);
318
+ assert.doesNotMatch(html, /__devOnly/);
319
+ assert.match(html, /production/);
320
+ });
321
+
322
+ test("CLI limits concurrent handler processes and waits for them", async (t) => {
323
+ const cwd = await mkdtemp(path.join(tmpdir(), "html-bundle-"));
324
+ t.after(() => rm(cwd, { force: true, recursive: true }));
325
+
326
+ await mkdir(path.join(cwd, "src", "assets"), { recursive: true });
327
+ await writeFile(
328
+ path.join(cwd, "src", "index.html"),
329
+ `<!DOCTYPE html><html lang="en"><head><title>Fixture</title></head><body><main>Hi</main></body></html>`,
330
+ );
331
+
332
+ for (let index = 0; index < 5; index++) {
333
+ await writeFile(
334
+ path.join(cwd, "src", "assets", `image-${index}.raw`),
335
+ `image ${index}`,
336
+ );
337
+ }
338
+
339
+ await writeFile(
340
+ path.join(cwd, "handler.mjs"),
341
+ `import { mkdir, readFile, writeFile } from "node:fs/promises";
342
+ import path from "node:path";
343
+
344
+ const file = process.argv[2];
345
+ const name = path.basename(file);
346
+ const start = Date.now();
347
+ await new Promise((resolve) => setTimeout(resolve, 120));
348
+ const end = Date.now();
349
+
350
+ await mkdir(path.join(process.cwd(), "build", "assets"), { recursive: true });
351
+ await writeFile(
352
+ path.join(process.cwd(), "build", "assets", name),
353
+ await readFile(file),
354
+ );
355
+ await writeFile(
356
+ path.join(process.cwd(), "build", name + ".json"),
357
+ JSON.stringify({ name, start, end }),
358
+ );
359
+ `,
360
+ );
361
+
362
+ await execFilePromise(
363
+ process.execPath,
364
+ [bundlePath, "--handler", "handler.mjs", "--handlerConcurrency", "2"],
365
+ { cwd },
366
+ );
367
+
368
+ const records = [];
369
+ for (let index = 0; index < 5; index++) {
370
+ const name = `image-${index}.raw`;
371
+ const output = await readFile(
372
+ path.join(cwd, "build", "assets", name),
373
+ "utf8",
374
+ );
375
+ records.push(
376
+ JSON.parse(
377
+ await readFile(path.join(cwd, "build", name + ".json"), "utf8"),
378
+ ),
379
+ );
380
+ assert.equal(output, `image ${index}`);
381
+ }
382
+
383
+ const events = records
384
+ .flatMap(({ start, end }) => [
385
+ { time: start, delta: 1 },
386
+ { time: end, delta: -1 },
387
+ ])
388
+ .sort((a, b) => a.time - b.time || a.delta - b.delta);
389
+ let active = 0;
390
+ let maxActive = 0;
391
+ for (const event of events) {
392
+ active += event.delta;
393
+ maxActive = Math.max(maxActive, active);
394
+ }
395
+
396
+ assert.ok(maxActive <= 2, `expected max concurrency <= 2, got ${maxActive}`);
397
+ });
@@ -93,6 +93,32 @@ test("shared hub patches a full-document title change in place", async () => {
93
93
  assert.equal(document.querySelector("title").textContent, "After");
94
94
  });
95
95
 
96
+ test("window.isHMR is hidden from the page's own initial scripts, then set for hot re-runs", async () => {
97
+ // Regression: the client is injected as the first <head> module so its hub and
98
+ // public API exist before the page's own scripts. It must NOT flip
99
+ // window.isHMR before those scripts run, or one-time guards such as
100
+ // `if (!window.isHMR) createRouter()` get skipped on the pristine load — the
101
+ // symptom being an SPA whose outlet never mounts (an almost-empty page).
102
+ const { window, document, loadPage } = await setup();
103
+ assert.equal(document.readyState, "interactive"); // pristine load, still parsing
104
+
105
+ loadPage("src/index.html", "idx1");
106
+ assert.notEqual(
107
+ window.isHMR,
108
+ true,
109
+ "isHMR must stay falsy while the page's own scripts run",
110
+ );
111
+
112
+ // DOMContentLoaded fires after those scripts; HMR mode is now observable so a
113
+ // subsequent hot re-execution can take the isHMR branch and skip re-init.
114
+ document.dispatchEvent(new window.Event("DOMContentLoaded"));
115
+ assert.equal(
116
+ window.isHMR,
117
+ true,
118
+ "isHMR is set once the initial load settles",
119
+ );
120
+ });
121
+
96
122
  test("full-document insertion before a composed mount preserves fetched content", async () => {
97
123
  const { document, loadPage, reloadCount } = await setup();
98
124
  const before =
@@ -169,6 +195,81 @@ test("single-root fragment update patches only its own region", async () => {
169
195
  assert.equal(region.textContent, "New");
170
196
  });
171
197
 
198
+ test("inactive fragment update does not append into the current document", async () => {
199
+ const { document, loadPage } = await setup();
200
+ document.body.innerHTML =
201
+ "<main><section id='active-route'>Checkbox route</section></main>";
202
+
203
+ loadPage("src/ssr.html", "ssr1");
204
+ window.__htmlBundleHMR.dispatch({
205
+ type: "html",
206
+ file: "src/ssr.html",
207
+ html: "<h1 data-hmr='ssr1'>Server-Side Rendering</h1><p data-hmr='ssr1'>SSR copy</p>",
208
+ });
209
+
210
+ assert.equal(document.querySelector('[data-hmr="ssr1"]'), null);
211
+ assert.equal(
212
+ document.body.textContent.includes("Server-Side Rendering"),
213
+ false,
214
+ );
215
+ assert.equal(
216
+ document.querySelector("#active-route")?.textContent,
217
+ "Checkbox route",
218
+ );
219
+ });
220
+
221
+ test("parent fragment update preserves a mounted child outlet", async () => {
222
+ const { document, loadPage } = await setup();
223
+ const previousHtml =
224
+ "<div data-hmr='doc1' class='layout'><aside>Old nav</aside><div data-outlet><h1>Getting started</h1><p>Parent default</p></div></div>";
225
+ const nextHtml =
226
+ "<div data-hmr='doc1' class='layout updated'><aside>New nav</aside><div data-outlet><h1>Getting started</h1><p>Parent default</p></div></div>";
227
+ document.body.innerHTML =
228
+ "<main><div data-hmr='doc1' class='layout'><aside>Old nav</aside><div data-outlet><h1 id='checkbox-route'>Checkbox route</h1><p>Child content</p></div></div></main>";
229
+
230
+ loadPage("src/documentation.html", "doc1");
231
+ window.__htmlBundleHMR.dispatch({
232
+ type: "html",
233
+ file: "src/documentation.html",
234
+ previousHtml,
235
+ html: nextHtml,
236
+ });
237
+
238
+ const region = document.querySelector('[data-hmr="doc1"]');
239
+ assert.match(region.className, /updated/);
240
+ assert.equal(region.querySelector("aside")?.textContent, "New nav");
241
+ assert.equal(
242
+ region.querySelector("#checkbox-route")?.textContent,
243
+ "Checkbox route",
244
+ );
245
+ assert.equal(
246
+ region
247
+ .querySelector("[data-outlet]")
248
+ ?.textContent.includes("Parent default"),
249
+ false,
250
+ );
251
+ });
252
+
253
+ test("fragment accept callback runs after a direct fragment patch", async () => {
254
+ const { document, loadPage } = await setup();
255
+ document.body.innerHTML = "<div data-hmr='app1'>Old</div>";
256
+ loadPage("src/app/index.html", "app1");
257
+
258
+ let accepted = false;
259
+ window.htmlBundleHMR.accept(() => {
260
+ accepted = true;
261
+ });
262
+
263
+ window.__htmlBundleHMR.dispatch({
264
+ type: "html",
265
+ file: "src/app/index.html",
266
+ html: "<div data-hmr='app1'>New</div>",
267
+ });
268
+
269
+ assert.equal(document.querySelector('[data-hmr="app1"]')?.textContent, "New");
270
+ assert.equal(accepted, true);
271
+ });
272
+
172
273
  test("dispose runs before re-patch and data persists across updates", async () => {
173
274
  const { document, loadPage } = await setup();
174
275
  document.body.innerHTML = "<div data-hmr='app1'>Old</div>";
@@ -3,15 +3,19 @@
3
3
  // normalised events the client depends on.
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { spawn } from "node:child_process";
6
+ import { execFile, spawn } from "node:child_process";
7
7
  import http from "node:http";
8
+ import https from "node:https";
8
9
  import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
9
10
  import { tmpdir } from "node:os";
10
11
  import path from "node:path";
12
+ import { promisify } from "node:util";
11
13
 
12
14
  const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
13
15
  const bundlePath = path.join(repoRoot, "dist", "bundle.mjs");
14
16
  const PORT = 5323;
17
+ const SECURE_PORT = 5324;
18
+ const execFilePromise = promisify(execFile);
15
19
 
16
20
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
21
 
@@ -84,6 +88,112 @@ async function until(predicate, ms = 6000) {
84
88
  return undefined;
85
89
  }
86
90
 
91
+ async function writeLocalhostCertificate(cwd, t) {
92
+ try {
93
+ await execFilePromise("openssl", [
94
+ "req",
95
+ "-x509",
96
+ "-newkey",
97
+ "rsa:2048",
98
+ "-nodes",
99
+ "-keyout",
100
+ path.join(cwd, "localhost-key.pem"),
101
+ "-out",
102
+ path.join(cwd, "localhost.pem"),
103
+ "-subj",
104
+ "/CN=localhost",
105
+ "-days",
106
+ "1",
107
+ "-addext",
108
+ "subjectAltName=DNS:localhost,IP:127.0.0.1",
109
+ ]);
110
+ return true;
111
+ } catch {
112
+ t.skip("openssl is required to generate the HTTPS test certificate");
113
+ return false;
114
+ }
115
+ }
116
+
117
+ async function requestHttpRedirect(pathname) {
118
+ let lastError;
119
+ for (let attempt = 0; attempt < 40; attempt++) {
120
+ try {
121
+ return await new Promise((resolve, reject) => {
122
+ const req = http.get(
123
+ `http://127.0.0.1:${SECURE_PORT}${pathname}`,
124
+ (res) => {
125
+ res.resume();
126
+ resolve({
127
+ statusCode: res.statusCode,
128
+ location: res.headers.location,
129
+ });
130
+ },
131
+ );
132
+ req.on("error", reject);
133
+ });
134
+ } catch (error) {
135
+ lastError = error;
136
+ await wait(100);
137
+ }
138
+ }
139
+ throw lastError;
140
+ }
141
+
142
+ function requestHttps(pathname) {
143
+ return new Promise((resolve, reject) => {
144
+ const req = https.get(
145
+ {
146
+ hostname: "127.0.0.1",
147
+ port: SECURE_PORT,
148
+ path: pathname,
149
+ rejectUnauthorized: false,
150
+ },
151
+ (res) => {
152
+ res.setEncoding("utf8");
153
+ let body = "";
154
+ res.on("data", (chunk) => (body += chunk));
155
+ res.on("end", () => resolve({ statusCode: res.statusCode, body }));
156
+ },
157
+ );
158
+ req.on("error", reject);
159
+ });
160
+ }
161
+
162
+ test("secure HMR server redirects plain HTTP on the same port", async (t) => {
163
+ const cwd = await mkdtemp(path.join(tmpdir(), "html-bundle-secure-hmr-"));
164
+ t.after(() => rm(cwd, { force: true, recursive: true }));
165
+ await mkdir(path.join(cwd, "src"), { recursive: true });
166
+ if (!(await writeLocalhostCertificate(cwd, t))) return;
167
+
168
+ await writeFile(
169
+ path.join(cwd, "bundle.config.js"),
170
+ `export default { port: ${SECURE_PORT}, host: "127.0.0.1", deletePrev: true };\n`,
171
+ );
172
+ await writeFile(
173
+ path.join(cwd, "src", "index.html"),
174
+ `<!DOCTYPE html><html><head><title>Secure fixture</title></head><body><main>Secure fixture</main></body></html>`,
175
+ );
176
+
177
+ const server = spawn(process.execPath, [bundlePath, "--hmr", "--secure"], {
178
+ cwd,
179
+ });
180
+ t.after(() => server.kill("SIGKILL"));
181
+ server.stderr.on("data", () => {});
182
+
183
+ await waitForListening(server);
184
+
185
+ const redirect = await requestHttpRedirect("/quickstart/checkbox");
186
+ assert.equal(redirect.statusCode, 307);
187
+ assert.equal(
188
+ redirect.location,
189
+ `https://127.0.0.1:${SECURE_PORT}/quickstart/checkbox`,
190
+ );
191
+
192
+ const app = await requestHttps("/");
193
+ assert.equal(app.statusCode, 200);
194
+ assert.match(app.body, /Secure fixture/);
195
+ });
196
+
87
197
  test("HMR server emits typed events and funnels module edits to owning pages", async (t) => {
88
198
  const cwd = await mkdtemp(path.join(tmpdir(), "html-bundle-hmr-"));
89
199
  t.after(() => rm(cwd, { force: true, recursive: true }));
package/types/static.d.ts CHANGED
@@ -1 +1,12 @@
1
1
  declare module "critical";
2
+
3
+ declare module "httpolyglot" {
4
+ import type { RequestListener, Server } from "node:http";
5
+ import type { ServerOptions } from "node:https";
6
+
7
+ const httpolyglot: {
8
+ createServer(options: ServerOptions, listener?: RequestListener): Server;
9
+ };
10
+
11
+ export default httpolyglot;
12
+ }