sitegen/framework/hot.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

323 lines
10 KiB
TypeScript

// This implements the ability to use TS, TSX, and more plugins
// in Node.js. It cannot be built on the ES module loader,
// because there is no exposed way to replace modules when
// needed (see nodejs#49442).
//
// It also allows using a simple compile cache, which is used by
// the site generator to determine when code changes.
export const projectRoot = path.resolve(import.meta.dirname, "../");
export const projectSrc = path.resolve(projectRoot, "src");
// Create a project-relative require. For convenience, it is generic-typed.
export const load = createRequire(
pathToFileURL(path.join(projectRoot, "run.js")).toString(),
) as {
<T = unknown>(id: string): T;
extensions: NodeJS.Dict<(mod: NodeJS.Module, file: string) => unknown>;
cache: NodeJS.Dict<NodeJS.Module>;
resolve: (id: string, o?: { paths: string[] }) => string;
};
export const { cache } = load;
// Register extensions by overwriting `require.extensions`
const require = load;
const exts = require.extensions;
exts[".ts"] = loadEsbuild;
exts[".tsx"] = loadEsbuild;
exts[".jsx"] = loadEsbuild;
exts[".marko"] = loadMarko;
exts[".mdx"] = loadMdx;
exts[".css"] = loadCss;
// Intercept all module load calls to track CSS imports + file times.
export interface FileStat {
cssImportsRecursive: string[] | null;
lastModified: number;
imports: string[];
}
const fileStats = new Map<string, FileStat>();
export function getFileStat(filepath: string) {
return fileStats.get(path.resolve(filepath));
}
function shouldTrackPath(filename: string) {
return !filename.includes("node_modules");
}
const Module = load<typeof import("node:module")>("node:module");
const ModulePrototypeUnderscoreCompile = Module.prototype._compile;
Module.prototype._compile = function (
content: string,
filename: string,
format: "module" | "commonjs",
) {
const result = ModulePrototypeUnderscoreCompile.call(
this,
content,
filename,
format,
);
if (shouldTrackPath(filename)) {
const stat = fs.statSync(filename);
const cssImportsMaybe: string[] = [];
const imports: string[] = [];
for (const childModule of this.children) {
const { filename: file, cloverClientRefs } = childModule;
if (file.endsWith(".css")) cssImportsMaybe.push(file);
else {
const child = fileStats.get(file);
if (!child) continue;
const { cssImportsRecursive } = child;
if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
imports.push(file);
(childModule.cloverImporters ??= []).push(this);
if (cloverClientRefs && cloverClientRefs.length > 0) {
(this.cloverClientRefs ??= []).push(...cloverClientRefs);
}
}
}
fileStats.set(filename, {
cssImportsRecursive: cssImportsMaybe.length > 0
? Array.from(new Set(cssImportsMaybe))
: null,
imports,
lastModified: Math.floor(stat.mtimeMs),
});
}
return result;
};
// Implement @/ prefix
const ModuleUnderscoreResolveFilename = Module._resolveFilename;
Module._resolveFilename = (...args) => {
if (args[0].startsWith("@/")) {
const replacedPath = "." + args[0].slice(1);
try {
return require.resolve(replacedPath, { paths: [projectSrc] });
} catch (err: any) {
if (
err.code === "MODULE_NOT_FOUND" &&
(err?.requireStack?.length ?? 0) <= 1
) {
err.message.replace(replacedPath, args[0]);
}
}
}
return ModuleUnderscoreResolveFilename(...args);
};
function loadEsbuild(module: NodeJS.Module, filepath: string) {
return loadEsbuildCode(module, filepath, fs.readFileSync(filepath, "utf8"));
}
interface LoadOptions {
scannedClientRefs?: string[];
}
export function loadEsbuildCode(
module: NodeJS.Module,
filepath: string,
src: string,
opt: LoadOptions = {},
) {
if (filepath === import.meta.filename) {
module.exports = self;
return;
}
let loader: any = "tsx";
if (filepath.endsWith(".ts")) loader = "ts";
else if (filepath.endsWith(".jsx")) loader = "jsx";
else if (filepath.endsWith(".js")) loader = "js";
if (opt.scannedClientRefs) {
module.cloverClientRefs = opt.scannedClientRefs;
} else {
let { code, refs } = resolveClientRefs(src, filepath);
module.cloverClientRefs = refs;
src = code;
}
if (src.includes("import.meta")) {
src = `
import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
import.meta.dirname = ${JSON.stringify(path.dirname(filepath))};
import.meta.filename = ${JSON.stringify(filepath)};
`
.trim()
.replace(/[\n\s]/g, "") + src;
}
src = esbuild.transformSync(src, {
loader,
format: "cjs",
target: "esnext",
jsx: "automatic",
jsxImportSource: "#engine",
jsxDev: true,
sourcefile: filepath,
sourcemap: "inline",
}).code;
return module._compile(src, filepath, "commonjs");
}
export function resolveClientRef(sourcePath: string, ref: string) {
const filePath = resolveFrom(sourcePath, ref);
if (!filePath.endsWith(".client.ts") && !filePath.endsWith(".client.tsx")) {
throw new Error("addScript must be a .client.ts or .client.tsx");
}
return path.relative(projectSrc, filePath);
}
let lazyMarko: typeof import("./marko.ts") | null = null;
function loadMarko(module: NodeJS.Module, filepath: string) {
lazyMarko ??= require<typeof import("./marko.ts")>("./framework/marko.ts");
lazyMarko.loadMarko(module, filepath);
}
function loadMdx(module: NodeJS.Module, filepath: string) {
const input = fs.readFileSync(filepath);
const out = mdx.compileSync(input, { jsxImportSource: "#engine" }).value;
const src = typeof out === "string" ? out : Buffer.from(out).toString("utf8");
return loadEsbuildCode(module, filepath, src);
}
function loadCss(module: NodeJS.Module, _filepath: string) {
module.exports = {};
}
export function reloadRecursive(filepath: string) {
filepath = path.resolve(filepath);
const existing = cache[filepath];
if (existing) deleteRecursiveInner(filepath, existing);
fileStats.clear();
return require(filepath);
}
export function unload(filepath: string) {
lazyMarko?.markoCache.delete(filepath);
filepath = path.resolve(filepath);
const module = cache[filepath];
if (!module) return;
delete cache[filepath];
for (const importer of module.cloverImporters ?? []) {
unload(importer.filename);
}
}
function deleteRecursiveInner(id: string, module: any) {
if (id.includes(path.sep + "node_modules" + path.sep)) {
return;
}
delete cache[id];
for (const child of module.children) {
if (child.filename.includes("/engine/")) return;
const existing = cache[child.filename];
if (existing === child) deleteRecursiveInner(child.filename, existing);
}
}
export function getCssImports(filepath: string) {
filepath = path.resolve(filepath);
if (!require.cache[filepath]) throw new Error(filepath + " was never loaded");
return fileStats.get(filepath)?.cssImportsRecursive ?? [];
}
export function getClientScriptRefs(filepath: string) {
filepath = path.resolve(filepath);
const module = require.cache[filepath];
if (!module) throw new Error(filepath + " was never loaded");
return module.cloverClientRefs ?? [];
}
export function getSourceCode(filepath: string) {
filepath = path.resolve(filepath);
const module = require.cache[filepath];
if (!module) throw new Error(filepath + " was never loaded");
if (!module.cloverSourceCode) {
throw new Error(filepath + " did not record source code");
}
return module.cloverSourceCode;
}
export function resolveFrom(src: string, dest: string) {
try {
return createRequire(src).resolve(dest);
} catch (err: any) {
if (err.code === "MODULE_NOT_FOUND" && err.requireStack.length <= 1) {
err.message = err.message.split("\n")[0] + " from '" + src + "'";
}
throw err;
}
}
const importRegExp =
/import\s+(\*\sas\s([a-zA-Z0-9$_]+)|{[^}]+})\s+from\s+(?:"#sitegen"|'#sitegen')/s;
const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))?/;
interface ResolvedClientRefs {
code: string;
refs: string[];
}
export function resolveClientRefs(
code: string,
filepath: string,
): ResolvedClientRefs {
// This match finds a call to 'import ... from "#sitegen"'
const importMatch = code.match(importRegExp);
if (!importMatch) return { code, refs: [] };
const items = importMatch[1];
let identifier = "";
if (items.startsWith("{")) {
const clauseMatch = items.match(getSitegenAddScriptRegExp);
if (!clauseMatch) return { code, refs: [] }; // did not import
identifier = clauseMatch[1] || "addScript";
} else if (items.startsWith("*")) {
identifier = importMatch[2] + "\\s*\\.\\s*addScript";
} else {
throw new Error("Impossible");
}
identifier = identifier.replaceAll("$", "\\$"); // only needed escape
const findCallsRegExp = new RegExp(
`\\b(${identifier})\\s*\\(("[^"]+"|'[^']+')\\)`,
"gs",
);
const scannedClientRefs = new Set<string>();
code = code.replace(findCallsRegExp, (_, call, arg) => {
const ref = JSON.parse(`"${arg.slice(1, -1)}"`);
const resolved = resolveClientRef(filepath, ref);
scannedClientRefs.add(resolved);
return `${call}(${JSON.stringify(getScriptId(resolved))})`;
});
return { code, refs: Array.from(scannedClientRefs) };
}
export function getScriptId(file: string) {
return (
path.isAbsolute(file) ? path.relative(projectSrc, file) : file
).replaceAll("\\", "/");
}
declare global {
namespace NodeJS {
interface Module {
cloverClientRefs?: string[];
cloverSourceCode?: string;
cloverImporters?: Module[];
_compile(
this: NodeJS.Module,
content: string,
filepath: string,
format: "module" | "commonjs",
): unknown;
}
}
}
declare module "node:module" {
export function _resolveFilename(id: string, parent: NodeJS.Module): unknown;
}
import * as fs from "#sitegen/fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import * as esbuild from "esbuild";
import { createRequire } from "node:module";
import * as mdx from "@mdx-js/mdx";
import * as self from "./hot.ts";
import { Buffer } from "node:buffer";