TheBird

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}