/** * Loads template file contents for {@link Engine.importTemplate}. * * - `.json` files are read directly. * - `.ts`, `.js`, `.mts`, `.cts`, `.mjs`, `.cjs` files are evaluated in a * short-lived child process and serialized to Extended JSON on stdout. * TypeScript templates (and the loader in dev) run via tsx; plain JS uses node. */ import { spawn } from "node:child_process"; import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; /** Extensions loaded via subprocess module evaluation. */ const MODULE_TEMPLATE_EXTENSIONS = new Set([ ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ]); /** Maximum time allowed for a template module child process. */ const MODULE_LOAD_TIMEOUT_MS = 30_000; /** Maximum stdout size from the loader child (50 MiB). */ const MODULE_LOAD_MAX_BUFFER_BYTES = 50 * 1024 * 1024; /** * Thrown when a template file cannot be read or loaded. */ export class TemplateLoadError extends Error { constructor(message: string) { super(message); this.name = "TemplateLoadError"; } } /** * Resolves the tsx CLI binary shipped with this package. */ function resolveTsxCliPath(): string { const require = createRequire(import.meta.url); const tsxPackageJsonPath = require.resolve("tsx/package.json"); return path.join(path.dirname(tsxPackageJsonPath), "dist/cli.mjs"); } /** * Resolves the loader script path for dev (.ts) and production (.js) layouts. */ function resolveTemplateModuleLoaderPath(): string { const directory = path.dirname(fileURLToPath(import.meta.url)); const compiledLoaderPath = path.join(directory, "template-module-loader.js"); if (fs.existsSync(compiledLoaderPath)) { return compiledLoaderPath; } const sourceLoaderPath = path.join(directory, "template-module-loader.ts"); if (fs.existsSync(sourceLoaderPath)) { return sourceLoaderPath; } throw new TemplateLoadError( "Template module loader script was not found in the xo-cli package.", ); } /** TypeScript extensions that require tsx to evaluate the template module. */ const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([ ".ts", ".tsx", ".mts", ".cts", ]); /** * Loads a TS/JS template module in an isolated child process. * Returns Extended JSON suitable for {@link parseTemplate}. */ async function loadTemplateModuleViaChildProcess( absolutePath: string, ): Promise { const loaderPath = resolveTemplateModuleLoaderPath(); const extension = path.extname(absolutePath).toLowerCase(); const loaderIsTypeScript = loaderPath.endsWith(".ts"); const useTsx = TYPESCRIPT_TEMPLATE_EXTENSIONS.has(extension) || loaderIsTypeScript; const executable = useTsx ? resolveTsxCliPath() : process.execPath; const args = [loaderPath, absolutePath]; return new Promise((resolve, reject) => { const child = spawn(executable, args, { cwd: path.dirname(absolutePath), stdio: ["ignore", "pipe", "pipe"], env: process.env, }); let stdout = ""; let stderr = ""; let stdoutBytes = 0; const timeout = setTimeout(() => { child.kill("SIGTERM"); reject( new TemplateLoadError( `Template module load timed out after ${MODULE_LOAD_TIMEOUT_MS / 1000}s`, ), ); }, MODULE_LOAD_TIMEOUT_MS); child.stdout.on("data", (chunk: Buffer | string) => { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); stdoutBytes += Buffer.byteLength(text, "utf8"); if (stdoutBytes > MODULE_LOAD_MAX_BUFFER_BYTES) { child.kill("SIGTERM"); reject( new TemplateLoadError( "Template module output exceeded the maximum allowed size.", ), ); return; } stdout += text; }); child.stderr.on("data", (chunk: Buffer | string) => { stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8"); }); child.on("error", (error) => { clearTimeout(timeout); reject( new TemplateLoadError( `Failed to start template module loader: ${error.message}`, ), ); }); child.on("close", (code) => { clearTimeout(timeout); if (code !== 0) { reject( new TemplateLoadError( stderr.trim() || `Template module loader exited with code ${code ?? "unknown"}`, ), ); return; } if (stdout.trim().length === 0) { reject(new TemplateLoadError("Template module loader returned no output.")); return; } resolve(stdout); }); }); } /** * Loads template contents from disk. * * @param filePath - Absolute or relative path to a JSON or module template file. * @returns Extended JSON string for {@link Engine.importTemplate}. */ export async function loadTemplateFromFile(filePath: string): Promise { const absolutePath = path.resolve(filePath); if (!fs.existsSync(absolutePath)) { throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`); } const extension = path.extname(absolutePath).toLowerCase(); if (extension === ".json") { return fs.promises.readFile(absolutePath, "utf8"); } if (MODULE_TEMPLATE_EXTENSIONS.has(extension)) { return loadTemplateModuleViaChildProcess(absolutePath); } throw new TemplateLoadError( `Unsupported template file extension "${extension}". ` + "Use .json or a JavaScript/TypeScript module that exports an XOTemplate.", ); }