// File watcher and live reloading site generator const debounceMilliseconds = 25; export async function main() { let subprocess: child_process.ChildProcess | null = null; // Catch up state by running a main build. const { incr } = await generate.main(); // ...and watch the files that cause invals. const watch = new Watch(rebuild); watch.add(...incr.invals.keys()); statusLine(); // ... and then serve it! serve(); function serve() { if (subprocess) { subprocess.removeListener("close", onSubprocessClose); subprocess.kill(); } subprocess = child_process.fork(".clover/out/server.js", [ "--development", ], { stdio: "inherit", }); subprocess.on("close", onSubprocessClose); } function onSubprocessClose(code: number | null, signal: string | null) { subprocess = null; const status = code != null ? `code ${code}` : `signal ${signal}`; console.error(`Backend process exited with ${status}`); } process.on("beforeExit", () => { subprocess?.removeListener("close", onSubprocessClose); }); function rebuild(files: string[]) { files = files.map((file) => path.relative(hot.projectRoot, file)); const changed: string[] = []; for (const file of files) { let mtimeMs: number | null = null; try { mtimeMs = fs.statSync(file).mtimeMs; } catch (err: any) { if (err?.code !== "ENOENT") throw err; } if (incr.updateStat(file, mtimeMs)) changed.push(file); } if (changed.length === 0) { console.warn("Files were modified but the 'modify' time did not change."); return; } withSpinner>>({ text: "Rebuilding", successText: generate.successText, failureText: () => "sitegen FAIL", }, async (spinner) => { console.info("---"); console.info( "Updated" + (changed.length === 1 ? " " + changed[0] : changed.map((file) => "\n- " + file)), ); const result = await generate.sitegen(spinner, incr); incr.toDisk(); // Allows picking up this state again for (const file of watch.files) { const relative = path.relative(hot.projectRoot, file); if (!incr.invals.has(relative)) watch.remove(file); } return result; }).then((result) => { // Restart the server if it was changed or not running. if ( !subprocess || result.inserted.some(({ kind }) => kind === "backendReplace") ) { serve(); } else if ( subprocess && result.inserted.some(({ kind }) => kind === "asset") ) { subprocess.send({ type: "clover.assets.reload" }); } return result; }).catch((err) => { console.error(util.inspect(err)); }).finally(statusLine); } function statusLine() { console.info( `Watching ${incr.invals.size} files \x1b[36m[last change: ${ new Date().toLocaleTimeString() }]\x1b[39m`, ); } } class Watch { files = new Set(); stale = new Set(); onChange: (files: string[]) => void; watchers: fs.FSWatcher[] = []; /** Has a trailing slash */ roots: string[] = []; debounce: ReturnType | null = null; constructor(onChange: Watch["onChange"]) { this.onChange = onChange; } add(...files: string[]) { const { roots, watchers } = this; let newRoots: string[] = []; for (let file of files) { file = path.resolve(file); if (this.files.has(file)) continue; this.files.add(file); // Find an existing watcher if (roots.some((root) => file.startsWith(root))) continue; if (newRoots.some((root) => file.startsWith(root))) continue; newRoots.push(path.dirname(file) + path.sep); } if (newRoots.length === 0) return; // Filter out directories that are already specified newRoots = newRoots .sort((a, b) => a.length - b.length) .filter((dir, i, a) => { for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false; return true; }); // Append Watches let i = roots.length; for (const root of newRoots) { this.watchers.push(fs.watch( root, { recursive: true, encoding: "utf-8" }, this.#handleEvent.bind(this, root), )); this.roots.push(root); } // If any new roots shadow over and old one, delete it! while (i > 0) { i -= 1; const root = roots[i]; if (newRoots.some((newRoot) => root.startsWith(newRoot))) { watchers.splice(i, 1)[0].close(); roots.splice(i, 1); } } } remove(...files: string[]) { for (const file of files) this.files.delete(path.resolve(file)); // Find watches that are covering no files const { roots, watchers } = this; const existingFiles = Array.from(this.files); let i = roots.length; while (i > 0) { i -= 1; const root = roots[i]; if (!existingFiles.some((file) => file.startsWith(root))) { watchers.splice(i, 1)[0].close(); roots.splice(i, 1); } } } stop() { for (const w of this.watchers) w.close(); } #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) { if (!subPath) return; const file = path.join(root, subPath); if (!this.files.has(file)) return; this.stale.add(file); const { debounce } = this; if (debounce !== null) clearTimeout(debounce); this.debounce = setTimeout(() => { this.debounce = null; this.onChange(Array.from(this.stale)); this.stale.clear(); }, debounceMilliseconds); } } import * as fs from "node:fs"; import { withSpinner } from "@paperclover/console/Spinner"; import * as generate from "./generate.ts"; import * as path from "node:path"; import * as util from "node:util"; import * as hot from "./hot.ts"; import * as child_process from "node:child_process";