Skip to content

Commit f4c9a73

Browse files
committed
fix(functions-push): restore runner, bundler, and project parity
1 parent 7c114ae commit f4c9a73

8 files changed

Lines changed: 679 additions & 187 deletions

File tree

scripts/functions-bundler.ts

Lines changed: 234 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { spawnSync } from "node:child_process";
12
import fs from "node:fs";
3+
import { createRequire } from "node:module";
24
import path from "node:path";
5+
import { pathToFileURL } from "node:url";
36

47
type EsbuildBuild = (options: Record<string, unknown>) => Promise<unknown>;
58
type EsbuildModule = {
@@ -36,53 +39,147 @@ function loadTsconfigPath(): string | undefined {
3639
return undefined;
3740
}
3841

39-
function createMarkKnownPackagesExternalPlugin(additionalPackages: string[]) {
40-
return {
41-
name: "make-known-packages-external",
42-
setup(build: {
43-
onResolve: (
44-
opts: { filter: RegExp },
45-
cb: (args: { path: string }) => { path: string; external: boolean },
46-
) => void;
47-
}) {
48-
const knownPackages = [
49-
"braintrust",
50-
"autoevals",
51-
"@braintrust/",
52-
"config",
53-
"lightningcss",
54-
"@mapbox/node-pre-gyp",
55-
"fsevents",
56-
"chokidar",
57-
...additionalPackages,
58-
];
59-
const escapedPackages = knownPackages.map((pkg) => {
60-
const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61-
if (pkg.endsWith("/")) {
62-
return `${escaped}.*`;
63-
}
64-
return `${escaped}(?:\\/.*)?`;
65-
});
66-
const knownPackagesFilter = new RegExp(
67-
`^(${escapedPackages.join("|")})$`,
42+
function buildExternalPackagePatterns(additionalPackages: string[]): string[] {
43+
const knownPackages = [
44+
"braintrust",
45+
"autoevals",
46+
"@braintrust/",
47+
"config",
48+
"lightningcss",
49+
"@mapbox/node-pre-gyp",
50+
"fsevents",
51+
"chokidar",
52+
...additionalPackages,
53+
];
54+
const patterns = new Set<string>(["node_modules/*"]);
55+
for (const pkg of knownPackages) {
56+
const trimmed = pkg.trim();
57+
if (!trimmed) {
58+
continue;
59+
}
60+
if (trimmed.endsWith("/")) {
61+
patterns.add(`${trimmed}*`);
62+
continue;
63+
}
64+
patterns.add(trimmed);
65+
patterns.add(`${trimmed}/*`);
66+
}
67+
return [...patterns];
68+
}
69+
70+
function findNodeModulesBinary(
71+
binary: string,
72+
startPath: string,
73+
): string | null {
74+
let current = path.resolve(startPath);
75+
if (!fs.existsSync(current)) {
76+
current = path.dirname(current);
77+
} else if (!fs.statSync(current).isDirectory()) {
78+
current = path.dirname(current);
79+
}
80+
81+
const binaryCandidates =
82+
process.platform === "win32" ? [`${binary}.cmd`, binary] : [binary];
83+
84+
while (true) {
85+
for (const candidateName of binaryCandidates) {
86+
const candidate = path.join(
87+
current,
88+
"node_modules",
89+
".bin",
90+
candidateName,
6891
);
69-
build.onResolve({ filter: knownPackagesFilter }, (args) => ({
70-
path: args.path,
71-
external: true,
72-
}));
73-
},
74-
};
92+
if (fs.existsSync(candidate)) {
93+
return candidate;
94+
}
95+
}
96+
97+
const parent = path.dirname(current);
98+
if (parent === current) {
99+
return null;
100+
}
101+
current = parent;
102+
}
103+
}
104+
105+
function resolveEsbuildBinary(sourceFile: string): string | null {
106+
const searchRoots = [path.resolve(sourceFile), process.cwd()];
107+
const seen = new Set<string>();
108+
for (const root of searchRoots) {
109+
const normalized = path.resolve(root);
110+
if (seen.has(normalized)) {
111+
continue;
112+
}
113+
seen.add(normalized);
114+
const candidate = findNodeModulesBinary("esbuild", normalized);
115+
if (candidate) {
116+
return candidate;
117+
}
118+
}
119+
return null;
120+
}
121+
122+
function resolveEsbuildModulePath(sourceFile: string): string | null {
123+
const filePath = path.resolve(sourceFile);
124+
try {
125+
const requireFromFile = createRequire(pathToFileURL(filePath).href);
126+
return requireFromFile.resolve("esbuild");
127+
} catch {
128+
// Fall through to process cwd.
129+
}
130+
131+
try {
132+
const requireFromCwd = createRequire(path.join(process.cwd(), "noop.js"));
133+
return requireFromCwd.resolve("esbuild");
134+
} catch {
135+
return null;
136+
}
137+
}
138+
139+
function normalizeEsbuildModule(loaded: unknown): EsbuildModule | null {
140+
if (isEsbuildModule(loaded)) {
141+
return loaded;
142+
}
143+
if (isObject(loaded) && isEsbuildModule(loaded.default)) {
144+
return loaded.default;
145+
}
146+
return null;
75147
}
76148

77-
async function loadEsbuild(): Promise<EsbuildModule> {
149+
async function loadEsbuild(sourceFile: string): Promise<EsbuildModule | null> {
150+
const resolvedPath = resolveEsbuildModulePath(sourceFile);
151+
if (resolvedPath) {
152+
if (typeof require === "function") {
153+
try {
154+
const loaded = require(resolvedPath) as unknown;
155+
const normalized = normalizeEsbuildModule(loaded);
156+
if (normalized) {
157+
return normalized;
158+
}
159+
} catch {
160+
// Fall through to dynamic import.
161+
}
162+
}
163+
164+
try {
165+
const loaded = (await import(
166+
pathToFileURL(resolvedPath).href
167+
)) as unknown;
168+
const normalized = normalizeEsbuildModule(loaded);
169+
if (normalized) {
170+
return normalized;
171+
}
172+
} catch {
173+
// Fall through to direct require/import.
174+
}
175+
}
176+
78177
if (typeof require === "function") {
79178
try {
80179
const loaded = require("esbuild") as unknown;
81-
if (isEsbuildModule(loaded)) {
82-
return loaded;
83-
}
84-
if (isObject(loaded) && isEsbuildModule(loaded.default)) {
85-
return loaded.default;
180+
const normalized = normalizeEsbuildModule(loaded);
181+
if (normalized) {
182+
return normalized;
86183
}
87184
} catch {
88185
// Fall through to dynamic import.
@@ -93,19 +190,80 @@ async function loadEsbuild(): Promise<EsbuildModule> {
93190
// Keep module name dynamic so TypeScript doesn't require local esbuild types at compile time.
94191
const specifier = "esbuild";
95192
const loaded = (await import(specifier)) as unknown;
96-
if (isEsbuildModule(loaded)) {
97-
return loaded;
98-
}
99-
if (isObject(loaded) && isEsbuildModule(loaded.default)) {
100-
return loaded.default;
193+
const normalized = normalizeEsbuildModule(loaded);
194+
if (normalized) {
195+
return normalized;
101196
}
102197
} catch {
103198
// handled below
104199
}
105200

106-
throw new Error(
107-
"failed to load esbuild for JS bundling; install esbuild in your project or use a runner that provides it",
108-
);
201+
return null;
202+
}
203+
204+
function computeNodeTargetVersion(): string {
205+
return typeof process.version === "string" && process.version.startsWith("v")
206+
? process.version.slice(1)
207+
: process.versions.node || "18";
208+
}
209+
210+
async function bundleWithEsbuildModule(
211+
esbuild: EsbuildModule,
212+
sourceFile: string,
213+
outputFile: string,
214+
tsconfig: string | undefined,
215+
external: string[],
216+
): Promise<void> {
217+
await esbuild.build({
218+
entryPoints: [sourceFile],
219+
bundle: true,
220+
treeShaking: true,
221+
platform: "node",
222+
target: `node${computeNodeTargetVersion()}`,
223+
write: true,
224+
outfile: outputFile,
225+
tsconfig,
226+
external,
227+
});
228+
}
229+
230+
function bundleWithEsbuildBinary(
231+
esbuildBinary: string,
232+
sourceFile: string,
233+
outputFile: string,
234+
tsconfig: string | undefined,
235+
external: string[],
236+
): void {
237+
const args: string[] = [
238+
sourceFile,
239+
"--bundle",
240+
"--tree-shaking=true",
241+
"--platform=node",
242+
`--target=node${computeNodeTargetVersion()}`,
243+
`--outfile=${outputFile}`,
244+
];
245+
246+
if (tsconfig) {
247+
args.push(`--tsconfig=${tsconfig}`);
248+
}
249+
for (const pattern of external) {
250+
args.push(`--external:${pattern}`);
251+
}
252+
253+
const result = spawnSync(esbuildBinary, args, { encoding: "utf8" });
254+
if (result.error) {
255+
throw new Error(
256+
`failed to invoke esbuild CLI at ${esbuildBinary}: ${result.error.message}`,
257+
);
258+
}
259+
if (result.status !== 0) {
260+
const stderr = (result.stderr ?? "").trim();
261+
const stdout = (result.stdout ?? "").trim();
262+
const details = stderr || stdout || "unknown error";
263+
throw new Error(
264+
`esbuild CLI exited with status ${String(result.status)}: ${details}`,
265+
);
266+
}
109267
}
110268

111269
async function main(): Promise<void> {
@@ -114,32 +272,42 @@ async function main(): Promise<void> {
114272
throw new Error("functions-bundler requires <SOURCE_FILE> <OUTPUT_FILE>");
115273
}
116274

117-
const esbuild = await loadEsbuild();
118275
const externalPackages = parseExternalPackages(
119276
process.env.BT_FUNCTIONS_PUSH_EXTERNAL_PACKAGES,
120277
);
278+
const external = buildExternalPackagePatterns(externalPackages);
121279
const tsconfig = loadTsconfigPath();
122280

123281
const outputDir = path.dirname(outputFile);
124282
fs.mkdirSync(outputDir, { recursive: true });
125283

126-
const targetVersion =
127-
typeof process.version === "string" && process.version.startsWith("v")
128-
? process.version.slice(1)
129-
: process.versions.node || "18";
284+
const esbuild = await loadEsbuild(sourceFile);
285+
if (esbuild) {
286+
await bundleWithEsbuildModule(
287+
esbuild,
288+
sourceFile,
289+
outputFile,
290+
tsconfig,
291+
external,
292+
);
293+
return;
294+
}
130295

131-
await esbuild.build({
132-
entryPoints: [sourceFile],
133-
bundle: true,
134-
treeShaking: true,
135-
platform: "node",
136-
target: `node${targetVersion}`,
137-
write: true,
138-
outfile: outputFile,
139-
tsconfig,
140-
external: ["node_modules/*", "fsevents"],
141-
plugins: [createMarkKnownPackagesExternalPlugin(externalPackages)],
142-
});
296+
const esbuildBinary = resolveEsbuildBinary(sourceFile);
297+
if (esbuildBinary) {
298+
bundleWithEsbuildBinary(
299+
esbuildBinary,
300+
sourceFile,
301+
outputFile,
302+
tsconfig,
303+
external,
304+
);
305+
return;
306+
}
307+
308+
throw new Error(
309+
"failed to load esbuild for JS bundling; install esbuild in your project or use a runner that provides it",
310+
);
143311
}
144312

145313
main().catch((error: unknown) => {

0 commit comments

Comments
 (0)