sitegen/framework/hot.ts
2025-06-07 17:01:34 -07:00

246 lines
7.5 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[];
/* Used by 'incremental.ts' */
srcIds: string[];
}
let fsGraph = new Map<string, FileStat>();
export function setFsGraph(g: Map<string, FileStat>) {
if (fsGraph.size > 0) {
throw new Error("Cannot restore fsGraph when it has been written into");
}
fsGraph = g;
}
export function getFsGraph() {
return fsGraph;
}
function shouldTrackPath(filename: string) {
return !filename.includes("node_modules") &&
!filename.includes(import.meta.dirname);
}
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",
) {
fs.writeMkdirSync(
".clover/debug-transpilation/" +
path.relative(projectRoot, filename).replaceAll("\\", "/").replaceAll(
"../",
"_/",
).replaceAll("/", "."),
content,
);
const result = ModulePrototypeUnderscoreCompile.call(
this,
content,
filename,
format,
);
const stat = fs.statSync(filename);
if (shouldTrackPath(filename)) {
const cssImportsMaybe: string[] = [];
const imports: string[] = [];
for (const { filename: file } of this.children) {
const relative = path.relative(projectRoot, file);
if (file.endsWith(".css")) cssImportsMaybe.push(relative);
else {
const child = fsGraph.get(relative);
if (!child) continue;
const { cssImportsRecursive } = child;
if (cssImportsRecursive) cssImportsMaybe.push(...cssImportsRecursive);
imports.push(relative);
}
}
const relative = path.relative(projectRoot, filename);
fsGraph.set(relative, {
cssImportsRecursive: cssImportsMaybe.length > 0
? Array.from(new Set(cssImportsMaybe))
: null,
imports,
lastModified: stat.mtimeMs,
srcIds: [],
});
}
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 <= 1) {
err.message.replace(replacedPath, args[0]);
}
}
}
return ModuleUnderscoreResolveFilename(...args);
};
function loadEsbuild(module: NodeJS.Module, filepath: string) {
let src = fs.readFileSync(filepath, "utf8");
return loadEsbuildCode(module, filepath, src);
}
function loadEsbuildCode(module: NodeJS.Module, filepath: string, src: string) {
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 (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)};
` + src;
}
src = esbuild.transformSync(src, {
loader,
format: "cjs",
target: "esnext",
jsx: "automatic",
jsxImportSource: "#ssr",
}).code;
return module._compile(src, filepath, "commonjs");
}
function loadMarko(module: NodeJS.Module, filepath: string) {
let src = fs.readFileSync(filepath, "utf8");
// A non-standard thing here is Clover Sitegen implements
// its own client side scripting stuff, so it overrides
// bare client import statements to it's own usage.
if (src.match(/^\s*client\s+import\s+["']/m)) {
src = src.replace(
/^\s*client\s+import\s+("[^"]+|'[^']+)[^\n]+/m,
"<CloverScriptInclude src=$1 />",
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";';
}
src = marko.compileSync(src, filepath).code;
src = src.replace("marko/debug/html", "#ssr/marko");
return loadEsbuildCode(module, filepath, src);
}
function loadMdx(module: NodeJS.Module, filepath: string) {
const input = fs.readFileSync(filepath);
const out = mdx.compileSync(input, { jsxImportSource: "#ssr" }).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) deleteRecursive(filepath, existing);
fsGraph.clear();
return require(filepath);
}
function deleteRecursive(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) deleteRecursive(child.filename, existing);
}
}
export function getCssImports(filepath: string) {
filepath = path.resolve(filepath);
if (!require.cache[filepath]) throw new Error(filepath + " was never loaded");
return fsGraph.get(path.relative(projectRoot, filepath))
?.cssImportsRecursive ?? [];
}
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;
}
}
declare global {
namespace NodeJS {
interface 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 "./fs.ts";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import * as esbuild from "esbuild";
import * as marko from "@marko/compiler";
import { createRequire } from "node:module";
import * as mdx from "@mdx-js/mdx";
import * as self from "./hot.ts";
import { Buffer } from "node:buffer";