html-bundle 6.4.1 → 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" });
@@ -75,39 +75,55 @@ function patch(message) {
75
75
  }
76
76
  }
77
77
  else {
78
- patchFragment(message.html);
78
+ patchFragment(message);
79
79
  }
80
80
  window.scrollTo(0, previousScroll);
81
81
  }
82
- function patchFragment(htmlText) {
83
- const incoming = parseHTML(htmlText);
82
+ function patchFragment(message) {
83
+ const incoming = parseHTML(message.html);
84
84
  // hydro-js returns a DocumentFragment for multi-root markup but the element
85
85
  // itself for a single root — normalise to a flat list of top-level nodes.
86
- const nextNodes = incoming.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
87
- incoming.nodeType === Node.DOCUMENT_NODE
88
- ? Array.from(incoming.childNodes)
89
- : [incoming];
86
+ const nextNodes = topLevelNodes(incoming);
90
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
+ : [];
91
96
  // Replace each existing region in place, drop the surplus, append the rest.
92
97
  regions.forEach((where, index) => {
93
98
  if (index < nextNodes.length) {
94
- 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
+ }
95
107
  }
96
108
  else {
97
109
  where.remove();
98
110
  }
99
111
  });
100
112
  for (let rest = regions.length; rest < nextNodes.length; rest++) {
101
- if (regions.length) {
102
- const template = document.createElement("template");
103
- nextNodes[regions.length - 1].after(template);
104
- render(nextNodes[rest], template, false);
105
- template.remove();
106
- }
107
- else {
108
- render(nextNodes[rest], false, false);
109
- }
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();
110
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];
111
127
  }
112
128
  function isFullDocument(htmlText) {
113
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.1",
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
@@ -130,41 +130,60 @@ function patch(message: HTMLMessage): void {
130
130
  dispatchEvent(new Event("popstate"));
131
131
  }
132
132
  } else {
133
- patchFragment(message.html);
133
+ patchFragment(message);
134
134
  }
135
135
  window.scrollTo(0, previousScroll);
136
136
  }
137
137
 
138
- function patchFragment(htmlText: string): void {
139
- const incoming = parseHTML(htmlText);
138
+ function patchFragment(message: HTMLMessage): void {
139
+ const incoming = parseHTML(message.html);
140
140
  // hydro-js returns a DocumentFragment for multi-root markup but the element
141
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];
142
+ const nextNodes = topLevelNodes(incoming);
147
143
  const regions = Array.from(document.querySelectorAll(REGION));
148
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
+
149
155
  // Replace each existing region in place, drop the surplus, append the rest.
150
156
  regions.forEach((where, index) => {
151
157
  if (index < nextNodes.length) {
152
- 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
+ }
153
165
  } else {
154
166
  where.remove();
155
167
  }
156
168
  });
157
169
 
158
170
  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
- }
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();
167
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];
168
187
  }
169
188
 
170
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
+ });
@@ -195,6 +195,81 @@ test("single-root fragment update patches only its own region", async () => {
195
195
  assert.equal(region.textContent, "New");
196
196
  });
197
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
+
198
273
  test("dispose runs before re-patch and data persists across updates", async () => {
199
274
  const { document, loadPage } = await setup();
200
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
+ }