UNPKG

htmelt

Version:

Bundle your HTML assets with Esbuild and LightningCSS. Custom plugins, HMR platform, and more.

470 lines (467 loc) 13.6 kB
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 };