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 +4 -2
- package/dist/bundle.d.mts +2 -0
- package/dist/bundle.mjs +35 -10
- package/dist/hmr-client.js +33 -17
- 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 +36 -17
- package/src/utils.mts +44 -14
- package/tests/bundle.test.mjs +119 -0
- package/tests/hmr-client.test.mjs +75 -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
|
@@ -75,39 +75,55 @@ function patch(message) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
else {
|
|
78
|
-
patchFragment(message
|
|
78
|
+
patchFragment(message);
|
|
79
79
|
}
|
|
80
80
|
window.scrollTo(0, previousScroll);
|
|
81
81
|
}
|
|
82
|
-
function patchFragment(
|
|
83
|
-
const incoming = parseHTML(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
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
|
@@ -130,41 +130,60 @@ function patch(message: HTMLMessage): void {
|
|
|
130
130
|
dispatchEvent(new Event("popstate"));
|
|
131
131
|
}
|
|
132
132
|
} else {
|
|
133
|
-
patchFragment(message
|
|
133
|
+
patchFragment(message);
|
|
134
134
|
}
|
|
135
135
|
window.scrollTo(0, previousScroll);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
function patchFragment(
|
|
139
|
-
const incoming = parseHTML(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
+
});
|
|
@@ -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
|
+
}
|