diff --git a/js/src/cli-util/bundle.ts b/js/src/cli-util/bundle.ts index 36e2cc561..3548714c5 100644 --- a/js/src/cli-util/bundle.ts +++ b/js/src/cli-util/bundle.ts @@ -34,6 +34,7 @@ export async function bundleCommand(args: BundleArgs) { files: args.files, tsconfig: args.tsconfig, externalPackages: args.external_packages, + bundleFormat: args.experimental_bundle_format, }); try { @@ -69,6 +70,7 @@ export async function bundleCommand(args: BundleArgs) { setCurrent: true, verbose: args.verbose, defaultIfExists: args.if_exists, + bundleFormat: args.experimental_bundle_format, }); if (numFailed > 0) { diff --git a/js/src/cli-util/types.ts b/js/src/cli-util/types.ts index dd6faefc4..00496a749 100644 --- a/js/src/cli-util/types.ts +++ b/js/src/cli-util/types.ts @@ -1,5 +1,7 @@ import { type IfExistsType as IfExists } from "../generated_types"; +export type BundleFormat = "cjs" | "esm"; + export interface CommonArgs { verbose: boolean; } @@ -15,6 +17,7 @@ export interface CompileArgs { tsconfig?: string; terminate_on_failure: boolean; external_packages?: string[]; + experimental_bundle_format?: BundleFormat; } export interface RunArgs extends CommonArgs, AuthArgs, CompileArgs { diff --git a/js/src/cli.ts b/js/src/cli.ts index ec5bea45c..9ff480eff 100755 --- a/js/src/cli.ts +++ b/js/src/cli.ts @@ -45,9 +45,9 @@ import { configureNode } from "./node"; import { isEmpty } from "./util"; import { loadEnvConfig } from "@next/env"; import { uploadHandleBundles } from "./functions/upload"; -import { loadModule } from "./functions/load-module"; +import { loadModule, loadModuleEsmFromFile } from "./functions/load-module"; import { bundleCommand } from "./cli-util/bundle"; -import { RunArgs } from "./cli-util/types"; +import { RunArgs, type BundleFormat } from "./cli-util/types"; import { pullCommand } from "./cli-util/pull"; import { runDevServer } from "../dev/server"; @@ -66,6 +66,8 @@ const INCLUDE_BUNDLE = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]; const EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**"]; const OUT_EXT = "js"; +const DEFAULT_BUNDLE_FORMAT: BundleFormat = "cjs"; + configureNode(); export interface BuildSuccess { @@ -92,14 +94,25 @@ export interface FileHandle { destroy: () => Promise; } -function evaluateBuildResults( +async function evaluateBuildResults( inFile: string, buildResult: esbuild.BuildResult, -): EvaluatorFile | null { - if (!buildResult.outputFiles) { + bundleFormat: BundleFormat, +): Promise { + if (!buildResult.outputFiles || buildResult.outputFiles.length === 0) { return null; } const moduleText = buildResult.outputFiles[0].text; + if (bundleFormat === "esm") { + const srcDir = path.dirname(inFile); + const srcBase = path.basename(inFile, path.extname(inFile)); + const runtimePath = path.join( + srcDir, + `.braintrust-eval-${srcBase}-${uuidv4().slice(0, 8)}.mjs`, + ); + await fs.promises.writeFile(runtimePath, moduleText, "utf8"); + return await loadModuleEsmFromFile({ inFile, modulePath: runtimePath }); + } return loadModule({ inFile, moduleText }); } @@ -212,7 +225,11 @@ function buildWatchPluginForEvaluator( return; } - const evalResult = evaluateBuildResults(inFile, result); + const evalResult = await evaluateBuildResults( + inFile, + result, + opts.bundleFormat ?? DEFAULT_BUNDLE_FORMAT, + ); if (!evalResult) { return; } @@ -304,6 +321,7 @@ async function initFile({ tsconfig, plugins, externalPackages, + bundleFormat, }: { inFile: string; outFile: string; @@ -311,6 +329,7 @@ async function initFile({ tsconfig?: string; plugins?: PluginMaker[]; externalPackages?: string[]; + bundleFormat: BundleFormat; }): Promise { const buildOptions = buildOpts({ fileName: inFile, @@ -318,6 +337,7 @@ async function initFile({ tsconfig, plugins, externalPackages, + bundleFormat, }); const ctx = await esbuild.context(buildOptions); @@ -335,7 +355,11 @@ async function initFile({ sourceFile: inFile, }; } - const evaluator = evaluateBuildResults(inFile, result) || { + const evaluator = (await evaluateBuildResults( + inFile, + result, + bundleFormat, + )) || { functions: [], prompts: [], evaluators: {}, @@ -348,17 +372,27 @@ async function initFile({ } }, bundle: async () => { + const isEsmBundle = bundleFormat === "esm"; + const baseOptions = buildOpts({ + fileName: inFile, + outFile: bundleFile, + tsconfig, + plugins, + externalPackages, + bundleFormat: isEsmBundle ? "esm" : "cjs", + }); const buildOptions: esbuild.BuildOptions = { - ...buildOpts({ - fileName: inFile, - outFile: bundleFile, - tsconfig, - plugins, - externalPackages, - }), - external: [], + ...baseOptions, + // For CJS bundles, we historically inlined everything. For ESM bundles, + // keep externals so CJS deps (node_modules) are not inlined. + external: isEsmBundle ? baseOptions.external : [], write: true, - plugins: [], + plugins: isEsmBundle ? baseOptions.plugins : [], + banner: isEsmBundle + ? { + js: "// @bt-esm\n", + } + : baseOptions.banner, minify: true, sourcemap: true, }; @@ -398,6 +432,7 @@ interface EvaluatorOpts { jsonl: boolean; filters: Filter[]; progressReporter: ProgressReporter; + bundleFormat: BundleFormat; } export function handleBuildFailure({ @@ -620,6 +655,7 @@ async function runOnce( setCurrent: opts.setCurrent, defaultIfExists: "replace", verbose: opts.verbose, + bundleFormat: opts.bundleFormat, }); } @@ -794,13 +830,16 @@ function buildOpts({ tsconfig, plugins: argPlugins, externalPackages, + bundleFormat, }: { fileName: string; outFile: string; tsconfig?: string; plugins?: PluginMaker[]; externalPackages?: string[]; + bundleFormat?: BundleFormat; }): esbuild.BuildOptions { + const effectiveFormat = bundleFormat ?? DEFAULT_BUNDLE_FORMAT; const plugins = [ nativeNodeModulesPlugin, createMarkKnownPackagesExternalPlugin(externalPackages), @@ -817,7 +856,8 @@ function buildOpts({ target: `node${process.version.slice(1)}`, tsconfig, external: ["node_modules/*", "fsevents"], - plugins: plugins, + plugins, + format: effectiveFormat, }; } @@ -827,12 +867,14 @@ export async function initializeHandles({ plugins, tsconfig, externalPackages, + bundleFormat, }: { files: string[]; mode: "eval" | "bundle"; plugins?: PluginMaker[]; tsconfig?: string; externalPackages?: string[]; + bundleFormat?: BundleFormat; }): Promise> { const files: Record = {}; const inputPaths = inputFiles.length > 0 ? inputFiles : ["."]; @@ -858,7 +900,6 @@ export async function initializeHandles({ } const tmpDir = path.join(os.tmpdir(), `btevals-${uuidv4().slice(0, 8)}`); - // fs.mkdirSync(tmpDir, { recursive: true }); const initPromises = []; for (const file of Object.keys(files)) { @@ -876,6 +917,7 @@ export async function initializeHandles({ plugins, tsconfig, externalPackages, + bundleFormat: bundleFormat ?? DEFAULT_BUNDLE_FORMAT, }), ); } @@ -901,6 +943,9 @@ async function run(args: RunArgs) { } } + const bundleFormat: BundleFormat = + args.experimental_bundle_format ?? DEFAULT_BUNDLE_FORMAT; + const evaluatorOpts: EvaluatorOpts = { verbose: args.verbose, apiKey: args.api_key, @@ -917,6 +962,7 @@ async function run(args: RunArgs) { : new BarProgressReporter(), filters: args.filter ? parseFilters(args.filter) : [], list: !!args.list, + bundleFormat, }; if (args.list && args.watch) { @@ -937,6 +983,7 @@ async function run(args: RunArgs) { tsconfig: args.tsconfig, plugins, externalPackages: args.external_packages, + bundleFormat, }); if (args.dev) { @@ -1012,6 +1059,10 @@ function addCompileArgs(parser: ArgumentParser) { nargs: "*", help: "Additional packages to mark as external during bundling. These packages will not be included in the bundle and must be available at runtime. Use this to resolve bundling errors with native modules or problematic dependencies. Example: --external-packages sqlite3 fsevents @mapbox/node-pre-gyp", }); + parser.add_argument("--experimental-bundle-format", { + choices: ["cjs", "esm"], + help: "Experimental: module format to use when bundling code. Defaults to cjs.", + }); } async function main() { diff --git a/js/src/functions/infer-source.ts b/js/src/functions/infer-source.ts index 983aeaaa9..0ab9d028b 100644 --- a/js/src/functions/infer-source.ts +++ b/js/src/functions/infer-source.ts @@ -1,7 +1,7 @@ import { SourceMapConsumer } from "source-map"; import * as fs from "fs/promises"; import { EvaluatorFile, warning } from "../framework"; -import { loadModule } from "./load-module"; +import { loadModule, loadModuleEsmFromFile } from "./load-module"; import { type CodeBundleType as CodeBundle } from "../generated_types"; import path from "path"; import type { Node } from "typescript"; @@ -14,27 +14,78 @@ interface SourceMapContext { sourceMap: SourceMapConsumer; } +type BundleFormat = "cjs" | "esm"; + +async function findNearestNodeModules( + startDir: string, +): Promise { + let dir = startDir; + // eslint-disable-next-line no-constant-condition + while (true) { + const candidate = path.join(dir, "node_modules"); + try { + // eslint-disable-next-line no-await-in-loop + const stat = await fs.stat(candidate); + if (stat.isDirectory()) { + return candidate; + } + } catch { + // ignore + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + export async function makeSourceMapContext({ inFile, outFile, sourceMapFile, + bundleFormat = "cjs", }: { inFile: string; outFile: string; sourceMapFile: string; + bundleFormat?: BundleFormat; }): Promise { const [inFileContents, outFileContents, sourceMap] = await Promise.all([ fs.readFile(inFile, "utf8"), fs.readFile(outFile, "utf8"), (async () => { - const sourceMap = await fs.readFile(sourceMapFile, "utf8"); - const sourceMapJSON = JSON.parse(sourceMap); + const sourceMapText = await fs.readFile(sourceMapFile, "utf8"); + const sourceMapJSON = JSON.parse(sourceMapText); return new SourceMapConsumer(sourceMapJSON); })(), ]); + + let outFileModule: EvaluatorFile; + if (bundleFormat === "esm") { + const runtimeDir = path.dirname(outFile); + const nodeModulesSrc = await findNearestNodeModules(path.dirname(inFile)); + if (nodeModulesSrc) { + const nodeModulesDest = path.join(runtimeDir, "node_modules"); + try { + await fs.mkdir(runtimeDir, { recursive: true }); + // Best-effort: create a symlink to the real node_modules so ESM imports like "braintrust" resolve. + await fs.symlink(nodeModulesSrc, nodeModulesDest, "junction"); + } catch { + // Ignore symlink errors; resolution may still work via global paths. + } + } + outFileModule = await loadModuleEsmFromFile({ + inFile, + modulePath: outFile, + }); + } else { + outFileModule = loadModule({ inFile, moduleText: outFileContents }); + } + return { inFiles: { [inFile]: inFileContents.split("\n") }, - outFileModule: loadModule({ inFile, moduleText: outFileContents }), + outFileModule, outFileLines: outFileContents.split("\n"), sourceMapDir: path.dirname(sourceMapFile), sourceMap, diff --git a/js/src/functions/load-module.ts b/js/src/functions/load-module.ts index 89d38b3e2..84e6ec0c7 100644 --- a/js/src/functions/load-module.ts +++ b/js/src/functions/load-module.ts @@ -1,5 +1,6 @@ import nodeModulesPaths from "../jest/nodeModulesPaths"; import path, { dirname } from "path"; +import { pathToFileURL } from "url"; import { _internalGetGlobalState } from "../logger"; import { EvaluatorFile } from "../framework"; @@ -40,3 +41,36 @@ export function loadModule({ return { ...globalThis._evals }; }); } + +// Use dynamic import to execute ESM output (e.g., for top-level await) +// while keeping the caller in CJS. This avoids TypeScript downleveling of `import()`. +async function dynamicImport(specifier: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const importer = new Function("specifier", "return import(specifier);") as ( + specifier: string, + ) => Promise; + return await importer(specifier); +} + +export async function loadModuleEsmFromFile({ + inFile, + modulePath, +}: { + inFile: string; + modulePath: string; +}): Promise { + return await evalWithModuleContext(inFile, async () => { + globalThis._evals = { + functions: [], + prompts: [], + evaluators: {}, + reporters: {}, + }; + globalThis._lazy_load = true; + globalThis.__inherited_braintrust_state = _internalGetGlobalState(); + + const url = pathToFileURL(modulePath).href; + await dynamicImport(url); + return { ...globalThis._evals }; + }); +} diff --git a/js/src/functions/upload.ts b/js/src/functions/upload.ts index 3cdf6bab7..db585f3f1 100644 --- a/js/src/functions/upload.ts +++ b/js/src/functions/upload.ts @@ -62,6 +62,7 @@ export async function uploadHandleBundles({ setCurrent, verbose, defaultIfExists, + bundleFormat = "cjs", }: { buildResults: BuildSuccess[]; evalToExperiment?: Record>; @@ -72,6 +73,7 @@ export async function uploadHandleBundles({ verbose: boolean; setCurrent: boolean; defaultIfExists: IfExists; + bundleFormat?: "cjs" | "esm"; }) { console.error( `Processing ${buildResults.length} ${pluralize("file", buildResults.length)}...`, @@ -208,6 +210,7 @@ export async function uploadHandleBundles({ handles, defaultIfExists, verbose, + bundleFormat, }); }); @@ -238,6 +241,7 @@ async function uploadBundles({ handles, defaultIfExists, verbose, + bundleFormat = "cjs", }: { sourceFile: string; prompts: FunctionEvent[]; @@ -248,6 +252,7 @@ async function uploadBundles({ handles: Record; defaultIfExists: IfExists; verbose: boolean; + bundleFormat?: "cjs" | "esm"; }): Promise { const orgId = _internalGetGlobalState().orgId; if (!orgId) { @@ -270,6 +275,7 @@ async function uploadBundles({ inFile: sourceFile, outFile: bundleFileName, sourceMapFile: bundleFileName + ".map", + bundleFormat, }); let pathInfo: z.infer | undefined = undefined;