1+ import { spawnSync } from "node:child_process" ;
12import fs from "node:fs" ;
3+ import { createRequire } from "node:module" ;
24import path from "node:path" ;
5+ import { pathToFileURL } from "node:url" ;
36
47type EsbuildBuild = ( options : Record < string , unknown > ) => Promise < unknown > ;
58type 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
111269async 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
145313main ( ) . catch ( ( error : unknown ) => {
0 commit comments