diff --git a/.changeset/warn-createrequire-deploy.md b/.changeset/warn-createrequire-deploy.md new file mode 100644 index 00000000000..54a3f6f5f0e --- /dev/null +++ b/.changeset/warn-createrequire-deploy.md @@ -0,0 +1,7 @@ +--- +"trigger.dev": patch +"@trigger.dev/build": patch +"@trigger.dev/core": patch +--- + +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. diff --git a/docs/config/extensions/custom.mdx b/docs/config/extensions/custom.mdx index 02c5980cf9b..157e116c719 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. Only implement it if your extension installs packages; extensions without it are assumed to install none. + +```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. diff --git a/packages/build/src/extensions/core/additionalPackages.ts b/packages/build/src/extensions/core/additionalPackages.ts index 5380a2cb48a..cfe20b29f22 100644 --- a/packages/build/src/extensions/core/additionalPackages.ts +++ b/packages/build/src/extensions/core/additionalPackages.ts @@ -19,6 +19,23 @@ export type AdditionalPackagesOptions = { export function additionalPackages(options: AdditionalPackagesOptions): BuildExtension { return { name: "additionalPackages", + installedPackagesForTarget(target) { + if (target !== "deploy") { + return []; + } + + 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") { return; 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/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 837a1760f49..1aef4640727 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -1,6 +1,17 @@ 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 { + BundleResult, + bundleWorker, + createBuildManifestFromBundle, + logBuildWarnings, +} from "./bundle.js"; +import { + collectCreateRequireWarningMessages, + CreateRequireCollector, + extensionInstalledPackageMatchers, + NODE_MODULES_SEGMENT_REGEX, +} from "./createRequireWarnings.js"; import { bundleSkills } from "./bundleSkills.js"; import { createBuildContext, @@ -47,6 +58,8 @@ export async function buildWorker(options: BuildWorkerOptions) { const resolvedConfig = options.resolvedConfig; + const extensionPackages = extensionInstalledPackageMatchers(resolvedConfig); + const externalsExtension = createExternalsBuildExtension( options.target, resolvedConfig, @@ -72,6 +85,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 +95,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 +145,23 @@ export async function buildWorker(options: BuildWorkerOptions) { buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); if (options.target !== "dev") { + const buildWarnings = [ + ...bundleResult.warnings.filter( + (warning) => + !warning.location?.file || !NODE_MODULES_SEGMENT_REGEX.test(warning.location.file) + ), + ...collectCreateRequireWarningMessages({ + usages: createRequireCollector.usages, + buildManifest, + extensionPackages, + target: options.target, + }), + ]; + + if (buildWarnings.length > 0) { + logBuildWarnings(buildWarnings, { color: !options.plain }); + } + buildManifest = options.rewritePaths ? rewriteBuildManifestPaths(buildManifest, options.destination) : buildManifest; diff --git a/packages/cli-v3/src/build/bundle.ts b/packages/cli-v3/src/build/bundle.ts index 4d1cfd53f86..6f703dce872 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,8 +342,14 @@ function dirToEntryPointGlob(dir: string): string[] { ]; } -export function logBuildWarnings(warnings: esbuild.Message[]) { - 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 new file mode 100644 index 00000000000..0e1b1f8a073 --- /dev/null +++ b/packages/cli-v3/src/build/createRequireWarnings.test.ts @@ -0,0 +1,714 @@ +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"; +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, + packagesInstalledByCommands, + scanSourceForCreateRequire, + unavailableCreateRequireUsages, +} 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("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("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("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("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("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); +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("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("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, + }); + + 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 }); + } + }); + + 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", () => { + 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(usage, "deploy"); + + 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"); + }); + + 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("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", + onBuildStart: () => {}, + installedPackagesForTarget: () => ["ffmpeg-static"], + }, + { name: "prisma", onBuildStart: () => {}, 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("assumes an undeclared extension installs nothing", () => { + const { matchers, incomplete } = extensionInstalledPackageMatchers( + configWith([{ name: "someThirdPartyExtension", onBuildComplete: () => {} }]) + ); + + expect(incomplete).toBe(false); + expect(matchers).toEqual([]); + }); + + it("marks the result incomplete for an additionalPackages extension that predates the hook", () => { + const { incomplete } = extensionInstalledPackageMatchers( + configWith([{ name: "additionalPackages", onBuildStart: () => {} }]) + ); + + 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); + }); + + 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, engineUsage], + buildManifest: manifest, + extensionPackages: { matchers: [], incomplete: false }, + target: "deploy", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]!.text).toContain("mssql"); + }); + + it("does not suppress anything for commands that install no specific package", () => { + const messages = collectCreateRequireWarningMessages({ + usages: [usage], + buildManifest: { + externals: [], + build: { commands: ["bun run generate", "apt-get install -y ffmpeg", "npm ci"] }, + } as unknown as BuildManifest, + extensionPackages: { matchers: [], incomplete: false }, + target: "deploy", + }); + + expect(messages).toHaveLength(1); + }); +}); + +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", + "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", + ]); + + expect(packages.sort()).toEqual([ + "@prisma/engines", + "prisma", + "sqlite3", + "typescript", + "wrangler", + ]); + }); +}); + +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..419ffde54d2 --- /dev/null +++ b/packages/cli-v3/src/build/createRequireWarnings.ts @@ -0,0 +1,729 @@ +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"; +import { readFile, stat } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import pLimit from "p-limit"; +import { tryCatch } from "@trigger.dev/core/v3"; +import { logger } from "../utilities/logger.js"; +import { + isBareModuleImport, + isBuiltinModule, + makeExternalRegexp, + packageNameForImportPath, +} from "./externals.js"; + +export { packageNameForImportPath as packageNameForSpecifier } from "./externals.js"; + +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; +}; + +/** + * Finds string-literal package specifiers loaded through `createRequire`, e.g. + * `createRequire(import.meta.url)("mssql")` or + * `const req = createRequire(import.meta.url); req("mssql")`. + * + * esbuild treats `createRequire` as an opaque call: nothing it loads is ever + * resolved, so such packages are neither bundled nor collected as externals + * and are missing from deployed images. The source is parsed with + * `@babel/parser`, so comments, strings, templates, regex literals and JSX + * can never confuse the scan; a file that fails to parse is skipped + * (diagnostics must never fail a build). Binding tracking is name-based, + * module-level, and per-file: computed specifiers, shadowed names, and a + * require function imported from another file are not followed. + */ +export function scanSourceForCreateRequire(source: string): CreateRequireSpecifier[] { + if (!source.includes("createRequire")) { + return []; + } + + const ast = parseWithFallbacks(source); + + if (!ast) { + return []; + } + + const collected = collectAstFacts(ast); + + const aliases = new Set(); + const namespaces = new Set(); + + for (const moduleImport of collected.moduleImports) { + if (moduleImport.kind === "createRequire") { + aliases.add(moduleImport.localName); + } else { + namespaces.add(moduleImport.localName); + } + } + + if (aliases.size === 0 && namespaces.size === 0) { + return []; + } + + const isCreateRequireCall = (node: AstNode): boolean => { + if (node.type !== "CallExpression") { + return false; + } + + const callee = node.callee as AstNode; + + if (callee.type === "Identifier") { + return aliases.has(callee.name as string); + } + + 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" + ); + } + + return false; + }; + + const requireFnNames = new Set(); + + for (const binding of collected.bindings) { + if (isCreateRequireCall(binding.value)) { + requireFnNames.add(binding.name); + } + } + + const lines = source.split("\n"); + const specifiers: CreateRequireSpecifier[] = []; + const seenStarts = new Set(); + + for (const call of collected.calls) { + const callee = call.callee; + + 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 (!isRequireFnCall && !isCreateRequireCall(callee)) { + continue; + } + + if ( + call.specifier === undefined || + call.start === undefined || + seenStarts.has(call.start) || + !isWarnableSpecifier(call.specifier) + ) { + continue; + } + + seenStarts.add(call.start); + specifiers.push({ + specifier: call.specifier, + line: call.line, + column: call.column, + lineText: lines[call.line - 1] ?? "", + }); + } + + specifiers.sort((a, b) => a.line - b.line || a.column - b.column); + + return specifiers; +} + +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", "decorators-legacy"], + ["typescript", "decorators-legacy"], + ["typescript"], + [], +]; + +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 }>; + calls: Array<{ + callee: AstNode; + specifier: string | undefined; + start: number | undefined; + line: number; + column: number; + }>; +}; + +function collectAstFacts(ast: AstNode): AstFacts { + const facts: AstFacts = { + moduleImports: [], + bindings: [], + calls: [], + }; + + const visit = (node: AstNode) => { + switch (node.type) { + case "ImportDeclaration": { + collectImportDeclaration(node, facts); + + return; + } + case "VariableDeclarator": { + collectVariableDeclarator(node, facts); + break; + } + case "AssignmentExpression": { + const left = node.left as AstNode; + const right = node.right as AstNode; + + if (node.operator === "=" && left.type === "Identifier") { + facts.bindings.push({ name: left.name as string, value: right }); + } + + 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; + } + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isAstNode(item)) { + visit(item); + } + } + } else if (isAstNode(value)) { + visit(value); + } + } + }; + + visit(ast); + + return facts; +} + +function isAstNode(value: unknown): value is AstNode { + return typeof value === "object" && value !== null && typeof (value as AstNode).type === "string"; +} + +function collectImportDeclaration(node: AstNode, facts: AstFacts) { + const importSource = node.source as AstNode; + + if (!MODULE_BUILTIN_SPECIFIERS.has(importSource.value as string)) { + return; + } + + 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" && imported.name === "createRequire") { + facts.moduleImports.push({ kind: "createRequire", localName }); + } + } else { + facts.moduleImports.push({ kind: "namespace", localName }); + } + } +} + +function collectVariableDeclarator(node: AstNode, facts: AstFacts) { + const id = node.id as AstNode; + const init = node.init as AstNode | null; + + if (!init) { + return; + } + + if (isModuleBuiltinLoad(init)) { + 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; + } + + 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 }); + } + } + } + + return; + } + + if (id.type === "Identifier") { + facts.bindings.push({ name: id.name as string, value: init }); + } +} + +function isModuleBuiltinLoad(node: AstNode): boolean { + const call = node.type === "AwaitExpression" ? (node.argument as AstNode) : node; + + if (call.type !== "CallExpression") { + return false; + } + + const callee = call.callee as AstNode; + const isLoader = + (callee.type === "Identifier" && callee.name === "require") || callee.type === "Import"; + + if (!isLoader) { + return false; + } + + const args = call.arguments as AstNode[]; + const arg = args[0]; + + return ( + arg !== undefined && + arg.type === "StringLiteral" && + MODULE_BUILTIN_SPECIFIERS.has(arg.value as string) + ); +} + +function stringArgumentValue(node: AstNode | undefined): string | undefined { + if (!node) { + return undefined; + } + + if (node.type === "StringLiteral") { + return node.value as string; + } + + 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 undefined; +} + +function isWarnableSpecifier(specifier: string): boolean { + if ( + specifier.length === 0 || + specifier.startsWith("#") || + specifier.startsWith("node:") || + specifier.includes("\\") || + /^[A-Za-z]:/.test(specifier) + ) { + return false; + } + + return isBareModuleImport(specifier) && !isBuiltinModule(packageNameForImportPath(specifier)); +} + +const SCANNABLE_FILE_REGEX = /\.(?:m|c)?(?:j|t)sx?$/; +export const NODE_MODULES_SEGMENT_REGEX = /(?:^|[\\/])node_modules[\\/]/; +const FILE_READ_CONCURRENCY = 16; + +type CollectorCacheEntry = { + mtimeMs: number; + size: number; + 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. + * 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[] = []; + private _cache = new Map(); + private _plugin: esbuild.Plugin | undefined; + + constructor(private readonly workingDir: string) {} + + get usages(): ReadonlyArray { + return this._usages; + } + + get plugin(): esbuild.Plugin { + this._plugin ??= { + name: "create-require-collector", + setup: (build) => { + build.onEnd(async (result) => { + if (!result.metafile) { + this._usages = []; + + return; + } + + try { + this._usages = await this.collect(result.metafile); + } catch (error) { + logger.debug("[createRequire] Scan failed; skipping warnings", { error }); + this._usages = []; + } + }); + }, + }; + + return this._plugin; + } + + 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 ( + seenPaths.has(cleanPath) || + !SCANNABLE_FILE_REGEX.test(cleanPath) || + NODE_MODULES_SEGMENT_REGEX.test(cleanPath) + ) { + continue; + } + + seenPaths.add(cleanPath); + files.push({ + inputPath: cleanPath, + filePath: isAbsolute(cleanPath) ? cleanPath : resolve(this.workingDir, cleanPath), + }); + } + + const limit = pLimit(FILE_READ_CONCURRENCY); + + const scanned = await Promise.all( + files.map((file) => + limit(async () => { + const [statError, stats] = await tryCatch(stat(file.filePath)); + + if (statError) { + logger.debug("[createRequire] Unable to stat bundle input file", { + filePath: file.filePath, + error: statError, + }); + + return undefined; + } + + const cached = this._cache.get(file.filePath); + + if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) { + return { inputPath: file.inputPath, specifiers: cached.specifiers }; + } + + const [readError, contents] = await tryCatch(readFile(file.filePath, "utf8")); + + if (readError) { + logger.debug("[createRequire] Unable to read bundle input file", { + filePath: file.filePath, + error: readError, + }); + + return undefined; + } + + const specifiers = scanSourceForCreateRequire(contents); + + this._cache.set(file.filePath, { + mtimeMs: stats.mtimeMs, + size: stats.size, + specifiers, + }); + + return { inputPath: file.inputPath, specifiers }; + }) + ) + ); + + const usages: CreateRequireUsage[] = []; + + for (const entry of scanned) { + if (!entry) { + continue; + } + + for (const found of entry.specifiers) { + usages.push({ + ...found, + file: entry.inputPath, + packageName: packageNameForImportPath(found.specifier), + }); + } + } + + return usages; + } +} + +export type ExtensionInstalledPackages = { + matchers: RegExp[]; + /** + * 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; +}; + +/** + * Package-name matchers for everything the configured build extensions + * declare they install into or externalize for the deployed image. Reads + * only the user's configured extensions; call it before internal extensions + * (e.g. the externals collector) are prepended to the build context, and it + * skips them by name as a second guard. Never throws: diagnostics must not + * fail a build. + */ +export function extensionInstalledPackageMatchers( + config: ResolvedConfig +): ExtensionInstalledPackages { + const matchers: RegExp[] = []; + let incomplete = false; + + for (const buildExtension of config.build?.extensions ?? []) { + if (buildExtension.name === "externals") { + continue; + } + + try { + const declaresPackages = + typeof buildExtension.installedPackagesForTarget === "function" || + typeof buildExtension.externalsForTarget === "function"; + + if (!declaresPackages) { + if (buildExtension.name === "additionalPackages") { + incomplete = true; + } + + continue; + } + + const declared = [ + ...(buildExtension.installedPackagesForTarget?.("deploy") ?? []), + ...(buildExtension.externalsForTarget?.("deploy") ?? []), + ]; + + for (const packageName of declared) { + matchers.push(makeExternalRegexp(packageName)); + } + } catch (error) { + logger.debug("[createRequire] Build extension package declaration failed", { + extension: buildExtension.name, + error, + }); + + incomplete = true; + } + } + + return { matchers, incomplete }; +} + +/** + * Filters collected usages down to the ones that will actually be missing at + * runtime in the deployed image: not in the resolved externals (the installed + * dependencies) and not declared as installed by a build extension. + */ +export function unavailableCreateRequireUsages( + usages: 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) + ) + ); +} + +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)) { + if (/(?:^|\s)(?:-g|--global)(?:\s|$)/.test(match[1]!)) { + continue; + } + + for (const token of match[1]!.trim().split(/\s+/)) { + if (token.length === 0 || token.startsWith("-")) { + continue; + } + + const aliasIndex = token.indexOf("@npm:"); + + if (aliasIndex > 0) { + names.add(token.slice(0, aliasIndex)); + continue; + } + + if (token.includes(":")) { + continue; + } + + const versionAt = token.lastIndexOf("@"); + const name = versionAt > 0 ? token.slice(0, versionAt) : token; + + if (name.length > 0 && !name.startsWith(".") && !name.startsWith("/")) { + names.add(name); + } + } + } + } + + return Array.from(names); +} + +/** + * The shared dev/deploy warning pipeline: suppress usages that will be + * available in the image (manifest externals, extension-declared packages, + * packages named in build-layer install commands), render the rest. Returns + * [] instead of throwing on any internal failure, and stays silent for dev + * when extension-installed packages can't be determined (see + * ExtensionInstalledPackages.incomplete). + */ +export function collectCreateRequireWarningMessages({ + usages, + buildManifest, + extensionPackages, + target, +}: { + usages: ReadonlyArray; + buildManifest: BuildManifest; + extensionPackages: ExtensionInstalledPackages; + target: BuildTarget; +}): esbuild.PartialMessage[] { + try { + if (target === "dev" && extensionPackages.incomplete) { + return []; + } + + const matchers = [ + ...extensionPackages.matchers, + ...packagesInstalledByCommands(buildManifest.build?.commands ?? []).map(makeExternalRegexp), + ]; + + const installedPackages = new Set( + (buildManifest.externals ?? []).map((external) => external.name) + ); + + return unavailableCreateRequireUsages(usages, installedPackages, matchers).map((usage) => + createRequireUsageToWarning(usage, target) + ); + } catch (error) { + logger.debug("[createRequire] Warning generation failed; skipping", { error }); + + return []; + } +} + +export function createRequireUsageToWarning( + usage: 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, + location: { + file: usage.file, + line: usage.line, + column: usage.column, + lineText: usage.lineText, + }, + notes: [ + { + text: `To fix this, install "${usage.packageName}" into the image by adding the additionalPackages build extension to your trigger.config.ts: + + import { additionalPackages } from "@trigger.dev/build/extensions/core"; + + export default defineConfig({ + // ... + build: { + extensions: [additionalPackages({ packages: ["${usage.packageName}"] })], + }, + }); + +Alternatively, replace the createRequire() call with a static import so the package is bundled. If this load is intentionally optional (guarded by try/catch with a fallback), you can ignore this warning. Docs: https://trigger.dev/docs/config/extensions/additionalPackages`, + }, + ], + }; +} diff --git a/packages/cli-v3/src/build/externals.ts b/packages/cli-v3/src/build/externals.ts index 38d8e4cdf50..087de0d5179 100644 --- a/packages/cli-v3/src/build/externals.ts +++ b/packages/cli-v3/src/build/externals.ts @@ -519,17 +519,17 @@ export function createExternalsBuildExtension( }; } -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}(?:/[^'"]*)?$`; +export function makeExternalRegexp(packageName: string): RegExp { + const pattern = `^${escapeRegExp(packageName)}(?:/[^'"]*)?$`; return new RegExp(pattern); } -function packageNameForImportPath(importPath: string): string { +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(/^@/, ""); @@ -572,12 +572,12 @@ 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)); } -function isBuiltinModule(path: string): boolean { +export function isBuiltinModule(path: string): boolean { return builtinModules.includes(path.replace("node:", "")); } diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index 24259c0c119..090730bdfb0 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -16,6 +16,11 @@ import { resolvePluginsForContext, } from "../build/extensions.js"; import { createExternalsBuildExtension, resolveAlwaysExternal } from "../build/externals.js"; +import { + collectCreateRequireWarningMessages, + CreateRequireCollector, + extensionInstalledPackageMatchers, +} 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 +88,8 @@ export async function startDevSession({ }); const externalsExtension = createExternalsBuildExtension("dev", rawConfig, alwaysExternal); + const createRequireCollector = new CreateRequireCollector(rawConfig.workingDir); + const extensionPackages = extensionInstalledPackageMatchers(rawConfig); const buildContext = createBuildContext("dev", rawConfig); buildContext.prependExtension(externalsExtension); await notifyExtensionOnBuildStart(buildContext); @@ -115,6 +122,17 @@ export async function startDevSession({ buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); + const createRequireWarnings = collectCreateRequireWarningMessages({ + usages: createRequireCollector.usages, + buildManifest, + extensionPackages, + target: "dev", + }); + + if (createRequireWarnings.length > 0) { + logBuildWarnings(createRequireWarnings); + } + try { logger.debug("Updated bundle", { bundle, buildManifest }); @@ -194,7 +212,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, diff --git a/packages/core/src/v3/build/extensions.ts b/packages/core/src/v3/build/extensions.ts index 6b461985567..bfa3520afa2 100644 --- a/packages/core/src/v3/build/extensions.ts +++ b/packages/core/src/v3/build/extensions.ts @@ -14,6 +14,14 @@ 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. Extensions that install no packages don't need to + * implement this. + */ + installedPackagesForTarget?: (target: BuildTarget) => string[] | undefined; onBuildStart?: (context: BuildContext) => Promise | void; onBuildComplete?: ( context: BuildContext, 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