Files
xo-cli/src/utils/load-template-from-file.ts

195 lines
5.5 KiB
TypeScript

/**
* 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<string> {
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<string>((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<string> {
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.",
);
}