sitegen/framework/bundle.ts
clover caruso f1d4be2553 feat: dynamic page regeneration (#24)
the asset system is reworked to support "dynamic" entries, where each
entry is a separate file on disk containing the latest generation's
headers+raw+gzip+zstd. when calling view.regenerate, it will look for
pages that had "export const regenerate" during generation, and render
those pages using the view system, but then store the results as assets
instead of sending as a response.

pages configured as regenerable are also bundled as views, using the
non-aliasing key "page:${page.id}". this cannot alias because file
paths may not contain a colon.
2025-08-11 22:43:27 -07:00

374 lines
11 KiB
TypeScript

async function trackEsbuild(io: Io, metafile: esbuild.Metafile) {
await Promise.all(
Object.keys(metafile.inputs)
.filter((file) => !isIgnoredSource(file))
.map((file) => io.trackFile(file)),
);
}
// This file implements client-side bundling, mostly wrapping esbuild.
export async function bundleClientJavaScript(
io: Io,
{
clientRefs,
extraPublicScripts,
dev = false,
}: {
clientRefs: string[];
extraPublicScripts: string[];
dev: boolean;
},
) {
const entryPoints = [
...new Set(
[...clientRefs.map((x) => `src/${x}`), ...extraPublicScripts].map(toAbs),
),
];
if (entryPoints.length === 0) return {};
const invalidFiles = entryPoints.filter(
(file) => !file.match(/\.client\.[tj]sx?/),
);
if (invalidFiles.length > 0) {
const cwd = process.cwd();
throw new Error(
"All client-side scripts should be named like '.client.ts'. Exceptions: \n" +
invalidFiles.map((x) => path.join(cwd, x)).join("\n"),
);
}
const clientPlugins: esbuild.Plugin[] = [
projectRelativeResolution(),
markoViaBuildCache(),
];
const bundle = await esbuild
.build({
assetNames: "/asset/[hash]",
bundle: true,
chunkNames: "/js/c.[hash]",
entryNames: "/js/[name]",
entryPoints,
format: "esm",
jsx: "automatic",
jsxDev: dev,
jsxImportSource: "#engine",
logLevel: "silent",
metafile: true,
minify: !dev,
outdir: "out!",
plugins: clientPlugins,
write: false,
define: {
ASSERT: "console.assert",
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
},
})
.catch((err: any) => {
err.message = `Client ${err.message}`;
throw err;
});
if (bundle.errors.length || bundle.warnings.length) {
throw new AggregateError(
bundle.errors.concat(bundle.warnings),
"JS bundle failed",
);
}
const publicScriptRoutes = extraPublicScripts.map(
(file) =>
"/js/" +
path
.relative(hot.projectSrc, file)
.replaceAll("\\", "/")
.replace(/\.client\.[tj]sx?/, ".js"),
);
const { metafile, outputFiles } = bundle;
const p = [];
p.push(trackEsbuild(io, metafile));
const scripts: Record<string, string> = {};
for (const file of outputFiles) {
const { text } = file;
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
const { entryPoint } = UNWRAP(metafile.outputs["out!" + route]);
// Register non-chunks as script entries.
const chunk = route.startsWith("/js/c.");
if (!chunk) {
const key = hot.getScriptId(toAbs(UNWRAP(entryPoint)));
console.log(route, key);
route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
scripts[key] = text;
}
// Register chunks and public scripts as assets.
if (chunk || publicScriptRoutes.includes(route)) {
p.push(io.writeAsset({ pathname: route, buffer: text }));
}
}
await Promise.all(p);
return scripts;
}
export type ServerPlatform = "node" | "passthru";
export interface ServerSideOptions {
entries: string[];
viewItems: sg.FileItem[];
viewRefs: incr.Ref<PageOrView>[];
styleMap: Map<string, incr.Ref<string>>;
scriptMap: incr.Ref<Record<string, string>>;
platform: ServerPlatform;
}
export async function bundleServerJavaScript({
viewItems,
viewRefs,
styleMap,
scriptMap: wScriptMap,
entries,
platform,
}: ServerSideOptions) {
const regenKeys: Record<string, string[]> = {};
const regenTtls: view.Ttl[] = [];
for (const ref of viewRefs) {
const value = UNWRAP(ref.value);
if (value.type === "page" && (value.regenerate?.tags?.length ?? 0) > 0) {
for (const tag of value.regenerate!.tags!) {
(regenKeys[tag] ??= []).push(`page:${value.id}`);
}
}
if (value.type === "page" && (value.regenerate?.seconds ?? 0) > 0) {
regenTtls.push({
key: `page:${value.id}` as view.Key,
seconds: value.regenerate!.seconds!,
});
}
}
const wViewSource = incr.work(
async (
_,
{ viewItems, regenKeys, regenTtls }: {
viewItems: sg.FileItem[];
regenKeys: Record<string, string[]>;
regenTtls: view.Ttl[];
},
) => {
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
return {
magicWord,
file: [
...viewItems.map(
(view, i) =>
`import * as view${i} from ${JSON.stringify(view.file)}`,
),
`const styles = ${magicWord}[-2]`,
`export const scripts = ${magicWord}[-1]`,
"export const views = {",
...viewItems.map((view, i) =>
[
` ${JSON.stringify(view.id)}: {`,
` component: view${i}.default,`,
` meta: view${i}.meta,`,
` layout: view${i}.layout?.default ?? null,`,
` inlineCss: styles[${magicWord}[${i}]]`,
` },`,
].join("\n")
),
"}",
`export const regenTags = ${JSON.stringify(regenKeys)};`,
`export const regenTtls = ${JSON.stringify(regenTtls)};`,
].join("\n"),
};
},
{ viewItems, regenKeys, regenTtls },
);
await incr.work(
async (io, { regenKeys, viewItems }) => {
io.writeFile(
"../ts/view.d.ts",
[
"export interface RegisteredViews {",
...viewItems
.filter((view) => !view.id.startsWith("page:"))
.map(
(view) =>
` ${JSON.stringify(view.id)}: ` +
`typeof import(${
JSON.stringify(path.relative(".clover/ts", toAbs(view.file)))
}),`,
),
"}",
"export type RegenKey = " +
(regenKeys.map((key) => JSON.stringify(key)).join(" | ") ||
"never"),
].join("\n"),
);
},
{ regenKeys: Object.keys(regenKeys), viewItems },
);
const wBundles = entries.map((entry) =>
incr.work(async (io, entry) => {
const pkg = await io.readJson<{
dependencies: Record<string, string>;
}>("package.json");
let magicWord = null as string | null;
// -- plugins --
const serverPlugins: esbuild.Plugin[] = [
virtualFiles({
// only add dependency when imported.
$views: async () => {
const view = await io.readWork(wViewSource);
({ magicWord } = view);
return view.file;
},
}),
projectRelativeResolution(),
markoViaBuildCache(),
{
name: "replace client references",
setup(b) {
b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
contents: hot.resolveClientRefs(
await fs.readFile(file, "utf-8"),
file,
).code,
loader: path.extname(file).slice(1) as esbuild.Loader,
}));
},
},
{
name: "mark css external",
setup(b) {
b.onResolve({ filter: /\.css$/ }, () => ({
path: ".",
namespace: "dropped",
}));
b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
contents: "",
}));
},
},
];
const { metafile, outputFiles } = await esbuild.build({
bundle: true,
chunkNames: "c.[hash]",
entryNames: path.basename(entry, path.extname(entry)),
entryPoints: [
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
],
platform: "node",
format: "esm",
minify: false,
outdir: "out!",
plugins: serverPlugins,
splitting: true,
logLevel: "silent",
write: false,
metafile: true,
jsx: "automatic",
jsxImportSource: "#engine",
jsxDev: false,
define: {
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
"globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
},
external: Object.keys(pkg.dependencies).filter(
(x) => !x.startsWith("@paperclover"),
),
});
await trackEsbuild(io, metafile);
let fileWithMagicWord: {
bytes: Buffer;
basename: string;
magicWord: string;
} | null = null;
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 (
magicWord &&
UNWRAP(metafile.outputs[key]).inputs["framework/lib/view.ts"]
) {
ASSERT(!fileWithMagicWord);
fileWithMagicWord = {
basename,
bytes: Buffer.from(output.contents),
magicWord,
};
} else {
io.writeFile(basename, Buffer.from(output.contents));
}
}
return fileWithMagicWord;
}, entry)
);
const wProcessed = wBundles.map(async (wBundle) => {
if (!(await wBundle)) return;
await incr.work(async (io) => {
// Only the reachable resources need to be read and inserted into the bundle.
// This is what Map<string, incr.Ref> is for
const { basename, bytes, magicWord } = UNWRAP(await io.readWork(wBundle));
const views = await Promise.all(viewRefs.map((ref) => io.readWork(ref)));
// Client JS
const scriptList = Object.entries(await io.readWork(wScriptMap));
const viewScriptsList = new Set(views.flatMap((view) => view.clientRefs));
const neededScripts = scriptList.filter(([k]) => viewScriptsList.has(k));
// CSS
const viewStyleKeys = views.map((view) => view.styleKey);
const viewCssBundles = await Promise.all(
viewStyleKeys.map((key) =>
io.readWork(UNWRAP(styleMap.get(key), "Style key: " + key))
),
);
const styleList = Array.from(new Set(viewCssBundles));
// Replace the magic word
const text = bytes
.toString("utf-8")
.replace(new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"), (_, i) => {
i = Number(i);
// Inline the styling data
if (i === -2) {
return JSON.stringify(styleList.map((cssText) => cssText));
}
// Inline the script data
if (i === -1) {
return JSON.stringify(Object.fromEntries(neededScripts));
}
// Reference an index into `styleList`
return `${styleList.indexOf(UNWRAP(viewCssBundles[i]))}`;
});
io.writeFile(basename, text);
});
});
await Promise.all(wProcessed);
}
import * as esbuild from "esbuild";
import * as path from "node:path";
import process from "node:process";
import * as hot from "./hot.ts";
import {
isIgnoredSource,
markoViaBuildCache,
projectRelativeResolution,
virtualFiles,
} from "./esbuild-support.ts";
import { Io, toAbs } from "./incremental.ts";
import * as fs from "#sitegen/fs";
import * as mime from "#sitegen/mime";
import * as incr from "./incremental.ts";
import * as sg from "#sitegen";
import type { PageOrView } from "./generate.ts";
import type * as view from "#sitegen/view";