authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-13 00:13:22-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:44-07:00
loge6cca067561965523a27ce63fabb551d3a6ef32f
tree0ff132f436e571b5d34c10e645dbf60934bb29dd
parent896eb7a6b0005ea9214fc7f59ee0a5ac5e5d5a6d
signature Commit is signed but in an unrecognized format.

incremental sitegen dev server!


8 files changed, 535 insertions(+), 233 deletions(-)

framework/bundle.ts+126-105
......@@ -1,8 +1,4 @@
11// This file implements client-side bundling, mostly wrapping esbuild.
2const clientPlugins: esbuild.Plugin[] = [
3 // There are currently no plugins needed by 'paperclover.net'
4];
5
62export async function bundleClientJavaScript(
73 referencedScripts: string[],
84 extraPublicScripts: string[],
......@@ -11,7 +7,7 @@ export async function bundleClientJavaScript(
117) {
128 const entryPoints = [
139 ...new Set([
14 ...referencedScripts,
10 ...referencedScripts.map((file) => path.resolve(hot.projectSrc, file)),
1511 ...extraPublicScripts,
1612 ]),
1713 ];
......@@ -26,6 +22,10 @@ export async function bundleClientJavaScript(
2622 );
2723 }
2824
25 const clientPlugins: esbuild.Plugin[] = [
26 // There are currently no plugins needed by 'paperclover.net'
27 ];
28
2929 const bundle = await esbuild.build({
3030 bundle: true,
3131 chunkNames: "/js/c.[hash]",
......@@ -51,7 +51,6 @@ export async function bundleClientJavaScript(
5151 );
5252 const { metafile } = bundle;
5353 const promises: Promise<void>[] = [];
54 // TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
5554 for (const file of bundle.outputFiles) {
5655 const { text } = file;
5756 let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
......@@ -61,7 +60,7 @@ export async function bundleClientJavaScript(
6160 // Register non-chunks as script entries.
6261 const chunk = route.startsWith("/js/c.");
6362 if (!chunk) {
64 const key = hot.getScriptId(sources[0]);
63 const key = hot.getScriptId(path.resolve(sources[0]));
6564 route = "/js/" + key + ".js";
6665 incr.put({
6766 sources,
......@@ -82,41 +81,40 @@ export async function bundleClientJavaScript(
8281 await Promise.all(promises);
8382}
8483
85type ServerPlatform = "node" | "passthru";
84export type ServerPlatform = "node" | "passthru";
8685export async function bundleServerJavaScript(
87 /** Has 'export default app;' */
88 _: string,
89 /** Views for dynamic loading */
90 viewEntryPoints: FileItem[],
86 incr: Incremental,
9187 platform: ServerPlatform = "node",
9288) {
93 const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION";
89 if (incr.hasArtifact("backendBundle", platform)) return;
90
91 // Comment
92 const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
93
9494 const viewSource = [
95 ...viewEntryPoints.map((view, i) =>
96 `import * as view${i} from ${JSON.stringify(view.file)}`
95 ...Array.from(
96 incr.out.viewMetadata,
97 ([, view], i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
9798 ),
98 `const styles = ${scriptMagic}[-2]`,
99 `export const scripts = ${scriptMagic}[-1]`,
99 `const styles = ${magicWord}[-2]`,
100 `export const scripts = ${magicWord}[-1]`,
100101 "export const views = {",
101 ...viewModules.flatMap(({ view, module }, i) => [
102 ` ${JSON.stringify(view.id)}: {`,
103 ` component: view${i}.default,`,
104 ` meta: view${i}.meta,`,
105 ` layout: ${
106 module.layout?.default ? `view${i}.layout?.default` : "undefined"
107 },`,
108 ` theme: ${
109 module.layout?.theme
110 ? `view${i}.layout?.theme`
111 : module.theme
112 ? `view${i}.theme`
113 : "undefined"
114 },`,
115 ` inlineCss: styles[${scriptMagic}[${i}]]`,
116 ` },`,
117 ]),
102 ...Array.from(incr.out.viewMetadata, ([key, view], i) =>
103 [
104 ` ${JSON.stringify(key)}: {`,
105 ` component: view${i}.default,`,
106 // ` meta: ${
107 // view.staticMeta ? JSON.stringify(view.staticMeta) : `view${i}.meta`
108 // },`,
109 ` meta: view${i}.meta,`,
110 ` layout: ${view.hasLayout ? `view${i}.layout?.default` : "null"},`,
111 ` inlineCss: styles[${magicWord}[${i}]]`,
112 ` },`,
113 ].join("\n")),
118114 "}",
119115 ].join("\n");
116
117 // -- plugins --
120118 const serverPlugins: esbuild.Plugin[] = [
121119 virtualFiles({
122120 "$views": viewSource,
......@@ -125,10 +123,21 @@ export async function bundleServerJavaScript(
125123 {
126124 name: "marko via build cache",
127125 setup(b) {
128 b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => ({
129 loader: "ts",
130 contents: hot.getSourceCode(file),
131 }));
126 b.onLoad(
127 { filter: /\.marko$/ },
128 async ({ path: file }) => {
129 const key = path.relative(hot.projectRoot, file)
130 .replaceAll("\\", "/");
131 const cacheEntry = incr.out.serverMarko.get(key);
132 if (!cacheEntry) {
133 throw new Error("Marko file not in cache: " + file);
134 }
135 return ({
136 loader: "ts",
137 contents: cacheEntry.src,
138 });
139 },
140 );
132141 },
133142 },
134143 {
......@@ -145,10 +154,10 @@ export async function bundleServerJavaScript(
145154 },
146155 },
147156 ];
148 const bundle = await esbuild.build({
157 const { metafile, outputFiles, warnings } = await esbuild.build({
149158 bundle: true,
150159 chunkNames: "c.[hash]",
151 entryNames: "[name]",
160 entryNames: "server",
152161 entryPoints: [
153162 path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
154163 ],
......@@ -161,81 +170,96 @@ export async function bundleServerJavaScript(
161170 write: false,
162171 metafile: true,
163172 });
164 const viewModules = viewEntryPoints.map((view) => {
165 const module = require(view.file);
166 if (!module.meta) {
167 throw new Error(`${view.file} is missing 'export const meta'`);
168 }
169 if (!module.default) {
170 throw new Error(`${view.file} is missing a default export.`);
173
174 const files: Record<string, Buffer> = {};
175 let fileWithMagicWord: string | null = null;
176 for (const output of outputFiles) {
177 const basename = output.path.replace(/^.*?!/, "");
178 const key = "out!" + basename.replaceAll("\\", "/");
179 // If this contains the generated "$views" file, then
180 // mark this file as the one for replacement. Because
181 // `splitting` is `true`, esbuild will not emit this
182 // file in more than one chunk.
183 if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
184 fileWithMagicWord = basename;
171185 }
172 return { module, view };
173 });
174 const viewData = viewModules.map(({ module, view }) => {
175 return {
176 id: view.id,
177 theme: module.theme,
178 cssImports: hot.getCssImports(view.file)
179 .concat("src/global.css")
180 .map((file) => path.resolve(file)),
181 clientRefs: hot.getClientScriptRefs(view.file),
182 };
186 files[basename] = Buffer.from(output.contents);
187 }
188 incr.put({
189 kind: "backendBundle",
190 key: platform,
191 value: {
192 magicWord,
193 files,
194 fileWithMagicWord,
195 },
196 sources: Object.keys(metafile.inputs).filter((x) =>
197 !x.startsWith("vfs:") &&
198 !x.startsWith("dropped:") &&
199 !x.includes("node_modules")
200 ),
183201 });
184 return {
185 views: viewData,
186 bundle,
187 scriptMagic,
188 };
189202}
190203
191type Await<T> = T extends Promise<infer R> ? R : T;
192
193export function finalizeServerJavaScript(
194 backend: Await<ReturnType<typeof bundleServerJavaScript>>,
195 viewCssBundles: css.Output[],
204export async function finalizeServerJavaScript(
196205 incr: Incremental,
206 platform: ServerPlatform,
197207) {
198 const { metafile, outputFiles } = backend.bundle;
208 if (incr.hasArtifact("backendReplace", platform)) return;
209 const {
210 files,
211 fileWithMagicWord,
212 magicWord,
213 } = UNWRAP(incr.getArtifact("backendBundle", platform));
199214
200 // Only the reachable script files need to be inserted into the bundle.
201 const reachableScriptKeys = new Set(
202 backend.views.flatMap((view) => view.clientRefs),
203 );
204 const reachableScripts = Object.fromEntries(
205 Array.from(incr.out.script)
206 .filter(([k]) => reachableScriptKeys.has(k)),
215 if (!fileWithMagicWord) return;
216
217 // Only the reachable resources need to be inserted into the bundle.
218 const viewScriptsList = new Set(
219 Array.from(incr.out.viewMetadata.values())
220 .flatMap((view) => view.clientRefs),
207221 );
222 const viewStyleKeys = Array.from(incr.out.viewMetadata.values())
223 .map((view) => css.styleKey(view.cssImports, view.theme));
224 const viewCssBundles = viewStyleKeys
225 .map((key) => UNWRAP(incr.out.style.get(key), "Style key: " + key));
208226
209227 // Deduplicate styles
210228 const styleList = Array.from(new Set(viewCssBundles));
211229
212 for (const output of outputFiles) {
213 const basename = output.path.replace(/^.*?!/, "");
214 const key = "out!" + basename.replaceAll("\\", "/");
230 // Replace the magic word
231 let text = files[fileWithMagicWord].toString("utf-8");
232 text = text.replace(
233 new RegExp(magicWord + "\\[(-?\\d)\\]", "gs"),
234 (_, i) => {
235 i = Number(i);
236 // Inline the styling data
237 if (i === -2) {
238 return JSON.stringify(styleList.map((cssText) => cssText));
239 }
240 // Inline the script data
241 if (i === -1) {
242 return JSON.stringify(Object.fromEntries(incr.out.script));
243 }
244 // Reference an index into `styleList`
245 return `${styleList.indexOf(viewCssBundles[i])}`;
246 },
247 );
215248
216 // If this contains the generated "$views" file, then
217 // replace the IDs with the bundled results.
218 let text = output.text;
219 if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
220 text = text.replace(
221 /CLOVER_CLIENT_SCRIPTS_DEFINITION\[(-?\d)\]/gs,
222 (_, i) => {
223 i = Number(i);
224 // Inline the styling data
225 if (i === -2) {
226 return JSON.stringify(styleList.map((item) => item.text));
227 }
228 // Inline the script data
229 if (i === -1) {
230 return JSON.stringify(Object.fromEntries(incr.out.script));
231 }
232 // Reference an index into `styleList`
233 return `${styleList.indexOf(viewCssBundles[i])}`;
234 },
235 );
236 }
237 fs.writeMkdirSync(path.join(".clover/backend/" + basename), text);
238 }
249 incr.put({
250 kind: "backendReplace",
251 key: platform,
252 sources: [
253 // Backend input code (includes view code)
254 ...incr.sourcesFor("backendBundle", platform),
255 // Script
256 ...Array.from(viewScriptsList)
257 .flatMap((key) => incr.sourcesFor("script", hot.getScriptId(key))),
258 // Style
259 ...viewStyleKeys.flatMap((key) => incr.sourcesFor("style", key)),
260 ],
261 value: Buffer.from(text),
262 });
239263}
240264
241265import * as esbuild from "esbuild";
......@@ -248,7 +272,4 @@ import {
248272 virtualFiles,
249273} from "./esbuild-support.ts";
250274import { Incremental } from "./incremental.ts";
251import type { FileItem } from "#sitegen";
252import * as marko from "@marko/compiler";
253275import * as css from "./css.ts";
254import * as fs from "./lib/fs.ts";
framework/css.ts+5-5
......@@ -51,10 +51,12 @@ export function styleKey(
5151) {
5252 cssImports = cssImports
5353 .map((file) =>
54 path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file
54 (path.isAbsolute(file) ? path.relative(hot.projectSrc, file) : file)
55 .replaceAll("\\", "/")
5556 )
5657 .sort();
57 return cssImports.join(":") + JSON.stringify(theme);
58 return cssImports.join(":") + ":" +
59 Object.entries(theme).map(([k, v]) => `${k}=${v}`);
5860}
5961
6062export async function bundleCssFiles(
......@@ -62,9 +64,7 @@ export async function bundleCssFiles(
6264 theme: Theme,
6365 dev: boolean = false,
6466): Promise<Output> {
65 cssImports = cssImports.map((file) =>
66 path.isAbsolute(file) ? path.relative(hot.projectRoot, file) : file
67 );
67 cssImports = cssImports.map((file) => path.resolve(hot.projectSrc, file));
6868 const plugin = {
6969 name: "clover css",
7070 setup(b) {
framework/generate.ts+48-36
......@@ -2,23 +2,35 @@
22// By using `Incremental`'s ability to automatically purge stale
33// assets, the `sitegen` function performs partial rebuilds.
44
5export function main(incremental?: Incremental) {
5export function main() {
66 return withSpinner<Record<string, unknown>, any>({
77 text: "Recovering State",
88 successText,
99 failureText: () => "sitegen FAIL",
1010 }, async (spinner) => {
11 // const incr = Incremental.fromDisk();
12 // await incr.statAllFiles();
13 const incr = new Incremental();
11 const incr = Incremental.fromDisk();
12 await incr.statAllFiles();
13 // const incr = new Incremental();
1414 const result = await sitegen(spinner, incr);
1515 incr.toDisk(); // Allows picking up this state again
1616 return result;
1717 }) as ReturnType<typeof sitegen>;
1818}
1919
20export function successText({ elapsed }: { elapsed: number }) {
21 return "sitegen! update in " + elapsed.toFixed(1) + "s";
20export function successText({
21 elapsed,
22 inserted,
23 referenced,
24 unreferenced,
25}: Awaited<ReturnType<typeof sitegen>>) {
26 const s = (array: unknown[]) => array.length === 1 ? "" : "s";
27 const kind = inserted.length === referenced.length ? "build" : "update";
28 const status = inserted.length > 0
29 ? `${kind} ${inserted.length} key${s(inserted)}`
30 : unreferenced.length > 0
31 ? `pruned ${unreferenced.length} key${s(unreferenced)}`
32 : `checked ${referenced.length} key${s(referenced)}`;
33 return `sitegen! ${status} in ${elapsed.toFixed(1)}s`;
2234}
2335
2436export async function sitegen(
......@@ -159,7 +171,10 @@ export async function sitegen(
159171 ...css.defaultTheme,
160172 ...pageTheme,
161173 };
162 const cssImports = [globalCssPath, ...hot.getCssImports(item.file)];
174 const cssImports = Array.from(
175 new Set([globalCssPath, ...hot.getCssImports(item.file)]),
176 (file) => path.relative(hot.projectSrc, file),
177 );
163178 ensureCssGetsBuilt(cssImports, theme, item.id);
164179
165180 // -- metadata --
......@@ -216,7 +231,7 @@ export async function sitegen(
216231 };
217232 const cssImports = hot.getCssImports(view.file)
218233 .concat("src/global.css")
219 .map((file) => path.relative(hot.projectRoot, path.resolve(file)));
234 .map((file) => path.relative(hot.projectSrc, path.resolve(file)));
220235 incr.put({
221236 kind: "viewMetadata",
222237 key: view.id,
......@@ -250,6 +265,9 @@ export async function sitegen(
250265 return !existing;
251266 });
252267
268 // Load the marko cache before render modules are loaded
269 incr.loadMarkoCache();
270
253271 // This is done in two passes so that a page that throws during evaluation
254272 // will report "Load Render Module" instead of "Render Static Page".
255273 const spinnerFormat = status.format;
......@@ -281,22 +299,12 @@ export async function sitegen(
281299 await viewQueue.done({ method: "stop" });
282300 status.format = spinnerFormat;
283301
284 // -- bundle backend and views --
285 // status.text = "Bundle backend code";
286 // const backend = await bundle.bundleServerJavaScript(
287 // join("backend.ts"),
288 // views,
289 // );
290 // const viewCssPromise = await Promise.all(
291 // backend.views.map((view) =>
292 // cssOnce.get(
293 // view.cssImports.join(":") + JSON.stringify(view.theme),
294 // () => cssQueue.add([view.id, view.cssImports, view.theme ?? {}]),
295 // )
296 // ),
297 // );
302 // -- bundle server javascript (backend and views) --
303 status.text = "Bundle JavaScript";
304 incr.snapshotMarkoCache();
305 const serverJavaScriptPromise = bundle.bundleServerJavaScript(incr, "node");
298306
299 // -- bundle scripts --
307 // -- bundle client javascript --
300308 const referencedScripts = Array.from(
301309 new Set(
302310 [
......@@ -317,19 +325,17 @@ export async function sitegen(
317325 (script) => path.resolve(hot.projectSrc, script),
318326 ).filter((file) => !incr.hasArtifact("script", hot.getScriptId(file)));
319327 const extraPublicScripts = scripts.map((entry) => entry.file);
320 const uniqueCount = new Set([
321 ...referencedScripts,
322 ...extraPublicScripts,
323 ]).size;
324 status.text = `Bundle ${uniqueCount} Scripts`;
325 await bundle.bundleClientJavaScript(
328 const clientJavaScriptPromise = bundle.bundleClientJavaScript(
326329 referencedScripts,
327330 extraPublicScripts,
328331 incr,
329332 );
330
331 // -- finalize backend bundle --
332 // await bundle.finalizeServerJavaScript(backend, await viewCssPromise, incr);
333 await Promise.all([
334 serverJavaScriptPromise,
335 clientJavaScriptPromise,
336 cssQueue.done({ method: "stop" }),
337 ]);
338 await bundle.finalizeServerJavaScript(incr, "node");
333339
334340 // -- copy/compress static files --
335341 async function doStaticFile(item: FileItem) {
......@@ -353,8 +359,6 @@ export async function sitegen(
353359 await staticQueue.done({ method: "stop" });
354360 status.format = spinnerFormat;
355361
356 await cssQueue.done({ method: "stop" });
357
358362 // -- concatenate static rendered pages --
359363 status.text = `Concat Pages`;
360364 await Promise.all(pages.map(async (page) => {
......@@ -399,11 +403,19 @@ export async function sitegen(
399403 // to this point have been left as dangling promises.
400404 await incr.wait();
401405
406 const { inserted, referenced, unreferenced } = incr.shake();
407
402408 // Flush the site to disk.
403409 status.format = spinnerFormat;
404410 status.text = `Incremental Flush`;
405 incr.flush(); // Write outputs
406 return { incr, elapsed: (performance.now() - startTime) / 1000 };
411 incr.flush("node"); // Write outputs
412 return {
413 incr,
414 inserted,
415 referenced,
416 unreferenced,
417 elapsed: (performance.now() - startTime) / 1000,
418 };
407419}
408420
409421function getItemText({ file }: FileItem) {
framework/hot.ts+54-40
......@@ -35,9 +35,10 @@ export interface FileStat {
3535 lastModified: number;
3636 imports: string[];
3737}
38let fsGraph = new Map<string, FileStat>();
39export function getFsGraph() {
40 return fsGraph;
38const fileStats = new Map<string, FileStat>();
39
40export function getFileStat(filepath: string) {
41 return fileStats.get(path.resolve(filepath));
4142}
4243
4344function shouldTrackPath(filename: string) {
......@@ -63,18 +64,16 @@ Module.prototype._compile = function (
6364 const cssImportsMaybe: string[] = [];
6465 const imports: string[] = [];
6566 for (const { filename: file } of this.children) {
66 const relative = path.relative(projectRoot, file);
67 if (file.endsWith(".css")) cssImportsMaybe.push(relative);
67 if (file.endsWith(".css")) cssImportsMaybe.push(file);
6868 else {
69 const child = fsGraph.get(relative);
69 const child = fileStats.get(file);
7070 if (!child) continue;
7171 const { cssImportsRecursive } = child;
7272 if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
73 imports.push(relative);
73 imports.push(file);
7474 }
7575 }
76 const relative = path.relative(projectRoot, filename);
77 fsGraph.set(relative, {
76 fileStats.set(filename, {
7877 cssImportsRecursive: cssImportsMaybe.length > 0
7978 ? Array.from(new Set(cssImportsMaybe))
8079 : null,
......@@ -155,36 +154,46 @@ function resolveClientRef(sourcePath: string, ref: string) {
155154 ) {
156155 throw new Error("addScript must be a .client.ts or .client.tsx");
157156 }
158 return filePath;
157 return path.relative(projectSrc, filePath);
159158}
160159
160// TODO: extract the marko compilation tools out, lazy load them
161export interface MarkoCacheEntry {
162 src: string;
163 scannedClientRefs: string[];
164}
165export const markoCache = new Map<string, MarkoCacheEntry>();
161166function loadMarko(module: NodeJS.Module, filepath: string) {
162 let src = fs.readFileSync(filepath, "utf8");
163 // A non-standard thing here is Clover Sitegen implements
164 // its own client side scripting stuff, so it overrides
165 // bare client import statements to it's own usage.
166 const scannedClientRefs = new Set<string>();
167 if (src.match(/^\s*client\s+import\s+["']/m)) {
168 src = src.replace(
169 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
170 (_, src) => {
171 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
172 const resolved = resolveClientRef(filepath, ref);
173 scannedClientRefs.add(resolved);
174 return `<CloverScriptInclude=${
175 JSON.stringify(getScriptId(resolved))
176 } />`;
177 },
178 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
179 }
180
181 src = marko.compileSync(src, filepath).code;
182 src = src.replace("marko/debug/html", "#ssr/marko");
167 let cache = markoCache.get(filepath);
168 if (!cache) {
169 let src = fs.readFileSync(filepath, "utf8");
170 // A non-standard thing here is Clover Sitegen implements
171 // its own client side scripting stuff, so it overrides
172 // bare client import statements to it's own usage.
173 const scannedClientRefs = new Set<string>();
174 if (src.match(/^\s*client\s+import\s+["']/m)) {
175 src = src.replace(
176 /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
177 (_, src) => {
178 const ref = JSON.parse(`"${src.slice(1, -1)}"`);
179 const resolved = resolveClientRef(filepath, ref);
180 scannedClientRefs.add(resolved);
181 return `<CloverScriptInclude=${
182 JSON.stringify(getScriptId(resolved))
183 } />`;
184 },
185 ) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
186 }
183187
184 module.cloverSourceCode = src;
188 src = marko.compileSync(src, filepath).code;
189 src = src.replace("marko/debug/html", "#ssr/marko");
190 cache = { src, scannedClientRefs: Array.from(scannedClientRefs) };
191 markoCache.set(filepath, cache);
192 }
185193
194 const { src, scannedClientRefs } = cache;
186195 return loadEsbuildCode(module, filepath, src, {
187 scannedClientRefs: Array.from(scannedClientRefs),
196 scannedClientRefs,
188197 });
189198}
190199
......@@ -202,12 +211,19 @@ function loadCss(module: NodeJS.Module, _filepath: string) {
202211export function reloadRecursive(filepath: string) {
203212 filepath = path.resolve(filepath);
204213 const existing = cache[filepath];
205 if (existing) deleteRecursive(filepath, existing);
206 fsGraph.clear();
214 if (existing) deleteRecursiveInner(filepath, existing);
215 fileStats.clear();
207216 return require(filepath);
208217}
209218
210function deleteRecursive(id: string, module: any) {
219export function unload(filepath: string) {
220 filepath = path.resolve(filepath);
221 const existing = cache[filepath];
222 if (existing) delete cache[filepath];
223 fileStats.delete(filepath);
224}
225
226function deleteRecursiveInner(id: string, module: any) {
211227 if (id.includes(path.sep + "node_modules" + path.sep)) {
212228 return;
213229 }
......@@ -215,15 +231,14 @@ function deleteRecursive(id: string, module: any) {
215231 for (const child of module.children) {
216232 if (child.filename.includes("/engine/")) return;
217233 const existing = cache[child.filename];
218 if (existing === child) deleteRecursive(child.filename, existing);
234 if (existing === child) deleteRecursiveInner(child.filename, existing);
219235 }
220236}
221237
222238export function getCssImports(filepath: string) {
223239 filepath = path.resolve(filepath);
224240 if (!require.cache[filepath]) throw new Error(filepath + " was never loaded");
225 return fsGraph.get(path.relative(projectRoot, filepath))
226 ?.cssImportsRecursive ?? [];
241 return fileStats.get(filepath)?.cssImportsRecursive ?? [];
227242}
228243
229244export function getClientScriptRefs(filepath: string) {
......@@ -296,7 +311,6 @@ export function resolveClientRefs(
296311
297312export function getScriptId(file: string) {
298313 return (path.isAbsolute(file) ? path.relative(projectSrc, file) : file)
299 .replace(/^\/?src\//, "")
300314 .replaceAll("\\", "/");
301315}
302316
framework/incremental.ts+236-38
......@@ -20,8 +20,19 @@ interface ArtifactMap {
2020 pageMetadata: PageMetadata;
2121 /* Metadata about a dynamic view */
2222 viewMetadata: ViewMetadata;
23 /* Cached '.marko' server compilation */
24 serverMarko: hot.MarkoCacheEntry;
25 /* Backend source code, pre-replacement. Keyed by platform type. */
26 backendBundle: BackendBundle;
27 /* One file in the backend receives post-processing. */
28 backendReplace: Buffer;
2329}
2430type ArtifactKind = keyof ArtifactMap;
31/* Automatic path tracing is performed to make it so that
32 * specifying 'sources: [file]' refers to it and everything it imports.
33 * These kinds do not have that behavior
34 */
35const exactDependencyKinds = ["serverMarko"];
2536export interface Asset {
2637 buffer: Buffer;
2738 headers: Record<string, string | undefined>;
......@@ -50,6 +61,11 @@ export interface ViewMetadata {
5061 clientRefs: string[];
5162 hasLayout: boolean;
5263}
64export interface BackendBundle {
65 magicWord: string;
66 fileWithMagicWord: string | null;
67 files: Record<string, Buffer>;
68}
5369
5470// -- incremental support types --
5571export interface PutBase {
......@@ -76,6 +92,9 @@ export class Incremental {
7692 style: new Map(),
7793 pageMetadata: new Map(),
7894 viewMetadata: new Map(),
95 serverMarko: new Map(),
96 backendBundle: new Map(),
97 backendReplace: new Map(),
7998 };
8099 /** Tracking filesystem entries to `srcId` */
81100 invals = new Map<SourceId, Invalidations>();
......@@ -92,16 +111,52 @@ export class Incremental {
92111 getItemText: (job) => `${job.algo.toUpperCase()} ${job.label}`,
93112 });
94113
114 /** Reset at the end of each update */
115 round = {
116 inserted: new Set<ArtifactId>(),
117 referenced: new Set<ArtifactId>(),
118 };
119
95120 getArtifact<T extends ArtifactKind>(kind: T, key: string) {
121 this.round.referenced.add(`${kind}\0${key}`);
96122 return this.out[kind].get(key);
97123 }
98124
99 hasArtifact<T extends ArtifactKind>(kind: T, key: string) {
100 return this.out[kind].has(key);
125 hasArtifact(kind: ArtifactKind, key: string) {
126 return this.getArtifact(kind, key) != null;
101127 }
102128
103129 sourcesFor(kind: ArtifactKind, key: string) {
104 return UNWRAP(this.sources.get(kind + "\0" + key));
130 return UNWRAP(
131 this.sources.get(kind + "\0" + key),
132 `No artifact '${kind}' '${key}'`,
133 );
134 }
135
136 shake() {
137 const toPublic = (str: string) => {
138 const [kind, key] = str.split("\0");
139 return { kind: kind as ArtifactKind, key };
140 };
141 const inserted = Array.from(this.round.inserted, toPublic);
142 const referenced = Array.from(this.round.referenced, toPublic);
143 const unreferenced: { kind: ArtifactKind; key: string }[] = [];
144
145 for (const kind in this.out) {
146 const map = this.out[kind as keyof typeof this.out];
147 if (!map) continue;
148 for (const key of map.keys()) {
149 if (!this.round.referenced.has(`${kind}\0${key}`)) {
150 unreferenced.push({ kind: kind as ArtifactKind, key });
151 console.warn("unreferened " + kind + " : " + key);
152 }
153 }
154 }
155
156 this.round.inserted.clear();
157 this.round.referenced.clear();
158
159 return { inserted, referenced, unreferenced };
105160 }
106161
107162 /*
......@@ -115,9 +170,25 @@ export class Incremental {
115170 key,
116171 value,
117172 }: Put<T>) {
118 console.log("put " + kind + ": " + key);
173 // These three invariants affect incremental accuracy.
174 if (this.round.inserted.has(`${kind}\0${key}`)) {
175 console.error(
176 `Artifact ${kind}:${key} was inserted multiple times in the same round!`,
177 );
178 } else if (!this.round.referenced.has(`${kind}\0${key}`)) {
179 console.error(
180 `Artifact ${kind}:${key} was inserted without checking if (!hasArtifact())`,
181 );
182 } else if (this.out[kind].has(key)) {
183 console.error(
184 `Artifact ${kind}:${key} is not stale, but overwritten.`,
185 );
186 }
187
119188 this.out[kind].set(key, value);
120189
190 this.round.inserted.add(`${kind}\0${key}`);
191
121192 // Update sources information
122193 ASSERT(sources.length > 0, "Missing sources for " + kind + " " + key);
123194 sources = sources.map((src) => path.normalize(src));
......@@ -150,8 +221,7 @@ export class Incremental {
150221 #getOrInitInvals(source: string) {
151222 let invals = this.invals.get(source);
152223 if (!invals) {
153 const g = hot.getFsGraph().get(source);
154 const lastModified = g?.lastModified ??
224 const lastModified = hot.getFileStat(source)?.lastModified ??
155225 fs.statSync(path.resolve(hot.projectRoot, source)).mtimeMs;
156226 this.invals.set(
157227 source,
......@@ -166,8 +236,7 @@ export class Incremental {
166236 }
167237
168238 #followImports(file: string) {
169 const graph = hot.getFsGraph();
170 const stat = graph.get(file);
239 const stat = hot.getFileStat(file);
171240 if (!stat) return;
172241 for (const i of stat.imports) {
173242 const invals = this.#getOrInitInvals(i);
......@@ -178,19 +247,23 @@ export class Incremental {
178247
179248 async statAllFiles() {
180249 for (const file of this.invals.keys()) {
181 const mtime = fs.statSync(file).mtimeMs;
182 this.updateStat(file, mtime);
250 try {
251 const mtime = fs.statSync(file).mtimeMs;
252 this.updateStat(file, mtime);
253 } catch (err) {
254 }
183255 }
184256 }
185257
186 updateStat(file: string, newLastModified: number) {
258 updateStat(file: string, newLastModified: number | null) {
187259 file = path.relative(hot.projectRoot, file);
188260 const stat = this.invals.get(file);
189261 ASSERT(stat, "Updated stat on untracked file " + file);
190 const hasUpdate = stat.lastModified < newLastModified;
262 const hasUpdate = !newLastModified || stat.lastModified < newLastModified;
191263 if (hasUpdate) {
192264 // Invalidate
193 console.info(file + " updated");
265 console.info(file + " " + (newLastModified ? "updated" : "deleted"));
266 hot.unload(file);
194267 const invalidQueue = [file];
195268 let currentInvalid;
196269 while (currentInvalid = invalidQueue.pop()) {
......@@ -204,12 +277,15 @@ export class Incremental {
204277 for (const out of outputs) {
205278 const [kind, artifactKey] = out.split("\0");
206279 this.out[kind as ArtifactKind].delete(artifactKey);
207 console.log("stale " + kind + ": " + artifactKey);
208280 }
209281 invalidQueue.push(...files);
210282 }
211283 }
212 stat.lastModified = newLastModified;
284 if (newLastModified) {
285 stat.lastModified = newLastModified;
286 } else {
287 this.invals.delete(file);
288 }
213289 return hasUpdate;
214290 }
215291
......@@ -266,6 +342,7 @@ export class Incremental {
266342 serialize() {
267343 const writer = new BufferWriter();
268344
345 // -- artifact --
269346 const asset = Array.from(
270347 this.out.asset,
271348 ([key, { buffer, hash, headers }]) => {
......@@ -283,6 +360,31 @@ export class Incremental {
283360 },
284361 );
285362 const script = Array.from(this.out.script);
363 const style = Array.from(this.out.style);
364 const pageMetadata = Array.from(this.out.pageMetadata);
365 const viewMetadata = Array.from(this.out.viewMetadata);
366 const serverMarko = Array.from(this.out.serverMarko);
367 const backendBundle = Array.from(this.out.backendBundle, ([k, v]) => {
368 return [k, {
369 magicWord: v.magicWord,
370 fileWithMagicWord: v.fileWithMagicWord,
371 files: Object.entries(v.files).map(
372 ([file, contents]) => [
373 file,
374 writer.write(contents, "backendBundle" + k + ":" + file),
375 ],
376 ),
377 }] satisfies SerializedMeta["backendBundle"][0];
378 });
379 const backendReplace = Array.from(
380 this.out.backendReplace,
381 ([k, v]) =>
382 [
383 k,
384 writer.write(v, "backendReplace" + k),
385 ] satisfies SerializedMeta["backendReplace"][0],
386 );
387 // -- incremental metadata --
286388 const invals = Array.from(this.invals, ([key, value]) => {
287389 const { lastModified, files, outputs } = value;
288390 return [key, {
......@@ -299,6 +401,12 @@ export class Incremental {
299401 script,
300402 invals,
301403 sources,
404 style,
405 pageMetadata,
406 viewMetadata,
407 serverMarko,
408 backendBundle,
409 backendReplace,
302410 } satisfies SerializedMeta;
303411 const meta = Buffer.from(JSON.stringify(json), "utf-8");
304412
......@@ -317,21 +425,39 @@ export class Incremental {
317425 buffer.subarray(4 + metaLength + start, 4 + metaLength + end);
318426
319427 const incr = new Incremental();
320 incr.out.asset = new Map<string, Asset>(meta.asset.map(([key, value]) => {
321 const { hash, raw, gzip, zstd, headers } = value;
322 if ((gzip || zstd) && !incr.compress.has(hash)) {
323 incr.compress.set(hash, {
324 gzip: gzip ? view(gzip) : undefined,
325 zstd: zstd ? view(zstd) : undefined,
326 });
327 }
328 return [key, {
329 buffer: view(raw),
330 headers: headers,
331 hash: hash,
332 }];
333 }));
334 incr.out.script = new Map(meta.script);
428 incr.out = {
429 asset: new Map(meta.asset.map(([key, value]) => {
430 const { hash, raw, gzip, zstd, headers } = value;
431 if ((gzip || zstd) && !incr.compress.has(hash)) {
432 incr.compress.set(hash, {
433 gzip: gzip ? view(gzip) : undefined,
434 zstd: zstd ? view(zstd) : undefined,
435 });
436 }
437 return [key, {
438 buffer: view(raw),
439 headers: headers,
440 hash: hash,
441 }];
442 })),
443 script: new Map(meta.script),
444 style: new Map(meta.style),
445 pageMetadata: new Map(meta.pageMetadata),
446 viewMetadata: new Map(meta.viewMetadata),
447 serverMarko: new Map(meta.serverMarko),
448 backendBundle: new Map(meta.backendBundle.map(([key, value]) => {
449 return [key, {
450 magicWord: value.magicWord,
451 fileWithMagicWord: value.fileWithMagicWord,
452 files: Object.fromEntries(
453 value.files.map(([file, contents]) => [file, view(contents)]),
454 ),
455 }];
456 })),
457 backendReplace: new Map(
458 meta.backendReplace.map(([key, contents]) => [key, view(contents)]),
459 ),
460 };
335461 incr.invals = new Map(meta.invals.map(([key, { m, f, o }]) => {
336462 return [key, {
337463 lastModified: m,
......@@ -343,6 +469,37 @@ export class Incremental {
343469 return incr;
344470 }
345471
472 /*
473 * Move the cached (server) marko transpilations from this incremental
474 * into the running process.
475 */
476 loadMarkoCache() {
477 hot.markoCache.clear();
478 for (const [key, value] of this.out.serverMarko) {
479 hot.markoCache.set(path.resolve(hot.projectRoot, key), value);
480 }
481 }
482
483 /*
484 * Move the cached (server) marko transpilations from this incremental
485 * into the running process.
486 */
487 snapshotMarkoCache() {
488 for (const [file, value] of hot.markoCache) {
489 const key = path.relative(hot.projectRoot, file).replaceAll("\\", "/");
490 // Only insert if it doesn't exist. Calling 'put' when it
491 // already exists would inform the user of extra calls to put.
492 if (!this.hasArtifact("serverMarko", key)) {
493 this.put({
494 kind: "serverMarko",
495 sources: [file],
496 key,
497 value,
498 });
499 }
500 }
501 }
502
346503 toDisk(file = ".clover/incr.state") {
347504 const buffer = this.serialize();
348505 fs.writeFileSync(file, buffer);
......@@ -362,23 +519,52 @@ export class Incremental {
362519 await this.compressQueue.done({ method: "success" });
363520 }
364521
365 async flush() {
522 async flush(
523 platform: bundle.ServerPlatform,
524 dir = path.resolve(".clover/out"),
525 ) {
366526 ASSERT(!this.compressQueue.active);
527 const join = (...args: string[]) => path.join(dir, ...args);
367528 const writer = new BufferWriter();
368 // TODO: ensure all assets are actually compressed and not fake lying.
529
530 // TODO: ensure all compressed got compressed
531
369532 const asset = Object.fromEntries(
370533 Array.from(this.out.asset, ([key, { buffer, hash, headers }]) => {
371534 const raw = writer.write(buffer, hash);
372535 const { gzip: gzipBuf, zstd: zstdBuf } = this.compress.get(hash) ?? {};
373 const gzip = gzipBuf ? writer.write(gzipBuf, hash + ".gz") : null;
374 const zstd = zstdBuf ? writer.write(zstdBuf, hash + ".zstd") : null;
536 const gzip = writer.write(UNWRAP(gzipBuf), hash + ".gz");
537 const zstd = writer.write(UNWRAP(zstdBuf), hash + ".zstd");
375538 return [key, { raw, gzip, zstd, headers }];
376539 }),
377540 );
378 await Promise.all([
379 fs.writeFile(".clover/static.json", JSON.stringify(asset)),
380 fs.writeFile(".clover/static.blob", writer.get()),
381 ]);
541 const backendBundle = UNWRAP(this.out.backendBundle.get(platform));
542
543 // Arrange output files
544 const outFiles: Array<[file: string, contents: string | Buffer]> = [
545 // Asset manifest
546 ["static.json", JSON.stringify(asset)],
547 ["static.blob", writer.get()],
548
549 // Backend
550 ...Object.entries(backendBundle.files).map(([subPath, contents]) =>
551 [
552 subPath,
553 subPath === backendBundle.fileWithMagicWord
554 ? UNWRAP(this.out.backendReplace.get(platform))
555 : contents,
556 ] as [string, Buffer]
557 ),
558 ];
559
560 // TODO: check duplicates
561
562 // Perform all i/o
563 await Promise.all(
564 outFiles.map(([subPath, contents]) =>
565 fs.writeMkdir(join(subPath), contents, { flush: true })
566 ),
567 );
382568 }
383569}
384570
......@@ -440,6 +626,17 @@ export interface SerializedMeta {
440626 headers: Record<string, string>;
441627 }]>;
442628 script: Array<[key: string, value: string]>;
629 style: Array<[key: string, value: string]>;
630 pageMetadata: Array<[key: string, PageMetadata]>;
631 viewMetadata: Array<[key: string, ViewMetadata]>;
632 serverMarko: Array<[key: string, hot.MarkoCacheEntry]>;
633 backendBundle: Array<[platform: string, {
634 magicWord: string;
635 fileWithMagicWord: string | null;
636 files: Array<[string, View]>;
637 }]>;
638 backendReplace: Array<[key: string, View]>;
639
443640 invals: Array<[key: string, {
444641 /** Modified */
445642 m: number;
......@@ -461,3 +658,4 @@ import * as mime from "#sitegen/mime";
461658import * as path from "node:path";
462659import { Buffer } from "node:buffer";
463660import * as css from "./css.ts";
661import type * as bundle from "./bundle.ts";
framework/lib/assets.ts+14-4
......@@ -8,9 +8,10 @@ export type StaticPageId = string;
88
99export async function reload() {
1010 const [map, buf] = await Promise.all([
11 fs.readFile(".clover/static.json", "utf8"),
12 fs.readFile(".clover/static.blob"),
11 fs.readFile(path.join(import.meta.dirname, "static.json"), "utf8"),
12 fs.readFile(path.join(import.meta.dirname, "static.blob")),
1313 ]);
14 console.log("new buffer loaded");
1415 assets = {
1516 map: JSON.parse(map),
1617 buf,
......@@ -18,8 +19,11 @@ export async function reload() {
1819}
1920
2021export async function reloadSync() {
21 const map = fs.readFileSync(".clover/static.json", "utf8");
22 const buf = fs.readFileSync(".clover/static.blob");
22 const map = fs.readFileSync(
23 path.join(import.meta.dirname, "static.json"),
24 "utf8",
25 );
26 const buf = fs.readFileSync(path.join(import.meta.dirname, "static.blob"));
2327 assets = {
2428 map: JSON.parse(map),
2529 buf,
......@@ -105,8 +109,14 @@ function assetInner(c: Context, asset: BuiltAsset, status: StatusCode) {
105109 return c.res = new Response(body, { headers, status });
106110}
107111
112process.on("message", (msg: any) => {
113 console.log({ msg });
114 if (msg?.type === "clover.assets.reload") reload();
115});
116
108117import * as fs from "#sitegen/fs";
109118import type { Context, Next } from "hono";
110119import type { StatusCode } from "hono/utils/http-status";
111120import type { BuiltAsset, BuiltAssetMap, View } from "../incremental.ts";
112121import { Buffer } from "node:buffer";
122import * as path from "node:path";
framework/lib/fs.ts+8-2
......@@ -22,9 +22,15 @@ export function mkdirSync(dir: string) {
2222 return nodeMkdirSync(dir, { recursive: true });
2323}
2424
25export async function writeMkdir(file: string, contents: Buffer | string) {
25export type WriteFileAsyncOptions = Parameters<typeof writeFile>[2];
26
27export async function writeMkdir(
28 file: string,
29 contents: Buffer | string,
30 options?: WriteFileAsyncOptions,
31) {
2632 await mkdir(path.dirname(file));
27 return writeFile(file, contents);
33 return writeFile(file, contents, options);
2834}
2935
3036export function writeMkdirSync(file: string, contents: Buffer | string) {
framework/watch.ts+44-3
......@@ -3,12 +3,39 @@
33const debounceMilliseconds = 25;
44
55export async function main() {
6 let subprocess: child_process.ChildProcess | null = null;
7
68 // Catch up state by running a main build.
79 const { incr } = await generate.main();
810 // ...and watch the files that cause invals.
911 const watch = new Watch(rebuild);
1012 watch.add(...incr.invals.keys());
1113 statusLine();
14 // ... an
15 serve();
16
17 function serve() {
18 if (subprocess) {
19 subprocess.removeListener("close", onSubprocessClose);
20 subprocess.kill();
21 }
22 subprocess = child_process.fork(".clover/out/server.js", [
23 "--development",
24 ], {
25 stdio: "inherit",
26 });
27 subprocess.on("close", onSubprocessClose);
28 }
29
30 function onSubprocessClose(code: number | null, signal: string | null) {
31 subprocess = null;
32 const status = code != null ? `code ${code}` : `signal ${signal}`;
33 console.error(`Backend process exited with ${status}`);
34 }
35
36 process.on("beforeExit", () => {
37 subprocess?.removeListener("close", onSubprocessClose);
38 });
1239
1340 function rebuild(files: string[]) {
1441 files = files.map((file) => path.relative(hot.projectRoot, file));
......@@ -20,7 +47,7 @@ export async function main() {
2047 console.warn("Files were modified but the 'modify' time did not change.");
2148 return;
2249 }
23 withSpinner<Record<string, unknown>, any>({
50 withSpinner<any, Awaited<ReturnType<typeof generate.sitegen>>>({
2451 text: "Rebuilding",
2552 successText: generate.successText,
2653 failureText: () => "sitegen FAIL",
......@@ -39,6 +66,20 @@ export async function main() {
3966 if (!incr.invals.has(relative)) watch.remove(file);
4067 }
4168 return result;
69 }).then((result) => {
70 // Restart the server if it was changed or not running.
71 if (
72 !subprocess ||
73 result.inserted.some(({ kind }) => kind === "backendReplace")
74 ) {
75 serve();
76 } else if (
77 subprocess &&
78 result.inserted.some(({ kind }) => kind === "asset")
79 ) {
80 subprocess.send({ type: "clover.assets.reload" });
81 }
82 return result;
4283 }).catch((err) => {
4384 console.error(util.inspect(err));
4485 }).finally(statusLine);
......@@ -142,10 +183,10 @@ class Watch {
142183 }
143184}
144185
145import { Incremental } from "./incremental.ts";
146186import * as fs from "node:fs";
147import { Spinner, withSpinner } from "@paperclover/console/Spinner";
187import { withSpinner } from "@paperclover/console/Spinner";
148188import * as generate from "./generate.ts";
149189import * as path from "node:path";
150190import * as util from "node:util";
151191import * as hot from "./hot.ts";
192import * as child_process from "node:child_process";