1// File watcher and live reloading site generator
2
3const debounceMilliseconds = 25;
4
5let server: child_process.ChildProcess | null = null;
6process.on("beforeExit", () => {
7 server?.removeListener("close", onSubprocessClose);
8});
9
10let watch: Watch;
11
12export async function main() {
13 child_process.execSync("./node_modules/.bin/dprint fmt --allow-no-files .", { stdio: "inherit" });
14 // Catch up state by running a main build.
15 if (!process.argv.includes("-f")) await incr.restore();
16 watch = new Watch(rebuild);
17 rebuild([]);
18}
19
20function serve() {
21 if (server) {
22 server.removeListener("close", onSubprocessClose);
23 server.kill();
24 }
25 server = subprocess.fork({
26 cmd: [".clover/o/backend.js", "--development"],
27 stdio: "inherit",
28 execArgv: ["--no-warnings"],
29 });
30 server.on("close", onSubprocessClose);
31}
32
33function onSubprocessClose(code: number | null, signal: string | null) {
34 server = null;
35 const status = code != null ? `code ${code}` : `signal ${signal}`;
36 console.error(`Backend process exited with ${status}`);
37}
38
39let isRunning = false;
40async function rebuild(files: string[]) {
41 const hasInvalidated = files.length === 0
42 || (await Promise.all(files.map((file) => incr.invalidate(file))))
43 .some(Boolean);
44 if (!hasInvalidated) return;
45 if (isRunning) return;
46 isRunning = true;
47 try {
48 const formattingFiles = files.filter(x => !x.endsWith(".mdo"));
49 if (formattingFiles.length > 0) {
50 child_process.execSync("./node_modules/.bin/dprint fmt --allow-no-files " + formattingFiles.join(" "), {
51 stdio: "inherit",
52 });
53 }
54 } catch (err) {
55 isRunning = false;
56 throw err;
57 }
58 incr.compile(generate.generate).then(({
59 watchFiles,
60 newOutputs,
61 newAssets,
62 }) => {
63 const removeWatch = [...watch.files].filter((x) => !watchFiles.has(x));
64 for (const file of removeWatch) watch.remove(file);
65 watch.add(...watchFiles);
66 // Restart the server if it was changed or not running.
67 if (!server || newOutputs.includes("backend.js")) {
68 serve();
69 } else if (server && newAssets) {
70 server.send({ type: "clover.assets.reload" });
71 }
72 }).catch((err) => {
73 console.error(util.inspect(err));
74 }).finally(statusLine);
75}
76
77function statusLine() {
78 isRunning = false;
79 if (watch.files.size === 0) return;
80 console.info(
81 `Watching ${watch.files.size} files `
82 + `\x1b[36m[last change: ${new Date().toLocaleTimeString()}]\x1b[39m`,
83 );
84}
85
86class Watch {
87 files = new Set<string>();
88 stale = new Set<string>();
89 onChange: (files: string[]) => void;
90 watchers: fs.FSWatcher[] = [];
91 /** Has a trailing slash */
92 roots: string[] = [];
93 debounce: ReturnType<typeof setTimeout> | null = null;
94
95 constructor(onChange: Watch["onChange"]) {
96 this.onChange = onChange;
97 }
98
99 add(...files: string[]) {
100 const { roots, watchers } = this;
101 let newRoots: string[] = [];
102 for (let file of files) {
103 file = path.resolve(file);
104 if (this.files.has(file)) continue;
105 this.files.add(file);
106 // Find an existing watcher
107 if (roots.some((root) => file.startsWith(root))) continue;
108 if (newRoots.some((root) => file.startsWith(root))) continue;
109 newRoots.push(path.dirname(file) + path.sep);
110 }
111 if (newRoots.length === 0) return;
112 // Filter out directories that are already specified
113 newRoots = newRoots
114 .sort((a, b) => a.length - b.length)
115 .filter((dir, i, a) => {
116 for (let j = 0; j < i; j++) if (dir.startsWith(a[j])) return false;
117 return true;
118 });
119 // Append Watches
120 let i = roots.length;
121 for (const root of newRoots) {
122 this.watchers.push(fs.watch(
123 root,
124 { recursive: true, encoding: "utf-8" },
125 this.#handleEvent.bind(this, root),
126 ));
127 this.roots.push(root);
128 }
129 // If any new roots shadow over and old one, delete it!
130 while (i > 0) {
131 i -= 1;
132 const root = roots[i];
133 if (newRoots.some((newRoot) => root.startsWith(newRoot))) {
134 watchers.splice(i, 1)[0].close();
135 roots.splice(i, 1);
136 }
137 }
138 }
139
140 remove(...files: string[]) {
141 for (const file of files) this.files.delete(path.resolve(file));
142 // Find watches that are covering no files
143 const { roots, watchers } = this;
144 const existingFiles = Array.from(this.files);
145 let i = roots.length;
146 while (i > 0) {
147 i -= 1;
148 const root = roots[i];
149 if (!existingFiles.some((file) => file.startsWith(root))) {
150 watchers.splice(i, 1)[0].close();
151 roots.splice(i, 1);
152 }
153 }
154 }
155
156 stop() {
157 for (const w of this.watchers) w.close();
158 }
159
160 #getFiles(absPath: string, event: fs.WatchEventType) {
161 const files = [];
162 if (this.files.has(absPath)) files.push(absPath);
163 if (event === "rename") {
164 const dir = path.dirname(absPath);
165 if (this.files.has(dir)) files.push(dir);
166 }
167 return files;
168 }
169
170 #handleEvent(root: string, event: fs.WatchEventType, subPath: string | null) {
171 if (!subPath) return;
172 const files = this.#getFiles(path.join(root, subPath), event);
173 if (files.length === 0) return;
174 for (const file of files) this.stale.add(file);
175 const { debounce } = this;
176 if (debounce !== null) clearTimeout(debounce);
177 this.debounce = setTimeout(() => {
178 this.debounce = null;
179 this.onChange(Array.from(this.stale));
180 this.stale.clear();
181 }, debounceMilliseconds);
182 }
183}
184
185import * as subprocess from "@clo/lib/subprocess";
186import * as child_process from "node:child_process";
187import * as fs from "node:fs";
188import * as path from "node:path";
189import * as util from "node:util";
190import * as generate from "./generate.ts";
191import * as incr from "./incremental.ts";