htmelt
Version:
Bundle your HTML assets with Esbuild and LightningCSS. Custom plugins, HMR platform, and more.
470 lines (467 loc) • 13.6 kB
JavaScript
import {
compileSeparateEntry
} from "./chunk-SE5MUBQP.mjs";
import {
loadVirtualFile
} from "./chunk-XFJFQI2F.mjs";
import {
findDirectoryUp,
resolveDevMapSources
} from "./chunk-SGZXFKQT.mjs";
// src/devServer.mts
import {
md5Hex,
mitt,
parseNamespace,
sendFile,
uriToFile,
uriToId
} from "@htmelt/plugin";
import builtinModules from "builtin-modules";
import * as esbuild from "esbuild";
import { getBuildExtensions } from "esbuild-extra";
import * as fs from "fs";
import { cyan, green, red } from "kleur/colors";
import * as path from "path";
import { parse as parseURL } from "url";
import * as uuid from "uuid";
import * as ws from "ws";
async function installHttpServer(config, servePlugins) {
const { url, port, https } = await config.loadServerConfig();
let createServer;
let cert;
let key;
if (https) {
createServer = (await import("https")).createServer;
if (https.cert) {
cert = https.cert;
key = https.key;
} else {
key = cert = await getCertificate("node_modules/.htmelt/self-signed");
}
} else {
createServer = (await import("http")).createServer;
}
const fsAllowRE = new RegExp(
`^/(${[config.build, config.assets].join("|")})/`
);
const serveFile = async (uri, request) => {
const id = uriToId(uri);
const namespace = parseNamespace(id);
const filePath = !namespace ? uriToFile(uri) : null;
let virtualFile = config.virtualFiles[uri];
if (!virtualFile) {
if (filePath) {
virtualFile = config.virtualFiles[filePath];
} else if (namespace) {
const rawId = id.slice(namespace.length + 1);
const alias = config.alias[rawId];
if (typeof alias !== "string") {
virtualFile = alias;
}
}
}
let file = null;
if (virtualFile) {
file = await loadVirtualFile(virtualFile, uri, config, request);
if (file) {
if (file.watchFiles) {
}
}
}
if (!file && filePath) {
let isAllowed = false;
if (uri.startsWith("/@fs/")) {
for (const dir of config.fsAllowedDirs) {
if (!path.relative(dir, filePath).startsWith("..")) {
isAllowed = true;
break;
}
}
} else {
const assetPath = path.join(process.cwd(), config.assets, uri);
try {
file = {
path: assetPath,
data: fs.readFileSync(assetPath)
};
} catch {
}
isAllowed = !file && fsAllowRE.test(uri);
}
if (isAllowed) {
try {
file = {
path: filePath,
data: fs.readFileSync(filePath)
};
} catch {
}
}
}
if (file && uri.endsWith(".map")) {
const map = JSON.parse(file.data.toString("utf8"));
resolveDevMapSources(
map,
process.cwd(),
filePath ? path.dirname(filePath) : process.cwd()
);
file.data = JSON.stringify(map);
}
return file;
};
const serve = async (req, response) => {
if (config.server.handler) {
await (void 0, config.server.handler)(req, response);
if (response.headersSent) {
return;
}
}
const request = Object.assign(req, parseURL(req.url));
request.searchParams = new URLSearchParams(request.search || "");
let file = null;
for (const plugin of servePlugins) {
file = await plugin.serve(request, response) || null;
if (response.headersSent)
return;
if (file)
break;
}
let uri = decodeURIComponent(request.pathname);
if (!file) {
file = await serveFile(uri, request);
if (!file && !uri.startsWith("/@fs/")) {
uri = path.posix.join("/", config.build, uri);
file = await serveFile(uri, request);
if (!file && !uri.endsWith("/")) {
file = await serveFile(uri + ".html", request);
}
if (!file) {
uri = path.posix.join(uri, "index.html");
file = await serveFile(uri, request);
}
if (!file) {
uri = path.posix.join("/", config.build, "/404.html");
file = await serveFile(uri, request);
}
if (!file) {
uri = path.posix.join("/", config.build, "/index.html");
file = await serveFile(uri, request);
}
}
}
if (file) {
sendFile(request.pathname, response, file);
} else {
if (req.headers.accept?.includes("text/html")) {
console.log(red("404: %s"), req.url);
}
response.statusCode = 404;
response.end();
}
};
const server = createServer({ cert, key }, (req, res) => {
serve(req, res).catch((err) => {
console.error(err);
res.statusCode = 500;
res.end();
});
});
server.listen(port, () => {
console.log(
cyan("%s server listening on port %s"),
url.protocol.slice(0, -1),
port
);
});
server.on("close", () => {
config.server.handlerContext?.dispose();
});
return server;
}
function installWebSocketServer(server, config, hmrInstances) {
const events = mitt();
const clients = /* @__PURE__ */ new Set();
const requests = {};
const context2 = clients;
context2.on = events.on.bind(events);
config.plugins.forEach((plugin) => {
if (!plugin.hmr)
return;
const instance = plugin.hmr(context2);
if (instance) {
hmrInstances.push(instance);
}
});
const evaluate = (client, src, args = []) => {
return new Promise((resolve2) => {
const id = uuid.v4();
requests[id] = resolve2;
client.pendingRequests.add(id);
client.socket.send(
JSON.stringify({
id,
src: new URL(src, config.server.url).href,
args
})
);
});
};
const compiledModules = /* @__PURE__ */ new Map();
const runningModules = /* @__PURE__ */ new Map();
class Client {
constructor(socket) {
this.socket = socket;
return Object.assign(
Object.setPrototypeOf(mitt(), Client.prototype),
this
);
}
pendingRequests = /* @__PURE__ */ new Set();
evaluate(expr) {
const path2 = `/${md5Hex(expr)}.js`;
if (!config.virtualFiles[path2]) {
config.setVirtualFile(path2, {
loader: "js",
current: { data: `export default () => ${expr}` }
});
}
return evaluate(this, path2);
}
async evaluateModule(file, args) {
const moduleUrl = typeof file === "string" ? new URL(file, import.meta.url) : file;
const mtime = fs.statSync(moduleUrl).mtimeMs;
const path2 = `/${md5Hex(moduleUrl.href)}.${mtime}.js`;
if (!config.virtualFiles[path2]) {
let compiled = compiledModules.get(moduleUrl.href);
if (compiled?.mtime != mtime) {
const entry = decodeURIComponent(moduleUrl.pathname);
const data = await compileSeparateEntry(entry, config, {
format: "esm"
});
compiledModules.set(
moduleUrl.href,
compiled = {
path: moduleUrl.pathname,
mtime,
data
}
);
}
config.setVirtualFile(path2, {
loader: "js",
current: compiled
});
}
let parallelCount = runningModules.get(path2) || 0;
runningModules.set(path2, parallelCount + 1);
const result = await evaluate(this, path2, args);
parallelCount = runningModules.get(path2);
runningModules.set(path2, --parallelCount);
if (parallelCount == 0) {
config.unsetVirtualFile(path2);
}
return result;
}
getURL() {
return this.evaluate("location.href");
}
reload() {
return this.evaluate("location.reload()");
}
}
const wss = new ws.WebSocketServer({ server });
wss.on("connection", (socket) => {
const client = new Client(socket);
client.on("*", (type, event) => {
events.emit(type, event);
});
clients.add(client);
socket.on("close", () => {
for (const id of client.pendingRequests) {
requests[id](null);
delete requests[id];
}
clients.delete(client);
});
socket.on("message", (data) => {
const event = JSON.parse(data.toString());
if (event.type == "result") {
client.pendingRequests.delete(event.id);
requests[event.id](event.result);
delete requests[event.id];
} else {
event.client = client;
client.emit(event.type, event);
events.emit(event.type, event);
}
});
events.emit("connect", {
type: "connect",
client
});
});
return clients;
}
async function getCertificate(cacheDir) {
const cachePath = path.join(cacheDir, "_cert.pem");
try {
const stat = fs.statSync(cachePath);
const content = fs.readFileSync(cachePath, "utf8");
if (Date.now() - stat.ctime.valueOf() > 30 * 24 * 60 * 60 * 1e3) {
throw "Certificate is too old";
}
return content;
} catch {
const content = (await import("./createCertificate-JUSB5HKG.mjs")).createCertificate();
try {
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, content);
} catch {
}
return content;
}
}
async function importHandler(handler, config) {
const handlerPath = path.resolve(handler.entry);
const handlerDir = path.dirname(handlerPath);
const nodeModulesRoot = findDirectoryUp(handlerDir, ["node_modules"]);
if (!nodeModulesRoot) {
throw Error("Could not find node_modules directory");
}
const nodeModulesDir = path.join(nodeModulesRoot, "node_modules");
const outFile = path.join(nodeModulesDir, `handler.${Date.now()}.mjs`);
fs.readdirSync(nodeModulesDir).forEach((name) => {
if (name.match(/^handler\.\d+\.mjs/)) {
fs.unlinkSync(path.join(nodeModulesDir, name));
}
});
const workspaceRoot = (findDirectoryUp(handlerDir, [".git", "pnpm-workspace.yaml"]) || handlerDir) + "/";
const userExternal = handler.external?.map((pattern) => {
if (typeof pattern === "string") {
return (file) => {
if (file.startsWith(workspaceRoot)) {
file = file.slice(workspaceRoot.length - 1);
}
return file.includes("/" + pattern + "/");
};
}
return (file) => pattern.test(file);
}) || [];
const createHandler = async (handlerPath2) => {
try {
const handlerModule = await import(handlerPath2);
const createHandler2 = handlerModule.default;
const isReload = !!config.server.handler;
config.server.handler = await createHandler2("development");
if (isReload) {
console.log(cyan("\u21BA"), "server.handler reloaded without error");
} else {
console.log(green("\u2714\uFE0F"), "server.handler loaded without error");
}
} catch (e) {
console.error("Failed to import handler:", e);
config.server.handler = (_req, res) => {
res.writeHead(500);
res.end("Failed to import handler");
};
}
};
const sourceMapSupport = await import("source-map-support");
sourceMapSupport.install({
hookRequire: true
});
const context2 = await esbuild.context({
entryPoints: [handlerPath],
entryNames: "[dir]/[name].[hash]",
bundle: true,
format: "esm",
outfile: outFile,
platform: "node",
plugins: [
externalize((file) => {
return file.includes("node_modules") || !file.startsWith(workspaceRoot) || userExternal.some((test) => test(file));
}),
reloadHandler(createHandler),
replaceImportMetaUrl()
],
sourcemap: true,
splitting: false,
write: false
});
await context2.watch();
return context2;
}
function replaceImportMetaUrl() {
const name = "replace-import-meta-url";
return {
name,
setup(build) {
const { onTransform } = getBuildExtensions(build, name);
onTransform({ loaders: ["js"] }, (args) => {
const code = args.code.replace(
/\bimport\.meta\.url\b/g,
JSON.stringify(new URL(args.initialPath || args.path, "file:").href)
);
return { code };
});
}
};
}
function reloadHandler(setHandlerPath) {
let lastHandlerPath;
return {
name: "reload-handler",
setup(build) {
build.onEnd(({ outputFiles }) => {
outputFiles.forEach((file) => {
fs.writeFileSync(file.path, file.contents);
});
const handlerPath = outputFiles[1].path;
if (handlerPath !== lastHandlerPath) {
lastHandlerPath = handlerPath;
setHandlerPath(handlerPath);
}
});
}
};
}
function externalize(filter) {
return {
name: "externalize",
setup(build) {
const skipped = /* @__PURE__ */ new Set();
build.onResolve({ filter: /^/ }, async ({ path: id, ...args }) => {
if (args.kind === "entry-point") {
return null;
}
if (!path.isAbsolute(id)) {
if (builtinModules.includes(id)) {
return { external: true };
}
const importKey = id + ":" + args.importer;
if (skipped.has(importKey)) {
return null;
}
skipped.add(importKey);
const resolved = await build.resolve(id, args);
skipped.delete(importKey);
if (resolved) {
id = resolved.path;
}
}
if (!filter(id)) {
return null;
}
return {
external: true
};
});
}
};
}
export {
installHttpServer,
installWebSocketServer,
importHandler
};