add a file watcher, live rebuild.

this is only verified functional on windows 7
This commit is contained in:
chloe caruso 2025-06-10 20:06:32 -07:00
parent c8b5e91251
commit 925366e79e
8 changed files with 190 additions and 47 deletions

View file

@ -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),
}));
},
},
{

View file

@ -1,20 +1,29 @@
export function main() {
return withSpinner({
export function main(incremental?: Incremental) {
return withSpinner<Record<string, unknown>, 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<typeof sitegen>;
}
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) {

View file

@ -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 `<CloverScriptInclude=${JSON.stringify(resolved)} />`;
return `<CloverScriptInclude=${
JSON.stringify(getScriptId(resolved))
} />`;
},
) + '\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 {

View file

@ -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) {

View file

@ -9,6 +9,9 @@ export interface View {
scripts: Record<string, string>;
}
let views: Record<string, View> = null!;
let scripts: Record<string, string> = 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<string, unknown>,
) {
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.

132
framework/watch.ts Normal file
View file

@ -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<Record<string, unknown>, 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<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);
}
}
}
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";

View file

@ -118,3 +118,4 @@ code {
font-family: "rmo", monospace;
font-size: inherit;
}

View file

@ -13,6 +13,7 @@
"rootDir": ".",
"skipLibCheck": true,
"strict": true,
"verbaitimModuleSyntax": true,
"target": "es2022"
}
}