trigger.dev
Version:
A Command-Line Interface for Trigger.dev projects
513 lines (510 loc) • 19.9 kB
JavaScript
import { parse } from "@babel/parser";
import { readFile, stat } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import pLimit from "p-limit";
import { tryCatch } from "@trigger.dev/core/v3";
import { logger } from "../utilities/logger.js";
import { isBareModuleImport, isBuiltinModule, makeExternalRegexp, packageNameForImportPath, } from "./externals.js";
export { packageNameForImportPath as packageNameForSpecifier } from "./externals.js";
/**
* Finds string-literal package specifiers loaded through `createRequire`, e.g.
* `createRequire(import.meta.url)("mssql")` or
* `const req = createRequire(import.meta.url); req("mssql")`.
*
* esbuild treats `createRequire` as an opaque call: nothing it loads is ever
* resolved, so such packages are neither bundled nor collected as externals
* and are missing from deployed images. The source is parsed with
* `@babel/parser`, so comments, strings, templates, regex literals and JSX
* can never confuse the scan; a file that fails to parse is skipped
* (diagnostics must never fail a build). Binding tracking is name-based,
* module-level, and per-file: computed specifiers, shadowed names, and a
* require function imported from another file are not followed.
*/
export function scanSourceForCreateRequire(source) {
if (!source.includes("createRequire")) {
return [];
}
const ast = parseWithFallbacks(source);
if (!ast) {
return [];
}
const collected = collectAstFacts(ast);
const aliases = new Set();
const namespaces = new Set();
for (const moduleImport of collected.moduleImports) {
if (moduleImport.kind === "createRequire") {
aliases.add(moduleImport.localName);
}
else {
namespaces.add(moduleImport.localName);
}
}
if (aliases.size === 0 && namespaces.size === 0) {
return [];
}
const isCreateRequireCall = (node) => {
if (node.type !== "CallExpression") {
return false;
}
const callee = node.callee;
if (callee.type === "Identifier") {
return aliases.has(callee.name);
}
if (callee.type === "MemberExpression") {
const object = callee.object;
const property = callee.property;
return (object.type === "Identifier" &&
namespaces.has(object.name) &&
property.type === "Identifier" &&
property.name === "createRequire");
}
return false;
};
const requireFnNames = new Set();
for (const binding of collected.bindings) {
if (isCreateRequireCall(binding.value)) {
requireFnNames.add(binding.name);
}
}
const lines = source.split("\n");
const specifiers = [];
const seenStarts = new Set();
for (const call of collected.calls) {
const callee = call.callee;
const isRequireFnCall = (callee.type === "Identifier" && requireFnNames.has(callee.name)) ||
(callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
requireFnNames.has(callee.object.name) &&
callee.property.type === "Identifier" &&
callee.property.name === "resolve");
if (!isRequireFnCall && !isCreateRequireCall(callee)) {
continue;
}
if (call.specifier === undefined ||
call.start === undefined ||
seenStarts.has(call.start) ||
!isWarnableSpecifier(call.specifier)) {
continue;
}
seenStarts.add(call.start);
specifiers.push({
specifier: call.specifier,
line: call.line,
column: call.column,
lineText: lines[call.line - 1] ?? "",
});
}
specifiers.sort((a, b) => a.line - b.line || a.column - b.column);
return specifiers;
}
const MODULE_BUILTIN_SPECIFIERS = new Set(["module", "node:module"]);
const PARSER_PLUGIN_ATTEMPTS = [
["typescript", "jsx", "decorators-legacy"],
["typescript", "decorators-legacy"],
["typescript"],
[],
];
function parseWithFallbacks(source) {
for (const plugins of PARSER_PLUGIN_ATTEMPTS) {
try {
return parse(source, {
sourceType: "unambiguous",
errorRecovery: true,
allowReturnOutsideFunction: true,
plugins,
});
}
catch (error) {
logger.debug("[createRequire] Parse attempt failed", { plugins, error });
}
}
return undefined;
}
function collectAstFacts(ast) {
const facts = {
moduleImports: [],
bindings: [],
calls: [],
};
const visit = (node) => {
switch (node.type) {
case "ImportDeclaration": {
collectImportDeclaration(node, facts);
return;
}
case "VariableDeclarator": {
collectVariableDeclarator(node, facts);
break;
}
case "AssignmentExpression": {
const left = node.left;
const right = node.right;
if (node.operator === "=" && left.type === "Identifier") {
facts.bindings.push({ name: left.name, value: right });
}
break;
}
case "CallExpression": {
const args = node.arguments;
facts.calls.push({
callee: node.callee,
specifier: stringArgumentValue(args[0]),
start: node.start ?? undefined,
line: node.loc?.start.line ?? 1,
column: node.loc?.start.column ?? 0,
});
break;
}
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) {
if (isAstNode(item)) {
visit(item);
}
}
}
else if (isAstNode(value)) {
visit(value);
}
}
};
visit(ast);
return facts;
}
function isAstNode(value) {
return typeof value === "object" && value !== null && typeof value.type === "string";
}
function collectImportDeclaration(node, facts) {
const importSource = node.source;
if (!MODULE_BUILTIN_SPECIFIERS.has(importSource.value)) {
return;
}
for (const specifier of node.specifiers) {
const localName = specifier.local.name;
if (specifier.type === "ImportSpecifier") {
const imported = specifier.imported;
if (imported.type === "Identifier" && imported.name === "createRequire") {
facts.moduleImports.push({ kind: "createRequire", localName });
}
}
else {
facts.moduleImports.push({ kind: "namespace", localName });
}
}
}
function collectVariableDeclarator(node, facts) {
const id = node.id;
const init = node.init;
if (!init) {
return;
}
if (isModuleBuiltinLoad(init)) {
if (id.type === "Identifier") {
facts.moduleImports.push({ kind: "namespace", localName: id.name });
}
else if (id.type === "ObjectPattern") {
for (const property of id.properties) {
if (property.type !== "ObjectProperty") {
continue;
}
const key = property.key;
const value = property.value;
if (key.type === "Identifier" &&
key.name === "createRequire" &&
value.type === "Identifier") {
facts.moduleImports.push({ kind: "createRequire", localName: value.name });
}
}
}
return;
}
if (id.type === "Identifier") {
facts.bindings.push({ name: id.name, value: init });
}
}
function isModuleBuiltinLoad(node) {
const call = node.type === "AwaitExpression" ? node.argument : node;
if (call.type !== "CallExpression") {
return false;
}
const callee = call.callee;
const isLoader = (callee.type === "Identifier" && callee.name === "require") || callee.type === "Import";
if (!isLoader) {
return false;
}
const args = call.arguments;
const arg = args[0];
return (arg !== undefined &&
arg.type === "StringLiteral" &&
MODULE_BUILTIN_SPECIFIERS.has(arg.value));
}
function stringArgumentValue(node) {
if (!node) {
return undefined;
}
if (node.type === "StringLiteral") {
return node.value;
}
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
const quasis = node.quasis;
const value = quasis[0]?.value;
return value?.cooked;
}
return undefined;
}
function isWarnableSpecifier(specifier) {
if (specifier.length === 0 ||
specifier.startsWith("#") ||
specifier.startsWith("node:") ||
specifier.includes("\\") ||
/^[A-Za-z]:/.test(specifier)) {
return false;
}
return isBareModuleImport(specifier) && !isBuiltinModule(packageNameForImportPath(specifier));
}
const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/;
export const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/;
const FILE_READ_CONCURRENCY = 16;
/**
* Scans the bundle's input files for packages loaded through `createRequire`.
* Only files outside `node_modules` are scanned: bundled libraries commonly
* use optional-require patterns that would drown real findings in noise.
* Each file is scanned independently; results are cached per file by mtime
* and size so dev rebuilds only re-read changed files.
*/
export class CreateRequireCollector {
workingDir;
_usages = [];
_cache = new Map();
_plugin;
constructor(workingDir) {
this.workingDir = workingDir;
}
get usages() {
return this._usages;
}
get plugin() {
this._plugin ??= {
name: "create-require-collector",
setup: (build) => {
build.onEnd(async (result) => {
if (!result.metafile) {
this._usages = [];
return;
}
try {
this._usages = await this.collect(result.metafile);
}
catch (error) {
logger.debug("[createRequire] Scan failed; skipping warnings", { error });
this._usages = [];
}
});
},
};
return this._plugin;
}
async collect(metafile) {
const files = [];
const seenPaths = new Set();
for (const inputPath of Object.keys(metafile.inputs)) {
const cleanPath = inputPath.split("?")[0];
if (seenPaths.has(cleanPath) ||
!SCANNABLE_FILE_REGEX.test(cleanPath) ||
NODE_MODULES_SEGMENT_REGEX.test(cleanPath)) {
continue;
}
seenPaths.add(cleanPath);
files.push({
inputPath: cleanPath,
filePath: isAbsolute(cleanPath) ? cleanPath : resolve(this.workingDir, cleanPath),
});
}
const limit = pLimit(FILE_READ_CONCURRENCY);
const scanned = await Promise.all(files.map((file) => limit(async () => {
const [statError, stats] = await tryCatch(stat(file.filePath));
if (statError) {
logger.debug("[createRequire] Unable to stat bundle input file", {
filePath: file.filePath,
error: statError,
});
return undefined;
}
const cached = this._cache.get(file.filePath);
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
return { inputPath: file.inputPath, specifiers: cached.specifiers };
}
const [readError, contents] = await tryCatch(readFile(file.filePath, "utf8"));
if (readError) {
logger.debug("[createRequire] Unable to read bundle input file", {
filePath: file.filePath,
error: readError,
});
return undefined;
}
const specifiers = scanSourceForCreateRequire(contents);
this._cache.set(file.filePath, {
mtimeMs: stats.mtimeMs,
size: stats.size,
specifiers,
});
return { inputPath: file.inputPath, specifiers };
})));
const usages = [];
for (const entry of scanned) {
if (!entry) {
continue;
}
for (const found of entry.specifiers) {
usages.push({
...found,
file: entry.inputPath,
packageName: packageNameForImportPath(found.specifier),
});
}
}
return usages;
}
}
/**
* Package-name matchers for everything the configured build extensions
* declare they install into or externalize for the deployed image. Reads
* only the user's configured extensions; call it before internal extensions
* (e.g. the externals collector) are prepended to the build context, and it
* skips them by name as a second guard. Never throws: diagnostics must not
* fail a build.
*/
export function extensionInstalledPackageMatchers(config) {
const matchers = [];
let incomplete = false;
for (const buildExtension of config.build?.extensions ?? []) {
if (buildExtension.name === "externals") {
continue;
}
try {
const declaresPackages = typeof buildExtension.installedPackagesForTarget === "function" ||
typeof buildExtension.externalsForTarget === "function";
if (!declaresPackages) {
if (buildExtension.name === "additionalPackages") {
incomplete = true;
}
continue;
}
const declared = [
...(buildExtension.installedPackagesForTarget?.("deploy") ?? []),
...(buildExtension.externalsForTarget?.("deploy") ?? []),
];
for (const packageName of declared) {
matchers.push(makeExternalRegexp(packageName));
}
}
catch (error) {
logger.debug("[createRequire] Build extension package declaration failed", {
extension: buildExtension.name,
error,
});
incomplete = true;
}
}
return { matchers, incomplete };
}
/**
* Filters collected usages down to the ones that will actually be missing at
* runtime in the deployed image: not in the resolved externals (the installed
* dependencies) and not declared as installed by a build extension.
*/
export function unavailableCreateRequireUsages(usages, installedPackages, externalMatchers) {
return usages.filter((usage) => !installedPackages.has(usage.packageName) &&
!externalMatchers.some((matcher) => matcher.test(usage.packageName) || matcher.test(usage.specifier)));
}
const INSTALL_COMMAND_REGEX = /\b(?:npm|pnpm|yarn|bun)\s+(?:install|i|add)\b([^&|;]*)/gi;
/**
* Package names installed by build-layer commands (`RUN npm install pkg`
* etc.). Such installs never reach the manifest externals, so the packages
* they name must not warn; commands that install nothing specific (npm ci,
* bun run) contribute nothing.
*/
export function packagesInstalledByCommands(commands) {
const names = new Set();
for (const command of commands) {
for (const match of command.matchAll(INSTALL_COMMAND_REGEX)) {
if (/(?:^|\s)(?:-g|--global)(?:\s|$)/.test(match[1])) {
continue;
}
for (const token of match[1].trim().split(/\s+/)) {
if (token.length === 0 || token.startsWith("-")) {
continue;
}
const aliasIndex = token.indexOf("@npm:");
if (aliasIndex > 0) {
names.add(token.slice(0, aliasIndex));
continue;
}
if (token.includes(":")) {
continue;
}
const versionAt = token.lastIndexOf("@");
const name = versionAt > 0 ? token.slice(0, versionAt) : token;
if (name.length > 0 && !name.startsWith(".") && !name.startsWith("/")) {
names.add(name);
}
}
}
}
return Array.from(names);
}
/**
* The shared dev/deploy warning pipeline: suppress usages that will be
* available in the image (manifest externals, extension-declared packages,
* packages named in build-layer install commands), render the rest. Returns
* [] instead of throwing on any internal failure, and stays silent for dev
* when extension-installed packages can't be determined (see
* ExtensionInstalledPackages.incomplete).
*/
export function collectCreateRequireWarningMessages({ usages, buildManifest, extensionPackages, target, }) {
try {
if (target === "dev" && extensionPackages.incomplete) {
return [];
}
const matchers = [
...extensionPackages.matchers,
...packagesInstalledByCommands(buildManifest.build?.commands ?? []).map(makeExternalRegexp),
];
const installedPackages = new Set((buildManifest.externals ?? []).map((external) => external.name));
return unavailableCreateRequireUsages(usages, installedPackages, matchers).map((usage) => createRequireUsageToWarning(usage, target));
}
catch (error) {
logger.debug("[createRequire] Warning generation failed; skipping", { error });
return [];
}
}
export function createRequireUsageToWarning(usage, target) {
const text = target === "dev"
? `"${usage.specifier}" is loaded with createRequire(). This works locally because your project's node_modules exists, but the package won't be available in the deployed image, so deploys of this code will fail at runtime. The bundler can't follow createRequire() calls, so "${usage.packageName}" is neither bundled into your code nor installed in the image.`
: `"${usage.specifier}" is loaded with createRequire() but won't be available in the deployed image, so loading it will fail at runtime. The bundler can't follow createRequire() calls, so "${usage.packageName}" is neither bundled into your code nor installed in the image.`;
return {
pluginName: "create-require-collector",
text,
location: {
file: usage.file,
line: usage.line,
column: usage.column,
lineText: usage.lineText,
},
notes: [
{
text: `To fix this, install "${usage.packageName}" into the image by adding the additionalPackages build extension to your trigger.config.ts:
import { additionalPackages } from "@trigger.dev/build/extensions/core";
export default defineConfig({
// ...
build: {
extensions: [additionalPackages({ packages: ["${usage.packageName}"] })],
},
});
Alternatively, replace the createRequire() call with a static import so the package is bundled. If this load is intentionally optional (guarded by try/catch with a fallback), you can ignore this warning. Docs: https://trigger.dev/docs/config/extensions/additionalPackages`,
},
],
};
}
//# sourceMappingURL=createRequireWarnings.js.map