| 1 | // File watcher and live reloading site generator |
| 2 | |
| 3 | const debounceMilliseconds = 25; |
| 4 | |
| 5 | let server: child_process.ChildProcess | null = null; |
| 6 | process.on("beforeExit", () => { |
| 7 | server?.removeListener("close", onSubprocessClose); |
| 8 | }); |
| 9 | |
| 10 | let watch: Watch; |
| 11 | |
| 12 | export 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 | |
| 20 | function 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 | |
| 33 | function 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 | |
| 39 | let isRunning = false; |
| 40 | async 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 | |
| 77 | function 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 | |
| 86 | class 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 | |
| 185 | import * as subprocess from "@clo/lib/subprocess"; |
| 186 | import * as child_process from "node:child_process"; |
| 187 | import * as fs from "node:fs"; |
| 188 | import * as path from "node:path"; |
| 189 | import * as util from "node:util"; |
| 190 | import * as generate from "./generate.ts"; |
| 191 | import * as incr from "./incremental.ts"; |