From fc8081693fdaf19f553ee189a12e30d9aeb9fbf4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 13:39:13 +0100 Subject: [PATCH 01/15] feat(cli): warn on createRequire packages missing from deployed images Packages loaded via createRequire(import.meta.url)("pkg") are invisible to the bundler: they are neither bundled nor installed into the deployed image, and the deploy succeeds silently before failing at runtime with a module-not-found error. Deploy builds now scan user source files for such loads, cross-check against the packages actually installed in the image, and warn with file and line, suggesting the additionalPackages build extension. Deploys also surface the bundler's own warnings for user files instead of discarding them. --- .changeset/warn-createrequire-deploy.md | 5 + packages/cli-v3/src/build/buildWorker.ts | 51 +++- packages/cli-v3/src/build/bundle.ts | 4 +- .../src/build/createRequireWarnings.test.ts | 198 +++++++++++++++ .../cli-v3/src/build/createRequireWarnings.ts | 234 ++++++++++++++++++ 5 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 .changeset/warn-createrequire-deploy.md create mode 100644 packages/cli-v3/src/build/createRequireWarnings.test.ts create mode 100644 packages/cli-v3/src/build/createRequireWarnings.ts diff --git a/.changeset/warn-createrequire-deploy.md b/.changeset/warn-createrequire-deploy.md new file mode 100644 index 00000000000..d8c7aca3939 --- /dev/null +++ b/.changeset/warn-createrequire-deploy.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Deploys now warn when a package is loaded with `createRequire()` but won't be available in the deployed image. The bundler can't follow `createRequire()` calls, so such a package is neither bundled nor installed, and previously this failed only at runtime with a confusing module-not-found error. The warning points at the exact file and line and suggests the `additionalPackages` build extension. Deploys also now surface the bundler's own warnings for your files (for example `require()` with a non-literal argument) instead of discarding them. diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index 837a1760f49..69a546b63bf 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -1,6 +1,13 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; -import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js"; +import * as esbuild from "esbuild"; +import { + BundleResult, + bundleWorker, + createBuildManifestFromBundle, + logBuildWarnings, +} from "./bundle.js"; +import { CreateRequireCollector, createRequireUsageToWarning } from "./createRequireWarnings.js"; import { bundleSkills } from "./bundleSkills.js"; import { createBuildContext, @@ -72,6 +79,7 @@ export async function buildWorker(options: BuildWorkerOptions) { const pluginsFromExtensions = resolvePluginsForContext(buildContext); const sdkVersionExtractor = new SdkVersionExtractor(); + const createRequireCollector = new CreateRequireCollector(resolvedConfig.workingDir); options.listener?.onBundleStart?.(); @@ -81,7 +89,11 @@ export async function buildWorker(options: BuildWorkerOptions) { destination: options.destination, watch: false, resolvedConfig, - plugins: [sdkVersionExtractor.plugin, ...pluginsFromExtensions], + plugins: [ + sdkVersionExtractor.plugin, + ...(options.target === "dev" ? [] : [createRequireCollector.plugin]), + ...pluginsFromExtensions, + ], jsxFactory: resolvedConfig.build.jsx.factory, jsxFragment: resolvedConfig.build.jsx.fragment, jsxAutomatic: resolvedConfig.build.jsx.automatic, @@ -127,6 +139,16 @@ export async function buildWorker(options: BuildWorkerOptions) { buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); if (options.target !== "dev") { + const buildWarnings = collectDeployBuildWarnings( + bundleResult, + createRequireCollector, + buildManifest + ); + + if (buildWarnings.length > 0) { + logBuildWarnings(buildWarnings); + } + buildManifest = options.rewritePaths ? rewriteBuildManifestPaths(buildManifest, options.destination) : buildManifest; @@ -142,6 +164,31 @@ export async function buildWorker(options: BuildWorkerOptions) { return buildManifest; } +/** + * Deploy-only diagnostics: esbuild's own warnings scoped to the user's files, + * plus packages loaded via createRequire() that end up neither bundled nor + * installed in the image (i.e. not in the manifest's externals). + */ +function collectDeployBuildWarnings( + bundleResult: BundleResult, + createRequireCollector: CreateRequireCollector, + buildManifest: BuildManifest +): esbuild.PartialMessage[] { + const esbuildWarnings = bundleResult.warnings.filter( + (warning) => warning.location?.file && !warning.location.file.includes("node_modules") + ); + + const installedPackages = new Set( + (buildManifest.externals ?? []).map((external) => external.name) + ); + + const createRequireWarnings = createRequireCollector.usages + .filter((usage) => !installedPackages.has(usage.packageName)) + .map(createRequireUsageToWarning); + + return [...esbuildWarnings, ...createRequireWarnings]; +} + /** @knipignore Exported for the CLI end-to-end suite. */ export function rewriteBuildManifestPaths( buildManifest: BuildManifest, diff --git a/packages/cli-v3/src/build/bundle.ts b/packages/cli-v3/src/build/bundle.ts index 4d1cfd53f86..e2347627748 100644 --- a/packages/cli-v3/src/build/bundle.ts +++ b/packages/cli-v3/src/build/bundle.ts @@ -55,6 +55,7 @@ export type BundleResult = { stop: (() => Promise) | undefined; /** Maps output file paths to their content hashes for deduplication */ outputHashes: Record; + warnings: esbuild.Message[]; }; export class BundleError extends Error { @@ -323,6 +324,7 @@ export async function getBundleResultFromBuild( contentHash: hasher.digest("hex"), metafile: result.metafile, outputHashes, + warnings: result.warnings, }; } @@ -340,7 +342,7 @@ function dirToEntryPointGlob(dir: string): string[] { ]; } -export function logBuildWarnings(warnings: esbuild.Message[]) { +export function logBuildWarnings(warnings: esbuild.PartialMessage[]) { const logs = esbuild.formatMessagesSync(warnings, { kind: "warning", color: true }); for (const log of logs) { console.warn(log); diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts new file mode 100644 index 00000000000..45a2758b794 --- /dev/null +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -0,0 +1,198 @@ +import { build } from "esbuild"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + CreateRequireCollector, + packageNameForSpecifier, + scanSourceForCreateRequire, +} from "./createRequireWarnings.js"; + +describe("scanSourceForCreateRequire", () => { + it("finds a direct createRequire invocation with a string literal", () => { + const source = `import { createRequire } from "node:module"; +const mssql = createRequire(import.meta.url)("mssql"); +`; + + const results = scanSourceForCreateRequire(source); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + specifier: "mssql", + line: 2, + column: 14, + lineText: `const mssql = createRequire(import.meta.url)("mssql");`, + }); + }); + + it("finds calls through a variable assigned from createRequire", () => { + const source = `import { createRequire } from "module"; +const req = createRequire(import.meta.url); +const pg = req("pg"); +const client = req('ioredis'); +`; + + const results = scanSourceForCreateRequire(source); + + expect(results.map((r) => r.specifier)).toEqual(["pg", "ioredis"]); + expect(results[0]).toMatchObject({ line: 3, column: 11 }); + }); + + it("finds calls through require.resolve on the created require", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const path = req.resolve("sharp"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["sharp"]); + }); + + it("supports an aliased createRequire import", () => { + const source = `import { createRequire as makeRequire } from "node:module"; +const mod = makeRequire(import.meta.url)("bcrypt"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["bcrypt"]); + }); + + it("supports member access on a module namespace", () => { + const source = `import mod from "node:module"; +const req = mod.createRequire(import.meta.url); +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("supports CJS destructuring of createRequire", () => { + const source = `const { createRequire } = require("module"); +const req = createRequire(__filename); +const lib = req("canvas"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["canvas"]); + }); + + it("keeps subpath and scoped specifiers intact", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const a = req("mssql/lib/tedious"); +const b = req("@aws-sdk/client-s3"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual([ + "mssql/lib/tedious", + "@aws-sdk/client-s3", + ]); + }); + + it("ignores relative, absolute, and internal-import specifiers", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +req("./data.json"); +req("../other.js"); +req("/abs/path.js"); +req("#internal/thing"); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("ignores node builtins with and without the node: prefix", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +req("fs"); +req("node:path"); +req("fs/promises"); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("ignores non-literal specifiers", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const name = "mssql"; +req(name); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("ignores a createRequire result that is only assigned, never called", () => { + const source = `import { createRequire } from "node:module"; +globalThis.require = createRequire(import.meta.url); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("returns nothing when the source doesn't mention createRequire", () => { + const source = `import mssql from "mssql"; +export const pool = mssql.connect(); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("does not treat unrelated variables with similar names as require functions", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const reqCount = tally("metrics"); +obj.req("not-a-require"); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); +}); + +describe("CreateRequireCollector", () => { + it("collects createRequire usages from bundle inputs", async () => { + const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); + + try { + const entryPoint = join(dir, "entry.ts"); + await writeFile( + entryPoint, + `import { createRequire } from "node:module"; +export const mssql = createRequire(import.meta.url)("mssql"); +` + ); + + const collector = new CreateRequireCollector(dir); + + await build({ + entryPoints: [entryPoint], + bundle: true, + metafile: true, + write: false, + format: "esm", + platform: "node", + outdir: dir, + absWorkingDir: dir, + logLevel: "silent", + plugins: [collector.plugin], + }); + + expect(collector.usages).toHaveLength(1); + expect(collector.usages[0]).toMatchObject({ + specifier: "mssql", + packageName: "mssql", + file: "entry.ts", + line: 2, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("packageNameForSpecifier", () => { + it("extracts the package name from plain, subpath, and scoped specifiers", () => { + expect(packageNameForSpecifier("mssql")).toBe("mssql"); + expect(packageNameForSpecifier("mssql/lib/tedious")).toBe("mssql"); + expect(packageNameForSpecifier("@aws-sdk/client-s3")).toBe("@aws-sdk/client-s3"); + expect(packageNameForSpecifier("@aws-sdk/client-s3/dist/index.js")).toBe("@aws-sdk/client-s3"); + }); +}); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts new file mode 100644 index 00000000000..acb079556f2 --- /dev/null +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -0,0 +1,234 @@ +import * as esbuild from "esbuild"; +import { readFile } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import { isAbsolute, resolve } from "node:path"; +import { tryCatch } from "@trigger.dev/core/v3"; +import { logger } from "../utilities/logger.js"; + +export type CreateRequireSpecifier = { + specifier: string; + /** 1-based, matching esbuild message locations */ + line: number; + /** 0-based, matching esbuild message locations */ + column: number; + lineText: string; +}; + +export type CreateRequireUsage = CreateRequireSpecifier & { + file: string; + packageName: string; +}; + +const IDENTIFIER = "[A-Za-z_$][\\w$]*"; +const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; + +/** + * 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. This scan is a best-effort heuristic — + * computed specifiers or a re-exported `createRequire` are not detected. + */ +export function scanSourceForCreateRequire(source: string): CreateRequireSpecifier[] { + if (!source.includes("createRequire")) { + return []; + } + + const aliases = collectCreateRequireAliases(source); + const aliasPattern = Array.from(aliases).map(escapeRegExp).join("|"); + const createRequireCall = `(?:${IDENTIFIER}\\s*\\.\\s*)?(?:${aliasPattern})\\s*\\([^()]*\\)`; + + const results: CreateRequireSpecifier[] = []; + const seen = new Set(); + + const pushHit = (index: number, specifier: string) => { + const key = `${index}:${specifier}`; + + if (seen.has(key) || !isWarnableSpecifier(specifier)) { + return; + } + + seen.add(key); + results.push({ specifier, ...locationAt(source, index) }); + }; + + const directCallRegex = new RegExp( + `(?(); + + for (const match of source.matchAll(assignmentRegex)) { + requireFnNames.add(match[1]!); + } + + for (const name of requireFnNames) { + const callRegex = new RegExp( + `(? a.line - b.line || a.column - b.column); + + return results; +} + +function collectCreateRequireAliases(source: string): Set { + const aliases = new Set(["createRequire"]); + + const bindingRegexes = [ + new RegExp(`import\\s*(?:type\\s*)?\\{([^}]*)\\}\\s*from\\s*["'](?:node:)?module["']`, "g"), + new RegExp( + `(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*require\\(\\s*["'](?:node:)?module["']\\s*\\)`, + "g" + ), + ]; + + for (const regex of bindingRegexes) { + for (const match of source.matchAll(regex)) { + const aliasMatch = match[1]!.match( + new RegExp(`createRequire\\s*(?:as\\s+|:\\s*)(${IDENTIFIER})`) + ); + + if (aliasMatch) { + aliases.add(aliasMatch[1]!); + } + } + } + + return aliases; +} + +export function packageNameForSpecifier(specifier: string): string { + const parts = specifier.split("/"); + + if (specifier.startsWith("@")) { + return parts.slice(0, 2).join("/"); + } + + return parts[0]!; +} + +function isWarnableSpecifier(specifier: string): boolean { + const nonPackagePrefixes = [".", "/", "~", "#", "file:", "data:", "node:"]; + + if (nonPackagePrefixes.some((prefix) => specifier.startsWith(prefix))) { + return false; + } + + return !builtinModules.includes(packageNameForSpecifier(specifier)); +} + +function locationAt( + source: string, + index: number +): { line: number; column: number; lineText: string } { + const before = source.slice(0, index); + const lineStart = before.lastIndexOf("\n") + 1; + const lineEnd = source.indexOf("\n", index); + + return { + line: (before.match(/\n/g)?.length ?? 0) + 1, + column: index - lineStart, + lineText: source.slice(lineStart, lineEnd === -1 ? undefined : lineEnd), + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; + +/** + * 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. + */ +export class CreateRequireCollector { + private _usages: CreateRequireUsage[] = []; + + constructor(private readonly workingDir: string) {} + + get usages(): ReadonlyArray { + return this._usages; + } + + get plugin(): esbuild.Plugin { + return { + name: "create-require-collector", + setup: (build) => { + build.onEnd(async (result) => { + this._usages = []; + + if (!result.metafile) { + return; + } + + for (const inputPath of Object.keys(result.metafile.inputs)) { + const cleanPath = inputPath.split("?")[0]!; + + if (!SCANNABLE_FILE_REGEX.test(cleanPath) || cleanPath.includes("node_modules")) { + continue; + } + + const filePath = isAbsolute(cleanPath) + ? cleanPath + : resolve(this.workingDir, cleanPath); + + const [readError, contents] = await tryCatch(readFile(filePath, "utf8")); + + if (readError) { + logger.debug("[createRequire] Unable to read bundle input file", { + inputPath, + filePath, + error: readError, + }); + + continue; + } + + for (const found of scanSourceForCreateRequire(contents)) { + this._usages.push({ + ...found, + file: cleanPath, + packageName: packageNameForSpecifier(found.specifier), + }); + } + } + }); + }, + }; + } +} + +export function createRequireUsageToWarning(usage: CreateRequireUsage): esbuild.PartialMessage { + return { + pluginName: "create-require-collector", + text: `"${usage.specifier}" is loaded with createRequire() but won't be available in the deployed image. The bundler can't follow createRequire() calls, so "${usage.packageName}" is neither bundled into your code nor installed in the image, and loading it will fail at runtime. Install it into the image with the additionalPackages build extension (https://trigger.dev/docs/config/extensions/additionalPackages), or import it statically so it gets bundled.`, + location: { + file: usage.file, + line: usage.line, + column: usage.column, + lineText: usage.lineText, + }, + }; +} From 37dd13e345bc27d3e796f6815763bcc5ad38c72d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 14:41:02 +0100 Subject: [PATCH 02/15] fix(cli): harden the createRequire scan against nested args and comment noise Match createRequire(fileURLToPath(import.meta.url)) by allowing one level of nested parens in the argument, skip hits on commented-out lines, and only scan files that import the module builtin so unrelated functions named createRequire never warn. --- .../src/build/createRequireWarnings.test.ts | 35 +++++++++++++++++++ .../cli-v3/src/build/createRequireWarnings.ts | 29 +++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index 45a2758b794..f1025f39ea6 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -128,6 +128,41 @@ globalThis.require = createRequire(import.meta.url); expect(scanSourceForCreateRequire(source)).toEqual([]); }); + it("finds calls when the createRequire argument contains a nested call", () => { + const source = `import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +const mssql = createRequire(fileURLToPath(import.meta.url))("mssql"); +const req = createRequire(fileURLToPath(import.meta.url)); +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["mssql", "pg"]); + }); + + it("ignores hits inside comments", () => { + const source = `import { createRequire } from "node:module"; +// const mssql = createRequire(import.meta.url)("mssql"); +/* const pg = createRequire(import.meta.url)("pg"); */ +/** + * Example: createRequire(import.meta.url)("sharp") + */ +const real = createRequire(import.meta.url)("bcrypt"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["bcrypt"]); + }); + + it("ignores files that never import the module builtin", () => { + const source = `function createRequire(config: string) { + return (name: string) => registry.get(config, name); +} +const load = createRequire("defaults"); +const plugin = load("mssql"); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + it("returns nothing when the source doesn't mention createRequire", () => { const source = `import mssql from "mssql"; export const pool = mssql.connect(); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index acb079556f2..68a37a0f6eb 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -21,6 +21,8 @@ export type CreateRequireUsage = CreateRequireSpecifier & { const IDENTIFIER = "[A-Za-z_$][\\w$]*"; const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; +const NESTED_CALL_ARGS = `(?:[^()]|\\([^()]*\\))*`; +const MODULE_IMPORT_REGEX = /(?:from\s*|require\(\s*|import\(\s*)["'](?:node:)?module["']/; /** * Finds string-literal package specifiers loaded through `createRequire`, e.g. @@ -33,13 +35,13 @@ const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; * computed specifiers or a re-exported `createRequire` are not detected. */ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifier[] { - if (!source.includes("createRequire")) { + if (!source.includes("createRequire") || !MODULE_IMPORT_REGEX.test(source)) { return []; } const aliases = collectCreateRequireAliases(source); const aliasPattern = Array.from(aliases).map(escapeRegExp).join("|"); - const createRequireCall = `(?:${IDENTIFIER}\\s*\\.\\s*)?(?:${aliasPattern})\\s*\\([^()]*\\)`; + const createRequireCall = `(?:${IDENTIFIER}\\s*\\.\\s*)?(?:${aliasPattern})\\s*\\(${NESTED_CALL_ARGS}\\)`; const results: CreateRequireSpecifier[] = []; const seen = new Set(); @@ -51,8 +53,14 @@ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifi return; } + const location = locationAt(source, index); + + if (isCommentedOut(location.lineText, location.column)) { + return; + } + seen.add(key); - results.push({ specifier, ...locationAt(source, index) }); + results.push({ specifier, ...location }); }; const directCallRegex = new RegExp( @@ -137,6 +145,21 @@ function isWarnableSpecifier(specifier: string): boolean { return !builtinModules.includes(packageNameForSpecifier(specifier)); } +/** + * Line-level heuristic for hits inside comments (commented-out code is the + * realistic false-positive source). A `//` or `/*` before the hit on the same + * line, or a line shaped like a block-comment continuation, means skip. + */ +function isCommentedOut(lineText: string, column: number): boolean { + const prefix = lineText.slice(0, column); + + if (prefix.includes("//") || prefix.includes("/*")) { + return true; + } + + return lineText.trimStart().startsWith("*"); +} + function locationAt( source: string, index: number From e64a9ae59284a83b9c320c3c12f23e2c70103692 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 14:51:15 +0100 Subject: [PATCH 03/15] feat(cli): show the exact trigger.config.ts fix in the createRequire warning The warning now carries a note with a copy-pasteable additionalPackages snippet naming trigger.config.ts, instead of only linking the docs. --- .../src/build/createRequireWarnings.test.ts | 21 +++++++++++++++++++ .../cli-v3/src/build/createRequireWarnings.ts | 18 +++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index f1025f39ea6..ae6f1fef3b7 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { CreateRequireCollector, + createRequireUsageToWarning, packageNameForSpecifier, scanSourceForCreateRequire, } from "./createRequireWarnings.js"; @@ -223,6 +224,26 @@ export const mssql = createRequire(import.meta.url)("mssql"); }); }); +describe("createRequireUsageToWarning", () => { + it("carries the concrete fix in a note", () => { + const warning = createRequireUsageToWarning({ + specifier: "mssql/lib/tedious", + packageName: "mssql", + file: "src/db.ts", + line: 12, + column: 20, + lineText: `const mssql = createRequire(import.meta.url)("mssql/lib/tedious");`, + }); + + expect(warning.location).toMatchObject({ file: "src/db.ts", line: 12, column: 20 }); + + const note = warning.notes?.[0]?.text ?? ""; + expect(note).toContain("trigger.config.ts"); + expect(note).toContain(`additionalPackages({ packages: ["mssql"] })`); + expect(note).toContain("https://trigger.dev/docs/config/extensions/additionalPackages"); + }); +}); + describe("packageNameForSpecifier", () => { it("extracts the package name from plain, subpath, and scoped specifiers", () => { expect(packageNameForSpecifier("mssql")).toBe("mssql"); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index 68a37a0f6eb..d451a1dfb76 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -246,12 +246,28 @@ export class CreateRequireCollector { export function createRequireUsageToWarning(usage: CreateRequireUsage): esbuild.PartialMessage { return { pluginName: "create-require-collector", - text: `"${usage.specifier}" is loaded with createRequire() but won't be available in the deployed image. The bundler can't follow createRequire() calls, so "${usage.packageName}" is neither bundled into your code nor installed in the image, and loading it will fail at runtime. Install it into the image with the additionalPackages build extension (https://trigger.dev/docs/config/extensions/additionalPackages), or import it statically so it gets bundled.`, + text: `"${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.`, 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, import the package statically so it gets bundled. Docs: https://trigger.dev/docs/config/extensions/additionalPackages`, + }, + ], }; } From bf7849c9135af3f94d199263c001000df597c0f0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 14:55:37 +0100 Subject: [PATCH 04/15] feat(cli,build): warn in dev too when createRequire loads a package deploys won't have The same createRequire scan now runs during dev builds and warns on every build that the package works locally but will be missing from the deployed image, so the problem surfaces while writing the code instead of after a deploy. additionalPackages declares its packages as deploy externals, so a configured fix suppresses the warning in both dev and deploy. --- packages/cli-v3/src/dev/devSession.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index 24259c0c119..d00b9ba1f02 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -15,7 +15,16 @@ import { notifyExtensionOnBuildStart, resolvePluginsForContext, } from "../build/extensions.js"; -import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/externals.js"; +import { + createExternalsBuildExtension, + deployExternalMatchers, + resolveAlwaysExternal, +} from "../build/externals.js"; +import { + CreateRequireCollector, + createRequireUsageToWarning, + unavailableCreateRequireUsages, +} from "../build/createRequireWarnings.js"; import { type DevCommandOptions } from "../commands/dev.js"; import { eventBus } from "../utilities/eventBus.js"; import { logger } from "../utilities/logger.js"; @@ -83,6 +92,8 @@ export async function startDevSession({ }); const externalsExtension = createExternalsBuildExtension("dev", rawConfig, alwaysExternal); + const createRequireCollector = new CreateRequireCollector(rawConfig.workingDir); + const externalMatchers = deployExternalMatchers(rawConfig, alwaysExternal); const buildContext = createBuildContext("dev", rawConfig); buildContext.prependExtension(externalsExtension); await notifyExtensionOnBuildStart(buildContext); @@ -115,6 +126,18 @@ export async function startDevSession({ buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); + const missingWhenDeployed = unavailableCreateRequireUsages( + createRequireCollector.usages, + new Set((buildManifest.externals ?? []).map((external) => external.name)), + externalMatchers + ); + + if (missingWhenDeployed.length > 0) { + logBuildWarnings( + missingWhenDeployed.map((usage) => createRequireUsageToWarning(usage, "dev")) + ); + } + try { logger.debug("Updated bundle", { bundle, buildManifest }); @@ -194,7 +217,7 @@ export async function startDevSession({ destination: destination.path, watch: true, resolvedConfig: rawConfig, - plugins: [...pluginsFromExtensions, onEnd], + plugins: [createRequireCollector.plugin, ...pluginsFromExtensions, onEnd], jsxFactory: rawConfig.build.jsx.factory, jsxFragment: rawConfig.build.jsx.fragment, jsxAutomatic: rawConfig.build.jsx.automatic, From 48e10d480005aa52c6e4c7d2a62c3eea64ade3d9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 15:27:00 +0100 Subject: [PATCH 05/15] feat(cli,build,core): complete the dev createRequire warning, diagnostics-only suppression Adds the scan/suppression halves of the dev warning (the devSession wiring landed in the previous commit): nested-arg matching feeds both targets, and a usage only warns when the package is missing from the resolved externals and every configured external. additionalPackages declares its packages via a new diagnostics-only BuildExtension field, installedPackagesForTarget, so a configured fix silences the warning without changing bundling output. --- .changeset/warn-createrequire-deploy.md | 4 +- .../src/extensions/core/additionalPackages.ts | 7 +++ packages/cli-v3/src/build/buildWorker.ts | 27 +++++--- .../src/build/createRequireWarnings.test.ts | 61 ++++++++++++++++--- .../cli-v3/src/build/createRequireWarnings.ts | 34 ++++++++++- packages/cli-v3/src/build/externals.ts | 28 +++++++++ packages/core/src/v3/build/extensions.ts | 7 +++ 7 files changed, 148 insertions(+), 20 deletions(-) diff --git a/.changeset/warn-createrequire-deploy.md b/.changeset/warn-createrequire-deploy.md index d8c7aca3939..69a9f3d78f0 100644 --- a/.changeset/warn-createrequire-deploy.md +++ b/.changeset/warn-createrequire-deploy.md @@ -1,5 +1,7 @@ --- "trigger.dev": patch +"@trigger.dev/build": patch +"@trigger.dev/core": patch --- -Deploys now warn when a package is loaded with `createRequire()` but won't be available in the deployed image. The bundler can't follow `createRequire()` calls, so such a package is neither bundled nor installed, and previously this failed only at runtime with a confusing module-not-found error. The warning points at the exact file and line and suggests the `additionalPackages` build extension. Deploys also now surface the bundler's own warnings for your files (for example `require()` with a non-literal argument) instead of discarding them. +`deploy` and `dev` now warn when a package is loaded with `createRequire()` but won't be available in the deployed image. The bundler can't follow `createRequire()` calls, so such a package is neither bundled nor installed, and previously this failed only at runtime in production with a confusing module-not-found error (`dev` works because your local `node_modules` exists, which made the failure deploy-only). The warning points at the exact file and line and shows the `additionalPackages` config that fixes it; packages declared via `additionalPackages` don't warn. Deploys also now surface the bundler's own warnings for your files (for example `require()` with a non-literal argument) instead of discarding them. Bundling output is unchanged. diff --git a/packages/build/src/extensions/core/additionalPackages.ts b/packages/build/src/extensions/core/additionalPackages.ts index 5380a2cb48a..0dfcecc2efb 100644 --- a/packages/build/src/extensions/core/additionalPackages.ts +++ b/packages/build/src/extensions/core/additionalPackages.ts @@ -19,6 +19,13 @@ export type AdditionalPackagesOptions = { export function additionalPackages(options: AdditionalPackagesOptions): BuildExtension { return { name: "additionalPackages", + installedPackagesForTarget(target) { + if (target === "dev") { + return []; + } + + return options.packages.map((pkg) => parsePackageName(pkg).name); + }, async onBuildStart(context) { if (context.target !== "deploy") { return; diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index 69a546b63bf..1e0f5d87934 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -7,7 +7,11 @@ import { createBuildManifestFromBundle, logBuildWarnings, } from "./bundle.js"; -import { CreateRequireCollector, createRequireUsageToWarning } from "./createRequireWarnings.js"; +import { + CreateRequireCollector, + createRequireUsageToWarning, + unavailableCreateRequireUsages, +} from "./createRequireWarnings.js"; import { bundleSkills } from "./bundleSkills.js"; import { createBuildContext, @@ -15,7 +19,7 @@ import { notifyExtensionOnBuildStart, resolvePluginsForContext, } from "./extensions.js"; -import { createExternalsBuildExtension } from "./externals.js"; +import { createExternalsBuildExtension, deployExternalMatchers } from "./externals.js"; import { tmpdir } from "node:os"; import { mkdtemp, rm } from "node:fs/promises"; import { join, relative, sep } from "node:path"; @@ -142,7 +146,9 @@ export async function buildWorker(options: BuildWorkerOptions) { const buildWarnings = collectDeployBuildWarnings( bundleResult, createRequireCollector, - buildManifest + buildManifest, + resolvedConfig, + options.forcedExternals ); if (buildWarnings.length > 0) { @@ -167,12 +173,15 @@ export async function buildWorker(options: BuildWorkerOptions) { /** * Deploy-only diagnostics: esbuild's own warnings scoped to the user's files, * plus packages loaded via createRequire() that end up neither bundled nor - * installed in the image (i.e. not in the manifest's externals). + * installed in the image (not in the manifest's externals and not configured + * as an external anywhere). */ function collectDeployBuildWarnings( bundleResult: BundleResult, createRequireCollector: CreateRequireCollector, - buildManifest: BuildManifest + buildManifest: BuildManifest, + resolvedConfig: ResolvedConfig, + forcedExternals: string[] = [] ): esbuild.PartialMessage[] { const esbuildWarnings = bundleResult.warnings.filter( (warning) => warning.location?.file && !warning.location.file.includes("node_modules") @@ -182,9 +191,11 @@ function collectDeployBuildWarnings( (buildManifest.externals ?? []).map((external) => external.name) ); - const createRequireWarnings = createRequireCollector.usages - .filter((usage) => !installedPackages.has(usage.packageName)) - .map(createRequireUsageToWarning); + const createRequireWarnings = unavailableCreateRequireUsages( + createRequireCollector.usages, + installedPackages, + deployExternalMatchers(resolvedConfig, forcedExternals) + ).map((usage) => createRequireUsageToWarning(usage, "deploy")); return [...esbuildWarnings, ...createRequireWarnings]; } diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index ae6f1fef3b7..a02e699645d 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -8,6 +8,7 @@ import { createRequireUsageToWarning, packageNameForSpecifier, scanSourceForCreateRequire, + unavailableCreateRequireUsages, } from "./createRequireWarnings.js"; describe("scanSourceForCreateRequire", () => { @@ -225,15 +226,17 @@ export const mssql = createRequire(import.meta.url)("mssql"); }); describe("createRequireUsageToWarning", () => { + const usage = { + specifier: "mssql/lib/tedious", + packageName: "mssql", + file: "src/db.ts", + line: 12, + column: 20, + lineText: `const mssql = createRequire(import.meta.url)("mssql/lib/tedious");`, + }; + it("carries the concrete fix in a note", () => { - const warning = createRequireUsageToWarning({ - specifier: "mssql/lib/tedious", - packageName: "mssql", - file: "src/db.ts", - line: 12, - column: 20, - lineText: `const mssql = createRequire(import.meta.url)("mssql/lib/tedious");`, - }); + const warning = createRequireUsageToWarning(usage, "deploy"); expect(warning.location).toMatchObject({ file: "src/db.ts", line: 12, column: 20 }); @@ -242,6 +245,48 @@ describe("createRequireUsageToWarning", () => { expect(note).toContain(`additionalPackages({ packages: ["mssql"] })`); expect(note).toContain("https://trigger.dev/docs/config/extensions/additionalPackages"); }); + + it("explains that the failure is deploy-only when building for dev", () => { + const warning = createRequireUsageToWarning(usage, "dev"); + + expect(warning.text).toContain("works locally"); + expect(warning.text).toContain("deploys of this code will fail at runtime"); + }); +}); + +describe("unavailableCreateRequireUsages", () => { + const usageFor = (specifier: string, packageName: string) => ({ + specifier, + packageName, + file: "src/db.ts", + line: 1, + column: 0, + lineText: "", + }); + + it("keeps usages that are neither installed nor configured as external", () => { + const usages = [usageFor("mssql", "mssql")]; + + expect(unavailableCreateRequireUsages(usages, new Set(), [])).toHaveLength(1); + }); + + it("drops usages whose package is in the resolved externals", () => { + const usages = [usageFor("sharp", "sharp"), usageFor("mssql", "mssql")]; + + const result = unavailableCreateRequireUsages(usages, new Set(["sharp"]), []); + + expect(result.map((u) => u.packageName)).toEqual(["mssql"]); + }); + + it("drops usages matching a configured external pattern", () => { + const usages = [usageFor("mssql/lib/tedious", "mssql"), usageFor("pg", "pg")]; + + const result = unavailableCreateRequireUsages(usages, new Set(), [ + new RegExp(`^mssql(?:/[^'"]*)?$`), + ]); + + expect(result.map((u) => u.packageName)).toEqual(["pg"]); + }); }); describe("packageNameForSpecifier", () => { diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index d451a1dfb76..0dd21d26d31 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -1,3 +1,4 @@ +import { BuildTarget } from "@trigger.dev/core/v3/schemas"; import * as esbuild from "esbuild"; import { readFile } from "node:fs/promises"; import { builtinModules } from "node:module"; @@ -243,10 +244,37 @@ export class CreateRequireCollector { } } -export function createRequireUsageToWarning(usage: CreateRequireUsage): esbuild.PartialMessage { +/** + * Filters collected usages down to the ones that will actually be missing at + * runtime in the deployed image: not in the resolved externals (installed + * dependencies) and not matching any configured external. + */ +export function unavailableCreateRequireUsages( + usages: ReadonlyArray, + installedPackages: Set, + externalMatchers: RegExp[] +): CreateRequireUsage[] { + return usages.filter( + (usage) => + !installedPackages.has(usage.packageName) && + !externalMatchers.some( + (matcher) => matcher.test(usage.packageName) || matcher.test(usage.specifier) + ) + ); +} + +export function createRequireUsageToWarning( + usage: CreateRequireUsage, + target: BuildTarget +): esbuild.PartialMessage { + 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: `"${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.`, + text, location: { file: usage.file, line: usage.line, @@ -266,7 +294,7 @@ export function createRequireUsageToWarning(usage: CreateRequireUsage): esbuild. }, }); -Alternatively, import the package statically so it gets bundled. Docs: https://trigger.dev/docs/config/extensions/additionalPackages`, +Alternatively, replace the createRequire() call with a static import so the package is bundled. Docs: https://trigger.dev/docs/config/extensions/additionalPackages`, }, ], }; diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index 38d8e4cdf50..eb556e124bf 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -414,6 +414,34 @@ function createExternalsCollector( }; } +/** + * Matchers for every package that will be available at runtime in the deployed + * image: configured externals (build.external, instrumented packages, + * extension-declared externals, server-forced externals) plus packages that + * extensions install into the image (e.g. additionalPackages). Diagnostics + * only, regardless of the target currently being built. + */ +export function deployExternalMatchers( + config: ResolvedConfig, + forcedExternal: string[] = [] +): RegExp[] { + const matchers = discoverMaybeExternals("deploy", config, forcedExternal).map( + (external) => external.filter + ); + + for (const buildExtension of config.build?.extensions ?? []) { + for (const packageName of buildExtension.installedPackagesForTarget?.("deploy") ?? []) { + const filter = makeExternalRegexp(packageName); + + if (filter) { + matchers.push(filter); + } + } + } + + return matchers; +} + type MaybeExternal = { raw: string; filter: RegExp }; function discoverMaybeExternals( diff --git a/packages/core/src/v3/build/extensions.ts b/packages/core/src/v3/build/extensions.ts index 6b461985567..449467793e1 100644 --- a/packages/core/src/v3/build/extensions.ts +++ b/packages/core/src/v3/build/extensions.ts @@ -14,6 +14,13 @@ export function esbuildPlugin(plugin: Plugin, options: RegisterPluginOptions = { export interface BuildExtension { name: string; externalsForTarget?: (target: BuildTarget) => string[] | undefined; + /** + * Package names this extension installs into the deployed image for the + * given target. Diagnostics only: the bundler ignores this, it just tells + * build warnings (e.g. the createRequire scan) the package will be + * available at runtime. + */ + installedPackagesForTarget?: (target: BuildTarget) => string[] | undefined; onBuildStart?: (context: BuildContext) => Promise | void; onBuildComplete?: ( context: BuildContext, From 25df644990a9dcaad51753ee9607a0c9eaaa4792 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 15:32:06 +0100 Subject: [PATCH 06/15] fix(cli): lexer-based comment handling and binding-verified createRequire detection Scanning now runs on a comment-stripped, template-blanked copy of the source (string-aware, offsets preserved), so commented-out code never registers require names, closed inline comments don't hide real calls, and // inside a string is not mistaken for a comment. Calls are only recognized when the createRequire binding provably comes from the module builtin (named import, namespace member, or CJS destructure), typed require variables and two-level-nested createRequire arguments are matched, whitespace before require( is accepted, and the node_modules skip matches path segments instead of substrings. --- .../src/build/createRequireWarnings.test.ts | 89 +++++++ .../cli-v3/src/build/createRequireWarnings.ts | 252 ++++++++++++++---- 2 files changed, 293 insertions(+), 48 deletions(-) diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index a02e699645d..08e327fd2e5 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -165,6 +165,80 @@ const plugin = load("mssql"); expect(scanSourceForCreateRequire(source)).toEqual([]); }); + it("ignores a local createRequire function even when the module builtin is imported for something else", () => { + const source = `import { builtinModules } from "node:module"; +function createRequire(config: string) { + return (name: string) => registry.get(config, name); +} +const load = createRequire("defaults"); +const plugin = load("mssql"); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("does not register require names from commented-out assignments", () => { + const source = `import { createRequire } from "node:module"; +// const req = createRequire(import.meta.url); +declare function req(name: string): unknown; +const y = req("mssql"); +`; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("still finds calls after a closed inline block comment", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +/* driver */ const mssql = req("mssql"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["mssql"]); + }); + + it("is not confused by // inside a string on the same line", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const api = "https://example.com"; const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("ignores code embedded in template literals", () => { + const source = + 'import { createRequire } from "node:module";\nconst req = createRequire(import.meta.url);\nconst snippet = `const x = req("fake-pkg");`;\n'; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("supports whitespace before the require parenthesis in CJS bindings", () => { + const source = `const { createRequire } = require ("module"); +const req = createRequire(__filename); +const lib = req("canvas"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["canvas"]); + }); + + it("supports a type annotation on the assigned require variable", () => { + const source = `import { createRequire } from "node:module"; +const req: NodeRequire = createRequire(import.meta.url); +const mssql = req("mssql"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["mssql"]); + }); + + it("supports two levels of nesting in the createRequire argument", () => { + const source = `import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +const mssql = createRequire(fileURLToPath(new URL(".", import.meta.url)))("mssql"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["mssql"]); + }); + it("returns nothing when the source doesn't mention createRequire", () => { const source = `import mssql from "mssql"; export const pool = mssql.connect(); @@ -219,6 +293,21 @@ export const mssql = createRequire(import.meta.url)("mssql"); file: "entry.ts", line: 2, }); + + await build({ + entryPoints: [entryPoint], + bundle: true, + metafile: true, + write: false, + format: "esm", + platform: "node", + outdir: dir, + absWorkingDir: dir, + logLevel: "silent", + plugins: [collector.plugin], + }); + + expect(collector.usages).toHaveLength(1); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index 0dd21d26d31..42344abbe54 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -22,8 +22,8 @@ export type CreateRequireUsage = CreateRequireSpecifier & { const IDENTIFIER = "[A-Za-z_$][\\w$]*"; const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; -const NESTED_CALL_ARGS = `(?:[^()]|\\([^()]*\\))*`; -const MODULE_IMPORT_REGEX = /(?:from\s*|require\(\s*|import\(\s*)["'](?:node:)?module["']/; +const NESTED_CALL_ARGS = `(?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*`; +const MODULE_SPECIFIER = `["'](?:node:)?module["']`; /** * Finds string-literal package specifiers loaded through `createRequire`, e.g. @@ -32,17 +32,36 @@ const MODULE_IMPORT_REGEX = /(?:from\s*|require\(\s*|import\(\s*)["'](?:node:)?m * * 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. This scan is a best-effort heuristic — - * computed specifiers or a re-exported `createRequire` are not detected. + * and are missing from deployed images. Scanning runs on a comment-stripped + * copy of the source and only recognizes createRequire bindings that actually + * come from the `module` builtin. Still a best-effort heuristic: computed + * specifiers or a re-exported `createRequire` are not detected. */ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifier[] { - if (!source.includes("createRequire") || !MODULE_IMPORT_REGEX.test(source)) { + if (!source.includes("createRequire")) { return []; } - const aliases = collectCreateRequireAliases(source); - const aliasPattern = Array.from(aliases).map(escapeRegExp).join("|"); - const createRequireCall = `(?:${IDENTIFIER}\\s*\\.\\s*)?(?:${aliasPattern})\\s*\\(${NESTED_CALL_ARGS}\\)`; + const code = stripCommentsAndTemplateText(source); + const { aliases, namespaces } = collectCreateRequireBindings(code); + + if (aliases.size === 0 && namespaces.size === 0) { + return []; + } + + const callHeads: string[] = []; + + if (aliases.size > 0) { + callHeads.push(`(?:${Array.from(aliases).map(escapeRegExp).join("|")})`); + } + + if (namespaces.size > 0) { + callHeads.push( + `(?:${Array.from(namespaces).map(escapeRegExp).join("|")})\\s*\\.\\s*createRequire` + ); + } + + const createRequireCall = `(?:${callHeads.join("|")})\\s*\\(${NESTED_CALL_ARGS}\\)`; const results: CreateRequireSpecifier[] = []; const seen = new Set(); @@ -54,14 +73,8 @@ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifi return; } - const location = locationAt(source, index); - - if (isCommentedOut(location.lineText, location.column)) { - return; - } - seen.add(key); - results.push({ specifier, ...location }); + results.push({ specifier, ...locationAt(source, index) }); }; const directCallRegex = new RegExp( @@ -69,18 +82,18 @@ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifi "g" ); - for (const match of source.matchAll(directCallRegex)) { + for (const match of code.matchAll(directCallRegex)) { pushHit(match.index!, match[2]!); } const assignmentRegex = new RegExp( - `(?:const|let|var)\\s+(${IDENTIFIER})\\s*=\\s*${createRequireCall}(?!\\s*\\()`, + `(?:const|let|var)\\s+(${IDENTIFIER})\\s*(?::\\s*[^=\\n;]+?)?\\s*=\\s*${createRequireCall}(?!\\s*\\()`, "g" ); const requireFnNames = new Set(); - for (const match of source.matchAll(assignmentRegex)) { + for (const match of code.matchAll(assignmentRegex)) { requireFnNames.add(match[1]!); } @@ -90,7 +103,7 @@ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifi "g" ); - for (const match of source.matchAll(callRegex)) { + for (const match of code.matchAll(callRegex)) { pushHit(match.index!, match[2]!); } } @@ -100,30 +113,184 @@ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifi return results; } -function collectCreateRequireAliases(source: string): Set { - const aliases = new Set(["createRequire"]); +type CreateRequireBindings = { + /** Local names bound to createRequire itself (named import or destructure) */ + aliases: Set; + /** Local names bound to the module builtin's namespace or default export */ + namespaces: Set; +}; + +function collectCreateRequireBindings(code: string): CreateRequireBindings { + const aliases = new Set(); + const namespaces = new Set(); + + const namedBindingRegexes = [ + new RegExp( + `import\\s*(?:type\\s+)?(?:(${IDENTIFIER})\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*${MODULE_SPECIFIER}`, + "g" + ), + new RegExp( + `(?:const|let|var)\\s*()\\{([^}]*)\\}\\s*=\\s*require\\s*\\(\\s*${MODULE_SPECIFIER}\\s*\\)`, + "g" + ), + ]; + + for (const regex of namedBindingRegexes) { + for (const match of code.matchAll(regex)) { + if (match[1]) { + namespaces.add(match[1]); + } + + for (const binding of match[2]!.split(",")) { + const bindingMatch = binding.match( + new RegExp(`^\\s*createRequire\\s*(?:(?:as\\s+|:\\s*)(${IDENTIFIER}))?\\s*$`) + ); - const bindingRegexes = [ - new RegExp(`import\\s*(?:type\\s*)?\\{([^}]*)\\}\\s*from\\s*["'](?:node:)?module["']`, "g"), + if (bindingMatch) { + aliases.add(bindingMatch[1] ?? "createRequire"); + } + } + } + } + + const namespaceBindingRegexes = [ + new RegExp(`import\\s+(${IDENTIFIER})\\s+from\\s*${MODULE_SPECIFIER}`, "g"), + new RegExp(`import\\s*\\*\\s*as\\s+(${IDENTIFIER})\\s+from\\s*${MODULE_SPECIFIER}`, "g"), new RegExp( - `(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*require\\(\\s*["'](?:node:)?module["']\\s*\\)`, + `(?:const|let|var)\\s+(${IDENTIFIER})\\s*=\\s*require\\s*\\(\\s*${MODULE_SPECIFIER}\\s*\\)`, "g" ), ]; - for (const regex of bindingRegexes) { - for (const match of source.matchAll(regex)) { - const aliasMatch = match[1]!.match( - new RegExp(`createRequire\\s*(?:as\\s+|:\\s*)(${IDENTIFIER})`) - ); + for (const regex of namespaceBindingRegexes) { + for (const match of code.matchAll(regex)) { + namespaces.add(match[1]!); + } + } + + return { aliases, namespaces }; +} - if (aliasMatch) { - aliases.add(aliasMatch[1]!); +/** + * Blanks out comments and template-literal text (interpolation code is kept) + * while preserving every character offset and newline, so regex scanning + * never matches inside a comment or a code snippet embedded in a template + * string, and locations computed on the result map 1:1 onto the original. + * Single- and double-quoted string contents are kept because specifier + * literals must stay extractable. Regex literals are not lexed (a rare + * unescaped `//` inside one reads as a line comment). + */ +export function stripCommentsAndTemplateText(source: string): string { + const out = source.split(""); + const interpolationBraceDepths: number[] = []; + let mode: "code" | "line" | "block" | "single" | "double" | "template" = "code"; + let i = 0; + + const blank = (index: number) => { + if (out[index] !== "\n") { + out[index] = " "; + } + }; + + while (i < source.length) { + const c = source[i]!; + const d = source[i + 1]; + + switch (mode) { + case "code": { + if (c === "/" && d === "/") { + mode = "line"; + blank(i); + blank(i + 1); + i += 2; + } else if (c === "/" && d === "*") { + mode = "block"; + blank(i); + blank(i + 1); + i += 2; + } else if (c === "'") { + mode = "single"; + i += 1; + } else if (c === '"') { + mode = "double"; + i += 1; + } else if (c === "`") { + mode = "template"; + i += 1; + } else if (c === "{" && interpolationBraceDepths.length > 0) { + interpolationBraceDepths[interpolationBraceDepths.length - 1]!++; + i += 1; + } else if (c === "}" && interpolationBraceDepths.length > 0) { + const depth = interpolationBraceDepths[interpolationBraceDepths.length - 1]!; + + if (depth === 0) { + interpolationBraceDepths.pop(); + mode = "template"; + } else { + interpolationBraceDepths[interpolationBraceDepths.length - 1] = depth - 1; + } + + i += 1; + } else { + i += 1; + } + break; + } + case "line": { + if (c === "\n") { + mode = "code"; + } else { + blank(i); + } + i += 1; + break; + } + case "block": { + if (c === "*" && d === "/") { + mode = "code"; + blank(i); + blank(i + 1); + i += 2; + } else { + blank(i); + i += 1; + } + break; + } + case "single": + case "double": { + if (c === "\\") { + i += 2; + } else { + if (c === (mode === "single" ? "'" : '"') || c === "\n") { + mode = "code"; + } + i += 1; + } + break; + } + case "template": { + if (c === "\\") { + blank(i); + blank(i + 1); + i += 2; + } else if (c === "`") { + mode = "code"; + i += 1; + } else if (c === "$" && d === "{") { + interpolationBraceDepths.push(0); + mode = "code"; + i += 2; + } else { + blank(i); + i += 1; + } + break; } } } - return aliases; + return out.join(""); } export function packageNameForSpecifier(specifier: string): string { @@ -146,21 +313,6 @@ function isWarnableSpecifier(specifier: string): boolean { return !builtinModules.includes(packageNameForSpecifier(specifier)); } -/** - * Line-level heuristic for hits inside comments (commented-out code is the - * realistic false-positive source). A `//` or `/*` before the hit on the same - * line, or a line shaped like a block-comment continuation, means skip. - */ -function isCommentedOut(lineText: string, column: number): boolean { - const prefix = lineText.slice(0, column); - - if (prefix.includes("//") || prefix.includes("/*")) { - return true; - } - - return lineText.trimStart().startsWith("*"); -} - function locationAt( source: string, index: number @@ -181,6 +333,7 @@ function escapeRegExp(value: string): string { } const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; +const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/; /** * Scans the bundle's input files for packages loaded through `createRequire`. @@ -210,7 +363,10 @@ export class CreateRequireCollector { for (const inputPath of Object.keys(result.metafile.inputs)) { const cleanPath = inputPath.split("?")[0]!; - if (!SCANNABLE_FILE_REGEX.test(cleanPath) || cleanPath.includes("node_modules")) { + if ( + !SCANNABLE_FILE_REGEX.test(cleanPath) || + NODE_MODULES_SEGMENT_REGEX.test(cleanPath) + ) { continue; } From 6d68c53960fe29776a013fe2e9add7c2450cc511 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 15:55:59 +0100 Subject: [PATCH 07/15] fix(cli,build): correct warning suppression sources and harden the scanner Deploy suppression now uses only the manifest externals (the actual image contents when the warning runs); configured externals like build.external no longer silence warnings for packages that are never installed. Dev suppression adds extension-declared packages and stays silent when those can't be determined (a hook throws, or an older additionalPackages lacks the declaration hook), so it never makes a false deploys-will-fail claim, and additionalPackages skips unparseable entries instead of throwing into dev startup. The lexer blanks regex-literal bodies and records quoted string spans so string contents can't false-positive, dynamic import("node:module") bindings and declare-then-assign variables are recognized, require functions exported from one file and imported into another are followed, dev rebuilds cache per-file scans by mtime and stat in parallel, and the dev/deploy pipelines share one warning builder. --- packages/cli-v3/src/dev/devSession.ts | 29 +++++++++++---------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index d00b9ba1f02..090730bdfb0 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -15,15 +15,11 @@ import { notifyExtensionOnBuildStart, resolvePluginsForContext, } from "../build/extensions.js"; +import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/externals.js"; import { - createExternalsBuildExtension, - deployExternalMatchers, - resolveAlwaysExternal, -} from "../build/externals.js"; -import { + collectCreateRequireWarningMessages, CreateRequireCollector, - createRequireUsageToWarning, - unavailableCreateRequireUsages, + extensionInstalledPackageMatchers, } from "../build/createRequireWarnings.js"; import { type DevCommandOptions } from "../commands/dev.js"; import { eventBus } from "../utilities/eventBus.js"; @@ -93,7 +89,7 @@ export async function startDevSession({ const externalsExtension = createExternalsBuildExtension("dev", rawConfig, alwaysExternal); const createRequireCollector = new CreateRequireCollector(rawConfig.workingDir); - const externalMatchers = deployExternalMatchers(rawConfig, alwaysExternal); + const extensionPackages = extensionInstalledPackageMatchers(rawConfig); const buildContext = createBuildContext("dev", rawConfig); buildContext.prependExtension(externalsExtension); await notifyExtensionOnBuildStart(buildContext); @@ -126,16 +122,15 @@ export async function startDevSession({ buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); - const missingWhenDeployed = unavailableCreateRequireUsages( - createRequireCollector.usages, - new Set((buildManifest.externals ?? []).map((external) => external.name)), - externalMatchers - ); + const createRequireWarnings = collectCreateRequireWarningMessages({ + usages: createRequireCollector.usages, + buildManifest, + extensionPackages, + target: "dev", + }); - if (missingWhenDeployed.length > 0) { - logBuildWarnings( - missingWhenDeployed.map((usage) => createRequireUsageToWarning(usage, "dev")) - ); + if (createRequireWarnings.length > 0) { + logBuildWarnings(createRequireWarnings); } try { From 813aa58b4ae58c267afdcd118d8f256a5848596d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 15:56:28 +0100 Subject: [PATCH 08/15] fix(cli,build): scanner and suppression halves of the previous commit Completes the change described in the prior commit message: the lexer, binding, cross-module and caching work in the scanner, the corrected suppression sources, and the never-throw additionalPackages declaration. --- .../src/extensions/core/additionalPackages.ts | 12 +- packages/cli-v3/src/build/buildWorker.ts | 55 +- .../src/build/createRequireWarnings.test.ts | 215 +++++++ .../cli-v3/src/build/createRequireWarnings.ts | 541 ++++++++++++++---- packages/cli-v3/src/build/externals.ts | 30 +- 5 files changed, 681 insertions(+), 172 deletions(-) diff --git a/packages/build/src/extensions/core/additionalPackages.ts b/packages/build/src/extensions/core/additionalPackages.ts index 0dfcecc2efb..0da67eb0446 100644 --- a/packages/build/src/extensions/core/additionalPackages.ts +++ b/packages/build/src/extensions/core/additionalPackages.ts @@ -24,7 +24,17 @@ export function additionalPackages(options: AdditionalPackagesOptions): BuildExt return []; } - return options.packages.map((pkg) => parsePackageName(pkg).name); + const names: string[] = []; + + for (const pkg of options.packages) { + try { + names.push(parsePackageName(pkg).name); + } catch { + continue; + } + } + + return names; }, async onBuildStart(context) { if (context.target !== "deploy") { diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index 1e0f5d87934..6868b55e4ce 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -1,6 +1,5 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; -import * as esbuild from "esbuild"; import { BundleResult, bundleWorker, @@ -8,9 +7,9 @@ import { logBuildWarnings, } from "./bundle.js"; import { + collectCreateRequireWarningMessages, CreateRequireCollector, - createRequireUsageToWarning, - unavailableCreateRequireUsages, + extensionInstalledPackageMatchers, } from "./createRequireWarnings.js"; import { bundleSkills } from "./bundleSkills.js"; import { @@ -19,7 +18,7 @@ import { notifyExtensionOnBuildStart, resolvePluginsForContext, } from "./extensions.js"; -import { createExternalsBuildExtension, deployExternalMatchers } from "./externals.js"; +import { createExternalsBuildExtension } from "./externals.js"; import { tmpdir } from "node:os"; import { mkdtemp, rm } from "node:fs/promises"; import { join, relative, sep } from "node:path"; @@ -143,13 +142,17 @@ export async function buildWorker(options: BuildWorkerOptions) { buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); if (options.target !== "dev") { - const buildWarnings = collectDeployBuildWarnings( - bundleResult, - createRequireCollector, - buildManifest, - resolvedConfig, - options.forcedExternals - ); + const buildWarnings = [ + ...bundleResult.warnings.filter( + (warning) => warning.location?.file && !warning.location.file.includes("node_modules") + ), + ...collectCreateRequireWarningMessages({ + usages: createRequireCollector.usages, + buildManifest, + extensionPackages: extensionInstalledPackageMatchers(resolvedConfig), + target: options.target, + }), + ]; if (buildWarnings.length > 0) { logBuildWarnings(buildWarnings); @@ -170,36 +173,6 @@ export async function buildWorker(options: BuildWorkerOptions) { return buildManifest; } -/** - * Deploy-only diagnostics: esbuild's own warnings scoped to the user's files, - * plus packages loaded via createRequire() that end up neither bundled nor - * installed in the image (not in the manifest's externals and not configured - * as an external anywhere). - */ -function collectDeployBuildWarnings( - bundleResult: BundleResult, - createRequireCollector: CreateRequireCollector, - buildManifest: BuildManifest, - resolvedConfig: ResolvedConfig, - forcedExternals: string[] = [] -): esbuild.PartialMessage[] { - const esbuildWarnings = bundleResult.warnings.filter( - (warning) => warning.location?.file && !warning.location.file.includes("node_modules") - ); - - const installedPackages = new Set( - (buildManifest.externals ?? []).map((external) => external.name) - ); - - const createRequireWarnings = unavailableCreateRequireUsages( - createRequireCollector.usages, - installedPackages, - deployExternalMatchers(resolvedConfig, forcedExternals) - ).map((usage) => createRequireUsageToWarning(usage, "deploy")); - - return [...esbuildWarnings, ...createRequireWarnings]; -} - /** @knipignore Exported for the CLI end-to-end suite. */ export function rewriteBuildManifestPaths( buildManifest: BuildManifest, diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index 08e327fd2e5..c24a0a3a667 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -3,10 +3,15 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { ResolvedConfig } from "@trigger.dev/core/v3/build"; +import { BuildManifest } from "@trigger.dev/core/v3/schemas"; import { + collectCreateRequireWarningMessages, CreateRequireCollector, createRequireUsageToWarning, + extensionInstalledPackageMatchers, packageNameForSpecifier, + scanSource, scanSourceForCreateRequire, unavailableCreateRequireUsages, } from "./createRequireWarnings.js"; @@ -239,6 +244,71 @@ const mssql = createRequire(fileURLToPath(new URL(".", import.meta.url)))("mssql expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["mssql"]); }); + it("supports bindings from a dynamic import of the module builtin", () => { + const source = `const { createRequire } = await import("node:module"); +const req = createRequire(import.meta.url); +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("supports a namespace bound from a dynamic import of the module builtin", () => { + const source = `const mod = await import("node:module"); +const mssql = mod.createRequire(import.meta.url)("mssql"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["mssql"]); + }); + + it("supports declare-then-assign require variables", () => { + const source = `import { createRequire } from "node:module"; +let req; +req = createRequire(import.meta.url); +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("does not warn for call-shaped text inside string literals", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const msg = 'try req("mssql") for details'; +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("strips a comment that follows a regex literal containing quotes", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const quote = /['"]/; /* old: req("bcrypt") */ +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("follows require functions imported from other scanned files", () => { + const util = `import { createRequire } from "node:module"; +export const cjsRequire = createRequire(import.meta.url); +`; + const task = `import { cjsRequire } from "./util.js"; +const mssql = cjsRequire("mssql"); +`; + + const { exportedRequireFns, specifiers } = scanSource(util); + + expect(exportedRequireFns).toEqual(["cjsRequire"]); + expect(specifiers).toEqual([]); + + const taskResults = scanSourceForCreateRequire(task, new Set(exportedRequireFns)); + + expect(taskResults.map((r) => r.specifier)).toEqual(["mssql"]); + }); + it("returns nothing when the source doesn't mention createRequire", () => { const source = `import mssql from "mssql"; export const pool = mssql.connect(); @@ -312,6 +382,51 @@ export const mssql = createRequire(import.meta.url)("mssql"); await rm(dir, { recursive: true, force: true }); } }); + + it("collects usages of a require function imported from another module", async () => { + const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); + + try { + await writeFile( + join(dir, "util.ts"), + `import { createRequire } from "node:module"; +export const cjsRequire = createRequire(import.meta.url); +` + ); + + const entryPoint = join(dir, "entry.ts"); + await writeFile( + entryPoint, + `import { cjsRequire } from "./util.js"; +export const mssql = cjsRequire("mssql"); +` + ); + + const collector = new CreateRequireCollector(dir); + + await build({ + entryPoints: [entryPoint], + bundle: true, + metafile: true, + write: false, + format: "esm", + platform: "node", + outdir: dir, + absWorkingDir: dir, + logLevel: "silent", + plugins: [collector.plugin], + }); + + expect(collector.usages).toHaveLength(1); + expect(collector.usages[0]).toMatchObject({ + specifier: "mssql", + packageName: "mssql", + file: "entry.ts", + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); describe("createRequireUsageToWarning", () => { @@ -378,6 +493,106 @@ describe("unavailableCreateRequireUsages", () => { }); }); +describe("extensionInstalledPackageMatchers", () => { + const configWith = (extensions: unknown[]) => + ({ build: { extensions } }) as unknown as ResolvedConfig; + + it("collects matchers from installedPackagesForTarget and externalsForTarget", () => { + const { matchers, incomplete } = extensionInstalledPackageMatchers( + configWith([ + { name: "custom", installedPackagesForTarget: () => ["ffmpeg-static"] }, + { name: "prisma", externalsForTarget: () => ["@prisma/client"] }, + ]) + ); + + expect(incomplete).toBe(false); + expect(matchers.some((m) => m.test("ffmpeg-static"))).toBe(true); + expect(matchers.some((m) => m.test("@prisma/client"))).toBe(true); + expect(matchers.some((m) => m.test("mssql"))).toBe(false); + }); + + it("marks the result incomplete instead of throwing when an extension hook throws", () => { + const { incomplete } = extensionInstalledPackageMatchers( + configWith([ + { + name: "boom", + installedPackagesForTarget: () => { + throw new Error("bad package entry"); + }, + }, + ]) + ); + + expect(incomplete).toBe(true); + }); + + it("marks the result incomplete for an additionalPackages extension without the hook", () => { + const { incomplete } = extensionInstalledPackageMatchers( + configWith([{ name: "additionalPackages" }]) + ); + + expect(incomplete).toBe(true); + }); +}); + +describe("collectCreateRequireWarningMessages", () => { + const usage = { + specifier: "mssql", + packageName: "mssql", + file: "src/db.ts", + line: 1, + column: 0, + lineText: "", + }; + + const manifestWith = (externals: Array<{ name: string; version: string }>) => + ({ externals }) as unknown as BuildManifest; + + it("warns for a package missing from the manifest externals", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: manifestWith([]), + extensionPackages: { matchers: [], incomplete: false }, + target: "deploy", + }); + + expect(messages).toHaveLength(1); + }); + + it("suppresses packages present in the manifest externals", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: manifestWith([{ name: "mssql", version: "10.0.0" }]), + extensionPackages: { matchers: [], incomplete: false }, + target: "deploy", + }); + + expect(messages).toEqual([]); + }); + + it("stays silent in dev when extension-installed packages are unknown", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: manifestWith([]), + extensionPackages: { matchers: [], incomplete: true }, + target: "dev", + }); + + expect(messages).toEqual([]); + }); + + it("still warns on deploy when extension-installed packages are unknown", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: manifestWith([]), + extensionPackages: { matchers: [], incomplete: true }, + target: "deploy", + }); + + expect(messages).toHaveLength(1); + }); +}); + describe("packageNameForSpecifier", () => { it("extracts the package name from plain, subpath, and scoped specifiers", () => { expect(packageNameForSpecifier("mssql")).toBe("mssql"); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index 42344abbe54..cf3a86fb851 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -1,10 +1,12 @@ -import { BuildTarget } from "@trigger.dev/core/v3/schemas"; +import { ResolvedConfig } from "@trigger.dev/core/v3/build"; +import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; import * as esbuild from "esbuild"; -import { readFile } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { builtinModules } from "node:module"; import { isAbsolute, resolve } from "node:path"; import { tryCatch } from "@trigger.dev/core/v3"; import { logger } from "../utilities/logger.js"; +import { makeExternalRegexp } from "./externals.js"; export type CreateRequireSpecifier = { specifier: string; @@ -20,10 +22,17 @@ export type CreateRequireUsage = CreateRequireSpecifier & { packageName: string; }; +export type SourceScanResult = { + specifiers: CreateRequireSpecifier[]; + /** Names of require functions this file creates and exports (`export const req = createRequire(...)`) */ + exportedRequireFns: string[]; +}; + const IDENTIFIER = "[A-Za-z_$][\\w$]*"; const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; const NESTED_CALL_ARGS = `(?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*`; const MODULE_SPECIFIER = `["'](?:node:)?module["']`; +const MODULE_LOAD = `(?:await\\s+)?(?:require|import)\\s*\\(\\s*${MODULE_SPECIFIER}\\s*\\)`; /** * Finds string-literal package specifiers loaded through `createRequire`, e.g. @@ -32,69 +41,120 @@ const MODULE_SPECIFIER = `["'](?:node:)?module["']`; * * 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. Scanning runs on a comment-stripped - * copy of the source and only recognizes createRequire bindings that actually - * come from the `module` builtin. Still a best-effort heuristic: computed - * specifiers or a re-exported `createRequire` are not detected. + * and are missing from deployed images. Scanning runs on a lexed copy of the + * source (comments, template text and regex literals blanked, string spans + * excluded from matching) and only recognizes createRequire bindings that + * actually come from the `module` builtin. Still a best-effort heuristic: + * computed or template-literal specifiers and re-exported createRequire are + * not detected. */ -export function scanSourceForCreateRequire(source: string): CreateRequireSpecifier[] { - if (!source.includes("createRequire")) { - return []; - } - - const code = stripCommentsAndTemplateText(source); - const { aliases, namespaces } = collectCreateRequireBindings(code); +export function scanSourceForCreateRequire( + source: string, + knownRequireFnExports?: ReadonlySet +): CreateRequireSpecifier[] { + return scanSource(source, knownRequireFnExports).specifiers; +} - if (aliases.size === 0 && namespaces.size === 0) { - return []; - } +export function scanSource( + source: string, + knownRequireFnExports?: ReadonlySet +): SourceScanResult { + const empty: SourceScanResult = { specifiers: [], exportedRequireFns: [] }; - const callHeads: string[] = []; + const mentionsKnownExport = knownRequireFnExports + ? Array.from(knownRequireFnExports).some((name) => source.includes(name)) + : false; - if (aliases.size > 0) { - callHeads.push(`(?:${Array.from(aliases).map(escapeRegExp).join("|")})`); + if (!source.includes("createRequire") && !mentionsKnownExport) { + return empty; } - if (namespaces.size > 0) { - callHeads.push( - `(?:${Array.from(namespaces).map(escapeRegExp).join("|")})\\s*\\.\\s*createRequire` - ); - } + const { code, stringSpans } = lexSource(source); - const createRequireCall = `(?:${callHeads.join("|")})\\s*\\(${NESTED_CALL_ARGS}\\)`; + const inString = (index: number) => + stringSpans.some(([start, end]) => index >= start && index < end); - const results: CreateRequireSpecifier[] = []; + const requireFnNames = new Set(); + const exportedRequireFns = new Set(); + const specifiers: CreateRequireSpecifier[] = []; const seen = new Set(); const pushHit = (index: number, specifier: string) => { const key = `${index}:${specifier}`; - if (seen.has(key) || !isWarnableSpecifier(specifier)) { + if (seen.has(key) || inString(index) || !isWarnableSpecifier(specifier)) { return; } seen.add(key); - results.push({ specifier, ...locationAt(source, index) }); + specifiers.push({ specifier, ...locationAt(source, index) }); }; - const directCallRegex = new RegExp( - `(? 0 || namespaces.size > 0) { + const callHeads: string[] = []; + + if (aliases.size > 0) { + callHeads.push(`(?:${Array.from(aliases).map(escapeRegExp).join("|")})`); + } + + if (namespaces.size > 0) { + callHeads.push( + `(?:${Array.from(namespaces).map(escapeRegExp).join("|")})\\s*\\.\\s*createRequire` + ); + } + + const createRequireCall = `(?:${callHeads.join("|")})\\s*\\(${NESTED_CALL_ARGS}\\)`; - for (const match of code.matchAll(directCallRegex)) { - pushHit(match.index!, match[2]!); + const directCallRegex = new RegExp( + `(? 0) { + const relativeImportRegex = new RegExp( + `import\\s*(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["'](\\.[^"'\\n]*)["']`, + "g" + ); - const requireFnNames = new Set(); + for (const match of code.matchAll(relativeImportRegex)) { + if (inString(match.index!)) { + continue; + } - for (const match of code.matchAll(assignmentRegex)) { - requireFnNames.add(match[1]!); + for (const binding of match[1]!.split(",")) { + const bindingMatch = binding.match( + new RegExp(`^\\s*(${IDENTIFIER})\\s*(?:as\\s+(${IDENTIFIER}))?\\s*$`) + ); + + if (bindingMatch && knownRequireFnExports.has(bindingMatch[1]!)) { + requireFnNames.add(bindingMatch[2] ?? bindingMatch[1]!); + } + } + } } for (const name of requireFnNames) { @@ -108,9 +168,9 @@ export function scanSourceForCreateRequire(source: string): CreateRequireSpecifi } } - results.sort((a, b) => a.line - b.line || a.column - b.column); + specifiers.sort((a, b) => a.line - b.line || a.column - b.column); - return results; + return { specifiers, exportedRequireFns: Array.from(exportedRequireFns) }; } type CreateRequireBindings = { @@ -120,7 +180,10 @@ type CreateRequireBindings = { namespaces: Set; }; -function collectCreateRequireBindings(code: string): CreateRequireBindings { +function collectCreateRequireBindings( + code: string, + inString: (index: number) => boolean +): CreateRequireBindings { const aliases = new Set(); const namespaces = new Set(); @@ -129,14 +192,15 @@ function collectCreateRequireBindings(code: string): CreateRequireBindings { `import\\s*(?:type\\s+)?(?:(${IDENTIFIER})\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*${MODULE_SPECIFIER}`, "g" ), - new RegExp( - `(?:const|let|var)\\s*()\\{([^}]*)\\}\\s*=\\s*require\\s*\\(\\s*${MODULE_SPECIFIER}\\s*\\)`, - "g" - ), + new RegExp(`(?:const|let|var)\\s*()\\{([^}]*)\\}\\s*=\\s*${MODULE_LOAD}`, "g"), ]; for (const regex of namedBindingRegexes) { for (const match of code.matchAll(regex)) { + if (inString(match.index!)) { + continue; + } + if (match[1]) { namespaces.add(match[1]); } @@ -156,34 +220,44 @@ function collectCreateRequireBindings(code: string): CreateRequireBindings { const namespaceBindingRegexes = [ new RegExp(`import\\s+(${IDENTIFIER})\\s+from\\s*${MODULE_SPECIFIER}`, "g"), new RegExp(`import\\s*\\*\\s*as\\s+(${IDENTIFIER})\\s+from\\s*${MODULE_SPECIFIER}`, "g"), - new RegExp( - `(?:const|let|var)\\s+(${IDENTIFIER})\\s*=\\s*require\\s*\\(\\s*${MODULE_SPECIFIER}\\s*\\)`, - "g" - ), + new RegExp(`(?:const|let|var)\\s+(${IDENTIFIER})\\s*=\\s*${MODULE_LOAD}`, "g"), ]; for (const regex of namespaceBindingRegexes) { for (const match of code.matchAll(regex)) { - namespaces.add(match[1]!); + if (!inString(match.index!)) { + namespaces.add(match[1]!); + } } } return { aliases, namespaces }; } +type LexedSource = { + /** Source with comments, template text and regex-literal bodies blanked (offsets preserved) */ + code: string; + /** Spans (start inclusive, end exclusive) of single/double-quoted strings, quotes included */ + stringSpans: Array<[number, number]>; +}; + /** - * Blanks out comments and template-literal text (interpolation code is kept) - * while preserving every character offset and newline, so regex scanning - * never matches inside a comment or a code snippet embedded in a template - * string, and locations computed on the result map 1:1 onto the original. - * Single- and double-quoted string contents are kept because specifier - * literals must stay extractable. Regex literals are not lexed (a rare - * unescaped `//` inside one reads as a line comment). + * Blanks out comments, template-literal text (interpolation code is kept) and + * regex-literal bodies while preserving every character offset and newline, + * and records the spans of single/double-quoted strings. Regex scanning then + * never matches inside a comment, a code snippet embedded in a template + * string, or a regex literal, and matches inside quoted strings can be + * rejected by span. Quoted string contents are kept in the output because + * specifier literals must stay extractable. Regex-vs-division detection uses + * the standard preceding-token heuristic and can misread rare forms. */ -export function stripCommentsAndTemplateText(source: string): string { +function lexSource(source: string): LexedSource { const out = source.split(""); + const stringSpans: Array<[number, number]> = []; const interpolationBraceDepths: number[] = []; - let mode: "code" | "line" | "block" | "single" | "double" | "template" = "code"; + let mode: "code" | "line" | "block" | "single" | "double" | "template" | "regex" = "code"; + let inCharClass = false; + let stringStart = 0; let i = 0; const blank = (index: number) => { @@ -208,11 +282,17 @@ export function stripCommentsAndTemplateText(source: string): string { blank(i); blank(i + 1); i += 2; + } else if (c === "/" && regexLiteralAllowedAt(source, i)) { + mode = "regex"; + inCharClass = false; + i += 1; } else if (c === "'") { mode = "single"; + stringStart = i; i += 1; } else if (c === '"') { mode = "double"; + stringStart = i; i += 1; } else if (c === "`") { mode = "template"; @@ -262,7 +342,11 @@ export function stripCommentsAndTemplateText(source: string): string { if (c === "\\") { i += 2; } else { - if (c === (mode === "single" ? "'" : '"') || c === "\n") { + if (c === (mode === "single" ? "'" : '"')) { + stringSpans.push([stringStart, i + 1]); + mode = "code"; + } else if (c === "\n") { + stringSpans.push([stringStart, i]); mode = "code"; } i += 1; @@ -287,10 +371,81 @@ export function stripCommentsAndTemplateText(source: string): string { } break; } + case "regex": { + if (c === "\\") { + blank(i); + blank(i + 1); + i += 2; + } else if (c === "[") { + inCharClass = true; + blank(i); + i += 1; + } else if (c === "]") { + inCharClass = false; + blank(i); + i += 1; + } else if (c === "/" && !inCharClass) { + mode = "code"; + i += 1; + } else if (c === "\n") { + mode = "code"; + i += 1; + } else { + blank(i); + i += 1; + } + break; + } } } - return out.join(""); + if (mode === "single" || mode === "double") { + stringSpans.push([stringStart, source.length]); + } + + return { code: out.join(""), stringSpans }; +} + +const REGEX_PRECEDING_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "do", + "else", + "case", + "yield", + "await", +]); + +function regexLiteralAllowedAt(source: string, index: number): boolean { + let j = index - 1; + + while (j >= 0 && /\s/.test(source[j]!)) { + j--; + } + + if (j < 0) { + return true; + } + + const prev = source[j]!; + + if (/[\w$]/.test(prev)) { + let start = j; + + while (start > 0 && /[\w$]/.test(source[start - 1]!)) { + start--; + } + + return REGEX_PRECEDING_KEYWORDS.has(source.slice(start, j + 1)); + } + + return !/[)\]"'`.]/.test(prev); } export function packageNameForSpecifier(specifier: string): string { @@ -335,13 +490,26 @@ function escapeRegExp(value: string): string { const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/; +type CollectorCacheEntry = { + mtimeMs: number; + size: number; + exportedRequireFns: string[]; + knownsSignature: string; + specifiers: CreateRequireSpecifier[]; +}; + /** * 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. + * Require functions exported from one scanned file and imported (by name, + * from a relative path) into another are followed. Scan results are cached + * per file by mtime and size so dev rebuilds only re-read changed files. */ export class CreateRequireCollector { private _usages: CreateRequireUsage[] = []; + private _cache = new Map(); + private _plugin: esbuild.Plugin | undefined; constructor(private readonly workingDir: string) {} @@ -350,7 +518,7 @@ export class CreateRequireCollector { } get plugin(): esbuild.Plugin { - return { + this._plugin ??= { name: "create-require-collector", setup: (build) => { build.onEnd(async (result) => { @@ -360,50 +528,183 @@ export class CreateRequireCollector { return; } - for (const inputPath of Object.keys(result.metafile.inputs)) { - const cleanPath = inputPath.split("?")[0]!; - - if ( - !SCANNABLE_FILE_REGEX.test(cleanPath) || - NODE_MODULES_SEGMENT_REGEX.test(cleanPath) - ) { - continue; - } - - const filePath = isAbsolute(cleanPath) - ? cleanPath - : resolve(this.workingDir, cleanPath); - - const [readError, contents] = await tryCatch(readFile(filePath, "utf8")); - - if (readError) { - logger.debug("[createRequire] Unable to read bundle input file", { - inputPath, - filePath, - error: readError, - }); - - continue; - } - - for (const found of scanSourceForCreateRequire(contents)) { - this._usages.push({ - ...found, - file: cleanPath, - packageName: packageNameForSpecifier(found.specifier), - }); - } + try { + await this.collect(result.metafile); + } catch (error) { + logger.debug("[createRequire] Scan failed; skipping warnings", { error }); + this._usages = []; } }); }, }; + + return this._plugin; + } + + private async collect(metafile: esbuild.Metafile): Promise { + const files: Array<{ inputPath: string; filePath: string }> = []; + + for (const inputPath of Object.keys(metafile.inputs)) { + const cleanPath = inputPath.split("?")[0]!; + + if (!SCANNABLE_FILE_REGEX.test(cleanPath) || NODE_MODULES_SEGMENT_REGEX.test(cleanPath)) { + continue; + } + + files.push({ + inputPath: cleanPath, + filePath: isAbsolute(cleanPath) ? cleanPath : resolve(this.workingDir, cleanPath), + }); + } + + const scanned = await Promise.all( + files.map(async ({ inputPath, filePath }) => { + const [statError, stats] = await tryCatch(stat(filePath)); + + if (statError) { + logger.debug("[createRequire] Unable to stat bundle input file", { + filePath, + error: statError, + }); + + return undefined; + } + + const cached = this._cache.get(filePath); + const unchanged = + cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size; + + let source: string | undefined; + + if (!unchanged) { + const [readError, contents] = await tryCatch(readFile(filePath, "utf8")); + + if (readError) { + logger.debug("[createRequire] Unable to read bundle input file", { + filePath, + error: readError, + }); + + return undefined; + } + + source = contents; + } + + return { inputPath, filePath, stats, cached: unchanged ? cached : undefined, source }; + }) + ); + + const exportedRequireFns = new Set(); + + const withExports = scanned + .filter((entry) => entry !== undefined) + .map((entry) => { + const exported = + entry.cached?.exportedRequireFns ?? scanSource(entry.source!).exportedRequireFns; + + for (const name of exported) { + exportedRequireFns.add(name); + } + + return { ...entry, exportedRequireFns: exported }; + }); + + const knownsSignature = Array.from(exportedRequireFns).sort().join(","); + + for (const entry of withExports) { + let specifiers: CreateRequireSpecifier[]; + + if (entry.cached && entry.cached.knownsSignature === knownsSignature) { + specifiers = entry.cached.specifiers; + } else { + const source = + entry.source ?? (await tryCatch(readFile(entry.filePath, "utf8")))[1] ?? undefined; + + if (source === undefined) { + continue; + } + + specifiers = scanSource(source, exportedRequireFns).specifiers; + + this._cache.set(entry.filePath, { + mtimeMs: entry.stats.mtimeMs, + size: entry.stats.size, + exportedRequireFns: entry.exportedRequireFns, + knownsSignature, + specifiers, + }); + } + + for (const found of specifiers) { + this._usages.push({ + ...found, + file: entry.inputPath, + packageName: packageNameForSpecifier(found.specifier), + }); + } + } } } +export type ExtensionInstalledPackages = { + matchers: RegExp[]; + /** + * True when what extensions install can't be fully determined (an extension + * hook threw, or an additionalPackages extension predates the + * installedPackagesForTarget hook). Dev-mode warnings must stay silent in + * that case rather than risk false "deploys will fail" claims; deploy-mode + * warnings are unaffected because the manifest externals are the truth + * there. + */ + incomplete: boolean; +}; + +/** + * Package-name matchers for everything the configured build extensions + * install into or externalize for the deployed image. Never throws: + * diagnostics must not fail a build. + */ +export function extensionInstalledPackageMatchers( + config: ResolvedConfig +): ExtensionInstalledPackages { + const matchers: RegExp[] = []; + let incomplete = false; + + for (const buildExtension of config.build?.extensions ?? []) { + try { + const declared = [ + ...(buildExtension.installedPackagesForTarget?.("deploy") ?? []), + ...(buildExtension.externalsForTarget?.("deploy") ?? []), + ]; + + for (const packageName of declared) { + matchers.push(makeExternalRegexp(packageName)); + } + + if ( + buildExtension.name === "additionalPackages" && + typeof buildExtension.installedPackagesForTarget !== "function" + ) { + incomplete = true; + } + } 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 (installed - * dependencies) and not matching any configured external. + * 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: ReadonlyArray, @@ -419,6 +720,44 @@ export function unavailableCreateRequireUsages( ); } +/** + * The shared dev/deploy warning pipeline: suppress usages that will be + * available in the image, 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, +}: { + usages: ReadonlyArray; + buildManifest: BuildManifest; + extensionPackages: ExtensionInstalledPackages; + target: BuildTarget; +}): esbuild.PartialMessage[] { + try { + if (target === "dev" && extensionPackages.incomplete) { + return []; + } + + const installedPackages = new Set( + (buildManifest.externals ?? []).map((external) => external.name) + ); + + return unavailableCreateRequireUsages( + usages, + installedPackages, + extensionPackages.matchers + ).map((usage) => createRequireUsageToWarning(usage, target)); + } catch (error) { + logger.debug("[createRequire] Warning generation failed; skipping", { error }); + + return []; + } +} + export function createRequireUsageToWarning( usage: CreateRequireUsage, target: BuildTarget @@ -450,7 +789,7 @@ export function createRequireUsageToWarning( }, }); -Alternatively, replace the createRequire() call with a static import so the package is bundled. Docs: https://trigger.dev/docs/config/extensions/additionalPackages`, +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`, }, ], }; diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index eb556e124bf..6d0b9b5e46e 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -414,34 +414,6 @@ function createExternalsCollector( }; } -/** - * Matchers for every package that will be available at runtime in the deployed - * image: configured externals (build.external, instrumented packages, - * extension-declared externals, server-forced externals) plus packages that - * extensions install into the image (e.g. additionalPackages). Diagnostics - * only, regardless of the target currently being built. - */ -export function deployExternalMatchers( - config: ResolvedConfig, - forcedExternal: string[] = [] -): RegExp[] { - const matchers = discoverMaybeExternals("deploy", config, forcedExternal).map( - (external) => external.filter - ); - - for (const buildExtension of config.build?.extensions ?? []) { - for (const packageName of buildExtension.installedPackagesForTarget?.("deploy") ?? []) { - const filter = makeExternalRegexp(packageName); - - if (filter) { - matchers.push(filter); - } - } - } - - return matchers; -} - type MaybeExternal = { raw: string; filter: RegExp }; function discoverMaybeExternals( @@ -547,7 +519,7 @@ export function createExternalsBuildExtension( }; } -function makeExternalRegexp(packageName: string): RegExp { +export function makeExternalRegexp(packageName: string): RegExp { // Escape special regex characters in the package name const escapedPkg = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); From 4046b86e4913ac2c6824c639c09be5d4f74b8832 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 16:27:35 +0100 Subject: [PATCH 09/15] fix(cli): close review-confirmed false positives and negatives in the createRequire warning Dev warnings stay silent when any hook-bearing extension declares no installed packages (layer installs are invisible in dev), and both targets stay silent when a build-layer command runs a JS package manager, since those installs never reach the manifest externals. The division-vs-regex heuristic handles postfix increments, non-null assertions and JSX closers, query-suffixed metafile inputs are scanned once, cross-file require functions match only when the import path resolves to the exporting file, file reads are concurrency-capped with per-file scans reused instead of recomputed, helper duplication with externals.ts is removed, and the changeset is rewritten as a single user-facing sentence. --- .changeset/warn-createrequire-deploy.md | 2 +- .../src/build/createRequireWarnings.test.ts | 155 +++++++++- .../cli-v3/src/build/createRequireWarnings.ts | 268 +++++++++++------- packages/cli-v3/src/build/externals.ts | 14 +- 4 files changed, 319 insertions(+), 120 deletions(-) diff --git a/.changeset/warn-createrequire-deploy.md b/.changeset/warn-createrequire-deploy.md index 69a9f3d78f0..2e9bb161526 100644 --- a/.changeset/warn-createrequire-deploy.md +++ b/.changeset/warn-createrequire-deploy.md @@ -4,4 +4,4 @@ "@trigger.dev/core": patch --- -`deploy` and `dev` now warn when a package is loaded with `createRequire()` but won't be available in the deployed image. The bundler can't follow `createRequire()` calls, so such a package is neither bundled nor installed, and previously this failed only at runtime in production with a confusing module-not-found error (`dev` works because your local `node_modules` exists, which made the failure deploy-only). The warning points at the exact file and line and shows the `additionalPackages` config that fixes it; packages declared via `additionalPackages` don't warn. Deploys also now surface the bundler's own warnings for your files (for example `require()` with a non-literal argument) instead of discarding them. Bundling output is unchanged. +`deploy` and `dev` now warn, with the file, line, and suggested `additionalPackages` fix, when code loads a package through `createRequire()` that won't be available in the deployed image and would previously only fail at runtime in production. diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index c24a0a3a667..b32f981526e 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -1,4 +1,4 @@ -import { build } from "esbuild"; +import { build, type BuildResult, type PluginBuild } from "esbuild"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -291,6 +291,26 @@ const pg = req("pg"); expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); }); + it("treats a slash after postfix increment or non-null assertion as division, not a regex", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const z = x++ / y; const pg = req("pg"); +const w = a! / b; const mssql = req("mssql"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg", "mssql"]); + }); + + it("does not let a misread division corrupt string tracking", () => { + const source = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const z = x++ / y; const msg = 'a/b then req("evil-pkg") here'; +const pg = req("pg"); +`; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + it("follows require functions imported from other scanned files", () => { const util = `import { createRequire } from "node:module"; export const cjsRequire = createRequire(import.meta.url); @@ -427,6 +447,89 @@ export const mssql = cjsRequire("mssql"); await rm(dir, { recursive: true, force: true }); } }); + + it("ignores a same-named import that resolves to a module without the require export", async () => { + const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); + + try { + await writeFile( + join(dir, "util.ts"), + `import { createRequire } from "node:module"; +export const cjsRequire = createRequire(import.meta.url); +export const unused = cjsRequire; +` + ); + await writeFile( + join(dir, "pluginLoader.ts"), + `export const cjsRequire = (name: string) => ({ name }); +` + ); + + const entryPoint = join(dir, "entry.ts"); + await writeFile( + entryPoint, + `import { cjsRequire } from "./pluginLoader.js"; +import { unused } from "./util.js"; +export const plugin = cjsRequire("my-plugin"); +export const keep = unused; +` + ); + + const collector = new CreateRequireCollector(dir); + + await build({ + entryPoints: [entryPoint], + bundle: true, + metafile: true, + write: false, + format: "esm", + platform: "node", + outdir: dir, + absWorkingDir: dir, + logLevel: "silent", + plugins: [collector.plugin], + }); + + expect(collector.usages).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("scans a file only once when it appears with and without a query suffix", async () => { + const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); + + try { + const entryPath = join(dir, "entry.ts"); + await writeFile( + entryPath, + `import { createRequire } from "node:module"; +export const mssql = createRequire(import.meta.url)("mssql"); +` + ); + + const collector = new CreateRequireCollector(dir); + const onEndCallbacks: Array<(result: BuildResult) => Promise> = []; + + collector.plugin.setup({ + onEnd: (callback: (result: BuildResult) => Promise) => onEndCallbacks.push(callback), + } as unknown as PluginBuild); + + await onEndCallbacks[0]!({ + metafile: { + inputs: { + "entry.ts": { bytes: 0, imports: [] }, + "entry.ts?sentryProxyModule=true": { bytes: 0, imports: [] }, + }, + outputs: {}, + }, + } as unknown as BuildResult); + + expect(collector.usages).toHaveLength(1); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); describe("createRequireUsageToWarning", () => { @@ -500,8 +603,12 @@ describe("extensionInstalledPackageMatchers", () => { it("collects matchers from installedPackagesForTarget and externalsForTarget", () => { const { matchers, incomplete } = extensionInstalledPackageMatchers( configWith([ - { name: "custom", installedPackagesForTarget: () => ["ffmpeg-static"] }, - { name: "prisma", externalsForTarget: () => ["@prisma/client"] }, + { + name: "custom", + onBuildStart: () => {}, + installedPackagesForTarget: () => ["ffmpeg-static"], + }, + { name: "prisma", onBuildStart: () => {}, externalsForTarget: () => ["@prisma/client"] }, ]) ); @@ -526,12 +633,18 @@ describe("extensionInstalledPackageMatchers", () => { expect(incomplete).toBe(true); }); - it("marks the result incomplete for an additionalPackages extension without the hook", () => { - const { incomplete } = extensionInstalledPackageMatchers( - configWith([{ name: "additionalPackages" }]) + it("marks the result incomplete for any hook-bearing extension that declares no packages", () => { + const oldAdditionalPackages = extensionInstalledPackageMatchers( + configWith([{ name: "additionalPackages", onBuildStart: () => {} }]) ); - expect(incomplete).toBe(true); + expect(oldAdditionalPackages.incomplete).toBe(true); + + const layerInstaller = extensionInstalledPackageMatchers( + configWith([{ name: "syncEnvVars", onBuildComplete: () => {} }]) + ); + + expect(layerInstaller.incomplete).toBe(true); }); }); @@ -591,6 +704,34 @@ describe("collectCreateRequireWarningMessages", () => { expect(messages).toHaveLength(1); }); + + it("stays silent when a build-layer command runs a JS package manager", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: { + externals: [], + build: { commands: ["npm install @prisma/engines@5.0.0"] }, + } as unknown as BuildManifest, + extensionPackages: { matchers: [], incomplete: false }, + target: "deploy", + }); + + expect(messages).toEqual([]); + }); + + it("still warns when build-layer commands don't install JS packages", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: { + externals: [], + build: { commands: ["apt-get install -y ffmpeg"] }, + } as unknown as BuildManifest, + extensionPackages: { matchers: [], incomplete: false }, + target: "deploy", + }); + + expect(messages).toHaveLength(1); + }); }); describe("packageNameForSpecifier", () => { diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index cf3a86fb851..f37e5758a87 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -2,11 +2,18 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; import * as esbuild from "esbuild"; import { readFile, stat } from "node:fs/promises"; -import { builtinModules } from "node:module"; -import { isAbsolute, resolve } from "node:path"; +import { dirname, isAbsolute, resolve } from "node:path"; +import pLimit from "p-limit"; import { tryCatch } from "@trigger.dev/core/v3"; import { logger } from "../utilities/logger.js"; -import { makeExternalRegexp } from "./externals.js"; +import { + escapeRegExp, + isBuiltinModule, + makeExternalRegexp, + packageNameForImportPath, +} from "./externals.js"; + +export { packageNameForImportPath as packageNameForSpecifier } from "./externals.js"; export type CreateRequireSpecifier = { specifier: string; @@ -28,6 +35,13 @@ export type SourceScanResult = { exportedRequireFns: string[]; }; +/** + * Decides whether an imported binding is a require function created in + * another scanned file. Receives the local binding's exported name and the + * import specifier it came from. + */ +export type KnownRequireFnImportPredicate = (name: string, fromSpecifier: string) => boolean; + const IDENTIFIER = "[A-Za-z_$][\\w$]*"; const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; const NESTED_CALL_ARGS = `(?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*`; @@ -52,20 +66,19 @@ export function scanSourceForCreateRequire( source: string, knownRequireFnExports?: ReadonlySet ): CreateRequireSpecifier[] { - return scanSource(source, knownRequireFnExports).specifiers; + return scanSource( + source, + knownRequireFnExports ? (name) => knownRequireFnExports.has(name) : undefined + ).specifiers; } export function scanSource( source: string, - knownRequireFnExports?: ReadonlySet + isKnownRequireFnImport?: KnownRequireFnImportPredicate ): SourceScanResult { const empty: SourceScanResult = { specifiers: [], exportedRequireFns: [] }; - const mentionsKnownExport = knownRequireFnExports - ? Array.from(knownRequireFnExports).some((name) => source.includes(name)) - : false; - - if (!source.includes("createRequire") && !mentionsKnownExport) { + if (!source.includes("createRequire") && !isKnownRequireFnImport) { return empty; } @@ -134,7 +147,7 @@ export function scanSource( } } - if (knownRequireFnExports && knownRequireFnExports.size > 0) { + if (isKnownRequireFnImport) { const relativeImportRegex = new RegExp( `import\\s*(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["'](\\.[^"'\\n]*)["']`, "g" @@ -150,7 +163,7 @@ export function scanSource( new RegExp(`^\\s*(${IDENTIFIER})\\s*(?:as\\s+(${IDENTIFIER}))?\\s*$`) ); - if (bindingMatch && knownRequireFnExports.has(bindingMatch[1]!)) { + if (bindingMatch && isKnownRequireFnImport(bindingMatch[1]!, match[2]!)) { requireFnNames.add(bindingMatch[2] ?? bindingMatch[1]!); } } @@ -445,17 +458,11 @@ function regexLiteralAllowedAt(source: string, index: number): boolean { return REGEX_PRECEDING_KEYWORDS.has(source.slice(start, j + 1)); } - return !/[)\]"'`.]/.test(prev); -} - -export function packageNameForSpecifier(specifier: string): string { - const parts = specifier.split("/"); - - if (specifier.startsWith("@")) { - return parts.slice(0, 2).join("/"); + if (prev === "+" || prev === "-") { + return source[j - 1] !== prev; } - return parts[0]!; + return !/[)\]"'`.!<]/.test(prev); } function isWarnableSpecifier(specifier: string): boolean { @@ -465,7 +472,7 @@ function isWarnableSpecifier(specifier: string): boolean { return false; } - return !builtinModules.includes(packageNameForSpecifier(specifier)); + return !isBuiltinModule(packageNameForImportPath(specifier)); } function locationAt( @@ -483,19 +490,18 @@ function locationAt( }; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/; +const FILE_READ_CONCURRENCY = 16; type CollectorCacheEntry = { mtimeMs: number; size: number; - exportedRequireFns: string[]; + /** Scan without cross-file require-fn knowledge */ + base: SourceScanResult; + /** Signature of the cross-file exports under which finalSpecifiers was computed */ knownsSignature: string; - specifiers: CreateRequireSpecifier[]; + finalSpecifiers: CreateRequireSpecifier[]; }; /** @@ -503,8 +509,9 @@ type CollectorCacheEntry = { * Only files outside `node_modules` are scanned: bundled libraries commonly * use optional-require patterns that would drown real findings in noise. * Require functions exported from one scanned file and imported (by name, - * from a relative path) into another are followed. Scan results are cached - * per file by mtime and size so dev rebuilds only re-read changed files. + * from a relative path resolving to the exporting file) into another are + * followed. Scan results are cached per file by mtime and size so dev + * rebuilds only re-read changed files. */ export class CreateRequireCollector { private _usages: CreateRequireUsage[] = []; @@ -543,80 +550,100 @@ export class CreateRequireCollector { private async collect(metafile: esbuild.Metafile): Promise { const files: Array<{ inputPath: string; filePath: string }> = []; + const seenPaths = new Set(); for (const inputPath of Object.keys(metafile.inputs)) { const cleanPath = inputPath.split("?")[0]!; - if (!SCANNABLE_FILE_REGEX.test(cleanPath) || NODE_MODULES_SEGMENT_REGEX.test(cleanPath)) { + 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 scanned = await Promise.all( - files.map(async ({ inputPath, filePath }) => { - const [statError, stats] = await tryCatch(stat(filePath)); - - if (statError) { - logger.debug("[createRequire] Unable to stat bundle input file", { - filePath, - error: statError, - }); - - return undefined; - } - - const cached = this._cache.get(filePath); - const unchanged = - cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size; - - let source: string | undefined; - - if (!unchanged) { - const [readError, contents] = await tryCatch(readFile(filePath, "utf8")); - - if (readError) { - logger.debug("[createRequire] Unable to read bundle input file", { - filePath, - error: readError, - }); - - return undefined; - } - - source = contents; - } - - return { inputPath, filePath, stats, cached: unchanged ? cached : undefined, source }; - }) - ); + 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); + const unchanged = + cached !== undefined && + cached.mtimeMs === stats.mtimeMs && + cached.size === stats.size; + + let source: string | undefined; + let base: SourceScanResult; + + if (unchanged) { + base = cached.base; + } else { + 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; + } + + source = contents; + base = scanSource(contents); + } + + return { ...file, stats, cached: unchanged ? cached : undefined, source, base }; + }) + ) + ) + ).filter((entry) => entry !== undefined); - const exportedRequireFns = new Set(); + const exportsByFileKey = new Map>(); + const exportedNames = new Set(); + const fileKey = (filePath: string) => filePath.replace(SCANNABLE_FILE_REGEX, ""); - const withExports = scanned - .filter((entry) => entry !== undefined) - .map((entry) => { - const exported = - entry.cached?.exportedRequireFns ?? scanSource(entry.source!).exportedRequireFns; + for (const entry of scanned) { + if (entry.base.exportedRequireFns.length > 0) { + exportsByFileKey.set(fileKey(entry.filePath), new Set(entry.base.exportedRequireFns)); - for (const name of exported) { - exportedRequireFns.add(name); + for (const name of entry.base.exportedRequireFns) { + exportedNames.add(name); } + } + } - return { ...entry, exportedRequireFns: exported }; - }); - - const knownsSignature = Array.from(exportedRequireFns).sort().join(","); + const knownsSignature = Array.from(exportsByFileKey.entries()) + .flatMap(([key, names]) => Array.from(names).map((name) => `${key}#${name}`)) + .sort() + .join(","); - for (const entry of withExports) { - let specifiers: CreateRequireSpecifier[]; + for (const entry of scanned) { + let finalSpecifiers: CreateRequireSpecifier[]; if (entry.cached && entry.cached.knownsSignature === knownsSignature) { - specifiers = entry.cached.specifiers; + finalSpecifiers = entry.cached.finalSpecifiers; } else { const source = entry.source ?? (await tryCatch(readFile(entry.filePath, "utf8")))[1] ?? undefined; @@ -625,22 +652,36 @@ export class CreateRequireCollector { continue; } - specifiers = scanSource(source, exportedRequireFns).specifiers; + const mentionsExportedName = Array.from(exportedNames).some((name) => + source.includes(name) + ); + + if (!mentionsExportedName) { + finalSpecifiers = entry.base.specifiers; + } else { + const importerDir = dirname(entry.filePath); + + finalSpecifiers = scanSource(source, (name, fromSpecifier) => { + const key = fileKey(resolve(importerDir, fromSpecifier)); + + return exportsByFileKey.get(key)?.has(name) ?? false; + }).specifiers; + } this._cache.set(entry.filePath, { mtimeMs: entry.stats.mtimeMs, size: entry.stats.size, - exportedRequireFns: entry.exportedRequireFns, + base: entry.base, knownsSignature, - specifiers, + finalSpecifiers, }); } - for (const found of specifiers) { + for (const found of finalSpecifiers) { this._usages.push({ ...found, file: entry.inputPath, - packageName: packageNameForSpecifier(found.specifier), + packageName: packageNameForImportPath(found.specifier), }); } } @@ -650,20 +691,21 @@ export class CreateRequireCollector { export type ExtensionInstalledPackages = { matchers: RegExp[]; /** - * True when what extensions install can't be fully determined (an extension - * hook threw, or an additionalPackages extension predates the - * installedPackagesForTarget hook). Dev-mode warnings must stay silent in - * that case rather than risk false "deploys will fail" claims; deploy-mode - * warnings are unaffected because the manifest externals are the truth - * there. + * True when what extensions install can't be fully determined: an extension + * hook threw, or an extension participates in the build (has build hooks) + * without declaring installedPackagesForTarget or externalsForTarget, so it + * may install packages invisibly (e.g. via a build layer). Dev-mode + * warnings must stay silent in that case rather than risk false "deploys + * will fail" claims; deploy-mode warnings are unaffected because the + * manifest externals capture layer dependencies there. */ incomplete: boolean; }; /** * Package-name matchers for everything the configured build extensions - * install into or externalize for the deployed image. Never throws: - * diagnostics must not fail a build. + * declare they install into or externalize for the deployed image. Never + * throws: diagnostics must not fail a build. */ export function extensionInstalledPackageMatchers( config: ResolvedConfig @@ -673,6 +715,18 @@ export function extensionInstalledPackageMatchers( for (const buildExtension of config.build?.extensions ?? []) { try { + const declaresPackages = + typeof buildExtension.installedPackagesForTarget === "function" || + typeof buildExtension.externalsForTarget === "function"; + const hasBuildHooks = + typeof buildExtension.onBuildStart === "function" || + typeof buildExtension.onBuildComplete === "function"; + + if (!declaresPackages && hasBuildHooks) { + incomplete = true; + continue; + } + const declared = [ ...(buildExtension.installedPackagesForTarget?.("deploy") ?? []), ...(buildExtension.externalsForTarget?.("deploy") ?? []), @@ -681,13 +735,6 @@ export function extensionInstalledPackageMatchers( for (const packageName of declared) { matchers.push(makeExternalRegexp(packageName)); } - - if ( - buildExtension.name === "additionalPackages" && - typeof buildExtension.installedPackagesForTarget !== "function" - ) { - incomplete = true; - } } catch (error) { logger.debug("[createRequire] Build extension package declaration failed", { extension: buildExtension.name, @@ -720,11 +767,16 @@ export function unavailableCreateRequireUsages( ); } +const PACKAGE_INSTALL_COMMAND_REGEX = /\b(?:npm|pnpm|yarn|bun)\b/i; + /** * The shared dev/deploy warning pipeline: suppress usages that will be * available in the image, 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). + * any internal failure, stays silent for dev when extension-installed + * packages can't be determined (see ExtensionInstalledPackages.incomplete), + * and stays silent for every target when a build-layer command runs a JS + * package manager, since such commands can install packages invisibly to the + * manifest externals. */ export function collectCreateRequireWarningMessages({ usages, @@ -742,6 +794,12 @@ export function collectCreateRequireWarningMessages({ return []; } + const commands = buildManifest.build?.commands ?? []; + + if (commands.some((command) => PACKAGE_INSTALL_COMMAND_REGEX.test(command))) { + return []; + } + const installedPackages = new Set( (buildManifest.externals ?? []).map((external) => external.name) ); diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index 6d0b9b5e46e..74a19dbfe44 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -520,16 +520,16 @@ export function createExternalsBuildExtension( } export function makeExternalRegexp(packageName: string): RegExp { - // Escape special regex characters in the package name - const escapedPkg = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - - // Create the regex pattern - const pattern = `^${escapedPkg}(?:/[^'"]*)?$`; + const pattern = `^${escapeRegExp(packageName)}(?:/[^'"]*)?$`; return new RegExp(pattern); } -function packageNameForImportPath(importPath: string): string { +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function packageNameForImportPath(importPath: string): string { // Remove any leading '@' to handle it separately const withoutAtSign = importPath.replace(/^@/, ""); @@ -577,7 +577,7 @@ function isBareModuleImport(path: string): boolean { return !excludes.some((exclude) => path.startsWith(exclude)); } -function isBuiltinModule(path: string): boolean { +export function isBuiltinModule(path: string): boolean { return builtinModules.includes(path.replace("node:", "")); } From cc36ea819575c371247d0e015ac03a58ac89d6de Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 16:55:39 +0100 Subject: [PATCH 10/15] refactor(cli,build,core): parse-based createRequire scanner, scoped install-command suppression Replaces the hand-rolled lexer and regex scanner with a @babel/parser scan (typescript/jsx with fallbacks, parse failures skip the file), eliminating the comment/string/regex/JSX misparse class outright; template-literal specifiers now also match. Cross-file require functions resolve through the metafile's own import records, so index files and path aliases work. Build-layer install commands suppress only the packages they actually name instead of silencing the whole feature, first-party extensions that install no node packages declare that so dev warnings stay active for them, extension matchers are computed before internal extensions are prepended, deploy warning output honors plain mode and keeps location-less messages with a segment-exact node_modules filter, and additionalPackages only claims packages for the deploy target. --- .../build/src/extensions/audioWaveform.ts | 4 + .../src/extensions/core/additionalFiles.ts | 1 + .../src/extensions/core/additionalPackages.ts | 2 +- packages/build/src/extensions/core/aptGet.ts | 1 + packages/build/src/extensions/core/ffmpeg.ts | 1 + .../build/src/extensions/core/syncEnvVars.ts | 1 + packages/build/src/extensions/lightpanda.ts | 1 + packages/build/src/extensions/puppeteer.ts | 4 + packages/build/src/extensions/typescript.ts | 1 + packages/cli-v3/package.json | 1 + packages/cli-v3/src/build/buildWorker.ts | 10 +- packages/cli-v3/src/build/bundle.ts | 10 +- .../src/build/createRequireWarnings.test.ts | 123 ++- .../cli-v3/src/build/createRequireWarnings.ts | 755 +++++++++--------- packages/cli-v3/src/build/externals.ts | 2 +- packages/core/src/v3/build/extensions.ts | 1 + pnpm-lock.yaml | 14 +- 17 files changed, 528 insertions(+), 404 deletions(-) diff --git a/packages/build/src/extensions/audioWaveform.ts b/packages/build/src/extensions/audioWaveform.ts index 0bf6e4e0fa1..03469ffb23c 100644 --- a/packages/build/src/extensions/audioWaveform.ts +++ b/packages/build/src/extensions/audioWaveform.ts @@ -17,6 +17,10 @@ export function audioWaveform(options: AudioWaveformOptions = {}): BuildExtensio class AudioWaveformExtension implements BuildExtension { public readonly name = "AudioWaveformExtension"; + installedPackagesForTarget() { + return []; + } + constructor(private options: AudioWaveformOptions = {}) {} async onBuildComplete(context: BuildContext, manifest: BuildManifest) { diff --git a/packages/build/src/extensions/core/additionalFiles.ts b/packages/build/src/extensions/core/additionalFiles.ts index cc2a04e0e09..2d0db93382a 100644 --- a/packages/build/src/extensions/core/additionalFiles.ts +++ b/packages/build/src/extensions/core/additionalFiles.ts @@ -8,6 +8,7 @@ export type AdditionalFilesOptions = { export function additionalFiles(options: AdditionalFilesOptions): BuildExtension { return { name: "additionalFiles", + installedPackagesForTarget: () => [], async onBuildComplete(context, manifest) { await addAdditionalFilesToBuild("additionalFiles", options, context, manifest); }, diff --git a/packages/build/src/extensions/core/additionalPackages.ts b/packages/build/src/extensions/core/additionalPackages.ts index 0da67eb0446..cfe20b29f22 100644 --- a/packages/build/src/extensions/core/additionalPackages.ts +++ b/packages/build/src/extensions/core/additionalPackages.ts @@ -20,7 +20,7 @@ export function additionalPackages(options: AdditionalPackagesOptions): BuildExt return { name: "additionalPackages", installedPackagesForTarget(target) { - if (target === "dev") { + if (target !== "deploy") { return []; } diff --git a/packages/build/src/extensions/core/aptGet.ts b/packages/build/src/extensions/core/aptGet.ts index c6d0b51e652..da0317e6cb3 100644 --- a/packages/build/src/extensions/core/aptGet.ts +++ b/packages/build/src/extensions/core/aptGet.ts @@ -7,6 +7,7 @@ export type AptGetOptions = { export function aptGet(options: AptGetOptions): BuildExtension { return { name: "aptGet", + installedPackagesForTarget: () => [], onBuildComplete(context) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/core/ffmpeg.ts b/packages/build/src/extensions/core/ffmpeg.ts index 11e8c0c80a8..86060adf7d8 100644 --- a/packages/build/src/extensions/core/ffmpeg.ts +++ b/packages/build/src/extensions/core/ffmpeg.ts @@ -24,6 +24,7 @@ export type FfmpegOptions = { export function ffmpeg(options: FfmpegOptions = {}): BuildExtension { return { name: "ffmpeg", + installedPackagesForTarget: () => [], onBuildComplete(context) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/core/syncEnvVars.ts b/packages/build/src/extensions/core/syncEnvVars.ts index 6da28a05eb0..2dd3984f801 100644 --- a/packages/build/src/extensions/core/syncEnvVars.ts +++ b/packages/build/src/extensions/core/syncEnvVars.ts @@ -77,6 +77,7 @@ export type SyncEnvVarsOptions = { export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOptions): BuildExtension { return { name: "SyncEnvVarsExtension", + installedPackagesForTarget: () => [], async onBuildComplete(context, manifest) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/lightpanda.ts b/packages/build/src/extensions/lightpanda.ts index 16c62a08b4f..7807567a1ac 100644 --- a/packages/build/src/extensions/lightpanda.ts +++ b/packages/build/src/extensions/lightpanda.ts @@ -10,6 +10,7 @@ export const lightpanda = ({ disableTelemetry = false, }: LightpandaOpts = {}): BuildExtension => ({ name: "lightpanda", + installedPackagesForTarget: () => [], onBuildComplete: async (context) => { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/puppeteer.ts b/packages/build/src/extensions/puppeteer.ts index 4da61a03914..35debd7f9d1 100644 --- a/packages/build/src/extensions/puppeteer.ts +++ b/packages/build/src/extensions/puppeteer.ts @@ -8,6 +8,10 @@ export function puppeteer() { class PuppeteerExtension implements BuildExtension { public readonly name = "PuppeteerExtension"; + installedPackagesForTarget() { + return []; + } + async onBuildComplete(context: BuildContext, manifest: BuildManifest) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/typescript.ts b/packages/build/src/extensions/typescript.ts index 1021d4b94a3..8f19aecd840 100644 --- a/packages/build/src/extensions/typescript.ts +++ b/packages/build/src/extensions/typescript.ts @@ -8,6 +8,7 @@ const decoratorMatcher = new RegExp(/((? [], onBuildStart(context) { const { convertCompilerOptionsFromJson, transpileModule, ModuleKind } = loadTypescript( context.workingDir diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index c9d1fd4784d..2d5320d1a4b 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -107,6 +107,7 @@ "ini": "^5.0.0", "json-stable-stringify": "^1.3.0", "jsonc-parser": "3.2.1", + "@babel/parser": "^7.29.7", "magicast": "^0.3.4", "minimatch": "^10.0.1", "mlly": "^1.7.1", diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index 6868b55e4ce..1aef4640727 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -10,6 +10,7 @@ import { collectCreateRequireWarningMessages, CreateRequireCollector, extensionInstalledPackageMatchers, + NODE_MODULES_SEGMENT_REGEX, } from "./createRequireWarnings.js"; import { bundleSkills } from "./bundleSkills.js"; import { @@ -57,6 +58,8 @@ export async function buildWorker(options: BuildWorkerOptions) { const resolvedConfig = options.resolvedConfig; + const extensionPackages = extensionInstalledPackageMatchers(resolvedConfig); + const externalsExtension = createExternalsBuildExtension( options.target, resolvedConfig, @@ -144,18 +147,19 @@ export async function buildWorker(options: BuildWorkerOptions) { if (options.target !== "dev") { const buildWarnings = [ ...bundleResult.warnings.filter( - (warning) => warning.location?.file && !warning.location.file.includes("node_modules") + (warning) => + !warning.location?.file || !NODE_MODULES_SEGMENT_REGEX.test(warning.location.file) ), ...collectCreateRequireWarningMessages({ usages: createRequireCollector.usages, buildManifest, - extensionPackages: extensionInstalledPackageMatchers(resolvedConfig), + extensionPackages, target: options.target, }), ]; if (buildWarnings.length > 0) { - logBuildWarnings(buildWarnings); + logBuildWarnings(buildWarnings, { color: !options.plain }); } buildManifest = options.rewritePaths diff --git a/packages/cli-v3/src/build/bundle.ts b/packages/cli-v3/src/build/bundle.ts index e2347627748..6f703dce872 100644 --- a/packages/cli-v3/src/build/bundle.ts +++ b/packages/cli-v3/src/build/bundle.ts @@ -342,8 +342,14 @@ function dirToEntryPointGlob(dir: string): string[] { ]; } -export function logBuildWarnings(warnings: esbuild.PartialMessage[]) { - const logs = esbuild.formatMessagesSync(warnings, { kind: "warning", color: true }); +export function logBuildWarnings( + warnings: esbuild.PartialMessage[], + options: { color?: boolean } = {} +) { + const logs = esbuild.formatMessagesSync(warnings, { + kind: "warning", + color: options.color ?? true, + }); for (const log of logs) { console.warn(log); } diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index b32f981526e..17b3b825356 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -1,5 +1,5 @@ import { build, type BuildResult, type PluginBuild } from "esbuild"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -11,6 +11,7 @@ import { createRequireUsageToWarning, extensionInstalledPackageMatchers, packageNameForSpecifier, + packagesInstalledByCommands, scanSource, scanSourceForCreateRequire, unavailableCreateRequireUsages, @@ -301,6 +302,42 @@ const w = a! / b; const mssql = req("mssql"); expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg", "mssql"]); }); + it("handles a negated regex test without corrupting later template scanning", () => { + const source = + 'import { createRequire } from "node:module";\n' + + "const req = createRequire(import.meta.url);\n" + + "if (!/[`'\"]/.test(input)) run();\n" + + 'const doc = `example: req("fake-pkg")`;\n' + + 'const pg = req("pg");\n'; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("handles comment-adjacent division in both directions", () => { + const falsePositiveCase = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); +const ratio = a /* per item */ / b; const example = "req('evil-pkg')"; +`; + + expect(scanSourceForCreateRequire(falsePositiveCase)).toEqual([]); + + const falseNegativeCase = `import { createRequire } from "node:module"; +const req = createRequire(import.meta.url); // setup +/["']/.test(input) && req("pg"); +`; + + expect(scanSourceForCreateRequire(falseNegativeCase).map((r) => r.specifier)).toEqual(["pg"]); + }); + + it("supports a plain template literal as the specifier", () => { + const source = + 'import { createRequire } from "node:module";\n' + + "const req = createRequire(import.meta.url);\n" + + "const pg = req(`pg`);\n"; + + expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); + }); + it("does not let a misread division corrupt string tracking", () => { const source = `import { createRequire } from "node:module"; const req = createRequire(import.meta.url); @@ -496,6 +533,48 @@ export const keep = unused; } }); + it("follows a require function exported from an index file", async () => { + const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); + + try { + await mkdir(join(dir, "util")); + await writeFile( + join(dir, "util", "index.ts"), + `import { createRequire } from "node:module"; +export const cjsRequire = createRequire(import.meta.url); +` + ); + + const entryPoint = join(dir, "entry.ts"); + await writeFile( + entryPoint, + `import { cjsRequire } from "./util"; +export const mssql = cjsRequire("mssql"); +` + ); + + const collector = new CreateRequireCollector(dir); + + await build({ + entryPoints: [entryPoint], + bundle: true, + metafile: true, + write: false, + format: "esm", + platform: "node", + outdir: dir, + absWorkingDir: dir, + logLevel: "silent", + plugins: [collector.plugin], + }); + + expect(collector.usages).toHaveLength(1); + expect(collector.usages[0]).toMatchObject({ specifier: "mssql", file: "entry.ts" }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("scans a file only once when it appears with and without a query suffix", async () => { const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); @@ -705,26 +784,35 @@ describe("collectCreateRequireWarningMessages", () => { expect(messages).toHaveLength(1); }); - it("stays silent when a build-layer command runs a JS package manager", () => { + it("suppresses only the packages named in build-layer install commands", () => { + const manifest = { + externals: [], + build: { commands: ["npm install @prisma/engines@5.0.0"] }, + } as unknown as BuildManifest; + + const engineUsage = { + ...usage, + specifier: "@prisma/engines", + packageName: "@prisma/engines", + }; + const messages = collectCreateRequireWarningMessages({ - usages: [usage], - buildManifest: { - externals: [], - build: { commands: ["npm install @prisma/engines@5.0.0"] }, - } as unknown as BuildManifest, + usages: [usage, engineUsage], + buildManifest: manifest, extensionPackages: { matchers: [], incomplete: false }, target: "deploy", }); - expect(messages).toEqual([]); + expect(messages).toHaveLength(1); + expect(messages[0]!.text).toContain("mssql"); }); - it("still warns when build-layer commands don't install JS packages", () => { + it("does not suppress anything for commands that install no specific package", () => { const messages = collectCreateRequireWarningMessages({ usages: [usage], buildManifest: { externals: [], - build: { commands: ["apt-get install -y ffmpeg"] }, + build: { commands: ["bun run generate", "apt-get install -y ffmpeg", "npm ci"] }, } as unknown as BuildManifest, extensionPackages: { matchers: [], incomplete: false }, target: "deploy", @@ -734,6 +822,21 @@ describe("collectCreateRequireWarningMessages", () => { }); }); +describe("packagesInstalledByCommands", () => { + it("extracts package names from install commands and ignores everything else", () => { + const packages = packagesInstalledByCommands([ + "npm install @prisma/engines@5.0.0", + "pnpm add wrangler prisma@3.0.0 --save-dev", + "yarn add -D typescript", + "bun run generate", + "npm ci", + "apt-get install -y ffmpeg", + ]); + + expect(packages.sort()).toEqual(["@prisma/engines", "prisma", "typescript", "wrangler"]); + }); +}); + describe("packageNameForSpecifier", () => { it("extracts the package name from plain, subpath, and scoped specifiers", () => { expect(packageNameForSpecifier("mssql")).toBe("mssql"); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index f37e5758a87..489e93924b9 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -1,3 +1,4 @@ +import { parse, ParserPlugin } from "@babel/parser"; import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; import * as esbuild from "esbuild"; @@ -6,12 +7,7 @@ import { dirname, isAbsolute, resolve } from "node:path"; import pLimit from "p-limit"; import { tryCatch } from "@trigger.dev/core/v3"; import { logger } from "../utilities/logger.js"; -import { - escapeRegExp, - isBuiltinModule, - makeExternalRegexp, - packageNameForImportPath, -} from "./externals.js"; +import { isBuiltinModule, makeExternalRegexp, packageNameForImportPath } from "./externals.js"; export { packageNameForImportPath as packageNameForSpecifier } from "./externals.js"; @@ -37,17 +33,11 @@ export type SourceScanResult = { /** * Decides whether an imported binding is a require function created in - * another scanned file. Receives the local binding's exported name and the - * import specifier it came from. + * another scanned file. Receives the binding's exported name and the import + * specifier it came from. */ export type KnownRequireFnImportPredicate = (name: string, fromSpecifier: string) => boolean; -const IDENTIFIER = "[A-Za-z_$][\\w$]*"; -const STRING_LITERAL = `(["'])([^"'\\n]+)\\1`; -const NESTED_CALL_ARGS = `(?:[^()]|\\((?:[^()]|\\([^()]*\\))*\\))*`; -const MODULE_SPECIFIER = `["'](?:node:)?module["']`; -const MODULE_LOAD = `(?:await\\s+)?(?:require|import)\\s*\\(\\s*${MODULE_SPECIFIER}\\s*\\)`; - /** * Finds string-literal package specifiers loaded through `createRequire`, e.g. * `createRequire(import.meta.url)("mssql")` or @@ -55,12 +45,12 @@ const MODULE_LOAD = `(?:await\\s+)?(?:require|import)\\s*\\(\\s*${MODULE_SPECIFI * * 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. Scanning runs on a lexed copy of the - * source (comments, template text and regex literals blanked, string spans - * excluded from matching) and only recognizes createRequire bindings that - * actually come from the `module` builtin. Still a best-effort heuristic: - * computed or template-literal specifiers and re-exported createRequire are - * not detected. + * 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 at + * module level: computed specifiers, re-exports of createRequire itself, and + * shadowed names are not followed. */ export function scanSourceForCreateRequire( source: string, @@ -72,6 +62,16 @@ export function scanSourceForCreateRequire( ).specifiers; } +type AstNode = { + type: string; + start?: number | null; + loc?: { start: { line: number; column: number } } | null; + [key: string]: unknown; +}; + +const MODULE_BUILTIN_SPECIFIERS = new Set(["module", "node:module"]); +const PARSER_PLUGIN_ATTEMPTS: ParserPlugin[][] = [["typescript", "jsx"], ["typescript"], []]; + export function scanSource( source: string, isKnownRequireFnImport?: KnownRequireFnImportPredicate @@ -82,103 +82,107 @@ export function scanSource( return empty; } - const { code, stringSpans } = lexSource(source); + const ast = parseWithFallbacks(source); - const inString = (index: number) => - stringSpans.some(([start, end]) => index >= start && index < end); + if (!ast) { + return empty; + } - const requireFnNames = new Set(); - const exportedRequireFns = new Set(); - const specifiers: CreateRequireSpecifier[] = []; - const seen = new Set(); + const collected = collectAstFacts(ast); - const pushHit = (index: number, specifier: string) => { - const key = `${index}:${specifier}`; + const aliases = new Set(); + const namespaces = new Set(); - if (seen.has(key) || inString(index) || !isWarnableSpecifier(specifier)) { - return; + for (const moduleImport of collected.moduleImports) { + if (moduleImport.kind === "createRequire") { + aliases.add(moduleImport.localName); + } else { + namespaces.add(moduleImport.localName); } + } - seen.add(key); - specifiers.push({ specifier, ...locationAt(source, index) }); - }; - - const { aliases, namespaces } = collectCreateRequireBindings(code, inString); + const isCreateRequireCall = (node: AstNode): boolean => { + if (node.type !== "CallExpression") { + return false; + } - if (aliases.size > 0 || namespaces.size > 0) { - const callHeads: string[] = []; + const callee = node.callee as AstNode; - if (aliases.size > 0) { - callHeads.push(`(?:${Array.from(aliases).map(escapeRegExp).join("|")})`); + if (callee.type === "Identifier") { + return aliases.has(callee.name as string); } - if (namespaces.size > 0) { - callHeads.push( - `(?:${Array.from(namespaces).map(escapeRegExp).join("|")})\\s*\\.\\s*createRequire` + if (callee.type === "MemberExpression") { + const object = callee.object as AstNode; + const property = callee.property as AstNode; + + return ( + object.type === "Identifier" && + namespaces.has(object.name as string) && + property.type === "Identifier" && + property.name === "createRequire" ); } - const createRequireCall = `(?:${callHeads.join("|")})\\s*\\(${NESTED_CALL_ARGS}\\)`; - - const directCallRegex = new RegExp( - `(?(); + const exportedRequireFns = new Set(); - const assignmentRegex = new RegExp( - `(?(); - for (const match of code.matchAll(relativeImportRegex)) { - if (inString(match.index!)) { - continue; - } + for (const call of collected.calls) { + const callee = call.callee; - for (const binding of match[1]!.split(",")) { - const bindingMatch = binding.match( - new RegExp(`^\\s*(${IDENTIFIER})\\s*(?:as\\s+(${IDENTIFIER}))?\\s*$`) - ); + const isRequireFnCall = + (callee.type === "Identifier" && requireFnNames.has(callee.name as string)) || + (callee.type === "MemberExpression" && + (callee.object as AstNode).type === "Identifier" && + requireFnNames.has((callee.object as AstNode).name as string) && + (callee.property as AstNode).type === "Identifier" && + (callee.property as AstNode).name === "resolve"); - if (bindingMatch && isKnownRequireFnImport(bindingMatch[1]!, match[2]!)) { - requireFnNames.add(bindingMatch[2] ?? bindingMatch[1]!); - } - } + if (!isRequireFnCall && !isCreateRequireCall(callee)) { + continue; } - } - - for (const name of requireFnNames) { - const callRegex = new RegExp( - `(? a.line - b.line || a.column - b.column); @@ -186,312 +190,256 @@ export function scanSource( return { specifiers, exportedRequireFns: Array.from(exportedRequireFns) }; } -type CreateRequireBindings = { - /** Local names bound to createRequire itself (named import or destructure) */ - aliases: Set; - /** Local names bound to the module builtin's namespace or default export */ - namespaces: Set; +function parseWithFallbacks(source: string): AstNode | undefined { + for (const plugins of PARSER_PLUGIN_ATTEMPTS) { + try { + return parse(source, { + sourceType: "unambiguous", + errorRecovery: true, + allowReturnOutsideFunction: true, + plugins, + }) as unknown as AstNode; + } catch (error) { + logger.debug("[createRequire] Parse attempt failed", { plugins, error }); + } + } + + return undefined; +} + +type AstFacts = { + moduleImports: Array<{ kind: "createRequire" | "namespace"; localName: string }>; + bindings: Array<{ name: string; value: AstNode; exported: boolean }>; + relativeNamedImports: Array<{ importedName: string; localName: string; fromSpecifier: string }>; + calls: Array<{ + callee: AstNode; + specifier: string | undefined; + start: number | undefined; + line: number; + column: number; + }>; }; -function collectCreateRequireBindings( - code: string, - inString: (index: number) => boolean -): CreateRequireBindings { - const aliases = new Set(); - const namespaces = new Set(); +function collectAstFacts(ast: AstNode): AstFacts { + const facts: AstFacts = { + moduleImports: [], + bindings: [], + relativeNamedImports: [], + calls: [], + }; - const namedBindingRegexes = [ - new RegExp( - `import\\s*(?:type\\s+)?(?:(${IDENTIFIER})\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*${MODULE_SPECIFIER}`, - "g" - ), - new RegExp(`(?:const|let|var)\\s*()\\{([^}]*)\\}\\s*=\\s*${MODULE_LOAD}`, "g"), - ]; - - for (const regex of namedBindingRegexes) { - for (const match of code.matchAll(regex)) { - if (inString(match.index!)) { - continue; + const visit = (node: AstNode, exported: boolean) => { + switch (node.type) { + case "ExportNamedDeclaration": { + const declaration = node.declaration as AstNode | null; + + if (declaration) { + visit(declaration, true); + } + + return; } + case "VariableDeclaration": { + for (const declarator of node.declarations as AstNode[]) { + visit(declarator, exported); + } - if (match[1]) { - namespaces.add(match[1]); + return; } + case "ImportDeclaration": { + collectImportDeclaration(node, facts); - for (const binding of match[2]!.split(",")) { - const bindingMatch = binding.match( - new RegExp(`^\\s*createRequire\\s*(?:(?:as\\s+|:\\s*)(${IDENTIFIER}))?\\s*$`) - ); + return; + } + case "VariableDeclarator": { + collectVariableDeclarator(node, exported, facts); + break; + } + case "AssignmentExpression": { + const left = node.left as AstNode; + const right = node.right as AstNode; - if (bindingMatch) { - aliases.add(bindingMatch[1] ?? "createRequire"); + if (node.operator === "=" && left.type === "Identifier") { + facts.bindings.push({ name: left.name as string, value: right, exported: false }); } + + break; + } + case "CallExpression": { + const args = node.arguments as AstNode[]; + + facts.calls.push({ + callee: node.callee as AstNode, + specifier: stringArgumentValue(args[0]), + start: node.start ?? undefined, + line: node.loc?.start.line ?? 1, + column: node.loc?.start.column ?? 0, + }); + + break; } } - } - const namespaceBindingRegexes = [ - new RegExp(`import\\s+(${IDENTIFIER})\\s+from\\s*${MODULE_SPECIFIER}`, "g"), - new RegExp(`import\\s*\\*\\s*as\\s+(${IDENTIFIER})\\s+from\\s*${MODULE_SPECIFIER}`, "g"), - new RegExp(`(?:const|let|var)\\s+(${IDENTIFIER})\\s*=\\s*${MODULE_LOAD}`, "g"), - ]; + visitChildren(node); + }; - for (const regex of namespaceBindingRegexes) { - for (const match of code.matchAll(regex)) { - if (!inString(match.index!)) { - namespaces.add(match[1]!); + const visitChildren = (node: AstNode) => { + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isAstNode(item)) { + visit(item, false); + } + } + } else if (isAstNode(value)) { + visit(value, false); } } - } + }; - return { aliases, namespaces }; + visit(ast, false); + + return facts; } -type LexedSource = { - /** Source with comments, template text and regex-literal bodies blanked (offsets preserved) */ - code: string; - /** Spans (start inclusive, end exclusive) of single/double-quoted strings, quotes included */ - stringSpans: Array<[number, number]>; -}; +function isAstNode(value: unknown): value is AstNode { + return typeof value === "object" && value !== null && typeof (value as AstNode).type === "string"; +} -/** - * Blanks out comments, template-literal text (interpolation code is kept) and - * regex-literal bodies while preserving every character offset and newline, - * and records the spans of single/double-quoted strings. Regex scanning then - * never matches inside a comment, a code snippet embedded in a template - * string, or a regex literal, and matches inside quoted strings can be - * rejected by span. Quoted string contents are kept in the output because - * specifier literals must stay extractable. Regex-vs-division detection uses - * the standard preceding-token heuristic and can misread rare forms. - */ -function lexSource(source: string): LexedSource { - const out = source.split(""); - const stringSpans: Array<[number, number]> = []; - const interpolationBraceDepths: number[] = []; - let mode: "code" | "line" | "block" | "single" | "double" | "template" | "regex" = "code"; - let inCharClass = false; - let stringStart = 0; - let i = 0; - - const blank = (index: number) => { - if (out[index] !== "\n") { - out[index] = " "; - } - }; +function collectImportDeclaration(node: AstNode, facts: AstFacts) { + const importSource = node.source as AstNode; + const specifierValue = importSource.value as string; + const specifiers = node.specifiers as AstNode[]; - while (i < source.length) { - const c = source[i]!; - const d = source[i + 1]; - - switch (mode) { - case "code": { - if (c === "/" && d === "/") { - mode = "line"; - blank(i); - blank(i + 1); - i += 2; - } else if (c === "/" && d === "*") { - mode = "block"; - blank(i); - blank(i + 1); - i += 2; - } else if (c === "/" && regexLiteralAllowedAt(source, i)) { - mode = "regex"; - inCharClass = false; - i += 1; - } else if (c === "'") { - mode = "single"; - stringStart = i; - i += 1; - } else if (c === '"') { - mode = "double"; - stringStart = i; - i += 1; - } else if (c === "`") { - mode = "template"; - i += 1; - } else if (c === "{" && interpolationBraceDepths.length > 0) { - interpolationBraceDepths[interpolationBraceDepths.length - 1]!++; - i += 1; - } else if (c === "}" && interpolationBraceDepths.length > 0) { - const depth = interpolationBraceDepths[interpolationBraceDepths.length - 1]!; - - if (depth === 0) { - interpolationBraceDepths.pop(); - mode = "template"; - } else { - interpolationBraceDepths[interpolationBraceDepths.length - 1] = depth - 1; - } + if (MODULE_BUILTIN_SPECIFIERS.has(specifierValue)) { + for (const specifier of specifiers) { + const localName = (specifier.local as AstNode).name as string; - i += 1; - } else { - i += 1; - } - break; - } - case "line": { - if (c === "\n") { - mode = "code"; - } else { - blank(i); + if (specifier.type === "ImportSpecifier") { + const imported = specifier.imported as AstNode; + + if (imported.type === "Identifier" && imported.name === "createRequire") { + facts.moduleImports.push({ kind: "createRequire", localName }); } - i += 1; - break; + } else { + facts.moduleImports.push({ kind: "namespace", localName }); } - case "block": { - if (c === "*" && d === "/") { - mode = "code"; - blank(i); - blank(i + 1); - i += 2; - } else { - blank(i); - i += 1; - } - break; + } + } else if (specifierValue.startsWith(".")) { + for (const specifier of specifiers) { + if (specifier.type !== "ImportSpecifier") { + continue; } - case "single": - case "double": { - if (c === "\\") { - i += 2; - } else { - if (c === (mode === "single" ? "'" : '"')) { - stringSpans.push([stringStart, i + 1]); - mode = "code"; - } else if (c === "\n") { - stringSpans.push([stringStart, i]); - mode = "code"; - } - i += 1; - } - break; + + const imported = specifier.imported as AstNode; + + if (imported.type === "Identifier") { + facts.relativeNamedImports.push({ + importedName: imported.name as string, + localName: (specifier.local as AstNode).name as string, + fromSpecifier: specifierValue, + }); } - case "template": { - if (c === "\\") { - blank(i); - blank(i + 1); - i += 2; - } else if (c === "`") { - mode = "code"; - i += 1; - } else if (c === "$" && d === "{") { - interpolationBraceDepths.push(0); - mode = "code"; - i += 2; - } else { - blank(i); - i += 1; + } + } +} + +function collectVariableDeclarator(node: AstNode, exported: boolean, facts: AstFacts) { + const id = node.id as AstNode; + const init = node.init as AstNode | null; + + if (!init) { + return; + } + + const moduleLoad = isModuleBuiltinLoad(init); + + if (moduleLoad) { + if (id.type === "Identifier") { + facts.moduleImports.push({ kind: "namespace", localName: id.name as string }); + } else if (id.type === "ObjectPattern") { + for (const property of id.properties as AstNode[]) { + if (property.type !== "ObjectProperty") { + continue; } - break; - } - case "regex": { - if (c === "\\") { - blank(i); - blank(i + 1); - i += 2; - } else if (c === "[") { - inCharClass = true; - blank(i); - i += 1; - } else if (c === "]") { - inCharClass = false; - blank(i); - i += 1; - } else if (c === "/" && !inCharClass) { - mode = "code"; - i += 1; - } else if (c === "\n") { - mode = "code"; - i += 1; - } else { - blank(i); - i += 1; + + const key = property.key as AstNode; + const value = property.value as AstNode; + + if ( + key.type === "Identifier" && + key.name === "createRequire" && + value.type === "Identifier" + ) { + facts.moduleImports.push({ kind: "createRequire", localName: value.name as string }); } - break; } } - } - if (mode === "single" || mode === "double") { - stringSpans.push([stringStart, source.length]); + return; } - return { code: out.join(""), stringSpans }; + if (id.type === "Identifier") { + facts.bindings.push({ name: id.name as string, value: init, exported }); + } } -const REGEX_PRECEDING_KEYWORDS = new Set([ - "return", - "typeof", - "instanceof", - "in", - "of", - "new", - "delete", - "void", - "do", - "else", - "case", - "yield", - "await", -]); - -function regexLiteralAllowedAt(source: string, index: number): boolean { - let j = index - 1; - - while (j >= 0 && /\s/.test(source[j]!)) { - j--; +function isModuleBuiltinLoad(node: AstNode): boolean { + const call = node.type === "AwaitExpression" ? (node.argument as AstNode) : node; + + if (call.type !== "CallExpression") { + return false; } - if (j < 0) { - return true; + const callee = call.callee as AstNode; + const isLoader = + (callee.type === "Identifier" && callee.name === "require") || callee.type === "Import"; + + if (!isLoader) { + return false; } - const prev = source[j]!; + const args = call.arguments as AstNode[]; + const arg = args[0]; - if (/[\w$]/.test(prev)) { - let start = j; + return ( + arg !== undefined && + arg.type === "StringLiteral" && + MODULE_BUILTIN_SPECIFIERS.has(arg.value as string) + ); +} - while (start > 0 && /[\w$]/.test(source[start - 1]!)) { - start--; - } +function stringArgumentValue(node: AstNode | undefined): string | undefined { + if (!node) { + return undefined; + } - return REGEX_PRECEDING_KEYWORDS.has(source.slice(start, j + 1)); + if (node.type === "StringLiteral") { + return node.value as string; } - if (prev === "+" || prev === "-") { - return source[j - 1] !== prev; + if (node.type === "TemplateLiteral" && (node.expressions as AstNode[]).length === 0) { + const quasis = node.quasis as AstNode[]; + const value = quasis[0]?.value as { cooked?: string } | undefined; + + return value?.cooked; } - return !/[)\]"'`.!<]/.test(prev); + return undefined; } function isWarnableSpecifier(specifier: string): boolean { const nonPackagePrefixes = [".", "/", "~", "#", "file:", "data:", "node:"]; - if (nonPackagePrefixes.some((prefix) => specifier.startsWith(prefix))) { + if (nonPackagePrefixes.some((prefix) => specifier.startsWith(prefix)) || specifier.length === 0) { return false; } return !isBuiltinModule(packageNameForImportPath(specifier)); } -function locationAt( - source: string, - index: number -): { line: number; column: number; lineText: string } { - const before = source.slice(0, index); - const lineStart = before.lastIndexOf("\n") + 1; - const lineEnd = source.indexOf("\n", index); - - return { - line: (before.match(/\n/g)?.length ?? 0) + 1, - column: index - lineStart, - lineText: source.slice(lineStart, lineEnd === -1 ? undefined : lineEnd), - }; -} - const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; -const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/; +export const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/; const FILE_READ_CONCURRENCY = 16; type CollectorCacheEntry = { @@ -508,10 +456,11 @@ type CollectorCacheEntry = { * 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. - * Require functions exported from one scanned file and imported (by name, - * from a relative path resolving to the exporting file) into another are - * followed. Scan results are cached per file by mtime and size so dev - * rebuilds only re-read changed files. + * Require functions exported from one scanned file and imported into another + * are followed, resolving import specifiers through the metafile's own + * resolved import records (with a filesystem fallback), so index files and + * path aliases work. Scan results are cached per file by mtime and size so + * dev rebuilds only re-read changed files. */ export class CreateRequireCollector { private _usages: CreateRequireUsage[] = []; @@ -551,23 +500,32 @@ export class CreateRequireCollector { private async collect(metafile: esbuild.Metafile): Promise { const files: Array<{ inputPath: string; filePath: string }> = []; const seenPaths = new Set(); + const importResolutions = new Map>(); - for (const inputPath of Object.keys(metafile.inputs)) { + for (const [inputPath, input] of Object.entries(metafile.inputs)) { const cleanPath = inputPath.split("?")[0]!; - if ( - seenPaths.has(cleanPath) || - !SCANNABLE_FILE_REGEX.test(cleanPath) || - NODE_MODULES_SEGMENT_REGEX.test(cleanPath) - ) { + if (!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 resolutions = importResolutions.get(cleanPath) ?? new Map(); + + for (const record of input.imports) { + if (record.original !== undefined && !record.external) { + resolutions.set(record.original, record.path.split("?")[0]!); + } + } + + importResolutions.set(cleanPath, resolutions); + + if (!seenPaths.has(cleanPath)) { + seenPaths.add(cleanPath); + files.push({ + inputPath: cleanPath, + filePath: isAbsolute(cleanPath) ? cleanPath : resolve(this.workingDir, cleanPath), + }); + } } const limit = pLimit(FILE_READ_CONCURRENCY); @@ -620,22 +578,26 @@ export class CreateRequireCollector { ) ).filter((entry) => entry !== undefined); + const fileKey = (filePath: string) => filePath.replace(SCANNABLE_FILE_REGEX, ""); + const exportsByInputPath = new Map>(); const exportsByFileKey = new Map>(); const exportedNames = new Set(); - const fileKey = (filePath: string) => filePath.replace(SCANNABLE_FILE_REGEX, ""); for (const entry of scanned) { if (entry.base.exportedRequireFns.length > 0) { - exportsByFileKey.set(fileKey(entry.filePath), new Set(entry.base.exportedRequireFns)); + const names = new Set(entry.base.exportedRequireFns); + + exportsByInputPath.set(entry.inputPath, names); + exportsByFileKey.set(fileKey(entry.filePath), names); - for (const name of entry.base.exportedRequireFns) { + for (const name of names) { exportedNames.add(name); } } } - const knownsSignature = Array.from(exportsByFileKey.entries()) - .flatMap(([key, names]) => Array.from(names).map((name) => `${key}#${name}`)) + const knownsSignature = Array.from(exportsByInputPath.entries()) + .flatMap(([inputPath, names]) => Array.from(names).map((name) => `${inputPath}#${name}`)) .sort() .join(","); @@ -659,9 +621,16 @@ export class CreateRequireCollector { if (!mentionsExportedName) { finalSpecifiers = entry.base.specifiers; } else { + const resolutions = importResolutions.get(entry.inputPath); const importerDir = dirname(entry.filePath); finalSpecifiers = scanSource(source, (name, fromSpecifier) => { + const metafileResolved = resolutions?.get(fromSpecifier); + + if (metafileResolved !== undefined) { + return exportsByInputPath.get(metafileResolved)?.has(name) ?? false; + } + const key = fileKey(resolve(importerDir, fromSpecifier)); return exportsByFileKey.get(key)?.has(name) ?? false; @@ -704,8 +673,11 @@ export type ExtensionInstalledPackages = { /** * Package-name matchers for everything the configured build extensions - * declare they install into or externalize for the deployed image. Never - * throws: diagnostics must not fail a build. + * 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: ResolvedConfig @@ -714,6 +686,10 @@ export function extensionInstalledPackageMatchers( let incomplete = false; for (const buildExtension of config.build?.extensions ?? []) { + if (buildExtension.name === "externals") { + continue; + } + try { const declaresPackages = typeof buildExtension.installedPackagesForTarget === "function" || @@ -767,16 +743,44 @@ export function unavailableCreateRequireUsages( ); } -const PACKAGE_INSTALL_COMMAND_REGEX = /\b(?:npm|pnpm|yarn|bun)\b/i; +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: ReadonlyArray): string[] { + const names = new Set(); + + for (const command of commands) { + for (const match of command.matchAll(INSTALL_COMMAND_REGEX)) { + for (const token of match[1]!.trim().split(/\s+/)) { + if (token.length === 0 || token.startsWith("-") || 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, render the rest. Returns [] instead of throwing on - * any internal failure, stays silent for dev when extension-installed - * packages can't be determined (see ExtensionInstalledPackages.incomplete), - * and stays silent for every target when a build-layer command runs a JS - * package manager, since such commands can install packages invisibly to the - * manifest externals. + * 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, @@ -794,21 +798,18 @@ export function collectCreateRequireWarningMessages({ return []; } - const commands = buildManifest.build?.commands ?? []; - - if (commands.some((command) => PACKAGE_INSTALL_COMMAND_REGEX.test(command))) { - 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, - extensionPackages.matchers - ).map((usage) => createRequireUsageToWarning(usage, target)); + return unavailableCreateRequireUsages(usages, installedPackages, matchers).map((usage) => + createRequireUsageToWarning(usage, target) + ); } catch (error) { logger.debug("[createRequire] Warning generation failed; skipping", { error }); diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index 74a19dbfe44..f872132566e 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -525,7 +525,7 @@ export function makeExternalRegexp(packageName: string): RegExp { return new RegExp(pattern); } -export function escapeRegExp(value: string): string { +function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/packages/core/src/v3/build/extensions.ts b/packages/core/src/v3/build/extensions.ts index 449467793e1..33797ba39c3 100644 --- a/packages/core/src/v3/build/extensions.ts +++ b/packages/core/src/v3/build/extensions.ts @@ -5,6 +5,7 @@ import { ResolvedConfig } from "./resolvedConfig.js"; export function esbuildPlugin(plugin: Plugin, options: RegisterPluginOptions = {}): BuildExtension { return { name: plugin.name, + installedPackagesForTarget: () => [], onBuildStart(context) { context.registerPlugin(plugin, options); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e94210014b8..50c91f08894 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1523,6 +1523,9 @@ importers: packages/cli-v3: dependencies: + '@babel/parser': + specifier: ^7.29.7 + version: 7.29.7 '@clack/prompts': specifier: 0.11.0 version: 0.11.0 @@ -2794,11 +2797,6 @@ packages: resolution: {integrity: sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.24.7': - resolution: {integrity: sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.27.0': resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} engines: {node: '>=6.0.0'} @@ -16899,10 +16897,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/parser@7.24.7': - dependencies: - '@babel/types': 7.29.7 - '@babel/parser@7.27.0': dependencies: '@babel/types': 7.29.7 @@ -26043,7 +26037,7 @@ snapshots: magicast@0.3.4: dependencies: - '@babel/parser': 7.24.7 + '@babel/parser': 7.29.7 '@babel/types': 7.24.7 source-map-js: 1.2.0 From 8c7f18466dd37e68d394faaa6c90894114ad76d7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 16:57:25 +0100 Subject: [PATCH 11/15] docs: correct the externalsForTarget contract and document installedPackagesForTarget externalsForTarget is synchronous; the example showed async, which the build consumes incorrectly and TypeScript rejects. Adds a section for the new diagnostics-only installedPackagesForTarget hook. --- docs/config/extensions/custom.mdx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/config/extensions/custom.mdx b/docs/config/extensions/custom.mdx index 02c5980cf9b..cfed9eea001 100644 --- a/docs/config/extensions/custom.mdx +++ b/docs/config/extensions/custom.mdx @@ -80,7 +80,7 @@ export default defineConfig({ extensions: [ { name: "my-extension", - externalsForTarget: async (target) => { + externalsForTarget: (target) => { return ["my-dependency"]; }, }, @@ -89,6 +89,19 @@ export default defineConfig({ }); ``` +### installedPackagesForTarget + +This tells build diagnostics which packages your extension installs into the deployed image for a given target, so warnings (like the one for packages loaded via `createRequire()`) don't fire for packages that will actually be available at runtime. The bundler ignores this hook, so declaring it never changes the build output. Return an empty array to declare that your extension installs no packages, which keeps those diagnostics active in `dev` for projects using your extension. + +```ts +{ + name: "my-extension", + installedPackagesForTarget: (target) => { + return target === "deploy" ? ["my-dependency"] : []; + }, +} +``` + ### onBuildStart This hook runs before the build starts. It receives the `BuildContext` object as an argument. From 1c659504e046816b90a7d79935ab47c0d109be49 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 17:17:53 +0100 Subject: [PATCH 12/15] fix(cli,build): dev warnings default on for undeclared extensions, scanner edge fixes Inverts the extension-declaration default: an extension that declares no installed packages is assumed to install none, so dev warnings stay live for third-party and yet-to-declare extensions instead of silently disabling the feature (proven twice by first-party sweeps missing extensions). The incomplete guard remains only where false warnings are genuinely likely: a throwing declaration hook, or an additionalPackages extension too old to declare, and the engine-only prisma mode now declares @prisma/engines. Also: specifier-form exports (export { req }) are followed cross-file, npm-alias install tokens suppress the aliased name, literal Windows path specifiers never warn, the bare-specifier check reuses isBareModuleImport, collector usages swap atomically per build, exported-name mention checks use identifier boundaries, and signature-miss re-reads run under the concurrency cap with failures logged. --- .changeset/warn-createrequire-deploy.md | 2 +- packages/build/src/extensions/prisma.ts | 8 ++ .../src/build/createRequireWarnings.test.ts | 45 +++++-- .../cli-v3/src/build/createRequireWarnings.ts | 125 ++++++++++++++---- packages/cli-v3/src/build/externals.ts | 4 +- 5 files changed, 145 insertions(+), 39 deletions(-) diff --git a/.changeset/warn-createrequire-deploy.md b/.changeset/warn-createrequire-deploy.md index 2e9bb161526..8fba0a2df51 100644 --- a/.changeset/warn-createrequire-deploy.md +++ b/.changeset/warn-createrequire-deploy.md @@ -4,4 +4,4 @@ "@trigger.dev/core": patch --- -`deploy` and `dev` now warn, with the file, line, and suggested `additionalPackages` fix, when code loads a package through `createRequire()` that won't be available in the deployed image and would previously only fail at runtime in production. +`deploy` and `dev` now warn, with the file, line, and suggested `additionalPackages` fix, when code loads a package through `createRequire()` that won't be available in the deployed image and would previously only fail at runtime in production. Deploys also now show the bundler's own warnings for your code instead of discarding them. diff --git a/packages/build/src/extensions/prisma.ts b/packages/build/src/extensions/prisma.ts index 35589eeca5b..1a1a62e4e52 100644 --- a/packages/build/src/extensions/prisma.ts +++ b/packages/build/src/extensions/prisma.ts @@ -815,6 +815,14 @@ export class PrismaEngineOnlyModeExtension implements BuildExtension { this._binaryTarget = options.binaryTarget ?? "debian-openssl-3.0.x"; } + installedPackagesForTarget(target: BuildTarget) { + if (target !== "deploy") { + return []; + } + + return ["@prisma/engines"]; + } + async onBuildComplete(context: BuildContext, manifest: BuildManifest) { if (context.target === "dev") { return; diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index 17b3b825356..264897ea372 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -348,6 +348,24 @@ const pg = req("pg"); expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]); }); + it("ignores literal Windows path specifiers", () => { + const source = + 'import { createRequire } from "node:module";\n' + + "const req = createRequire(import.meta.url);\n" + + 'const helper = req("C:\\\\tools\\\\helper.cjs");\n'; + + expect(scanSourceForCreateRequire(source)).toEqual([]); + }); + + it("records require functions exported in specifier form", () => { + const source = `import { createRequire } from "node:module"; +const cjsRequire = createRequire(import.meta.url); +export { cjsRequire }; +`; + + expect(scanSource(source).exportedRequireFns).toEqual(["cjsRequire"]); + }); + it("follows require functions imported from other scanned files", () => { const util = `import { createRequire } from "node:module"; export const cjsRequire = createRequire(import.meta.url); @@ -712,18 +730,21 @@ describe("extensionInstalledPackageMatchers", () => { expect(incomplete).toBe(true); }); - it("marks the result incomplete for any hook-bearing extension that declares no packages", () => { - const oldAdditionalPackages = extensionInstalledPackageMatchers( - configWith([{ name: "additionalPackages", onBuildStart: () => {} }]) + it("assumes an undeclared extension installs nothing", () => { + const { matchers, incomplete } = extensionInstalledPackageMatchers( + configWith([{ name: "someThirdPartyExtension", onBuildComplete: () => {} }]) ); - expect(oldAdditionalPackages.incomplete).toBe(true); + expect(incomplete).toBe(false); + expect(matchers).toEqual([]); + }); - const layerInstaller = extensionInstalledPackageMatchers( - configWith([{ name: "syncEnvVars", onBuildComplete: () => {} }]) + it("marks the result incomplete for an additionalPackages extension that predates the hook", () => { + const { incomplete } = extensionInstalledPackageMatchers( + configWith([{ name: "additionalPackages", onBuildStart: () => {} }]) ); - expect(layerInstaller.incomplete).toBe(true); + expect(incomplete).toBe(true); }); }); @@ -828,12 +849,20 @@ describe("packagesInstalledByCommands", () => { "npm install @prisma/engines@5.0.0", "pnpm add wrangler prisma@3.0.0 --save-dev", "yarn add -D typescript", + "npm install sqlite3@npm:@vscode/sqlite3", + "npm install file:../local-lib", "bun run generate", "npm ci", "apt-get install -y ffmpeg", ]); - expect(packages.sort()).toEqual(["@prisma/engines", "prisma", "typescript", "wrangler"]); + expect(packages.sort()).toEqual([ + "@prisma/engines", + "prisma", + "sqlite3", + "typescript", + "wrangler", + ]); }); }); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index 489e93924b9..048228ddb74 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -7,7 +7,13 @@ import { dirname, isAbsolute, resolve } from "node:path"; import pLimit from "p-limit"; import { tryCatch } from "@trigger.dev/core/v3"; import { logger } from "../utilities/logger.js"; -import { isBuiltinModule, makeExternalRegexp, packageNameForImportPath } from "./externals.js"; +import { + escapeRegExp, + isBareModuleImport, + isBuiltinModule, + makeExternalRegexp, + packageNameForImportPath, +} from "./externals.js"; export { packageNameForImportPath as packageNameForSpecifier } from "./externals.js"; @@ -140,6 +146,12 @@ export function scanSource( } } + for (const name of collected.exportSpecifierNames) { + if (requireFnNames.has(name)) { + exportedRequireFns.add(name); + } + } + if (isKnownRequireFnImport) { for (const relativeImport of collected.relativeNamedImports) { if (isKnownRequireFnImport(relativeImport.importedName, relativeImport.fromSpecifier)) { @@ -210,6 +222,8 @@ function parseWithFallbacks(source: string): AstNode | undefined { type AstFacts = { moduleImports: Array<{ kind: "createRequire" | "namespace"; localName: string }>; bindings: Array<{ name: string; value: AstNode; exported: boolean }>; + /** Local names exported via `export { name }` (specifier form) */ + exportSpecifierNames: string[]; relativeNamedImports: Array<{ importedName: string; localName: string; fromSpecifier: string }>; calls: Array<{ callee: AstNode; @@ -224,6 +238,7 @@ function collectAstFacts(ast: AstNode): AstFacts { const facts: AstFacts = { moduleImports: [], bindings: [], + exportSpecifierNames: [], relativeNamedImports: [], calls: [], }; @@ -235,6 +250,14 @@ function collectAstFacts(ast: AstNode): AstFacts { if (declaration) { visit(declaration, true); + } else if (!node.source) { + for (const specifier of (node.specifiers as AstNode[]) ?? []) { + const local = specifier.local as AstNode | undefined; + + if (specifier.type === "ExportSpecifier" && local?.type === "Identifier") { + facts.exportSpecifierNames.push(local.name as string); + } + } } return; @@ -429,13 +452,17 @@ function stringArgumentValue(node: AstNode | undefined): string | undefined { } function isWarnableSpecifier(specifier: string): boolean { - const nonPackagePrefixes = [".", "/", "~", "#", "file:", "data:", "node:"]; - - if (nonPackagePrefixes.some((prefix) => specifier.startsWith(prefix)) || specifier.length === 0) { + if ( + specifier.length === 0 || + specifier.startsWith("#") || + specifier.startsWith("node:") || + specifier.includes("\\") || + /^[A-Za-z]:/.test(specifier) + ) { return false; } - return !isBuiltinModule(packageNameForImportPath(specifier)); + return isBareModuleImport(specifier) && !isBuiltinModule(packageNameForImportPath(specifier)); } const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; @@ -478,14 +505,14 @@ export class CreateRequireCollector { name: "create-require-collector", setup: (build) => { build.onEnd(async (result) => { - this._usages = []; - if (!result.metafile) { + this._usages = []; + return; } try { - await this.collect(result.metafile); + this._usages = await this.collect(result.metafile); } catch (error) { logger.debug("[createRequire] Scan failed; skipping warnings", { error }); this._usages = []; @@ -497,7 +524,8 @@ export class CreateRequireCollector { return this._plugin; } - private async collect(metafile: esbuild.Metafile): Promise { + private async collect(metafile: esbuild.Metafile): Promise { + const usages: CreateRequireUsage[] = []; const files: Array<{ inputPath: string; filePath: string }> = []; const seenPaths = new Set(); const importResolutions = new Map>(); @@ -601,22 +629,49 @@ export class CreateRequireCollector { .sort() .join(","); + const exportedNamePattern = + exportedNames.size > 0 + ? new RegExp(`\\b(?:${Array.from(exportedNames).map(escapeRegExp).join("|")})\\b`) + : undefined; + + const needsSource = scanned.filter( + (entry) => + entry.source === undefined && + !(entry.cached && entry.cached.knownsSignature === knownsSignature) + ); + + await Promise.all( + needsSource.map((entry) => + limit(async () => { + const [readError, contents] = await tryCatch(readFile(entry.filePath, "utf8")); + + if (readError) { + logger.debug("[createRequire] Unable to re-read bundle input file", { + filePath: entry.filePath, + error: readError, + }); + + return; + } + + entry.source = contents; + }) + ) + ); + for (const entry of scanned) { let finalSpecifiers: CreateRequireSpecifier[]; if (entry.cached && entry.cached.knownsSignature === knownsSignature) { finalSpecifiers = entry.cached.finalSpecifiers; } else { - const source = - entry.source ?? (await tryCatch(readFile(entry.filePath, "utf8")))[1] ?? undefined; + const source = entry.source; if (source === undefined) { continue; } - const mentionsExportedName = Array.from(exportedNames).some((name) => - source.includes(name) - ); + const mentionsExportedName = exportedNamePattern?.test(source) ?? false; if (!mentionsExportedName) { finalSpecifiers = entry.base.specifiers; @@ -647,26 +702,29 @@ export class CreateRequireCollector { } for (const found of finalSpecifiers) { - this._usages.push({ + usages.push({ ...found, file: entry.inputPath, packageName: packageNameForImportPath(found.specifier), }); } } + + return usages; } } export type ExtensionInstalledPackages = { matchers: RegExp[]; /** - * True when what extensions install can't be fully determined: an extension - * hook threw, or an extension participates in the build (has build hooks) - * without declaring installedPackagesForTarget or externalsForTarget, so it - * may install packages invisibly (e.g. via a build layer). Dev-mode - * warnings must stay silent in that case rather than risk false "deploys - * will fail" claims; deploy-mode warnings are unaffected because the - * manifest externals capture layer dependencies there. + * True when what extensions install can't be determined in a way that + * makes false warnings likely: an extension hook threw, or an + * additionalPackages extension predates the installedPackagesForTarget + * hook (the exact extension the warning's own fix advice prescribes). + * Dev-mode warnings stay silent in that case; deploy-mode warnings are + * unaffected because the manifest externals capture layer dependencies + * there. Extensions that declare nothing are assumed to install nothing: + * package-installing extensions are the rare case and declare themselves. */ incomplete: boolean; }; @@ -694,12 +752,12 @@ export function extensionInstalledPackageMatchers( const declaresPackages = typeof buildExtension.installedPackagesForTarget === "function" || typeof buildExtension.externalsForTarget === "function"; - const hasBuildHooks = - typeof buildExtension.onBuildStart === "function" || - typeof buildExtension.onBuildComplete === "function"; - if (!declaresPackages && hasBuildHooks) { - incomplete = true; + if (!declaresPackages) { + if (buildExtension.name === "additionalPackages") { + incomplete = true; + } + continue; } @@ -757,7 +815,18 @@ export function packagesInstalledByCommands(commands: ReadonlyArray): st for (const command of commands) { for (const match of command.matchAll(INSTALL_COMMAND_REGEX)) { for (const token of match[1]!.trim().split(/\s+/)) { - if (token.length === 0 || token.startsWith("-") || token.includes(":")) { + 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; } diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index f872132566e..eb25fac665b 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -525,7 +525,7 @@ export function makeExternalRegexp(packageName: string): RegExp { return new RegExp(pattern); } -function escapeRegExp(value: string): string { +export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -572,7 +572,7 @@ function resolveSync(id: string, resolveDir: string) { } } -function isBareModuleImport(path: string): boolean { +export function isBareModuleImport(path: string): boolean { const excludes = [".", "/", "~", "file:", "data:"]; return !excludes.some((exclude) => path.startsWith(exclude)); } From b320848ac8600e5b6a6277375b1af9ec21375437 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 17:32:35 +0100 Subject: [PATCH 13/15] refactor(cli,build,core): scope the createRequire warning to per-file scanning Removes cross-file require-function following and its collector machinery (two-phase scan, exports-signature cache, metafile import resolution, export-specifier tracking): a require helper imported from another module is no longer followed, which just leaves that pattern unwarned as before this feature. Each file now scans independently with a simple per-file mtime cache. With undeclared extensions assumed to install nothing, the empty installedPackagesForTarget stubs on built-in extensions are removed too; only extensions that actually install packages declare (additionalPackages and the engine-only prisma mode). --- docs/config/extensions/custom.mdx | 2 +- .../build/src/extensions/audioWaveform.ts | 4 - .../src/extensions/core/additionalFiles.ts | 1 - packages/build/src/extensions/core/aptGet.ts | 1 - packages/build/src/extensions/core/ffmpeg.ts | 1 - .../build/src/extensions/core/syncEnvVars.ts | 1 - packages/build/src/extensions/lightpanda.ts | 1 - packages/build/src/extensions/puppeteer.ts | 4 - packages/build/src/extensions/typescript.ts | 1 - .../src/build/createRequireWarnings.test.ts | 165 +------- .../cli-v3/src/build/createRequireWarnings.ts | 384 ++++-------------- packages/cli-v3/src/build/externals.ts | 2 +- packages/core/src/v3/build/extensions.ts | 4 +- 13 files changed, 95 insertions(+), 476 deletions(-) diff --git a/docs/config/extensions/custom.mdx b/docs/config/extensions/custom.mdx index cfed9eea001..157e116c719 100644 --- a/docs/config/extensions/custom.mdx +++ b/docs/config/extensions/custom.mdx @@ -91,7 +91,7 @@ export default defineConfig({ ### installedPackagesForTarget -This tells build diagnostics which packages your extension installs into the deployed image for a given target, so warnings (like the one for packages loaded via `createRequire()`) don't fire for packages that will actually be available at runtime. The bundler ignores this hook, so declaring it never changes the build output. Return an empty array to declare that your extension installs no packages, which keeps those diagnostics active in `dev` for projects using your extension. +This tells build diagnostics which packages your extension installs into the deployed image for a given target, so warnings (like the one for packages loaded via `createRequire()`) don't fire for packages that will actually be available at runtime. The bundler ignores this hook, so declaring it never changes the build output. Only implement it if your extension installs packages; extensions without it are assumed to install none. ```ts { diff --git a/packages/build/src/extensions/audioWaveform.ts b/packages/build/src/extensions/audioWaveform.ts index 03469ffb23c..0bf6e4e0fa1 100644 --- a/packages/build/src/extensions/audioWaveform.ts +++ b/packages/build/src/extensions/audioWaveform.ts @@ -17,10 +17,6 @@ export function audioWaveform(options: AudioWaveformOptions = {}): BuildExtensio class AudioWaveformExtension implements BuildExtension { public readonly name = "AudioWaveformExtension"; - installedPackagesForTarget() { - return []; - } - constructor(private options: AudioWaveformOptions = {}) {} async onBuildComplete(context: BuildContext, manifest: BuildManifest) { diff --git a/packages/build/src/extensions/core/additionalFiles.ts b/packages/build/src/extensions/core/additionalFiles.ts index 2d0db93382a..cc2a04e0e09 100644 --- a/packages/build/src/extensions/core/additionalFiles.ts +++ b/packages/build/src/extensions/core/additionalFiles.ts @@ -8,7 +8,6 @@ export type AdditionalFilesOptions = { export function additionalFiles(options: AdditionalFilesOptions): BuildExtension { return { name: "additionalFiles", - installedPackagesForTarget: () => [], async onBuildComplete(context, manifest) { await addAdditionalFilesToBuild("additionalFiles", options, context, manifest); }, diff --git a/packages/build/src/extensions/core/aptGet.ts b/packages/build/src/extensions/core/aptGet.ts index da0317e6cb3..c6d0b51e652 100644 --- a/packages/build/src/extensions/core/aptGet.ts +++ b/packages/build/src/extensions/core/aptGet.ts @@ -7,7 +7,6 @@ export type AptGetOptions = { export function aptGet(options: AptGetOptions): BuildExtension { return { name: "aptGet", - installedPackagesForTarget: () => [], onBuildComplete(context) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/core/ffmpeg.ts b/packages/build/src/extensions/core/ffmpeg.ts index 86060adf7d8..11e8c0c80a8 100644 --- a/packages/build/src/extensions/core/ffmpeg.ts +++ b/packages/build/src/extensions/core/ffmpeg.ts @@ -24,7 +24,6 @@ export type FfmpegOptions = { export function ffmpeg(options: FfmpegOptions = {}): BuildExtension { return { name: "ffmpeg", - installedPackagesForTarget: () => [], onBuildComplete(context) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/core/syncEnvVars.ts b/packages/build/src/extensions/core/syncEnvVars.ts index 2dd3984f801..6da28a05eb0 100644 --- a/packages/build/src/extensions/core/syncEnvVars.ts +++ b/packages/build/src/extensions/core/syncEnvVars.ts @@ -77,7 +77,6 @@ export type SyncEnvVarsOptions = { export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOptions): BuildExtension { return { name: "SyncEnvVarsExtension", - installedPackagesForTarget: () => [], async onBuildComplete(context, manifest) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/lightpanda.ts b/packages/build/src/extensions/lightpanda.ts index 7807567a1ac..16c62a08b4f 100644 --- a/packages/build/src/extensions/lightpanda.ts +++ b/packages/build/src/extensions/lightpanda.ts @@ -10,7 +10,6 @@ export const lightpanda = ({ disableTelemetry = false, }: LightpandaOpts = {}): BuildExtension => ({ name: "lightpanda", - installedPackagesForTarget: () => [], onBuildComplete: async (context) => { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/puppeteer.ts b/packages/build/src/extensions/puppeteer.ts index 35debd7f9d1..4da61a03914 100644 --- a/packages/build/src/extensions/puppeteer.ts +++ b/packages/build/src/extensions/puppeteer.ts @@ -8,10 +8,6 @@ export function puppeteer() { class PuppeteerExtension implements BuildExtension { public readonly name = "PuppeteerExtension"; - installedPackagesForTarget() { - return []; - } - async onBuildComplete(context: BuildContext, manifest: BuildManifest) { if (context.target === "dev") { return; diff --git a/packages/build/src/extensions/typescript.ts b/packages/build/src/extensions/typescript.ts index 8f19aecd840..1021d4b94a3 100644 --- a/packages/build/src/extensions/typescript.ts +++ b/packages/build/src/extensions/typescript.ts @@ -8,7 +8,6 @@ const decoratorMatcher = new RegExp(/((? [], onBuildStart(context) { const { convertCompilerOptionsFromJson, transpileModule, ModuleKind } = loadTypescript( context.workingDir diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index 264897ea372..805b3d4f39e 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -1,5 +1,5 @@ import { build, type BuildResult, type PluginBuild } from "esbuild"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -12,7 +12,6 @@ import { extensionInstalledPackageMatchers, packageNameForSpecifier, packagesInstalledByCommands, - scanSource, scanSourceForCreateRequire, unavailableCreateRequireUsages, } from "./createRequireWarnings.js"; @@ -357,33 +356,6 @@ const pg = req("pg"); expect(scanSourceForCreateRequire(source)).toEqual([]); }); - it("records require functions exported in specifier form", () => { - const source = `import { createRequire } from "node:module"; -const cjsRequire = createRequire(import.meta.url); -export { cjsRequire }; -`; - - expect(scanSource(source).exportedRequireFns).toEqual(["cjsRequire"]); - }); - - it("follows require functions imported from other scanned files", () => { - const util = `import { createRequire } from "node:module"; -export const cjsRequire = createRequire(import.meta.url); -`; - const task = `import { cjsRequire } from "./util.js"; -const mssql = cjsRequire("mssql"); -`; - - const { exportedRequireFns, specifiers } = scanSource(util); - - expect(exportedRequireFns).toEqual(["cjsRequire"]); - expect(specifiers).toEqual([]); - - const taskResults = scanSourceForCreateRequire(task, new Set(exportedRequireFns)); - - expect(taskResults.map((r) => r.specifier)).toEqual(["mssql"]); - }); - it("returns nothing when the source doesn't mention createRequire", () => { const source = `import mssql from "mssql"; export const pool = mssql.connect(); @@ -458,141 +430,6 @@ export const mssql = createRequire(import.meta.url)("mssql"); } }); - it("collects usages of a require function imported from another module", async () => { - const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); - - try { - await writeFile( - join(dir, "util.ts"), - `import { createRequire } from "node:module"; -export const cjsRequire = createRequire(import.meta.url); -` - ); - - const entryPoint = join(dir, "entry.ts"); - await writeFile( - entryPoint, - `import { cjsRequire } from "./util.js"; -export const mssql = cjsRequire("mssql"); -` - ); - - const collector = new CreateRequireCollector(dir); - - await build({ - entryPoints: [entryPoint], - bundle: true, - metafile: true, - write: false, - format: "esm", - platform: "node", - outdir: dir, - absWorkingDir: dir, - logLevel: "silent", - plugins: [collector.plugin], - }); - - expect(collector.usages).toHaveLength(1); - expect(collector.usages[0]).toMatchObject({ - specifier: "mssql", - packageName: "mssql", - file: "entry.ts", - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it("ignores a same-named import that resolves to a module without the require export", async () => { - const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); - - try { - await writeFile( - join(dir, "util.ts"), - `import { createRequire } from "node:module"; -export const cjsRequire = createRequire(import.meta.url); -export const unused = cjsRequire; -` - ); - await writeFile( - join(dir, "pluginLoader.ts"), - `export const cjsRequire = (name: string) => ({ name }); -` - ); - - const entryPoint = join(dir, "entry.ts"); - await writeFile( - entryPoint, - `import { cjsRequire } from "./pluginLoader.js"; -import { unused } from "./util.js"; -export const plugin = cjsRequire("my-plugin"); -export const keep = unused; -` - ); - - const collector = new CreateRequireCollector(dir); - - await build({ - entryPoints: [entryPoint], - bundle: true, - metafile: true, - write: false, - format: "esm", - platform: "node", - outdir: dir, - absWorkingDir: dir, - logLevel: "silent", - plugins: [collector.plugin], - }); - - expect(collector.usages).toEqual([]); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it("follows a require function exported from an index file", async () => { - const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); - - try { - await mkdir(join(dir, "util")); - await writeFile( - join(dir, "util", "index.ts"), - `import { createRequire } from "node:module"; -export const cjsRequire = createRequire(import.meta.url); -` - ); - - const entryPoint = join(dir, "entry.ts"); - await writeFile( - entryPoint, - `import { cjsRequire } from "./util"; -export const mssql = cjsRequire("mssql"); -` - ); - - const collector = new CreateRequireCollector(dir); - - await build({ - entryPoints: [entryPoint], - bundle: true, - metafile: true, - write: false, - format: "esm", - platform: "node", - outdir: dir, - absWorkingDir: dir, - logLevel: "silent", - plugins: [collector.plugin], - }); - - expect(collector.usages).toHaveLength(1); - expect(collector.usages[0]).toMatchObject({ specifier: "mssql", file: "entry.ts" }); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - it("scans a file only once when it appears with and without a query suffix", async () => { const dir = await mkdtemp(join(tmpdir(), "create-require-collector-")); diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index 048228ddb74..e869b6b7c74 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -3,12 +3,11 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; import * as esbuild from "esbuild"; import { readFile, stat } from "node:fs/promises"; -import { dirname, isAbsolute, resolve } from "node:path"; +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 { - escapeRegExp, isBareModuleImport, isBuiltinModule, makeExternalRegexp, @@ -31,19 +30,6 @@ export type CreateRequireUsage = CreateRequireSpecifier & { packageName: string; }; -export type SourceScanResult = { - specifiers: CreateRequireSpecifier[]; - /** Names of require functions this file creates and exports (`export const req = createRequire(...)`) */ - exportedRequireFns: string[]; -}; - -/** - * Decides whether an imported binding is a require function created in - * another scanned file. Receives the binding's exported name and the import - * specifier it came from. - */ -export type KnownRequireFnImportPredicate = (name: string, fromSpecifier: string) => boolean; - /** * Finds string-literal package specifiers loaded through `createRequire`, e.g. * `createRequire(import.meta.url)("mssql")` or @@ -54,44 +40,19 @@ export type KnownRequireFnImportPredicate = (name: string, fromSpecifier: string * 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 at - * module level: computed specifiers, re-exports of createRequire itself, and - * shadowed names are not followed. + * (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: string, - knownRequireFnExports?: ReadonlySet -): CreateRequireSpecifier[] { - return scanSource( - source, - knownRequireFnExports ? (name) => knownRequireFnExports.has(name) : undefined - ).specifiers; -} - -type AstNode = { - type: string; - start?: number | null; - loc?: { start: { line: number; column: number } } | null; - [key: string]: unknown; -}; - -const MODULE_BUILTIN_SPECIFIERS = new Set(["module", "node:module"]); -const PARSER_PLUGIN_ATTEMPTS: ParserPlugin[][] = [["typescript", "jsx"], ["typescript"], []]; - -export function scanSource( - source: string, - isKnownRequireFnImport?: KnownRequireFnImportPredicate -): SourceScanResult { - const empty: SourceScanResult = { specifiers: [], exportedRequireFns: [] }; - - if (!source.includes("createRequire") && !isKnownRequireFnImport) { - return empty; +export function scanSourceForCreateRequire(source: string): CreateRequireSpecifier[] { + if (!source.includes("createRequire")) { + return []; } const ast = parseWithFallbacks(source); if (!ast) { - return empty; + return []; } const collected = collectAstFacts(ast); @@ -107,6 +68,10 @@ export function scanSource( } } + if (aliases.size === 0 && namespaces.size === 0) { + return []; + } + const isCreateRequireCall = (node: AstNode): boolean => { if (node.type !== "CallExpression") { return false; @@ -134,29 +99,10 @@ export function scanSource( }; const requireFnNames = new Set(); - const exportedRequireFns = new Set(); for (const binding of collected.bindings) { if (isCreateRequireCall(binding.value)) { requireFnNames.add(binding.name); - - if (binding.exported) { - exportedRequireFns.add(binding.name); - } - } - } - - for (const name of collected.exportSpecifierNames) { - if (requireFnNames.has(name)) { - exportedRequireFns.add(name); - } - } - - if (isKnownRequireFnImport) { - for (const relativeImport of collected.relativeNamedImports) { - if (isKnownRequireFnImport(relativeImport.importedName, relativeImport.fromSpecifier)) { - requireFnNames.add(relativeImport.localName); - } } } @@ -199,9 +145,19 @@ export function scanSource( specifiers.sort((a, b) => a.line - b.line || a.column - b.column); - return { specifiers, exportedRequireFns: Array.from(exportedRequireFns) }; + return specifiers; } +type AstNode = { + type: string; + start?: number | null; + loc?: { start: { line: number; column: number } } | null; + [key: string]: unknown; +}; + +const MODULE_BUILTIN_SPECIFIERS = new Set(["module", "node:module"]); +const PARSER_PLUGIN_ATTEMPTS: ParserPlugin[][] = [["typescript", "jsx"], ["typescript"], []]; + function parseWithFallbacks(source: string): AstNode | undefined { for (const plugins of PARSER_PLUGIN_ATTEMPTS) { try { @@ -221,10 +177,7 @@ function parseWithFallbacks(source: string): AstNode | undefined { type AstFacts = { moduleImports: Array<{ kind: "createRequire" | "namespace"; localName: string }>; - bindings: Array<{ name: string; value: AstNode; exported: boolean }>; - /** Local names exported via `export { name }` (specifier form) */ - exportSpecifierNames: string[]; - relativeNamedImports: Array<{ importedName: string; localName: string; fromSpecifier: string }>; + bindings: Array<{ name: string; value: AstNode }>; calls: Array<{ callee: AstNode; specifier: string | undefined; @@ -238,44 +191,18 @@ function collectAstFacts(ast: AstNode): AstFacts { const facts: AstFacts = { moduleImports: [], bindings: [], - exportSpecifierNames: [], - relativeNamedImports: [], calls: [], }; - const visit = (node: AstNode, exported: boolean) => { + const visit = (node: AstNode) => { switch (node.type) { - case "ExportNamedDeclaration": { - const declaration = node.declaration as AstNode | null; - - if (declaration) { - visit(declaration, true); - } else if (!node.source) { - for (const specifier of (node.specifiers as AstNode[]) ?? []) { - const local = specifier.local as AstNode | undefined; - - if (specifier.type === "ExportSpecifier" && local?.type === "Identifier") { - facts.exportSpecifierNames.push(local.name as string); - } - } - } - - return; - } - case "VariableDeclaration": { - for (const declarator of node.declarations as AstNode[]) { - visit(declarator, exported); - } - - return; - } case "ImportDeclaration": { collectImportDeclaration(node, facts); return; } case "VariableDeclarator": { - collectVariableDeclarator(node, exported, facts); + collectVariableDeclarator(node, facts); break; } case "AssignmentExpression": { @@ -283,7 +210,7 @@ function collectAstFacts(ast: AstNode): AstFacts { const right = node.right as AstNode; if (node.operator === "=" && left.type === "Identifier") { - facts.bindings.push({ name: left.name as string, value: right, exported: false }); + facts.bindings.push({ name: left.name as string, value: right }); } break; @@ -303,24 +230,20 @@ function collectAstFacts(ast: AstNode): AstFacts { } } - visitChildren(node); - }; - - const visitChildren = (node: AstNode) => { for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const item of value) { if (isAstNode(item)) { - visit(item, false); + visit(item); } } } else if (isAstNode(value)) { - visit(value, false); + visit(value); } } }; - visit(ast, false); + visit(ast); return facts; } @@ -331,43 +254,27 @@ function isAstNode(value: unknown): value is AstNode { function collectImportDeclaration(node: AstNode, facts: AstFacts) { const importSource = node.source as AstNode; - const specifierValue = importSource.value as string; - const specifiers = node.specifiers as AstNode[]; - if (MODULE_BUILTIN_SPECIFIERS.has(specifierValue)) { - for (const specifier of specifiers) { - const localName = (specifier.local as AstNode).name as string; - - if (specifier.type === "ImportSpecifier") { - const imported = specifier.imported as AstNode; + if (!MODULE_BUILTIN_SPECIFIERS.has(importSource.value as string)) { + return; + } - if (imported.type === "Identifier" && imported.name === "createRequire") { - facts.moduleImports.push({ kind: "createRequire", localName }); - } - } else { - facts.moduleImports.push({ kind: "namespace", localName }); - } - } - } else if (specifierValue.startsWith(".")) { - for (const specifier of specifiers) { - if (specifier.type !== "ImportSpecifier") { - continue; - } + for (const specifier of node.specifiers as AstNode[]) { + const localName = (specifier.local as AstNode).name as string; + if (specifier.type === "ImportSpecifier") { const imported = specifier.imported as AstNode; - if (imported.type === "Identifier") { - facts.relativeNamedImports.push({ - importedName: imported.name as string, - localName: (specifier.local as AstNode).name as string, - fromSpecifier: specifierValue, - }); + if (imported.type === "Identifier" && imported.name === "createRequire") { + facts.moduleImports.push({ kind: "createRequire", localName }); } + } else { + facts.moduleImports.push({ kind: "namespace", localName }); } } } -function collectVariableDeclarator(node: AstNode, exported: boolean, facts: AstFacts) { +function collectVariableDeclarator(node: AstNode, facts: AstFacts) { const id = node.id as AstNode; const init = node.init as AstNode | null; @@ -375,9 +282,7 @@ function collectVariableDeclarator(node: AstNode, exported: boolean, facts: AstF return; } - const moduleLoad = isModuleBuiltinLoad(init); - - if (moduleLoad) { + if (isModuleBuiltinLoad(init)) { if (id.type === "Identifier") { facts.moduleImports.push({ kind: "namespace", localName: id.name as string }); } else if (id.type === "ObjectPattern") { @@ -403,7 +308,7 @@ function collectVariableDeclarator(node: AstNode, exported: boolean, facts: AstF } if (id.type === "Identifier") { - facts.bindings.push({ name: id.name as string, value: init, exported }); + facts.bindings.push({ name: id.name as string, value: init }); } } @@ -472,22 +377,15 @@ const FILE_READ_CONCURRENCY = 16; type CollectorCacheEntry = { mtimeMs: number; size: number; - /** Scan without cross-file require-fn knowledge */ - base: SourceScanResult; - /** Signature of the cross-file exports under which finalSpecifiers was computed */ - knownsSignature: string; - finalSpecifiers: CreateRequireSpecifier[]; + specifiers: CreateRequireSpecifier[]; }; /** * 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. - * Require functions exported from one scanned file and imported into another - * are followed, resolving import specifiers through the metafile's own - * resolved import records (with a filesystem fallback), so index files and - * path aliases work. Scan results are cached per file by mtime and size so - * dev rebuilds only re-read changed files. + * 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 { private _usages: CreateRequireUsage[] = []; @@ -525,183 +423,81 @@ export class CreateRequireCollector { } private async collect(metafile: esbuild.Metafile): Promise { - const usages: CreateRequireUsage[] = []; const files: Array<{ inputPath: string; filePath: string }> = []; const seenPaths = new Set(); - const importResolutions = new Map>(); - for (const [inputPath, input] of Object.entries(metafile.inputs)) { + for (const inputPath of Object.keys(metafile.inputs)) { const cleanPath = inputPath.split("?")[0]!; - if (!SCANNABLE_FILE_REGEX.test(cleanPath) || NODE_MODULES_SEGMENT_REGEX.test(cleanPath)) { + if ( + seenPaths.has(cleanPath) || + !SCANNABLE_FILE_REGEX.test(cleanPath) || + NODE_MODULES_SEGMENT_REGEX.test(cleanPath) + ) { continue; } - const resolutions = importResolutions.get(cleanPath) ?? new Map(); - - for (const record of input.imports) { - if (record.original !== undefined && !record.external) { - resolutions.set(record.original, record.path.split("?")[0]!); - } - } - - importResolutions.set(cleanPath, resolutions); - - if (!seenPaths.has(cleanPath)) { - seenPaths.add(cleanPath); - files.push({ - inputPath: cleanPath, - filePath: isAbsolute(cleanPath) ? cleanPath : resolve(this.workingDir, cleanPath), - }); - } + 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); - const unchanged = - cached !== undefined && - cached.mtimeMs === stats.mtimeMs && - cached.size === stats.size; - - let source: string | undefined; - let base: SourceScanResult; - - if (unchanged) { - base = cached.base; - } else { - 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; - } - - source = contents; - base = scanSource(contents); - } - - return { ...file, stats, cached: unchanged ? cached : undefined, source, base }; - }) - ) - ) - ).filter((entry) => entry !== undefined); - - const fileKey = (filePath: string) => filePath.replace(SCANNABLE_FILE_REGEX, ""); - const exportsByInputPath = new Map>(); - const exportsByFileKey = new Map>(); - const exportedNames = new Set(); - - for (const entry of scanned) { - if (entry.base.exportedRequireFns.length > 0) { - const names = new Set(entry.base.exportedRequireFns); - - exportsByInputPath.set(entry.inputPath, names); - exportsByFileKey.set(fileKey(entry.filePath), names); + const scanned = await Promise.all( + files.map((file) => + limit(async () => { + const [statError, stats] = await tryCatch(stat(file.filePath)); - for (const name of names) { - exportedNames.add(name); - } - } - } + if (statError) { + logger.debug("[createRequire] Unable to stat bundle input file", { + filePath: file.filePath, + error: statError, + }); - const knownsSignature = Array.from(exportsByInputPath.entries()) - .flatMap(([inputPath, names]) => Array.from(names).map((name) => `${inputPath}#${name}`)) - .sort() - .join(","); + return undefined; + } - const exportedNamePattern = - exportedNames.size > 0 - ? new RegExp(`\\b(?:${Array.from(exportedNames).map(escapeRegExp).join("|")})\\b`) - : undefined; + const cached = this._cache.get(file.filePath); - const needsSource = scanned.filter( - (entry) => - entry.source === undefined && - !(entry.cached && entry.cached.knownsSignature === knownsSignature) - ); + if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) { + return { inputPath: file.inputPath, specifiers: cached.specifiers }; + } - await Promise.all( - needsSource.map((entry) => - limit(async () => { - const [readError, contents] = await tryCatch(readFile(entry.filePath, "utf8")); + const [readError, contents] = await tryCatch(readFile(file.filePath, "utf8")); if (readError) { - logger.debug("[createRequire] Unable to re-read bundle input file", { - filePath: entry.filePath, + logger.debug("[createRequire] Unable to read bundle input file", { + filePath: file.filePath, error: readError, }); - return; + return undefined; } - entry.source = contents; + const specifiers = scanSourceForCreateRequire(contents); + + this._cache.set(file.filePath, { + mtimeMs: stats.mtimeMs, + size: stats.size, + specifiers, + }); + + return { inputPath: file.inputPath, specifiers }; }) ) ); - for (const entry of scanned) { - let finalSpecifiers: CreateRequireSpecifier[]; - - if (entry.cached && entry.cached.knownsSignature === knownsSignature) { - finalSpecifiers = entry.cached.finalSpecifiers; - } else { - const source = entry.source; - - if (source === undefined) { - continue; - } - - const mentionsExportedName = exportedNamePattern?.test(source) ?? false; - - if (!mentionsExportedName) { - finalSpecifiers = entry.base.specifiers; - } else { - const resolutions = importResolutions.get(entry.inputPath); - const importerDir = dirname(entry.filePath); - - finalSpecifiers = scanSource(source, (name, fromSpecifier) => { - const metafileResolved = resolutions?.get(fromSpecifier); - - if (metafileResolved !== undefined) { - return exportsByInputPath.get(metafileResolved)?.has(name) ?? false; - } - - const key = fileKey(resolve(importerDir, fromSpecifier)); - - return exportsByFileKey.get(key)?.has(name) ?? false; - }).specifiers; - } + const usages: CreateRequireUsage[] = []; - this._cache.set(entry.filePath, { - mtimeMs: entry.stats.mtimeMs, - size: entry.stats.size, - base: entry.base, - knownsSignature, - finalSpecifiers, - }); + for (const entry of scanned) { + if (!entry) { + continue; } - for (const found of finalSpecifiers) { + for (const found of entry.specifiers) { usages.push({ ...found, file: entry.inputPath, diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index eb25fac665b..087de0d5179 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -525,7 +525,7 @@ export function makeExternalRegexp(packageName: string): RegExp { return new RegExp(pattern); } -export function escapeRegExp(value: string): string { +function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/packages/core/src/v3/build/extensions.ts b/packages/core/src/v3/build/extensions.ts index 33797ba39c3..bfa3520afa2 100644 --- a/packages/core/src/v3/build/extensions.ts +++ b/packages/core/src/v3/build/extensions.ts @@ -5,7 +5,6 @@ import { ResolvedConfig } from "./resolvedConfig.js"; export function esbuildPlugin(plugin: Plugin, options: RegisterPluginOptions = {}): BuildExtension { return { name: plugin.name, - installedPackagesForTarget: () => [], onBuildStart(context) { context.registerPlugin(plugin, options); }, @@ -19,7 +18,8 @@ export interface BuildExtension { * Package names this extension installs into the deployed image for the * given target. Diagnostics only: the bundler ignores this, it just tells * build warnings (e.g. the createRequire scan) the package will be - * available at runtime. + * available at runtime. Extensions that install no packages don't need to + * implement this. */ installedPackagesForTarget?: (target: BuildTarget) => string[] | undefined; onBuildStart?: (context: BuildContext) => Promise | void; From 08de7db013fc25113a57c0753f9812b79a474180 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 17:34:36 +0100 Subject: [PATCH 14/15] fix(cli): parse decorator syntax and never suppress on global installs Files using decorators parse instead of being skipped, and npm install -g commands no longer suppress the warning for packages task code can't resolve. --- .../cli-v3/src/build/createRequireWarnings.test.ts | 1 + packages/cli-v3/src/build/createRequireWarnings.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli-v3/src/build/createRequireWarnings.test.ts b/packages/cli-v3/src/build/createRequireWarnings.test.ts index 805b3d4f39e..0e1b1f8a073 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.test.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -688,6 +688,7 @@ describe("packagesInstalledByCommands", () => { "yarn add -D typescript", "npm install sqlite3@npm:@vscode/sqlite3", "npm install file:../local-lib", + "npm install -g wrangler-cli", "bun run generate", "npm ci", "apt-get install -y ffmpeg", diff --git a/packages/cli-v3/src/build/createRequireWarnings.ts b/packages/cli-v3/src/build/createRequireWarnings.ts index e869b6b7c74..419ffde54d2 100644 --- a/packages/cli-v3/src/build/createRequireWarnings.ts +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -156,7 +156,12 @@ type AstNode = { }; const MODULE_BUILTIN_SPECIFIERS = new Set(["module", "node:module"]); -const PARSER_PLUGIN_ATTEMPTS: ParserPlugin[][] = [["typescript", "jsx"], ["typescript"], []]; +const PARSER_PLUGIN_ATTEMPTS: ParserPlugin[][] = [ + ["typescript", "jsx", "decorators-legacy"], + ["typescript", "decorators-legacy"], + ["typescript"], + [], +]; function parseWithFallbacks(source: string): AstNode | undefined { for (const plugins of PARSER_PLUGIN_ATTEMPTS) { @@ -610,6 +615,10 @@ export function packagesInstalledByCommands(commands: ReadonlyArray): st 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; From b1a98d535aeefe0aefb2fd7a323702e399bf7c26 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 31 Aug 2026 17:45:09 +0100 Subject: [PATCH 15/15] Improved changeset --- .changeset/warn-createrequire-deploy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/warn-createrequire-deploy.md b/.changeset/warn-createrequire-deploy.md index 8fba0a2df51..54a3f6f5f0e 100644 --- a/.changeset/warn-createrequire-deploy.md +++ b/.changeset/warn-createrequire-deploy.md @@ -4,4 +4,4 @@ "@trigger.dev/core": patch --- -`deploy` and `dev` now warn, with the file, line, and suggested `additionalPackages` fix, when code loads a package through `createRequire()` that won't be available in the deployed image and would previously only fail at runtime in production. Deploys also now show the bundler's own warnings for your code instead of discarding them. +The `trigger.dev deploy` and `trigger.dev dev` commands now warn (with the suggested fix) when your code loads a package through `createRequire()` that won't be available in the deployed image. Previously it would fail at runtime in production to load the package. Deploys also now show bundler warnings for your code instead of discarding them.