html-bundle
Version:
A very simple bundler for HTML SFC
280 lines (250 loc) • 9.28 kB
text/typescript
import type { Node } from "@web/parse5-utils";
import type { Config } from "./bundle.mjs";
import type { Router } from "express-serve-static-core";
import { copyFile, mkdir, readFile } from "fs/promises";
import path from "path";
import http, { type Server } from "http";
import https, { type Server as HTTPSServer } from "https";
import express from "express";
import postcssrc from "postcss-load-config";
import cssnano from "cssnano";
import { parse, parseFragment, serialize } from "parse5";
import {
createScript,
getTagName,
findElement,
appendChild,
} from "@web/parse5-utils";
export const bundleConfig = await getBundleConfig();
export function fileCopy(file: string) {
return copyFile(file, getBuildPath(file));
}
export function createDir(file: string) {
const buildPath = getBuildPath(file);
const dir = buildPath.split("/").slice(0, -1).join("/");
return mkdir(dir, { recursive: true });
}
export function getBuildPath(file: string) {
return file.replace(`${bundleConfig.src}/`, `${bundleConfig.build}/`);
}
const CONNECTIONS: Array<any> = []; // In order to send the HMR information
export let serverSentEvents:
| undefined
| (({ file, html }: { file: string; html?: string }) => void);
export async function createDefaultServer(
isSecure: boolean
): Promise<[Router, Server | HTTPSServer]> {
const router = express.Router();
const app = express();
app.use(router);
app.use(express.static(path.join(process.cwd(), bundleConfig.build)));
router.get("/hmr", (_req, reply) => {
reply.setHeader("Content-Type", "text/event-stream");
reply.setHeader("Cache-Control", "no-cache");
!isSecure && reply.setHeader("Connection", "keep-alive");
CONNECTIONS.push(reply);
serverSentEvents = (data) => {
if (/\.(jsx?|tsx?)$/.test(data.file)) {
data.file = data.file.replace(".ts", ".js").replace(".jsx", ".js");
}
CONNECTIONS.forEach((rep) => {
rep.write(`data: ${JSON.stringify(data)}\n\n`);
});
};
});
app.use(async (_req, res) => {
res.setHeader("Content-Type", "text/html");
const file = await readFile(
path.join(process.cwd(), bundleConfig.build, "index.html"),
{
encoding: "utf-8",
}
);
res.send(file);
});
return [
router,
isSecure
? https.createServer(
{
key:
bundleConfig.key ||
(await readFile(path.join(process.cwd(), "localhost-key.pem"))),
cert:
bundleConfig.cert ||
(await readFile(path.join(process.cwd(), "localhost.pem"))),
},
app
)
: http.createServer({}, app),
];
}
export async function getPostCSSConfig() {
try {
return await postcssrc({});
} catch {
return { plugins: [cssnano], options: {}, file: "" };
}
}
async function getBundleConfig(): Promise<Config> {
const base = {
build: "build",
src: "src",
port: 5000,
esbuild: {},
"html-minifier-terser": {},
deletePrev: true,
critical: {},
isCritical: false,
hmr: false,
secure: false,
handler: "",
host: "::",
};
try {
const cfgPath = path.resolve(process.cwd(), "bundle.config.js");
const config = await import(`file://${cfgPath}`);
return { ...base, ...config.default };
} catch {
return base;
}
}
const htmlIdMap = new Map();
export function addHMRCode(
html: string,
file: string,
ast?: ReturnType<typeof parse | typeof parseFragment>
) {
if (!htmlIdMap.has(file)) {
htmlIdMap.set(file, randomText());
}
const script = createScript(
{ type: "module" },
getHMRCode(file, htmlIdMap.get(file), bundleConfig.src)
);
let DOM;
if (html.includes("<!DOCTYPE html>") || html.includes("<html")) {
DOM = ast || parse(html);
const headNode = findElement(DOM as Node, (e) => getTagName(e) === "head");
appendChild(headNode, script);
} else {
DOM = ast || parseFragment(html);
appendChild(DOM, script);
}
//@ts-ignore
DOM.childNodes.forEach((node) =>
node.attrs?.push({ name: "data-hmr", value: htmlIdMap.get(file) })
);
return serialize(DOM as any);
}
function randomText() {
return Math.random().toString(32).slice(2);
}
function getHMRCode(file: string, id: string, src: string) {
return `import { render, html, $, $$, setShouldSetReactivity } from "hydro-js";
window.isHMR = true;
window.lastCalled = new Map();
if (!window.eventsource${id}) {
window.eventsource${id} = new EventSource("/hmr");
window.eventsource${id}.addEventListener('error', (e) => {
setTimeout(() => {
window.eventsource${id} = new EventSource("/hmr");
}, 1000);
});
window.eventsource${id}.addEventListener("message", ({ data }) => {
if (window.lastScroll == null) {
window.lastScroll = window.scrollY;
}
const dataObj = JSON.parse(data);
const file = "${file}";
if (file === dataObj.file && "html" in dataObj) {
let newHTML;
try {
newHTML = html\`\${dataObj.html}\`
} catch {
setShouldSetReactivity(false);
newHTML = html\`\${dataObj.html}\`
setShouldSetReactivity(true);
}
if (dataObj.html.startsWith('<!DOCTYPE html>') || dataObj.html.startsWith('<html')) {
document.head.remove(); // Don't try to diff the head – just re-run the scripts
// Restore Scroll
window.addEventListener("afterRouting", () => {
window.scrollTo(0, window.lastScroll);
delete window.lastScroll;
}, { once: true })
render(newHTML, document.documentElement, false);
} else {
const hmrID = "${id}";
const hmrElems = Array.from(newHTML.childNodes);
const hmrWheres = Array.from($$(\`[data-hmr="\${hmrID}"]\`))
// render new elements for old elements. Then, remove rest old elements and add add new elements after the last old one
hmrWheres.forEach((where, index) => {
if (index < hmrElems.length) {
render(hmrElems[index], where, false);
} else {
where.remove();
}
});
for (let rest = hmrWheres.length; rest < hmrElems.length; rest++) {
if (hmrWheres.length) {
const template = document.createElement('template');
hmrElems[hmrWheres.length - 1].after(template);
render(hmrElems[rest], template, false);
template.remove();
} else {
render(hmrElems[rest], false, false)
}
}
}
$$('link[rel="stylesheet"][href]').forEach(link => {
link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
})
if (dataObj.html.includes("<script")) updateElem("script");
if (dataObj.file === \`${src}/index.html\`) {
dispatchEvent(new Event("popstate"));
}
} else if (dataObj.file.endsWith(".css")) {
const now = performance.now();
if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
$$('link[rel="stylesheet"][href]').forEach(link => {
link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
})
window.lastCalled.set(dataObj.file, now)
}
} else if (dataObj.file.endsWith(".js")) {
const now = performance.now();
if (!window.lastCalled.has(dataObj.file) || now - window.lastCalled.get(dataObj.file) > 100) {
$$('link[rel="stylesheet"][href]').forEach(link => {
link.setAttribute("href", link.getAttribute("href") + "?v=" + String(Math.random().toFixed(4)).slice(2));
})
updateElem("script");
window.lastCalled.set(dataObj.file, now)
}
}
function updateElem(type) {
const hmrId = "${id}";
const noSrcFile = dataObj.file.replace(\`${src}/\`, '');
const attr = type === "script" ? "src" : "href";
const elem = $(\`[data-hmr="\${hmrId}"] \${type}[\${attr}^="\${noSrcFile}"]\`); // could be $(\`\${type}[data-hmr="\${hmrId}"][\${attr}^="\${noSrcFile}"]\`) ?
if (elem) {
updateOne(type, attr, elem)
} else {
for(const e of $$(\`[data-hmr="\${hmrId}"] \${type}\`)) {
updateOne(type, attr, e);
}
}
}
function updateOne(type, attr, elem) {
const clone = document.createElement(type);
for (const key of elem.getAttributeNames()) {
clone.setAttribute(key, elem.getAttribute(key));
}
const attrVal = elem.getAttribute(attr);
if (attrVal) clone.setAttribute(attr, attrVal + "?v=" + String(Math.random().toFixed(4)).slice(2));
render(clone, elem, false);
}
});
}
`;
}