198 lines
5.9 KiB
TypeScript
198 lines
5.9 KiB
TypeScript
// 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<any, Awaited<ReturnType<typeof generate.sitegen>>>({
|
|
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<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();
|
|
}
|
|
|
|
#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";
|