sitegen/framework/watch.ts
clover caruso 8c0bd4c6c6 rewrite incremental.ts (#21)
the problems with the original implementation was mostly around error
handling. sources had to be tracked manually and provided to each
incremental output. the `hasArtifact` check was frequently forgotten.
this has been re-abstracted through `incr.work()`, which is given an
`io` object. all fs reads and module loads go through this interface,
which allows the sources to be properly tracked, even if it throws.

closes #12
2025-08-02 20:56:36 -04:00

174 lines
5 KiB
TypeScript

// File watcher and live reloading site generator
const debounceMilliseconds = 25;
let subprocess: child_process.ChildProcess | null = null;
process.on("beforeExit", () => {
subprocess?.removeListener("close", onSubprocessClose);
});
let watch: Watch;
export async function main() {
// Catch up state by running a main build.
await incr.restore();
watch = new Watch(rebuild);
rebuild([]);
}
function serve() {
if (subprocess) {
subprocess.removeListener("close", onSubprocessClose);
subprocess.kill();
}
subprocess = child_process.fork(".clover/o/backend.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}`);
}
async function rebuild(files: string[]) {
const hasInvalidated = files.length === 0
|| (await Promise.all(files.map(incr.invalidate))).some(Boolean);
if (!hasInvalidated) return;
incr.compile(generate.generate).then(({
watchFiles,
newOutputs,
newAssets
}) => {
const removeWatch = [...watch.files].filter(x => !watchFiles.has(x))
for (const file of removeWatch) watch.remove(file);
watch.add(...watchFiles);
// Restart the server if it was changed or not running.
if (!subprocess || newOutputs.includes("backend.js")) {
serve();
} else if (subprocess && newAssets) {
subprocess.send({ type: "clover.assets.reload" });
}
}).catch((err) => {
console.error(util.inspect(err));
}).finally(statusLine);
}
function statusLine() {
console.info(
`Watching ${watch.files.size} files `
+ `\x1b[36m[last change: ${new Date().toLocaleTimeString()}]\x1b[39m`,
);
}
class Watch {
files = new Set<string>();
stale = new Set<string>();
onChange: (files: string[]) => void;
watchers: fs.FSWatcher[] = [];
/** Has a trailing slash */
roots: string[] = [];
debounce: ReturnType<typeof setTimeout> | 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();
}
#getFiles(absPath: string, event: fs.WatchEventType) {
const files = [];
if (this.files.has(absPath)) files.push(absPath);
if (event === 'rename') {
const dir = path.dirname(absPath);
if (this.files.has(dir)) files.push(dir);
}
return files;
}
#handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
if (!subPath) return;
const files = this.#getFiles(path.join(root, subPath), event);
if (files.length === 0) return;
for(const file of files) 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 incr from "./incremental.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";