From 925366e79ee7e69dff817a26f7753593ace82bf8 Mon Sep 17 00:00:00 2001 From: chloe caruso Date: Tue, 10 Jun 2025 20:06:32 -0700 Subject: [PATCH] add a file watcher, live rebuild. this is only verified functional on windows 7 --- framework/bundle.ts | 30 ++++----- framework/generate.ts | 39 ++++++------ framework/hot.ts | 14 ++++- framework/incremental.ts | 15 +++-- framework/lib/view.ts | 5 +- framework/watch.ts | 132 +++++++++++++++++++++++++++++++++++++++ src/global.css | 1 + tsconfig.json | 1 + 8 files changed, 190 insertions(+), 47 deletions(-) create mode 100644 framework/watch.ts diff --git a/framework/bundle.ts b/framework/bundle.ts index f1cd71a..a81bca1 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -21,8 +21,8 @@ export async function bundleClientJavaScript( if (invalidFiles.length > 0) { const cwd = process.cwd(); throw new Error( - "All client-side scripts should be named like '.client.ts'. Exceptions: " + - invalidFiles.map((x) => path.join(cwd, x)).join(","), + "All client-side scripts should be named like '.client.ts'. Exceptions: \n" + + invalidFiles.map((x) => path.join(cwd, x)).join("\n"), ); } @@ -36,9 +36,9 @@ export async function bundleClientJavaScript( minify: !dev, outdir: "/out!", plugins: clientPlugins, - splitting: true, write: false, metafile: true, + external: ["node_modules/"], }); if (bundle.errors.length || bundle.warnings.length) { throw new AggregateError( @@ -61,11 +61,12 @@ export async function bundleClientJavaScript( // Register non-chunks as script entries. const chunk = route.startsWith("/js/c."); if (!chunk) { - route = route.replace(".client.js", ".js"); + const key = hot.getScriptId(sources[0]); + route = "/js/" + key + ".js"; incr.put({ sources, type: "script", - key: route.slice("/js/".length, -".js".length), + key, value: text, }); } @@ -130,23 +131,14 @@ export async function bundleServerJavaScript( virtualFiles({ "$views": viewSource, }), - // banFiles([ - // "hot.ts", - // "incremental.ts", - // "bundle.ts", - // "generate.ts", - // "css.ts", - // ].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))), projectRelativeResolution(), { - name: "marko", + name: "marko via build cache", setup(b) { - b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => { - return { - loader: "ts", - contents: hot.getSourceCode(file), - }; - }); + b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => ({ + loader: "ts", + contents: hot.getSourceCode(file), + })); }, }, { diff --git a/framework/generate.ts b/framework/generate.ts index 6f17654..5a92717 100644 --- a/framework/generate.ts +++ b/framework/generate.ts @@ -1,20 +1,29 @@ -export function main() { - return withSpinner({ +export function main(incremental?: Incremental) { + return withSpinner, any>({ text: "Recovering State", - successText: ({ elapsed }) => - "sitegen! update in " + elapsed.toFixed(1) + "s", + successText, failureText: () => "sitegen FAIL", - }, sitegen); + }, async (spinner) => { + const incr = Incremental.fromDisk(); + await incr.statAllFiles(); + const result = await sitegen(spinner, incr); + incr.toDisk(); // Allows picking up this state again + return result; + }) as ReturnType; } -async function sitegen(status: Spinner) { +export function successText({ elapsed }: { elapsed: number }) { + return "sitegen! update in " + elapsed.toFixed(1) + "s"; +} + +export async function sitegen( + status: Spinner, + incr: Incremental, +) { const startTime = performance.now(); let root = path.resolve(import.meta.dirname, "../src"); const join = (...sub: string[]) => path.join(root, ...sub); - const incr = new Incremental(); - // const incr = Incremental.fromDisk(); - await incr.statAllFiles(); // Sitegen reviews every defined section for resources to process const sections: sg.Section[] = @@ -213,7 +222,7 @@ async function sitegen(status: Spinner) { ...renderResults.flatMap((r) => r.clientRefs), ...backend.views.flatMap((r) => r.clientRefs), ]), - (script) => path.join(hot.projectSrc, script), + (script) => path.resolve(hot.projectSrc, script), ); const extraPublicScripts = scripts.map((entry) => entry.file); const uniqueCount = new Set([ @@ -264,12 +273,7 @@ async function sitegen(status: Spinner) { head, inlineCss: css.text, scripts: scriptFiles.map( - (id) => - UNWRAP( - incr.out.script.get( - path.basename(id).replace(/\.client\.[jt]sx?$/, ""), - ), - ), + (file) => UNWRAP(incr.out.script.get(hot.getScriptId(file))), ).map((x) => `{${x}}`).join("\n"), }); await incr.putAsset({ @@ -293,8 +297,7 @@ async function sitegen(status: Spinner) { status.format = spinnerFormat; status.text = `Incremental Flush`; incr.flush(); // Write outputs - incr.toDisk(); // Allows picking up this state again - return { elapsed: (performance.now() - startTime) / 1000 }; + return { incr, elapsed: (performance.now() - startTime) / 1000 }; } function getItemText({ file }: FileItem) { diff --git a/framework/hot.ts b/framework/hot.ts index eaeb2fc..c25b650 100644 --- a/framework/hot.ts +++ b/framework/hot.ts @@ -155,7 +155,7 @@ function resolveClientRef(sourcePath: string, ref: string) { ) { throw new Error("addScript must be a .client.ts or .client.tsx"); } - return path.relative(projectSrc, filePath); + return filePath; } function loadMarko(module: NodeJS.Module, filepath: string) { @@ -171,7 +171,9 @@ function loadMarko(module: NodeJS.Module, filepath: string) { const ref = JSON.parse(`"${src.slice(1, -1)}"`); const resolved = resolveClientRef(filepath, ref); scannedClientRefs.add(resolved); - return ``; + return ``; }, ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n'; } @@ -287,11 +289,17 @@ export function resolveClientRefs( const ref = JSON.parse(`"${arg.slice(1, -1)}"`); const resolved = resolveClientRef(filepath, ref); scannedClientRefs.add(resolved); - return `${call}(${JSON.stringify(resolved)})`; + return `${call}(${JSON.stringify(getScriptId(resolved))})`; }); return { code, refs: Array.from(scannedClientRefs) }; } +export function getScriptId(file: string) { + return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file) + .replace(/^\/?src\//, "") + .replaceAll("\\", "/"); +} + declare global { namespace NodeJS { interface Module { diff --git a/framework/incremental.ts b/framework/incremental.ts index 97c236e..8a0be90 100644 --- a/framework/incremental.ts +++ b/framework/incremental.ts @@ -136,13 +136,15 @@ export class Incremental { } } - updateStat(fileKey: string, newLastModified: number) { - const stat = this.invals.get(fileKey); - ASSERT(stat, "Updated stat on untracked file " + fileKey); - if (stat.lastModified < newLastModified) { + updateStat(file: string, newLastModified: number) { + file = path.relative(hot.projectRoot, file); + const stat = this.invals.get(file); + ASSERT(stat, "Updated stat on untracked file " + file); + const hasUpdate = stat.lastModified < newLastModified; + if (hasUpdate) { // Invalidate - console.info(fileKey + " updated"); - const invalidQueue = [fileKey]; + console.info(file + " updated"); + const invalidQueue = [file]; let currentInvalid; while (currentInvalid = invalidQueue.pop()) { const invalidations = this.invals.get(currentInvalid); @@ -160,6 +162,7 @@ export class Incremental { } } stat.lastModified = newLastModified; + return hasUpdate; } async putAsset(info: PutAsset) { diff --git a/framework/lib/view.ts b/framework/lib/view.ts index 87677a2..8a1c0f6 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -9,6 +9,9 @@ export interface View { scripts: Record; } +let views: Record = null!; +let scripts: Record = null!; + // An older version of the Clover Engine supported streaming suspense // boundaries, but those were never used. Pages will wait until they // are fully rendered before sending. @@ -17,7 +20,7 @@ export async function renderView( id: string, props: Record, ) { - const { views, scripts } = require("$views"); + views ?? ({ views, scripts } = require("$views")); // The view contains pre-bundled CSS and scripts, but keeps the scripts // separate for run-time dynamic scripts. For example, the file viewer // includes the canvas for the current page, but only the current page. diff --git a/framework/watch.ts b/framework/watch.ts new file mode 100644 index 0000000..1ad18b2 --- /dev/null +++ b/framework/watch.ts @@ -0,0 +1,132 @@ +// File watcher and live reloading site generator + +const debounceMilliseconds = 25; + +export async function main() { + // Catch up state + const { incr } = await generate.main(); + + // Initialize a watch + const watch = new Watch(rebuild); + statusLine(); + + function rebuild(files: string[]) { + files = files.map((file) => path.relative(hot.projectRoot, file)); + const changed: string[] = []; + for (const file of files) { + if (incr.updateStat(file, fs.statSync(file).mtimeMs)) changed.push(file); + } + if (changed.length === 0) { + console.warn("Files were modified but the 'modify' time did not change."); + return; + } + withSpinner, any>({ + text: "Recovering State", + successText: generate.successText, + failureText: () => "sitegen FAIL", + }, async (spinner) => { + console.clear(); + console.log( + "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 + return result; + }).catch((err) => { + console.error(util.inspect(err)); + }).finally(statusLine); + } + + function statusLine() { + watch.add(...incr.invals.keys()); + console.info( + `Watching ${incr.invals.size} files \x1b[36m[last change: ${ + new Date().toLocaleTimeString() + }]\x1b[39m`, + ); + } +} + +class Watch { + files = new Set(); + stale = new Set(); + onChange: (files: string[]) => void; + watchers: fs.FSWatcher[] = []; + /** Has a trailing slash */ + roots: string[] = []; + debounce: ReturnType | 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); + } + } + } + + 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 { Incremental } from "./incremental.ts"; +import * as fs from "node:fs"; +import { Spinner, 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"; diff --git a/src/global.css b/src/global.css index d25b9e4..bf093ee 100644 --- a/src/global.css +++ b/src/global.css @@ -118,3 +118,4 @@ code { font-family: "rmo", monospace; font-size: inherit; } + diff --git a/tsconfig.json b/tsconfig.json index 943bf39..c7e6fda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "rootDir": ".", "skipLibCheck": true, "strict": true, + "verbaitimModuleSyntax": true, "target": "es2022" } }