the asset system is reworked to support "dynamic" entries, where each entry is a separate file on disk containing the latest generation's headers+raw+gzip+zstd. when calling view.regenerate, it will look for pages that had "export const regenerate" during generation, and render those pages using the view system, but then store the results as assets instead of sending as a response. pages configured as regenerable are also bundled as views, using the non-aliasing key "page:${page.id}". this cannot alias because file paths may not contain a colon.
175 lines
5.1 KiB
TypeScript
175 lines
5.1 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.
|
|
if (!process.argv.includes("-f")) 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((file) => incr.invalidate(file))))
|
|
.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";
|