Skip to content

Commit fc80816

Browse files
committed
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.
1 parent 1d55693 commit fc80816

5 files changed

Lines changed: 489 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
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.

packages/cli-v3/src/build/buildWorker.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
22
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas";
3-
import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js";
3+
import * as esbuild from "esbuild";
4+
import {
5+
BundleResult,
6+
bundleWorker,
7+
createBuildManifestFromBundle,
8+
logBuildWarnings,
9+
} from "./bundle.js";
10+
import { CreateRequireCollector, createRequireUsageToWarning } from "./createRequireWarnings.js";
411
import { bundleSkills } from "./bundleSkills.js";
512
import {
613
createBuildContext,
@@ -72,6 +79,7 @@ export async function buildWorker(options: BuildWorkerOptions) {
7279
const pluginsFromExtensions = resolvePluginsForContext(buildContext);
7380

7481
const sdkVersionExtractor = new SdkVersionExtractor();
82+
const createRequireCollector = new CreateRequireCollector(resolvedConfig.workingDir);
7583

7684
options.listener?.onBundleStart?.();
7785

@@ -81,7 +89,11 @@ export async function buildWorker(options: BuildWorkerOptions) {
8189
destination: options.destination,
8290
watch: false,
8391
resolvedConfig,
84-
plugins: [sdkVersionExtractor.plugin, ...pluginsFromExtensions],
92+
plugins: [
93+
sdkVersionExtractor.plugin,
94+
...(options.target === "dev" ? [] : [createRequireCollector.plugin]),
95+
...pluginsFromExtensions,
96+
],
8597
jsxFactory: resolvedConfig.build.jsx.factory,
8698
jsxFragment: resolvedConfig.build.jsx.fragment,
8799
jsxAutomatic: resolvedConfig.build.jsx.automatic,
@@ -127,6 +139,16 @@ export async function buildWorker(options: BuildWorkerOptions) {
127139
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
128140

129141
if (options.target !== "dev") {
142+
const buildWarnings = collectDeployBuildWarnings(
143+
bundleResult,
144+
createRequireCollector,
145+
buildManifest
146+
);
147+
148+
if (buildWarnings.length > 0) {
149+
logBuildWarnings(buildWarnings);
150+
}
151+
130152
buildManifest = options.rewritePaths
131153
? rewriteBuildManifestPaths(buildManifest, options.destination)
132154
: buildManifest;
@@ -142,6 +164,31 @@ export async function buildWorker(options: BuildWorkerOptions) {
142164
return buildManifest;
143165
}
144166

167+
/**
168+
* Deploy-only diagnostics: esbuild's own warnings scoped to the user's files,
169+
* plus packages loaded via createRequire() that end up neither bundled nor
170+
* installed in the image (i.e. not in the manifest's externals).
171+
*/
172+
function collectDeployBuildWarnings(
173+
bundleResult: BundleResult,
174+
createRequireCollector: CreateRequireCollector,
175+
buildManifest: BuildManifest
176+
): esbuild.PartialMessage[] {
177+
const esbuildWarnings = bundleResult.warnings.filter(
178+
(warning) => warning.location?.file && !warning.location.file.includes("node_modules")
179+
);
180+
181+
const installedPackages = new Set(
182+
(buildManifest.externals ?? []).map((external) => external.name)
183+
);
184+
185+
const createRequireWarnings = createRequireCollector.usages
186+
.filter((usage) => !installedPackages.has(usage.packageName))
187+
.map(createRequireUsageToWarning);
188+
189+
return [...esbuildWarnings, ...createRequireWarnings];
190+
}
191+
145192
/** @knipignore Exported for the CLI end-to-end suite. */
146193
export function rewriteBuildManifestPaths(
147194
buildManifest: BuildManifest,

packages/cli-v3/src/build/bundle.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type BundleResult = {
5555
stop: (() => Promise<void>) | undefined;
5656
/** Maps output file paths to their content hashes for deduplication */
5757
outputHashes: Record<string, string>;
58+
warnings: esbuild.Message[];
5859
};
5960

6061
export class BundleError extends Error {
@@ -323,6 +324,7 @@ export async function getBundleResultFromBuild(
323324
contentHash: hasher.digest("hex"),
324325
metafile: result.metafile,
325326
outputHashes,
327+
warnings: result.warnings,
326328
};
327329
}
328330

@@ -340,7 +342,7 @@ function dirToEntryPointGlob(dir: string): string[] {
340342
];
341343
}
342344

343-
export function logBuildWarnings(warnings: esbuild.Message[]) {
345+
export function logBuildWarnings(warnings: esbuild.PartialMessage[]) {
344346
const logs = esbuild.formatMessagesSync(warnings, { kind: "warning", color: true });
345347
for (const log of logs) {
346348
console.warn(log);
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { build } from "esbuild";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { describe, expect, it } from "vitest";
6+
import {
7+
CreateRequireCollector,
8+
packageNameForSpecifier,
9+
scanSourceForCreateRequire,
10+
} from "./createRequireWarnings.js";
11+
12+
describe("scanSourceForCreateRequire", () => {
13+
it("finds a direct createRequire invocation with a string literal", () => {
14+
const source = `import { createRequire } from "node:module";
15+
const mssql = createRequire(import.meta.url)("mssql");
16+
`;
17+
18+
const results = scanSourceForCreateRequire(source);
19+
20+
expect(results).toHaveLength(1);
21+
expect(results[0]).toMatchObject({
22+
specifier: "mssql",
23+
line: 2,
24+
column: 14,
25+
lineText: `const mssql = createRequire(import.meta.url)("mssql");`,
26+
});
27+
});
28+
29+
it("finds calls through a variable assigned from createRequire", () => {
30+
const source = `import { createRequire } from "module";
31+
const req = createRequire(import.meta.url);
32+
const pg = req("pg");
33+
const client = req('ioredis');
34+
`;
35+
36+
const results = scanSourceForCreateRequire(source);
37+
38+
expect(results.map((r) => r.specifier)).toEqual(["pg", "ioredis"]);
39+
expect(results[0]).toMatchObject({ line: 3, column: 11 });
40+
});
41+
42+
it("finds calls through require.resolve on the created require", () => {
43+
const source = `import { createRequire } from "node:module";
44+
const req = createRequire(import.meta.url);
45+
const path = req.resolve("sharp");
46+
`;
47+
48+
expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["sharp"]);
49+
});
50+
51+
it("supports an aliased createRequire import", () => {
52+
const source = `import { createRequire as makeRequire } from "node:module";
53+
const mod = makeRequire(import.meta.url)("bcrypt");
54+
`;
55+
56+
expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["bcrypt"]);
57+
});
58+
59+
it("supports member access on a module namespace", () => {
60+
const source = `import mod from "node:module";
61+
const req = mod.createRequire(import.meta.url);
62+
const pg = req("pg");
63+
`;
64+
65+
expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["pg"]);
66+
});
67+
68+
it("supports CJS destructuring of createRequire", () => {
69+
const source = `const { createRequire } = require("module");
70+
const req = createRequire(__filename);
71+
const lib = req("canvas");
72+
`;
73+
74+
expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual(["canvas"]);
75+
});
76+
77+
it("keeps subpath and scoped specifiers intact", () => {
78+
const source = `import { createRequire } from "node:module";
79+
const req = createRequire(import.meta.url);
80+
const a = req("mssql/lib/tedious");
81+
const b = req("@aws-sdk/client-s3");
82+
`;
83+
84+
expect(scanSourceForCreateRequire(source).map((r) => r.specifier)).toEqual([
85+
"mssql/lib/tedious",
86+
"@aws-sdk/client-s3",
87+
]);
88+
});
89+
90+
it("ignores relative, absolute, and internal-import specifiers", () => {
91+
const source = `import { createRequire } from "node:module";
92+
const req = createRequire(import.meta.url);
93+
req("./data.json");
94+
req("../other.js");
95+
req("/abs/path.js");
96+
req("#internal/thing");
97+
`;
98+
99+
expect(scanSourceForCreateRequire(source)).toEqual([]);
100+
});
101+
102+
it("ignores node builtins with and without the node: prefix", () => {
103+
const source = `import { createRequire } from "node:module";
104+
const req = createRequire(import.meta.url);
105+
req("fs");
106+
req("node:path");
107+
req("fs/promises");
108+
`;
109+
110+
expect(scanSourceForCreateRequire(source)).toEqual([]);
111+
});
112+
113+
it("ignores non-literal specifiers", () => {
114+
const source = `import { createRequire } from "node:module";
115+
const req = createRequire(import.meta.url);
116+
const name = "mssql";
117+
req(name);
118+
`;
119+
120+
expect(scanSourceForCreateRequire(source)).toEqual([]);
121+
});
122+
123+
it("ignores a createRequire result that is only assigned, never called", () => {
124+
const source = `import { createRequire } from "node:module";
125+
globalThis.require = createRequire(import.meta.url);
126+
`;
127+
128+
expect(scanSourceForCreateRequire(source)).toEqual([]);
129+
});
130+
131+
it("returns nothing when the source doesn't mention createRequire", () => {
132+
const source = `import mssql from "mssql";
133+
export const pool = mssql.connect();
134+
`;
135+
136+
expect(scanSourceForCreateRequire(source)).toEqual([]);
137+
});
138+
139+
it("does not treat unrelated variables with similar names as require functions", () => {
140+
const source = `import { createRequire } from "node:module";
141+
const req = createRequire(import.meta.url);
142+
const reqCount = tally("metrics");
143+
obj.req("not-a-require");
144+
`;
145+
146+
expect(scanSourceForCreateRequire(source)).toEqual([]);
147+
});
148+
});
149+
150+
describe("CreateRequireCollector", () => {
151+
it("collects createRequire usages from bundle inputs", async () => {
152+
const dir = await mkdtemp(join(tmpdir(), "create-require-collector-"));
153+
154+
try {
155+
const entryPoint = join(dir, "entry.ts");
156+
await writeFile(
157+
entryPoint,
158+
`import { createRequire } from "node:module";
159+
export const mssql = createRequire(import.meta.url)("mssql");
160+
`
161+
);
162+
163+
const collector = new CreateRequireCollector(dir);
164+
165+
await build({
166+
entryPoints: [entryPoint],
167+
bundle: true,
168+
metafile: true,
169+
write: false,
170+
format: "esm",
171+
platform: "node",
172+
outdir: dir,
173+
absWorkingDir: dir,
174+
logLevel: "silent",
175+
plugins: [collector.plugin],
176+
});
177+
178+
expect(collector.usages).toHaveLength(1);
179+
expect(collector.usages[0]).toMatchObject({
180+
specifier: "mssql",
181+
packageName: "mssql",
182+
file: "entry.ts",
183+
line: 2,
184+
});
185+
} finally {
186+
await rm(dir, { recursive: true, force: true });
187+
}
188+
});
189+
});
190+
191+
describe("packageNameForSpecifier", () => {
192+
it("extracts the package name from plain, subpath, and scoped specifiers", () => {
193+
expect(packageNameForSpecifier("mssql")).toBe("mssql");
194+
expect(packageNameForSpecifier("mssql/lib/tedious")).toBe("mssql");
195+
expect(packageNameForSpecifier("@aws-sdk/client-s3")).toBe("@aws-sdk/client-s3");
196+
expect(packageNameForSpecifier("@aws-sdk/client-s3/dist/index.js")).toBe("@aws-sdk/client-s3");
197+
});
198+
});

0 commit comments

Comments
 (0)