221 lines
8.3 KiB
JavaScript
221 lines
8.3 KiB
JavaScript
import fs from "node:fs";
|
|
import { builtinModules } from "node:module";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { build as analyzeBuild } from "esbuild";
|
|
|
|
const BUILTIN_MODULES = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]);
|
|
const OPAQUE_REQUIRE_PATTERN = /\brequireFromHere\(\s*["'`]([^"'`]+)["'`]\s*\)/gu;
|
|
|
|
// A dynamic import whose specifier is held in a local string variable is opaque to esbuild, so the two
|
|
// halves are matched separately and paired by position afterwards. Spanning both halves in one pattern
|
|
// (declaration, then a lazy `[\s\S]{0,500}?` gap, then the import) overflows the regex stack on a bundled
|
|
// server artifact, where a single generated line can run into the megabytes.
|
|
const OPAQUE_STRING_BINDING_PATTERN = /\b(?:const|let|var)\s+([$_A-Za-z][\w$]*)\s*=\s*["'`]([^"'`]+)["'`]\s*;/gu;
|
|
// The comment body uses an unrolled loop rather than a lazy span so it cannot backtrack across the file.
|
|
const OPAQUE_DYNAMIC_IMPORT_PATTERN = /\bimport\(\s*(?:\/\*[^*]*(?:\*(?!\/)[^*]*)*\*\/\s*)?([$_A-Za-z][\w$]*)\s*\)/gu;
|
|
/** How far after its declaration the variable may be imported and still count as the same binding. */
|
|
const OPAQUE_BINDING_MAX_DISTANCE = 500;
|
|
|
|
export const RUNTIME_CLOSURE_TARGETS = {
|
|
web: {
|
|
entryPoints: [
|
|
"build/server-runtime/server.js",
|
|
"apps/web/build/server/index.js",
|
|
"apps/web/build-portal/server/index.js",
|
|
"packages/db/dist/deploy-permission-catalog-sync.server.js",
|
|
],
|
|
manifestPath: "runtime/web/package.json",
|
|
},
|
|
api: {
|
|
entryPoints: ["apps/api/dist/server.js"],
|
|
manifestPath: "runtime/api/package.json",
|
|
},
|
|
worker: {
|
|
entryPoints: [
|
|
"apps/worker/dist/index.js",
|
|
"apps/worker/dist/health.js",
|
|
"apps/worker/dist/jobs/plugin-sandbox-worker.js",
|
|
"apps/worker/dist/jobs/file-preview-decoder-process.js",
|
|
],
|
|
manifestPath: "runtime/worker/package.json",
|
|
},
|
|
mcp: {
|
|
entryPoints: ["apps/mcp/dist/server.js"],
|
|
manifestPath: "runtime/mcp/package.json",
|
|
},
|
|
hocuspocus: {
|
|
entryPoints: ["apps/hocuspocus/dist/index.js"],
|
|
manifestPath: "runtime/hocuspocus/package.json",
|
|
},
|
|
"portal-domain-controller": {
|
|
entryPoints: ["apps/portal-domain-controller/dist/index.js"],
|
|
manifestPath: "runtime/portal-domain-controller/package.json",
|
|
},
|
|
};
|
|
|
|
export function packageNameFromSpecifier(specifier) {
|
|
if (!specifier || specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#") || BUILTIN_MODULES.has(specifier)) {
|
|
return null;
|
|
}
|
|
const segments = specifier.split("/");
|
|
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
|
}
|
|
|
|
export async function collectRuntimePackageImports(rootDir, entryPoints) {
|
|
const packageNames = new Set();
|
|
const absoluteEntryPoints = entryPoints.map((entryPoint) => path.join(rootDir, entryPoint));
|
|
|
|
for (const [index, entryPoint] of absoluteEntryPoints.entries()) {
|
|
if (!fs.existsSync(entryPoint)) {
|
|
throw new Error(`missing runtime artifact entry point: ${entryPoints[index]}`);
|
|
}
|
|
}
|
|
|
|
const analysis = await analyzeBuild({
|
|
absWorkingDir: rootDir,
|
|
bundle: true,
|
|
entryPoints: absoluteEntryPoints,
|
|
format: "esm",
|
|
logLevel: "silent",
|
|
metafile: true,
|
|
outdir: path.join(rootDir, ".runtime-closure-analysis"),
|
|
packages: "external",
|
|
platform: "node",
|
|
write: false,
|
|
});
|
|
|
|
for (const input of Object.values(analysis.metafile.inputs)) {
|
|
for (const imported of input.imports) {
|
|
const packageName = packageNameFromSpecifier(imported.path);
|
|
if (imported.external && packageName) packageNames.add(packageName);
|
|
}
|
|
}
|
|
|
|
for (const inputPath of Object.keys(analysis.metafile.inputs)) {
|
|
const absoluteInputPath = path.isAbsolute(inputPath) ? inputPath : path.join(rootDir, inputPath);
|
|
if (!fs.existsSync(absoluteInputPath) || !/\.[cm]?js$/u.test(absoluteInputPath)) continue;
|
|
const source = fs.readFileSync(absoluteInputPath, "utf8");
|
|
for (const specifier of collectOpaquePackageSpecifiers(source)) {
|
|
const packageName = packageNameFromSpecifier(specifier);
|
|
if (packageName) packageNames.add(packageName);
|
|
}
|
|
}
|
|
|
|
return [...packageNames].sort();
|
|
}
|
|
|
|
export function collectOpaquePackageSpecifiers(source) {
|
|
const specifiers = [];
|
|
for (const match of source.matchAll(OPAQUE_REQUIRE_PATTERN)) {
|
|
if (match[1]) specifiers.push(match[1]);
|
|
}
|
|
|
|
// Declarations first, keyed by name and kept in source order, so each import can be paired with the
|
|
// nearest preceding one. Re-assigning the same name later shadows the earlier value, as it would at runtime.
|
|
const bindings = new Map();
|
|
for (const match of source.matchAll(OPAQUE_STRING_BINDING_PATTERN)) {
|
|
if (!match[1] || !match[2]) continue;
|
|
const declarations = bindings.get(match[1]) ?? [];
|
|
declarations.push({ specifier: match[2], endIndex: match.index + match[0].length });
|
|
bindings.set(match[1], declarations);
|
|
}
|
|
|
|
for (const match of source.matchAll(OPAQUE_DYNAMIC_IMPORT_PATTERN)) {
|
|
const declarations = bindings.get(match[1]);
|
|
if (!declarations) continue;
|
|
const declaration = lastDeclarationWithinReach(declarations, match.index);
|
|
if (declaration) specifiers.push(declaration.specifier);
|
|
}
|
|
|
|
return specifiers;
|
|
}
|
|
|
|
function lastDeclarationWithinReach(declarations, importIndex) {
|
|
for (let index = declarations.length - 1; index >= 0; index -= 1) {
|
|
const declaration = declarations[index];
|
|
if (declaration.endIndex > importIndex) continue;
|
|
return importIndex - declaration.endIndex <= OPAQUE_BINDING_MAX_DISTANCE ? declaration : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function verifyRuntimeClosure(rootDir, targetName, targetConfig = RUNTIME_CLOSURE_TARGETS[targetName]) {
|
|
if (!targetConfig) throw new Error(`unknown runtime closure target: ${targetName}`);
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, targetConfig.manifestPath), "utf8"));
|
|
const declaredDependencies = Object.keys(manifest.dependencies ?? {}).sort();
|
|
const importedPackages = await collectRuntimePackageImports(rootDir, targetConfig.entryPoints);
|
|
const missingDependencies = importedPackages.filter((packageName) => !declaredDependencies.includes(packageName));
|
|
|
|
return {
|
|
targetName,
|
|
manifestPath: targetConfig.manifestPath,
|
|
declaredDependencies,
|
|
importedPackages,
|
|
missingDependencies,
|
|
};
|
|
}
|
|
|
|
export function parseRuntimeSelection(args) {
|
|
const selected = [];
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const argument = args[index];
|
|
let targetName;
|
|
|
|
if (argument === "--") {
|
|
continue;
|
|
} else if (argument === "--runtime") {
|
|
targetName = args[index + 1];
|
|
index += 1;
|
|
} else if (argument.startsWith("--runtime=")) {
|
|
targetName = argument.slice("--runtime=".length);
|
|
} else {
|
|
throw new Error(`unknown runtime closure argument: ${argument}`);
|
|
}
|
|
|
|
if (!targetName) throw new Error("--runtime requires a target name");
|
|
if (!(targetName in RUNTIME_CLOSURE_TARGETS)) {
|
|
throw new Error(`unknown runtime closure target: ${targetName}`);
|
|
}
|
|
if (!selected.includes(targetName)) selected.push(targetName);
|
|
}
|
|
|
|
return selected.length > 0 ? selected : Object.keys(RUNTIME_CLOSURE_TARGETS);
|
|
}
|
|
|
|
export async function verifyRuntimeClosures(
|
|
rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
|
targetNames = Object.keys(RUNTIME_CLOSURE_TARGETS)
|
|
) {
|
|
return Promise.all(targetNames.map((targetName) => verifyRuntimeClosure(rootDir, targetName)));
|
|
}
|
|
|
|
async function runCli() {
|
|
let targetNames;
|
|
try {
|
|
targetNames = parseRuntimeSelection(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(`FAIL ${(error instanceof Error && error.message) || String(error)}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const results = await verifyRuntimeClosures(undefined, targetNames);
|
|
let failed = false;
|
|
|
|
for (const result of results) {
|
|
if (result.missingDependencies.length > 0) {
|
|
failed = true;
|
|
console.error(`FAIL runtime=${result.targetName} missing=${result.missingDependencies.join(",")}`);
|
|
} else {
|
|
console.log(`PASS runtime=${result.targetName} imports=${result.importedPackages.length} dependencies=${result.declaredDependencies.length}`);
|
|
}
|
|
}
|
|
|
|
if (failed) process.exitCode = 1;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli();
|