Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions js/src/cli-util/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions js/src/cli-util/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type IfExistsType as IfExists } from "../generated_types";

export type BundleFormat = "cjs" | "esm";

export interface CommonArgs {
verbose: boolean;
}
Expand All @@ -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 {
Expand Down
87 changes: 69 additions & 18 deletions js/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 {
Expand All @@ -92,14 +94,25 @@ export interface FileHandle {
destroy: () => Promise<void>;
}

function evaluateBuildResults(
async function evaluateBuildResults(
inFile: string,
buildResult: esbuild.BuildResult,
): EvaluatorFile | null {
if (!buildResult.outputFiles) {
bundleFormat: BundleFormat,
): Promise<EvaluatorFile | null> {
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 });
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -304,20 +321,23 @@ async function initFile({
tsconfig,
plugins,
externalPackages,
bundleFormat,
}: {
inFile: string;
outFile: string;
bundleFile: string;
tsconfig?: string;
plugins?: PluginMaker[];
externalPackages?: string[];
bundleFormat: BundleFormat;
}): Promise<FileHandle> {
const buildOptions = buildOpts({
fileName: inFile,
outFile,
tsconfig,
plugins,
externalPackages,
bundleFormat,
});
const ctx = await esbuild.context(buildOptions);

Expand All @@ -335,7 +355,11 @@ async function initFile({
sourceFile: inFile,
};
}
const evaluator = evaluateBuildResults(inFile, result) || {
const evaluator = (await evaluateBuildResults(
inFile,
result,
bundleFormat,
)) || {
functions: [],
prompts: [],
evaluators: {},
Expand All @@ -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,
};
Expand Down Expand Up @@ -398,6 +432,7 @@ interface EvaluatorOpts {
jsonl: boolean;
filters: Filter[];
progressReporter: ProgressReporter;
bundleFormat: BundleFormat;
}

export function handleBuildFailure({
Expand Down Expand Up @@ -620,6 +655,7 @@ async function runOnce(
setCurrent: opts.setCurrent,
defaultIfExists: "replace",
verbose: opts.verbose,
bundleFormat: opts.bundleFormat,
});
}

Expand Down Expand Up @@ -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),
Expand All @@ -817,7 +856,8 @@ function buildOpts({
target: `node${process.version.slice(1)}`,
tsconfig,
external: ["node_modules/*", "fsevents"],
plugins: plugins,
plugins,
format: effectiveFormat,
};
}

Expand All @@ -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<Record<string, FileHandle>> {
const files: Record<string, boolean> = {};
const inputPaths = inputFiles.length > 0 ? inputFiles : ["."];
Expand All @@ -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)) {
Expand All @@ -876,6 +917,7 @@ export async function initializeHandles({
plugins,
tsconfig,
externalPackages,
bundleFormat: bundleFormat ?? DEFAULT_BUNDLE_FORMAT,
}),
);
}
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -937,6 +983,7 @@ async function run(args: RunArgs) {
tsconfig: args.tsconfig,
plugins,
externalPackages: args.external_packages,
bundleFormat,
});

if (args.dev) {
Expand Down Expand Up @@ -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() {
Expand Down
59 changes: 55 additions & 4 deletions js/src/functions/infer-source.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,27 +14,78 @@ interface SourceMapContext {
sourceMap: SourceMapConsumer;
}

type BundleFormat = "cjs" | "esm";

async function findNearestNodeModules(
startDir: string,
): Promise<string | null> {
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<SourceMapContext> {
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,
Expand Down
Loading
Loading