Skip to content

Commit ade99cf

Browse files
thomasballingerConvex, Inc.
authored andcommitted
dev --once --debug-node-apis (#35201)
Flag to bundle non-'use node' entry points one at a time in order to provide an import trace if they use Node.js APIs. If this works well it could run automatically on relevant bundler errors. GitOrigin-RevId: bfbdc200f0f7a7246137671513f6b60e6858aee6
1 parent 63efd59 commit ade99cf

File tree

16 files changed

+506
-45
lines changed

16 files changed

+506
-45
lines changed
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import path from "path";
2+
import esbuild, { BuildFailure, LogLevel, Plugin } from "esbuild";
3+
import {
4+
Context,
5+
logError,
6+
changeSpinner,
7+
logFailure,
8+
logVerbose,
9+
logMessage,
10+
} from "./context.js";
11+
import { wasmPlugin } from "./wasm.js";
12+
import dependencyTrackerPlugin from "./depgraph.js";
13+
14+
export async function innerEsbuild({
15+
entryPoints,
16+
platform,
17+
dir,
18+
extraConditions,
19+
generateSourceMaps,
20+
plugins,
21+
chunksFolder,
22+
logLevel,
23+
}: {
24+
entryPoints: string[];
25+
platform: esbuild.Platform;
26+
dir: string;
27+
extraConditions: string[];
28+
generateSourceMaps: boolean;
29+
plugins: Plugin[];
30+
chunksFolder: string;
31+
logLevel?: LogLevel;
32+
}) {
33+
const result = await esbuild.build({
34+
entryPoints,
35+
bundle: true,
36+
platform: platform,
37+
format: "esm",
38+
target: "esnext",
39+
jsx: "automatic",
40+
outdir: "out",
41+
outbase: dir,
42+
conditions: ["convex", "module", ...extraConditions],
43+
plugins,
44+
write: false,
45+
sourcemap: generateSourceMaps,
46+
splitting: true,
47+
chunkNames: path.join(chunksFolder, "[hash]"),
48+
treeShaking: true,
49+
minifySyntax: true,
50+
minifyIdentifiers: true,
51+
// Enabling minifyWhitespace breaks sourcemaps on convex backends.
52+
// The sourcemaps produced are valid on https://evanw.github.io/source-map-visualization
53+
// but something we're doing (perhaps involving https://github.com/getsentry/rust-sourcemap)
54+
// makes everything map to the same line.
55+
minifyWhitespace: false, // false is the default, just showing for clarify.
56+
keepNames: true,
57+
define: {
58+
"process.env.NODE_ENV": '"production"',
59+
},
60+
metafile: true,
61+
logLevel: logLevel || "warning",
62+
});
63+
return result;
64+
}
65+
66+
export function isEsbuildBuildError(e: any): e is BuildFailure {
67+
return (
68+
"errors" in e &&
69+
"warnings" in e &&
70+
Array.isArray(e.errors) &&
71+
Array.isArray(e.warnings)
72+
);
73+
}
74+
75+
/**
76+
* Bundle non-"use node" entry points one at a time to track down the first file with an error
77+
* is being imported.
78+
*/
79+
export async function debugIsolateBundlesSerially(
80+
ctx: Context,
81+
{
82+
entryPoints,
83+
extraConditions,
84+
dir,
85+
}: {
86+
entryPoints: string[];
87+
extraConditions: string[];
88+
dir: string;
89+
},
90+
): Promise<void> {
91+
logMessage(
92+
ctx,
93+
`Bundling convex entry points one at a time to track down things that can't be bundled for the Convex JS runtime.`,
94+
);
95+
let i = 1;
96+
for (const entryPoint of entryPoints) {
97+
changeSpinner(
98+
ctx,
99+
`bundling entry point ${entryPoint} (${i++}/${entryPoints.length})...`,
100+
);
101+
102+
const { plugin, tracer } = dependencyTrackerPlugin();
103+
try {
104+
await innerEsbuild({
105+
entryPoints: [entryPoint],
106+
platform: "browser",
107+
generateSourceMaps: true,
108+
chunksFolder: "_deps",
109+
extraConditions,
110+
dir,
111+
plugins: [plugin, wasmPlugin],
112+
logLevel: "silent",
113+
});
114+
} catch (error) {
115+
if (!isEsbuildBuildError(error) || !error.errors[0]) {
116+
return await ctx.crash({
117+
exitCode: 1,
118+
errorType: "invalid filesystem data",
119+
printedMessage: null,
120+
});
121+
}
122+
123+
const buildError = error.errors[0];
124+
const errorFile = buildError.location?.file;
125+
if (!errorFile) {
126+
return await ctx.crash({
127+
exitCode: 1,
128+
errorType: "invalid filesystem data",
129+
printedMessage: null,
130+
});
131+
}
132+
133+
const importedPath = buildError.text.match(/"([^"]+)"/)?.[1];
134+
if (!importedPath) continue;
135+
136+
const full = path.resolve(errorFile);
137+
logError(ctx, "");
138+
logError(
139+
ctx,
140+
`Bundling ${entryPoint} resulted in ${error.errors.length} esbuild errors.`,
141+
);
142+
logError(
143+
ctx,
144+
`One of the bundling errors occurred while bundling ${full}:\n`,
145+
);
146+
logError(
147+
ctx,
148+
esbuild
149+
.formatMessagesSync([buildError], {
150+
kind: "error",
151+
color: true,
152+
})
153+
.join("\n"),
154+
);
155+
logError(ctx, "It would help to avoid importing this file.");
156+
const chains = tracer.traceImportChains(entryPoint, full);
157+
const chain: string[] = chains[0];
158+
chain.reverse();
159+
160+
logError(ctx, ``);
161+
if (chain.length > 0) {
162+
const problematicFileRelative = formatFilePath(dir, chain[0]);
163+
164+
if (chain.length === 1) {
165+
logError(ctx, ` ${problematicFileRelative}`);
166+
} else {
167+
logError(ctx, ` ${problematicFileRelative} is imported by`);
168+
169+
for (let i = 1; i < chain.length - 1; i++) {
170+
const fileRelative = formatFilePath(dir, chain[i]);
171+
logError(ctx, ` ${fileRelative}, which is imported by`);
172+
}
173+
174+
const entryPointFile = chain[chain.length - 1];
175+
const entryPointRelative = formatFilePath(dir, entryPointFile);
176+
177+
logError(
178+
ctx,
179+
` ${entryPointRelative}, which doesn't use "use node"\n`,
180+
);
181+
logError(
182+
ctx,
183+
` For registered action functions to use Node.js APIs in any code they run they must be defined\n` +
184+
` in a file with 'use node' at the top. See https://docs.convex.dev/functions/runtimes#nodejs-runtime\n`,
185+
);
186+
}
187+
}
188+
189+
logFailure(ctx, "Bundling failed");
190+
return await ctx.crash({
191+
exitCode: 1,
192+
errorType: "invalid filesystem data",
193+
printedMessage: "Bundling failed.",
194+
});
195+
}
196+
logVerbose(ctx, `${entryPoint} bundled`);
197+
}
198+
}
199+
200+
// Helper function to format file paths consistently
201+
function formatFilePath(baseDir: string, filePath: string): string {
202+
// If it's already a relative path like "./shared", just return it
203+
if (!path.isAbsolute(filePath)) {
204+
// For relative paths, ensure they start with "convex/"
205+
if (!filePath.startsWith("convex/")) {
206+
// If it's a path like "./subdir/file.ts" or "subdir/file.ts"
207+
const cleanPath = filePath.replace(/^\.\//, "");
208+
return `convex/${cleanPath}`;
209+
}
210+
return filePath;
211+
}
212+
213+
// Get the path relative to the base directory
214+
const relativePath = path.relative(baseDir, filePath);
215+
216+
// Remove any leading "./" that path.relative might add
217+
const cleanPath = relativePath.replace(/^\.\//, "");
218+
219+
// Check if this is a path within the convex directory
220+
const isConvexPath =
221+
cleanPath.startsWith("convex/") ||
222+
cleanPath.includes("/convex/") ||
223+
path.dirname(cleanPath) === "convex";
224+
225+
if (isConvexPath) {
226+
// If it already starts with convex/, return it as is
227+
if (cleanPath.startsWith("convex/")) {
228+
return cleanPath;
229+
}
230+
231+
// For files in the convex directory
232+
if (path.dirname(cleanPath) === "convex") {
233+
const filename = path.basename(cleanPath);
234+
return `convex/${filename}`;
235+
}
236+
237+
// For files in subdirectories of convex
238+
const convexIndex = cleanPath.indexOf("convex/");
239+
if (convexIndex >= 0) {
240+
return cleanPath.substring(convexIndex);
241+
}
242+
}
243+
244+
// For any other path, assume it's in the convex directory
245+
// This handles cases where the file is in a subdirectory of convex
246+
// but the path doesn't include "convex/" explicitly
247+
return `convex/${cleanPath}`;
248+
}

0 commit comments

Comments
 (0)