dev.ts
8.2 kB · typescript · 196 lines
1import type { HTMLBundle } from "bun";2import { existsSync, statSync, watch, type FSWatcher } from "node:fs";3import { relative, resolve, sep } from "node:path";4import { escape, forget, scan, type Output, type Site, type Spec } from "./ssg/build.ts";5import { clean, find, lost, reply, type } from "./serve.ts";67/* OPTIONS */89export type Entry = { route: string; file: string };1011export type Mount = [string, string];1213export type Options = {14 html?: (site: Site) => Entry[];15 scripts?: (site: Site) => Entry[];16 disk?: (site: Site) => Mount[];17 watch?: string[];18 extra?: (site: Site, path: string) => Promise<Response | null> | Response | null;19 line?: (site: Site) => string;20};2122/* STATE */2324type Held = { runs: number; watchers: FSWatcher[]; timer: ReturnType<typeof setTimeout> | null };2526const held = ((globalThis as unknown as { __dev?: Held }).__dev ??= { runs: 0, watchers: [], timer: null });2728const HOT = process.execArgv.includes("--hot");2930const HTML = { "content-type": "text/html; charset=utf-8" };3132/* OVERLAY */3334const SNIPPET = `<script>(function(){var box=null;function show(text){if(!box){box=document.createElement("pre");box.style.cssText="position:fixed;inset:auto 0 0 0;margin:0;padding:1rem;background:#300;color:#fcc;font:12px/1.4 monospace;white-space:pre-wrap;z-index:99999";document.body.appendChild(box)}box.textContent=text}function hide(){if(box){box.remove();box=null}}function swap(){fetch(location.href).then(function(r){return r.text()}).then(function(html){var next=Array.from(html.matchAll(/href="([^"]+\\.css)"/g)).map(function(m){return m[1]});document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link,i){if(next[i]&&link.getAttribute("href")!==next[i])link.href=next[i]})})}function open(lost){var ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+"/__dev");ws.onopen=function(){if(lost)location.reload()};ws.onmessage=function(e){var msg=String(e.data);if(msg==="css"){hide();swap()}else if(msg.indexOf("error")===0)show(msg.slice(6));else location.reload()};ws.onclose=function(){setTimeout(function(){open(true)},500)}}open(false)})()</script>`;3536const inject = (html: string) => (html.includes("</body>") ? html.replace("</body>", `${SNIPPET}</body>`) : `${html}${SNIPPET}`);3738async function dressed(response: Response): Promise<Response> {39 if (!(response.headers.get("content-type") ?? "").startsWith("text/html")) return response;40 return new Response(inject(await response.text()), { status: response.status, headers: HTML });41}4243const failed = (error: unknown) => {44 const text = error instanceof Error ? (error.stack ?? error.message) : String(error);45 const body = `<!doctype html><meta charset="utf-8"><title>dev: error</title><pre style="white-space:pre-wrap;padding:1rem;font:13px/1.4 monospace">${escape(text)}</pre>`;46 return new Response(inject(body), { status: 500, headers: HTML });47};4849/* DISK */5051function within(dir: string, rest: string): string | null {52 const base = resolve(dir);53 const full = resolve(base, `./${rest}`);54 return full === base || full.startsWith(base + sep) ? full : null;55}5657export function disk(mounts: Mount[], path: string): Response | null {58 for (const [at, dir] of mounts) {59 if (!dir || !path.startsWith(at)) continue;60 const found = within(dir, path.slice(at.length));61 if (!found || !existsSync(found) || !statSync(found).isFile()) continue;62 return new Response(Bun.file(found), { headers: { "content-type": type(found) } });63 }64 return null;65}6667/* WATCH */6869function watched(spec: Spec, site: Site, more: string[]): string[] {70 const paths = new Set<string>([import.meta.dir]);71 for (const one of Object.values(site.inputs)) if (!one.missing) paths.add(one.path);72 if (site.kit) paths.add(site.kit.path);73 for (const dir of spec.templates ?? []) paths.add(resolve(site.root, dir));74 for (const one of more) paths.add(resolve(site.root, one));75 return [...paths].filter((path) => existsSync(path));76}7778const skipped = (path: string) => path.includes("/.") || path.endsWith("~") || (HOT && path in require.cache);7980/* MAIN */8182export async function main(spec: Spec, options: Options = {}) {83 for (const one of held.watchers) one.close();84 held.watchers = [];85 if (held.timer) clearTimeout(held.timer);86 held.timer = null;87 held.runs++;88 forget();89 let site = await scan(spec);90 const html = options.html?.(site) ?? [];91 const scripts = options.scripts?.(site) ?? [];92 const mounts = options.disk?.(site) ?? [];93 const built = new Map<string, Output>();94 const owned = (path: string) => html.some((one) => path.startsWith(one.route));95 const routes: Record<string, HTMLBundle> = {};96 for (const one of html) {97 const page = (await import(one.file)).default as HTMLBundle;98 routes[one.route] = page;99 if (one.route.length > 1) routes[one.route.replace(/\/$/, "")] = page;100 }101102 async function script(path: string): Promise<Response | null> {103 const kept = built.get(path);104 if (kept) return reply(kept);105 const entry = scripts.find((one) => one.route === path);106 if (!entry || !existsSync(entry.file)) return null;107 const done = await Bun.build({ entrypoints: [entry.file], root: site.root, define: { "process.env.NODE_ENV": '"development"' }, naming: { asset: "[name]-[hash].[ext]" } });108 if (!done.success) throw new Error(`dev: ${relative(site.root, entry.file)} failed to bundle\n${done.logs.join("\n")}`);109 for (const item of done.outputs) {110 const at = item.path.replace(/^\.\//, "");111 built.set(`/${at}`, { path: at, bytes: new Uint8Array(await item.arrayBuffer()) });112 }113 const hit = built.get(path);114 return hit ? reply(hit) : null;115 }116117 async function answer(path: string): Promise<Response> {118 if (!clean(path)) return lost(spec, site);119 return (120 (await script(path)) ??121 (await options.extra?.(site, path)) ??122 (owned(path) ? null : await find(spec, site, path)) ??123 disk(mounts, path) ??124 (await lost(spec, site))125 );126 }127128 const server = Bun.serve({129 port: Number(process.env.PORT ?? (site.config.dev as { port?: number } | undefined)?.port ?? 3000),130 hostname: "127.0.0.1",131 development: true,132 routes,133 websocket: {134 open(ws) {135 ws.subscribe("dev");136 },137 message() {},138 },139 async fetch(request) {140 const path = decodeURIComponent(new URL(request.url).pathname);141 if (path === "/__dev") return server.upgrade(request) ? undefined : new Response("upgrade failed", { status: 400 });142 try {143 return await dressed(await answer(path));144 } catch (error) {145 console.error(`dev: ${path}\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);146 return failed(error);147 }148 },149 });150151 const pending = new Set<string>();152 let busy: Promise<void> | null = null;153154 async function refresh() {155 held.timer = null;156 if (busy) {157 held.timer = setTimeout(refresh, 80);158 return;159 }160 const files = [...pending].map((one) => relative(site.root, one));161 pending.clear();162 const styled = files.length > 0 && files.every((one) => one.endsWith(".css"));163 busy = (async () => {164 try {165 forget();166 built.clear();167 site = await scan(spec);168 console.log(`dev: ${styled ? "css" : "reload"} (${files.join(", ")})`);169 server.publish("dev", styled ? "css" : "reload");170 } catch (error) {171 const text = error instanceof Error ? error.message : String(error);172 console.error(`dev: scan failed\n${text}`);173 server.publish("dev", `error\n${text}`);174 }175 })();176 await busy;177 busy = null;178 }179180 function changed(path: string) {181 if (skipped(path)) return;182 pending.add(path);183 if (held.timer) clearTimeout(held.timer);184 held.timer = setTimeout(refresh, 80);185 }186187 for (const path of watched(spec, site, options.watch ?? [])) {188 const dir = statSync(path).isDirectory();189 held.watchers.push(watch(path, { recursive: dir }, (_, file) => changed(dir && file ? resolve(path, String(file)) : path)));190 }191192 if (held.runs > 1) server.publish("dev", "reload");193 const note = options.line ? `, ${options.line(site)}` : "";194 const mode = HOT ? "" : " (no --hot: a script edit needs a restart)";195 console.log(`dev${held.runs > 1 ? ` run ${held.runs}` : ""}: ${site.routes.length} routes${note} at ${server.url}${mode}`);196}