195 lines
5.5 KiB
TypeScript
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.",
|
|
);
|
|
}
|