incremental sitegen dev server!

This commit is contained in:
chloe caruso 2025-06-13 00:13:22 -07:00
parent d5ef829f01
commit a41569983f
8 changed files with 535 additions and 233 deletions

View file

@ -1,8 +1,4 @@
// This file implements client-side bundling, mostly wrapping esbuild. // This file implements client-side bundling, mostly wrapping esbuild.
const clientPlugins: esbuild.Plugin[] = [
// There are currently no plugins needed by 'paperclover.net'
];
export async function bundleClientJavaScript( export async function bundleClientJavaScript(
referencedScripts: string[], referencedScripts: string[],
extraPublicScripts: string[], extraPublicScripts: string[],
@ -11,7 +7,7 @@ export async function bundleClientJavaScript(
) { ) {
const entryPoints = [ const entryPoints = [
...new Set([ ...new Set([
...referencedScripts, ...referencedScripts.map((file) => path.resolve(hot.projectSrc, file)),
...extraPublicScripts, ...extraPublicScripts,
]), ]),
]; ];
@ -26,6 +22,10 @@ export async function bundleClientJavaScript(
); );
} }
const clientPlugins: esbuild.Plugin[] = [
// There are currently no plugins needed by 'paperclover.net'
];
const bundle = await esbuild.build({ const bundle = await esbuild.build({
bundle: true, bundle: true,
chunkNames: "/js/c.[hash]", chunkNames: "/js/c.[hash]",
@ -51,7 +51,6 @@ export async function bundleClientJavaScript(
); );
const { metafile } = bundle; const { metafile } = bundle;
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
// TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
for (const file of bundle.outputFiles) { for (const file of bundle.outputFiles) {
const { text } = file; const { text } = file;
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
@ -61,7 +60,7 @@ export async function bundleClientJavaScript(
// Register non-chunks as script entries. // Register non-chunks as script entries.
const chunk = route.startsWith("/js/c."); const chunk = route.startsWith("/js/c.");
if (!chunk) { if (!chunk) {
const key = hot.getScriptId(sources[0]); const key = hot.getScriptId(path.resolve(sources[0]));
route = "/js/" + key + ".js"; route = "/js/" + key + ".js";
incr.put({ incr.put({
sources, sources,
@ -82,41 +81,40 @@ export async function bundleClientJavaScript(
await Promise.all(promises); await Promise.all(promises);
} }
type ServerPlatform = "node" | "passthru"; export type ServerPlatform = "node" | "passthru";
export async function bundleServerJavaScript( export async function bundleServerJavaScript(
/** Has 'export default app;' */ incr: Incremental,
_: string,
/** Views for dynamic loading */
viewEntryPoints: FileItem[],
platform: ServerPlatform = "node", platform: ServerPlatform = "node",
) { ) {
const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION"; if (incr.hasArtifact("backendBundle", platform)) return;
// Comment
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
const viewSource = [ const viewSource = [
...viewEntryPoints.map((view, i) => ...Array.from(
`import * as view${i} from ${JSON.stringify(view.file)}` incr.out.viewMetadata,
([, view], i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
), ),
`const styles = ${scriptMagic}[-2]`, `const styles = ${magicWord}[-2]`,
`export const scripts = ${scriptMagic}[-1]`, `export const scripts = ${magicWord}[-1]`,
"export const views = {", "export const views = {",
...viewModules.flatMap(({ view, module }, i) => [ ...Array.from(incr.out.viewMetadata, ([key, view], i) =>
` ${JSON.stringify(view.id)}: {`, [
` ${JSON.stringify(key)}: {`,
` component: view${i}.default,`, ` component: view${i}.default,`,
// ` meta: ${
// view.staticMeta ? JSON.stringify(view.staticMeta) : `view${i}.meta`
// },`,
` meta: view${i}.meta,`, ` meta: view${i}.meta,`,
` layout: ${ ` layout: ${view.hasLayout ? `view${i}.layout?.default` : "null"},`,
module.layout?.default ? `view${i}.layout?.default` : "undefined" ` inlineCss: styles[${magicWord}[${i}]]`,
},`,
` theme: ${
module.layout?.theme
? `view${i}.layout?.theme`
: module.theme
? `view${i}.theme`
: "undefined"
},`,
` inlineCss: styles[${scriptMagic}[${i}]]`,
` },`, ` },`,
]), ].join("\n")),
"}", "}",
].join("\n"); ].join("\n");
// -- plugins --
const serverPlugins: esbuild.Plugin[] = [ const serverPlugins: esbuild.Plugin[] = [
virtualFiles({ virtualFiles({
"$views": viewSource, "$views": viewSource,
@ -125,10 +123,21 @@ export async function bundleServerJavaScript(
{ {
name: "marko via build cache", name: "marko via build cache",
setup(b) { setup(b) {
b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => ({ b.onLoad(
{ filter: /\.marko$/ },
async ({ path: file }) => {
const key = path.relative(hot.projectRoot, file)
.replaceAll("\\", "/");
const cacheEntry = incr.out.serverMarko.get(key);
if (!cacheEntry) {
throw new Error("Marko file not in cache: " + file);
}
return ({
loader: "ts", loader: "ts",
contents: hot.getSourceCode(file), contents: cacheEntry.src,
})); });
},
);
}, },
}, },
{ {
@ -145,10 +154,10 @@ export async function bundleServerJavaScript(
}, },
}, },
]; ];
const bundle = await esbuild.build({ const { metafile, outputFiles, warnings } = await esbuild.build({
bundle: true, bundle: true,
chunkNames: "c.[hash]", chunkNames: "c.[hash]",
entryNames: "[name]", entryNames: "server",
entryPoints: [ entryPoints: [
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"), path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
], ],
@ -161,69 +170,72 @@ export async function bundleServerJavaScript(
write: false, write: false,
metafile: true, metafile: true,
}); });
const viewModules = viewEntryPoints.map((view) => {
const module = require(view.file); const files: Record<string, Buffer> = {};
if (!module.meta) { let fileWithMagicWord: string | null = null;
throw new Error(`${view.file} is missing 'export const meta'`); for (const output of outputFiles) {
const basename = output.path.replace(/^.*?!/, "");
const key = "out!" + basename.replaceAll("\\", "/");
// If this contains the generated "$views" file, then
// mark this file as the one for replacement. Because
// `splitting` is `true`, esbuild will not emit this
// file in more than one chunk.
if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
fileWithMagicWord = basename;
} }
if (!module.default) { files[basename] = Buffer.from(output.contents);
throw new Error(`${view.file} is missing a default export.`);
} }
return { module, view }; incr.put({
kind: "backendBundle",
key: platform,
value: {
magicWord,
files,
fileWithMagicWord,
},
sources: Object.keys(metafile.inputs).filter((x) =>
!x.startsWith("vfs:") &&
!x.startsWith("dropped:") &&
!x.includes("node_modules")
),
}); });
const viewData = viewModules.map(({ module, view }) => {
return {
id: view.id,
theme: module.theme,
cssImports: hot.getCssImports(view.file)
.concat("src/global.css")
.map((file) => path.resolve(file)),
clientRefs: hot.getClientScriptRefs(view.file),
};
});
return {
views: viewData,
bundle,
scriptMagic,
};
} }
type Await<T> = T extends Promise<infer R> ? R : T; export async function finalizeServerJavaScript(
export function finalizeServerJavaScript(
backend: Await<ReturnType<typeof bundleServerJavaScript>>,
viewCssBundles: css.Output[],
incr: Incremental, incr: Incremental,
platform: ServerPlatform,
) { ) {
const { metafile, outputFiles } = backend.bundle; if (incr.hasArtifact("backendReplace", platform)) return;
const {
files,
fileWithMagicWord,
magicWord,
} = UNWRAP(incr.getArtifact("backendBundle", platform));
// Only the reachable script files need to be inserted into the bundle. if (!fileWithMagicWord) return;
const reachableScriptKeys = new Set(
backend.views.flatMap((view) => view.clientRefs), // Only the reachable resources need to be inserted into the bundle.
); const viewScriptsList = new Set(
const reachableScripts = Object.fromEntries( Array.from(incr.out.viewMetadata.values())
Array.from(incr.out.script) .flatMap((view) => view.clientRefs),
.filter(([k]) => reachableScriptKeys.has(k)),
); );
const viewStyleKeys = Array.from(incr.out.viewMetadata.values())
.map((view) => css.styleKey(view.cssImports, view.theme));
const viewCssBundles = viewStyleKeys
.map((key) => UNWRAP(incr.out.style.get(key), "Style key: " + key));
// Deduplicate styles // Deduplicate styles
const styleList = Array.from(new Set(viewCssBundles)); const styleList = Array.from(new Set(viewCssBundles));
for (const output of outputFiles) { // Replace the magic word
const basename = output.path.replace(/^.*?!/, ""); let text = files[fileWithMagicWord].toString("utf-8");
const key = "out!" + basename.replaceAll("\\", "/");
// If this contains the generated "$views" file, then
// replace the IDs with the bundled results.
let text = output.text;
if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
text = text.replace( text = text.replace(
/CLOVER_CLIENT_SCRIPTS_DEFINITION\[(-?\d)\]/gs, new RegExp(magicWord + "\\[(-?\\d)\\]", "gs"),
(_, i) => { (_, i) => {
i = Number(i); i = Number(i);
// Inline the styling data // Inline the styling data
if (i === -2) { if (i === -2) {
return JSON.stringify(styleList.map((item) => item.text)); return JSON.stringify(styleList.map((cssText) => cssText));
} }
// Inline the script data // Inline the script data
if (i === -1) { if (i === -1) {
@ -233,9 +245,21 @@ export function finalizeServerJavaScript(
return `${styleList.indexOf(viewCssBundles[i])}`; return `${styleList.indexOf(viewCssBundles[i])}`;
}, },
); );
}
fs.writeMkdirSync(path.join(".clover/backend/" + basename), text); incr.put({
} kind: "backendReplace",
key: platform,
sources: [
// Backend input code (includes view code)
...incr.sourcesFor("backendBundle", platform),
// Script
...Array.from(viewScriptsList)
.flatMap((key) => incr.sourcesFor("script", hot.getScriptId(key))),
// Style
...viewStyleKeys.flatMap((key) => incr.sourcesFor("style", key)),
],
value: Buffer.from(text),
});
} }
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
@ -248,7 +272,4 @@ import {
virtualFiles, virtualFiles,
} from "./esbuild-support.ts"; } from "./esbuild-support.ts";
import { Incremental } from "./incremental.ts"; import { Incremental } from "./incremental.ts";
import type { FileItem } from "#sitegen";
import * as marko from "@marko/compiler";
import * as css from "./css.ts"; import * as css from "./css.ts";
import * as fs from "./lib/fs.ts";

View file

@ -51,10 +51,12 @@ export function styleKey(
) { ) {
cssImports = cssImports cssImports = cssImports
.map((file) => .map((file) =>
path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file (path.isAbsolute(file) ? path.relative(hot.projectSrc, file) : file)
.replaceAll("\\", "/")
) )
.sort(); .sort();
return cssImports.join(":") + JSON.stringify(theme); return cssImports.join(":") + ":" +
Object.entries(theme).map(([k, v]) => `${k}=${v}`);
} }
export async function bundleCssFiles( export async function bundleCssFiles(
@ -62,9 +64,7 @@ export async function bundleCssFiles(
theme: Theme, theme: Theme,
dev: boolean = false, dev: boolean = false,
): Promise<Output> { ): Promise<Output> {
cssImports = cssImports.map((file) => cssImports = cssImports.map((file) => path.resolve(hot.projectSrc, file));
path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file
);
const plugin = { const plugin = {
name: "clover css", name: "clover css",
setup(b) { setup(b) {

View file

@ -2,23 +2,35 @@
// By using `Incremental`'s ability to automatically purge stale // By using `Incremental`'s ability to automatically purge stale
// assets, the `sitegen` function performs partial rebuilds. // assets, the `sitegen` function performs partial rebuilds.
export function main(incremental?: Incremental) { export function main() {
return withSpinner<Record<string, unknown>, any>({ return withSpinner<Record<string, unknown>, any>({
text: "Recovering State", text: "Recovering State",
successText, successText,
failureText: () => "sitegen FAIL", failureText: () => "sitegen FAIL",
}, async (spinner) => { }, async (spinner) => {
// const incr = Incremental.fromDisk(); const incr = Incremental.fromDisk();
// await incr.statAllFiles(); await incr.statAllFiles();
const incr = new Incremental(); // const incr = new Incremental();
const result = await sitegen(spinner, incr); const result = await sitegen(spinner, incr);
incr.toDisk(); // Allows picking up this state again incr.toDisk(); // Allows picking up this state again
return result; return result;
}) as ReturnType<typeof sitegen>; }) as ReturnType<typeof sitegen>;
} }
export function successText({ elapsed }: { elapsed: number }) { export function successText({
return "sitegen! update in " + elapsed.toFixed(1) + "s"; elapsed,
inserted,
referenced,
unreferenced,
}: Awaited<ReturnType<typeof sitegen>>) {
const s = (array: unknown[]) => array.length === 1 ? "" : "s";
const kind = inserted.length === referenced.length ? "build" : "update";
const status = inserted.length > 0
? `${kind} ${inserted.length} key${s(inserted)}`
: unreferenced.length > 0
? `pruned ${unreferenced.length} key${s(unreferenced)}`
: `checked ${referenced.length} key${s(referenced)}`;
return `sitegen! ${status} in ${elapsed.toFixed(1)}s`;
} }
export async function sitegen( export async function sitegen(
@ -159,7 +171,10 @@ export async function sitegen(
...css.defaultTheme, ...css.defaultTheme,
...pageTheme, ...pageTheme,
}; };
const cssImports = [globalCssPath, ...hot.getCssImports(item.file)]; const cssImports = Array.from(
new Set([globalCssPath, ...hot.getCssImports(item.file)]),
(file) => path.relative(hot.projectSrc, file),
);
ensureCssGetsBuilt(cssImports, theme, item.id); ensureCssGetsBuilt(cssImports, theme, item.id);
// -- metadata -- // -- metadata --
@ -216,7 +231,7 @@ export async function sitegen(
}; };
const cssImports = hot.getCssImports(view.file) const cssImports = hot.getCssImports(view.file)
.concat("src/global.css") .concat("src/global.css")
.map((file) => path.relative(hot.projectRoot, path.resolve(file))); .map((file) => path.relative(hot.projectSrc, path.resolve(file)));
incr.put({ incr.put({
kind: "viewMetadata", kind: "viewMetadata",
key: view.id, key: view.id,
@ -250,6 +265,9 @@ export async function sitegen(
return !existing; return !existing;
}); });
// Load the marko cache before render modules are loaded
incr.loadMarkoCache();
// This is done in two passes so that a page that throws during evaluation // This is done in two passes so that a page that throws during evaluation
// will report "Load Render Module" instead of "Render Static Page". // will report "Load Render Module" instead of "Render Static Page".
const spinnerFormat = status.format; const spinnerFormat = status.format;
@ -281,22 +299,12 @@ export async function sitegen(
await viewQueue.done({ method: "stop" }); await viewQueue.done({ method: "stop" });
status.format = spinnerFormat; status.format = spinnerFormat;
// -- bundle backend and views -- // -- bundle server javascript (backend and views) --
// status.text = "Bundle backend code"; status.text = "Bundle JavaScript";
// const backend = await bundle.bundleServerJavaScript( incr.snapshotMarkoCache();
// join("backend.ts"), const serverJavaScriptPromise = bundle.bundleServerJavaScript(incr, "node");
// views,
// );
// const viewCssPromise = await Promise.all(
// backend.views.map((view) =>
// cssOnce.get(
// view.cssImports.join(":") + JSON.stringify(view.theme),
// () => cssQueue.add([view.id, view.cssImports, view.theme ?? {}]),
// )
// ),
// );
// -- bundle scripts -- // -- bundle client javascript --
const referencedScripts = Array.from( const referencedScripts = Array.from(
new Set( new Set(
[ [
@ -317,19 +325,17 @@ export async function sitegen(
(script) => path.resolve(hot.projectSrc, script), (script) => path.resolve(hot.projectSrc, script),
).filter((file) => !incr.hasArtifact("script", hot.getScriptId(file))); ).filter((file) => !incr.hasArtifact("script", hot.getScriptId(file)));
const extraPublicScripts = scripts.map((entry) => entry.file); const extraPublicScripts = scripts.map((entry) => entry.file);
const uniqueCount = new Set([ const clientJavaScriptPromise = bundle.bundleClientJavaScript(
...referencedScripts,
...extraPublicScripts,
]).size;
status.text = `Bundle ${uniqueCount} Scripts`;
await bundle.bundleClientJavaScript(
referencedScripts, referencedScripts,
extraPublicScripts, extraPublicScripts,
incr, incr,
); );
await Promise.all([
// -- finalize backend bundle -- serverJavaScriptPromise,
// await bundle.finalizeServerJavaScript(backend, await viewCssPromise, incr); clientJavaScriptPromise,
cssQueue.done({ method: "stop" }),
]);
await bundle.finalizeServerJavaScript(incr, "node");
// -- copy/compress static files -- // -- copy/compress static files --
async function doStaticFile(item: FileItem) { async function doStaticFile(item: FileItem) {
@ -353,8 +359,6 @@ export async function sitegen(
await staticQueue.done({ method: "stop" }); await staticQueue.done({ method: "stop" });
status.format = spinnerFormat; status.format = spinnerFormat;
await cssQueue.done({ method: "stop" });
// -- concatenate static rendered pages -- // -- concatenate static rendered pages --
status.text = `Concat Pages`; status.text = `Concat Pages`;
await Promise.all(pages.map(async (page) => { await Promise.all(pages.map(async (page) => {
@ -399,11 +403,19 @@ export async function sitegen(
// to this point have been left as dangling promises. // to this point have been left as dangling promises.
await incr.wait(); await incr.wait();
const { inserted, referenced, unreferenced } = incr.shake();
// Flush the site to disk. // Flush the site to disk.
status.format = spinnerFormat; status.format = spinnerFormat;
status.text = `Incremental Flush`; status.text = `Incremental Flush`;
incr.flush(); // Write outputs incr.flush("node"); // Write outputs
return { incr, elapsed: (performance.now() - startTime) / 1000 }; return {
incr,
inserted,
referenced,
unreferenced,
elapsed: (performance.now() - startTime) / 1000,
};
} }
function getItemText({ file }: FileItem) { function getItemText({ file }: FileItem) {

View file

@ -35,9 +35,10 @@ export interface FileStat {
lastModified: number; lastModified: number;
imports: string[]; imports: string[];
} }
let fsGraph = new Map<string, FileStat>(); const fileStats = new Map<string, FileStat>();
export function getFsGraph() {
return fsGraph; export function getFileStat(filepath: string) {
return fileStats.get(path.resolve(filepath));
} }
function shouldTrackPath(filename: string) { function shouldTrackPath(filename: string) {
@ -63,18 +64,16 @@ Module.prototype._compile = function (
const cssImportsMaybe: string[] = []; const cssImportsMaybe: string[] = [];
const imports: string[] = []; const imports: string[] = [];
for (const { filename: file } of this.children) { for (const { filename: file } of this.children) {
const relative = path.relative(projectRoot, file); if (file.endsWith(".css")) cssImportsMaybe.push(file);
if (file.endsWith(".css")) cssImportsMaybe.push(relative);
else { else {
const child = fsGraph.get(relative); const child = fileStats.get(file);
if (!child) continue; if (!child) continue;
const { cssImportsRecursive } = child; const { cssImportsRecursive } = child;
if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive); if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
imports.push(relative); imports.push(file);
} }
} }
const relative = path.relative(projectRoot, filename); fileStats.set(filename, {
fsGraph.set(relative, {
cssImportsRecursive: cssImportsMaybe.length > 0 cssImportsRecursive: cssImportsMaybe.length > 0
? Array.from(new Set(cssImportsMaybe)) ? Array.from(new Set(cssImportsMaybe))
: null, : null,
@ -155,10 +154,18 @@ function resolveClientRef(sourcePath: string, ref: string) {
) { ) {
throw new Error("addScript must be a .client.ts or .client.tsx"); throw new Error("addScript must be a .client.ts or .client.tsx");
} }
return filePath; return path.relative(projectSrc, filePath);
} }
// TODO: extract the marko compilation tools out, lazy load them
export interface MarkoCacheEntry {
src: string;
scannedClientRefs: string[];
}
export const markoCache = new Map<string, MarkoCacheEntry>();
function loadMarko(module: NodeJS.Module, filepath: string) { function loadMarko(module: NodeJS.Module, filepath: string) {
let cache = markoCache.get(filepath);
if (!cache) {
let src = fs.readFileSync(filepath, "utf8"); let src = fs.readFileSync(filepath, "utf8");
// A non-standard thing here is Clover Sitegen implements // A non-standard thing here is Clover Sitegen implements
// its own client side scripting stuff, so it overrides // its own client side scripting stuff, so it overrides
@ -180,11 +187,13 @@ function loadMarko(module: NodeJS.Module, filepath: string) {
src = marko.compileSync(src, filepath).code; src = marko.compileSync(src, filepath).code;
src = src.replace("marko/debug/html", "#ssr/marko"); src = src.replace("marko/debug/html", "#ssr/marko");
cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
markoCache.set(filepath, cache);
}
module.cloverSourceCode = src; const { src, scannedClientRefs } = cache;
return loadEsbuildCode(module, filepath, src, { return loadEsbuildCode(module, filepath, src, {
scannedClientRefs: Array.from(scannedClientRefs), scannedClientRefs,
}); });
} }
@ -202,12 +211,19 @@ function loadCss(module: NodeJS.Module, _filepath: string) {
export function reloadRecursive(filepath: string) { export function reloadRecursive(filepath: string) {
filepath = path.resolve(filepath); filepath = path.resolve(filepath);
const existing = cache[filepath]; const existing = cache[filepath];
if (existing) deleteRecursive(filepath, existing); if (existing) deleteRecursiveInner(filepath, existing);
fsGraph.clear(); fileStats.clear();
return require(filepath); return require(filepath);
} }
function deleteRecursive(id: string, module: any) { export function unload(filepath: string) {
filepath = path.resolve(filepath);
const existing = cache[filepath];
if (existing) delete cache[filepath];
fileStats.delete(filepath);
}
function deleteRecursiveInner(id: string, module: any) {
if (id.includes(path.sep + "node_modules" + path.sep)) { if (id.includes(path.sep + "node_modules" + path.sep)) {
return; return;
} }
@ -215,15 +231,14 @@ function deleteRecursive(id: string, module: any) {
for (const child of module.children) { for (const child of module.children) {
if (child.filename.includes("/engine/")) return; if (child.filename.includes("/engine/")) return;
const existing = cache[child.filename]; const existing = cache[child.filename];
if (existing === child) deleteRecursive(child.filename, existing); if (existing === child) deleteRecursiveInner(child.filename, existing);
} }
} }
export function getCssImports(filepath: string) { export function getCssImports(filepath: string) {
filepath = path.resolve(filepath); filepath = path.resolve(filepath);
if (!require.cache[filepath]) throw new Error(filepath + " was never loaded"); if (!require.cache[filepath]) throw new Error(filepath + " was never loaded");
return fsGraph.get(path.relative(projectRoot, filepath)) return fileStats.get(filepath)?.cssImportsRecursive ?? [];
?.cssImportsRecursive ?? [];
} }
export function getClientScriptRefs(filepath: string) { export function getClientScriptRefs(filepath: string) {
@ -296,7 +311,6 @@ export function resolveClientRefs(
export function getScriptId(file: string) { export function getScriptId(file: string) {
return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file) return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file)
.replace(/^\/?src\//, "")
.replaceAll("\\", "/"); .replaceAll("\\", "/");
} }

View file

@ -20,8 +20,19 @@ interface ArtifactMap {
pageMetadata: PageMetadata; pageMetadata: PageMetadata;
/* Metadata about a dynamic view */ /* Metadata about a dynamic view */
viewMetadata: ViewMetadata; viewMetadata: ViewMetadata;
/* Cached '.marko' server compilation */
serverMarko: hot.MarkoCacheEntry;
/* Backend source code, pre-replacement. Keyed by platform type. */
backendBundle: BackendBundle;
/* One file in the backend receives post-processing. */
backendReplace: Buffer;
} }
type ArtifactKind = keyof ArtifactMap; type ArtifactKind = keyof ArtifactMap;
/* Automatic path tracing is performed to make it so that
* specifying 'sources: [file]' refers to it and everything it imports.
* These kinds do not have that behavior
*/
const exactDependencyKinds = ["serverMarko"];
export interface Asset { export interface Asset {
buffer: Buffer; buffer: Buffer;
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
@ -50,6 +61,11 @@ export interface ViewMetadata {
clientRefs: string[]; clientRefs: string[];
hasLayout: boolean; hasLayout: boolean;
} }
export interface BackendBundle {
magicWord: string;
fileWithMagicWord: string | null;
files: Record<string, Buffer>;
}
// -- incremental support types -- // -- incremental support types --
export interface PutBase { export interface PutBase {
@ -76,6 +92,9 @@ export class Incremental {
style: new Map(), style: new Map(),
pageMetadata: new Map(), pageMetadata: new Map(),
viewMetadata: new Map(), viewMetadata: new Map(),
serverMarko: new Map(),
backendBundle: new Map(),
backendReplace: new Map(),
}; };
/** Tracking filesystem entries to `srcId` */ /** Tracking filesystem entries to `srcId` */
invals = new Map<SourceId, Invalidations>(); invals = new Map<SourceId, Invalidations>();
@ -92,16 +111,52 @@ export class Incremental {
getItemText: (job) => `${job.algo.toUpperCase()} ${job.label}`, getItemText: (job) => `${job.algo.toUpperCase()} ${job.label}`,
}); });
/** Reset at the end of each update */
round = {
inserted: new Set<ArtifactId>(),
referenced: new Set<ArtifactId>(),
};
getArtifact<T extends ArtifactKind>(kind: T, key: string) { getArtifact<T extends ArtifactKind>(kind: T, key: string) {
this.round.referenced.add(`${kind}\0${key}`);
return this.out[kind].get(key); return this.out[kind].get(key);
} }
hasArtifact<T extends ArtifactKind>(kind: T, key: string) { hasArtifact(kind: ArtifactKind, key: string) {
return this.out[kind].has(key); return this.getArtifact(kind, key) != null;
} }
sourcesFor(kind: ArtifactKind, key: string) { sourcesFor(kind: ArtifactKind, key: string) {
return UNWRAP(this.sources.get(kind + "\0" + key)); return UNWRAP(
this.sources.get(kind + "\0" + key),
`No artifact '${kind}' '${key}'`,
);
}
shake() {
const toPublic = (str: string) => {
const [kind, key] = str.split("\0");
return { kind: kind as ArtifactKind, key };
};
const inserted = Array.from(this.round.inserted, toPublic);
const referenced = Array.from(this.round.referenced, toPublic);
const unreferenced: { kind: ArtifactKind; key: string }[] = [];
for (const kind in this.out) {
const map = this.out[kind as keyof typeof this.out];
if (!map) continue;
for (const key of map.keys()) {
if (!this.round.referenced.has(`${kind}\0${key}`)) {
unreferenced.push({ kind: kind as ArtifactKind, key });
console.warn("unreferened " + kind + " : " + key);
}
}
}
this.round.inserted.clear();
this.round.referenced.clear();
return { inserted, referenced, unreferenced };
} }
/* /*
@ -115,9 +170,25 @@ export class Incremental {
key, key,
value, value,
}: Put<T>) { }: Put<T>) {
console.log("put " + kind + ": " + key); // These three invariants affect incremental accuracy.
if (this.round.inserted.has(`${kind}\0${key}`)) {
console.error(
`Artifact ${kind}:${key} was inserted multiple times in the same round!`,
);
} else if (!this.round.referenced.has(`${kind}\0${key}`)) {
console.error(
`Artifact ${kind}:${key} was inserted without checking if (!hasArtifact())`,
);
} else if (this.out[kind].has(key)) {
console.error(
`Artifact ${kind}:${key} is not stale, but overwritten.`,
);
}
this.out[kind].set(key, value); this.out[kind].set(key, value);
this.round.inserted.add(`${kind}\0${key}`);
// Update sources information // Update sources information
ASSERT(sources.length > 0, "Missing sources for " + kind + " " + key); ASSERT(sources.length > 0, "Missing sources for " + kind + " " + key);
sources = sources.map((src) => path.normalize(src)); sources = sources.map((src) => path.normalize(src));
@ -150,8 +221,7 @@ export class Incremental {
#getOrInitInvals(source: string) { #getOrInitInvals(source: string) {
let invals = this.invals.get(source); let invals = this.invals.get(source);
if (!invals) { if (!invals) {
const g = hot.getFsGraph().get(source); const lastModified = hot.getFileStat(source)?.lastModified ??
const lastModified = g?.lastModified ??
fs.statSync(path.resolve(hot.projectRoot, source)).mtimeMs; fs.statSync(path.resolve(hot.projectRoot, source)).mtimeMs;
this.invals.set( this.invals.set(
source, source,
@ -166,8 +236,7 @@ export class Incremental {
} }
#followImports(file: string) { #followImports(file: string) {
const graph = hot.getFsGraph(); const stat = hot.getFileStat(file);
const stat = graph.get(file);
if (!stat) return; if (!stat) return;
for (const i of stat.imports) { for (const i of stat.imports) {
const invals = this.#getOrInitInvals(i); const invals = this.#getOrInitInvals(i);
@ -178,19 +247,23 @@ export class Incremental {
async statAllFiles() { async statAllFiles() {
for (const file of this.invals.keys()) { for (const file of this.invals.keys()) {
try {
const mtime = fs.statSync(file).mtimeMs; const mtime = fs.statSync(file).mtimeMs;
this.updateStat(file, mtime); this.updateStat(file, mtime);
} catch (err) {
}
} }
} }
updateStat(file: string, newLastModified: number) { updateStat(file: string, newLastModified: number | null) {
file = path.relative(hot.projectRoot, file); file = path.relative(hot.projectRoot, file);
const stat = this.invals.get(file); const stat = this.invals.get(file);
ASSERT(stat, "Updated stat on untracked file " + file); ASSERT(stat, "Updated stat on untracked file " + file);
const hasUpdate = stat.lastModified < newLastModified; const hasUpdate = !newLastModified || stat.lastModified < newLastModified;
if (hasUpdate) { if (hasUpdate) {
// Invalidate // Invalidate
console.info(file + " updated"); console.info(file + " " + (newLastModified ? "updated" : "deleted"));
hot.unload(file);
const invalidQueue = [file]; const invalidQueue = [file];
let currentInvalid; let currentInvalid;
while (currentInvalid = invalidQueue.pop()) { while (currentInvalid = invalidQueue.pop()) {
@ -204,12 +277,15 @@ export class Incremental {
for (const out of outputs) { for (const out of outputs) {
const [kind, artifactKey] = out.split("\0"); const [kind, artifactKey] = out.split("\0");
this.out[kind as ArtifactKind].delete(artifactKey); this.out[kind as ArtifactKind].delete(artifactKey);
console.log("stale " + kind + ": " + artifactKey);
} }
invalidQueue.push(...files); invalidQueue.push(...files);
} }
} }
if (newLastModified) {
stat.lastModified = newLastModified; stat.lastModified = newLastModified;
} else {
this.invals.delete(file);
}
return hasUpdate; return hasUpdate;
} }
@ -266,6 +342,7 @@ export class Incremental {
serialize() { serialize() {
const writer = new BufferWriter(); const writer = new BufferWriter();
// -- artifact --
const asset = Array.from( const asset = Array.from(
this.out.asset, this.out.asset,
([key, { buffer, hash, headers }]) => { ([key, { buffer, hash, headers }]) => {
@ -283,6 +360,31 @@ export class Incremental {
}, },
); );
const script = Array.from(this.out.script); const script = Array.from(this.out.script);
const style = Array.from(this.out.style);
const pageMetadata = Array.from(this.out.pageMetadata);
const viewMetadata = Array.from(this.out.viewMetadata);
const serverMarko = Array.from(this.out.serverMarko);
const backendBundle = Array.from(this.out.backendBundle, ([k, v]) => {
return [k, {
magicWord: v.magicWord,
fileWithMagicWord: v.fileWithMagicWord,
files: Object.entries(v.files).map(
([file, contents]) => [
file,
writer.write(contents, "backendBundle" + k + ":" + file),
],
),
}] satisfies SerializedMeta["backendBundle"][0];
});
const backendReplace = Array.from(
this.out.backendReplace,
([k, v]) =>
[
k,
writer.write(v, "backendReplace" + k),
] satisfies SerializedMeta["backendReplace"][0],
);
// -- incremental metadata --
const invals = Array.from(this.invals, ([key, value]) => { const invals = Array.from(this.invals, ([key, value]) => {
const { lastModified, files, outputs } = value; const { lastModified, files, outputs } = value;
return [key, { return [key, {
@ -299,6 +401,12 @@ export class Incremental {
script, script,
invals, invals,
sources, sources,
style,
pageMetadata,
viewMetadata,
serverMarko,
backendBundle,
backendReplace,
} satisfies SerializedMeta; } satisfies SerializedMeta;
const meta = Buffer.from(JSON.stringify(json), "utf-8"); const meta = Buffer.from(JSON.stringify(json), "utf-8");
@ -317,7 +425,8 @@ export class Incremental {
buffer.subarray(4 + metaLength + start, 4 + metaLength + end); buffer.subarray(4 + metaLength + start, 4 + metaLength + end);
const incr = new Incremental(); const incr = new Incremental();
incr.out.asset = new Map<string, Asset>(meta.asset.map(([key, value]) => { incr.out = {
asset: new Map(meta.asset.map(([key, value]) => {
const { hash, raw, gzip, zstd, headers } = value; const { hash, raw, gzip, zstd, headers } = value;
if ((gzip || zstd) && !incr.compress.has(hash)) { if ((gzip || zstd) && !incr.compress.has(hash)) {
incr.compress.set(hash, { incr.compress.set(hash, {
@ -330,8 +439,25 @@ export class Incremental {
headers: headers, headers: headers,
hash: hash, hash: hash,
}]; }];
})); })),
incr.out.script = new Map(meta.script); script: new Map(meta.script),
style: new Map(meta.style),
pageMetadata: new Map(meta.pageMetadata),
viewMetadata: new Map(meta.viewMetadata),
serverMarko: new Map(meta.serverMarko),
backendBundle: new Map(meta.backendBundle.map(([key, value]) => {
return [key, {
magicWord: value.magicWord,
fileWithMagicWord: value.fileWithMagicWord,
files: Object.fromEntries(
value.files.map(([file, contents]) => [file, view(contents)]),
),
}];
})),
backendReplace: new Map(
meta.backendReplace.map(([key, contents]) => [key, view(contents)]),
),
};
incr.invals = new Map(meta.invals.map(([key, { m, f, o }]) => { incr.invals = new Map(meta.invals.map(([key, { m, f, o }]) => {
return [key, { return [key, {
lastModified: m, lastModified: m,
@ -343,6 +469,37 @@ export class Incremental {
return incr; return incr;
} }
/*
* Move the cached (server) marko transpilations from this incremental
* into the running process.
*/
loadMarkoCache() {
hot.markoCache.clear();
for (const [key, value] of this.out.serverMarko) {
hot.markoCache.set(path.resolve(hot.projectRoot, key), value);
}
}
/*
* Move the cached (server) marko transpilations from this incremental
* into the running process.
*/
snapshotMarkoCache() {
for (const [file, value] of hot.markoCache) {
const key = path.relative(hot.projectRoot, file).replaceAll("\\", "/");
// Only insert if it doesn't exist. Calling 'put' when it
// already exists would inform the user of extra calls to put.
if (!this.hasArtifact("serverMarko", key)) {
this.put({
kind: "serverMarko",
sources: [file],
key,
value,
});
}
}
}
toDisk(file = ".clover/incr.state") { toDisk(file = ".clover/incr.state") {
const buffer = this.serialize(); const buffer = this.serialize();
fs.writeFileSync(file, buffer); fs.writeFileSync(file, buffer);
@ -362,23 +519,52 @@ export class Incremental {
await this.compressQueue.done({ method: "success" }); await this.compressQueue.done({ method: "success" });
} }
async flush() { async flush(
platform: bundle.ServerPlatform,
dir = path.resolve(".clover/out"),
) {
ASSERT(!this.compressQueue.active); ASSERT(!this.compressQueue.active);
const join = (...args: string[]) => path.join(dir, ...args);
const writer = new BufferWriter(); const writer = new BufferWriter();
// TODO: ensure all assets are actually compressed and not fake lying.
// TODO: ensure all compressed got compressed
const asset = Object.fromEntries( const asset = Object.fromEntries(
Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => { Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => {
const raw = writer.write(buffer, hash); const raw = writer.write(buffer, hash);
const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {}; const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {};
const gzip = gzipBuf ? writer.write(gzipBuf, hash + ".gz") : null; const gzip = writer.write(UNWRAP(gzipBuf), hash + ".gz");
const zstd = zstdBuf ? writer.write(zstdBuf, hash + ".zstd") : null; const zstd = writer.write(UNWRAP(zstdBuf), hash + ".zstd");
return [key, { raw, gzip, zstd, headers }]; return [key, { raw, gzip, zstd, headers }];
}), }),
); );
await Promise.all([ const backendBundle = UNWRAP(this.out.backendBundle.get(platform));
fs.writeFile(".clover/static.json", JSON.stringify(asset)),
fs.writeFile(".clover/static.blob", writer.get()), // Arrange output files
]); const outFiles: Array<[file: string, contents: string | Buffer]> = [
// Asset manifest
["static.json", JSON.stringify(asset)],
["static.blob", writer.get()],
// Backend
...Object.entries(backendBundle.files).map(([subPath, contents]) =>
[
subPath,
subPath === backendBundle.fileWithMagicWord
? UNWRAP(this.out.backendReplace.get(platform))
: contents,
] as [string, Buffer]
),
];
// TODO: check duplicates
// Perform all i/o
await Promise.all(
outFiles.map(([subPath, contents]) =>
fs.writeMkdir(join(subPath), contents, { flush: true })
),
);
} }
} }
@ -440,6 +626,17 @@ export interface SerializedMeta {
headers: Record<string, string>; headers: Record<string, string>;
}]>; }]>;
script: Array<[key: string, value: string]>; script: Array<[key: string, value: string]>;
style: Array<[key: string, value: string]>;
pageMetadata: Array<[key: string, PageMetadata]>;
viewMetadata: Array<[key: string, ViewMetadata]>;
serverMarko: Array<[key: string, hot.MarkoCacheEntry]>;
backendBundle: Array<[platform: string, {
magicWord: string;
fileWithMagicWord: string | null;
files: Array<[string, View]>;
}]>;
backendReplace: Array<[key: string, View]>;
invals: Array<[key: string, { invals: Array<[key: string, {
/** Modified */ /** Modified */
m: number; m: number;
@ -461,3 +658,4 @@ import * as mime from "#sitegen/mime";
import * as path from "node:path"; import * as path from "node:path";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import * as css from "./css.ts"; import * as css from "./css.ts";
import type * as bundle from "./bundle.ts";

View file

@ -8,9 +8,10 @@ export type StaticPageId = string;
export async function reload() { export async function reload() {
const [map, buf] = await Promise.all([ const [map, buf] = await Promise.all([
fs.readFile(".clover/static.json", "utf8"), fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),
fs.readFile(".clover/static.blob"), fs.readFile(path.join(import.meta.dirname, "static.blob")),
]); ]);
console.log("new buffer loaded");
assets = { assets = {
map: JSON.parse(map), map: JSON.parse(map),
buf, buf,
@ -18,8 +19,11 @@ export async function reload() {
} }
export async function reloadSync() { export async function reloadSync() {
const map = fs.readFileSync(".clover/static.json", "utf8"); const map = fs.readFileSync(
const buf = fs.readFileSync(".clover/static.blob"); path.join(import.meta.dirname, "static.json"),
"utf8",
);
const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
assets = { assets = {
map: JSON.parse(map), map: JSON.parse(map),
buf, buf,
@ -105,8 +109,14 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
return c.res = new Response(body, { headers, status }); return c.res = new Response(body, { headers, status });
} }
process.on("message", (msg: any) => {
console.log({ msg });
if (msg?.type === "clover.assets.reload") reload();
});
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import type { StatusCode } from "hono/utils/http-status"; import type { StatusCode } from "hono/utils/http-status";
import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts"; import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import * as path from "node:path";

View file

@ -22,9 +22,15 @@ export function mkdirSync(dir: string) {
return nodeMkdirSync(dir, { recursive: true }); return nodeMkdirSync(dir, { recursive: true });
} }
export async function writeMkdir(file: string, contents: Buffer | string) { export type WriteFileAsyncOptions = Parameters<typeof writeFile>[2];
export async function writeMkdir(
file: string,
contents: Buffer | string,
options?: WriteFileAsyncOptions,
) {
await mkdir(path.dirname(file)); await mkdir(path.dirname(file));
return writeFile(file, contents); return writeFile(file, contents, options);
} }
export function writeMkdirSync(file: string, contents: Buffer | string) { export function writeMkdirSync(file: string, contents: Buffer | string) {

View file

@ -3,12 +3,39 @@
const debounceMilliseconds = 25; const debounceMilliseconds = 25;
export async function main() { export async function main() {
let subprocess: child_process.ChildProcess | null = null;
// Catch up state by running a main build. // Catch up state by running a main build.
const { incr } = await generate.main(); const { incr } = await generate.main();
// ...and watch the files that cause invals. // ...and watch the files that cause invals.
const watch = new Watch(rebuild); const watch = new Watch(rebuild);
watch.add(...incr.invals.keys()); watch.add(...incr.invals.keys());
statusLine(); statusLine();
// ... an
serve();
function serve() {
if (subprocess) {
subprocess.removeListener("close", onSubprocessClose);
subprocess.kill();
}
subprocess = child_process.fork(".clover/out/server.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}`);
}
process.on("beforeExit", () => {
subprocess?.removeListener("close", onSubprocessClose);
});
function rebuild(files: string[]) { function rebuild(files: string[]) {
files = files.map((file) => path.relative(hot.projectRoot, file)); files = files.map((file) => path.relative(hot.projectRoot, file));
@ -20,7 +47,7 @@ export async function main() {
console.warn("Files were modified but the 'modify' time did not change."); console.warn("Files were modified but the 'modify' time did not change.");
return; return;
} }
withSpinner<Record<string, unknown>, any>({ withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
text: "Rebuilding", text: "Rebuilding",
successText: generate.successText, successText: generate.successText,
failureText: () => "sitegen FAIL", failureText: () => "sitegen FAIL",
@ -39,6 +66,20 @@ export async function main() {
if (!incr.invals.has(relative)) watch.remove(file); if (!incr.invals.has(relative)) watch.remove(file);
} }
return result; return result;
}).then((result) => {
// Restart the server if it was changed or not running.
if (
!subprocess ||
result.inserted.some(({ kind }) => kind === "backendReplace")
) {
serve();
} else if (
subprocess &&
result.inserted.some(({ kind }) => kind === "asset")
) {
subprocess.send({ type: "clover.assets.reload" });
}
return result;
}).catch((err) => { }).catch((err) => {
console.error(util.inspect(err)); console.error(util.inspect(err));
}).finally(statusLine); }).finally(statusLine);
@ -142,10 +183,10 @@ class Watch {
} }
} }
import { Incremental } from "./incremental.ts";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { Spinner, withSpinner } from "@paperclover/console/Spinner"; import { withSpinner } from "@paperclover/console/Spinner";
import * as generate from "./generate.ts"; import * as generate from "./generate.ts";
import * as path from "node:path"; import * as path from "node:path";
import * as util from "node:util"; import * as util from "node:util";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import * as child_process from "node:child_process";