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 +4 -2
- package/dist/bundle.d.mts +2 -0
- package/dist/bundle.mjs +35 -10
- package/dist/hmr-client.js +54 -18
- package/dist/utils.d.mts +1 -1
- package/dist/utils.mjs +32 -9
- package/package.json +4 -2
- package/src/bundle.mts +43 -9
- package/src/hmr-client.ts +56 -18
- package/src/utils.mts +44 -14
- package/tests/bundle.test.mjs +119 -0
- package/tests/hmr-client.test.mjs +101 -0
- package/tests/hmr-server.test.mjs +111 -1
- package/types/static.d.ts +11 -0
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
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
|
-
|
|
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
|
-
|
|
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" });
|
package/dist/hmr-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
78
|
+
patchFragment(message);
|
|
59
79
|
}
|
|
60
80
|
window.scrollTo(0, previousScroll);
|
|
61
81
|
}
|
|
62
|
-
function patchFragment(
|
|
63
|
-
const incoming = parseHTML(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 {
|
|
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
|
-
?
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
133
|
+
patchFragment(message);
|
|
115
134
|
}
|
|
116
135
|
window.scrollTo(0, previousScroll);
|
|
117
136
|
}
|
|
118
137
|
|
|
119
|
-
function patchFragment(
|
|
120
|
-
const incoming = parseHTML(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
+
}
|
package/tests/bundle.test.mjs
CHANGED
|
@@ -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
|
+
}
|