diff --git a/js/package.json b/js/package.json index 573969f9f..f49db7201 100644 --- a/js/package.json +++ b/js/package.json @@ -181,6 +181,7 @@ "@types/async": "^3.2.24", "@types/cli-progress": "^3.11.5", "@types/cors": "^2.8.17", + "@types/esquery": "^1.5.4", "@types/express": "^5.0.0", "@types/http-errors": "^2.0.4", "@types/mustache": "^4.2.5", @@ -210,7 +211,6 @@ }, "dependencies": { "@next/env": "^14.2.3", - "@types/estree": "^1.0.8", "@vercel/functions": "^1.0.2", "acorn": "^8.16.0", "acorn-import-attributes": "^1.9.5", diff --git a/js/src/auto-instrumentations/bundler/plugin.ts b/js/src/auto-instrumentations/bundler/plugin.ts index dda1b7490..9f53addfe 100644 --- a/js/src/auto-instrumentations/bundler/plugin.ts +++ b/js/src/auto-instrumentations/bundler/plugin.ts @@ -1,11 +1,20 @@ import { createUnplugin } from "unplugin"; import { create, type InstrumentationConfig } from "../orchestrion-js"; -import { extname, join, sep } from "path"; +import { dirname, extname, join, resolve, sep } from "path"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import moduleDetailsFromPath from "module-details-from-path"; import { getDefaultInstrumentationConfigs } from "../configs/all"; import { applySpecialCasePatch } from "../loader/special-case-patches"; +import { + buildTopLevelImportHookSourceWrapper, + getDefaultTopLevelImportHooks, + type TopLevelImportHookTarget, +} from "../loader/top-level-export-patches"; +import { readDisabledInstrumentationEnvConfig } from "../../instrumentation/config"; + +const TOP_LEVEL_ORIGINAL_IMPORT_PREFIX = "braintrust-top-level-original:"; +const TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX = `\0${TOP_LEVEL_ORIGINAL_IMPORT_PREFIX}`; export interface LegacyBundlerPluginOptions { /** @@ -75,6 +84,18 @@ export const unplugin = createUnplugin( const allInstrumentations = getDefaultInstrumentationConfigs({ additionalInstrumentations: options.instrumentations, }); + const disabledIntegrationConfig = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + const topLevelImportHookTarget: TopLevelImportHookTarget = + options.browser === false ? "node" : "browser"; + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig, + target: topLevelImportHookTarget, + }); + const originalSources = new Map(); + const originalDirectories = new Map(); + let nextOriginalId = 0; // Default to browser build, use polyfill unless explicitly disabled const dcModule = options.browser === false ? undefined : "dc-browser"; @@ -85,11 +106,37 @@ export const unplugin = createUnplugin( return { name: "code-transformer", enforce: "pre", + resolveId(id: string, importer?: string) { + if (id.startsWith(TOP_LEVEL_ORIGINAL_IMPORT_PREFIX)) { + return `${TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX}${id.slice( + TOP_LEVEL_ORIGINAL_IMPORT_PREFIX.length, + )}`; + } + if ( + id.startsWith(".") && + importer?.startsWith(TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX) + ) { + const originalDirectory = originalDirectories.get(importer); + if (originalDirectory) { + return resolve(originalDirectory, id); + } + } + return null; + }, + load(id: string) { + if (id.startsWith(TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX)) { + return originalSources.get(id) ?? null; + } + return null; + }, transform(code: string, id: string) { if (!id) { // Some modules apparently don't have an id? return null; } + if (id.startsWith(TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX)) { + return null; + } // Convert file:// URLs to regular paths at entry point // Node.js ESM loader hooks provide file:// URLs, but downstream code expects paths @@ -123,25 +170,52 @@ export const unplugin = createUnplugin( const normalizedModulePath = moduleDetails.path.replace(/\\/g, "/"); const moduleVersion = getModuleVersion(moduleDetails.basedir); + let nextCode = code; + let didPatch = false; + // Per-package source patches (see loader/special-case-patches.ts). - // Same anti-pattern fallback the runtime loader uses — mirrored here - // so bundled apps get the patches without relying on hook.mjs. - // Skipped for browser bundles since the wrapper templates use - // `node:module`/`require` to resolve `@mastra/observability`. + // Skipped for browser bundles to preserve the historical behavior of + // the OpenAI APIPromise patch path. if (options.browser !== true) { const patched = applySpecialCasePatch({ packageName: moduleName, modulePath: normalizedModulePath, - source: code, + source: nextCode, format: isModule ? "esm" : "cjs", }); if (patched !== null) { - return { code: patched, map: null }; + nextCode = patched; + didPatch = true; } } + const originalModuleSpecifier = `${TOP_LEVEL_ORIGINAL_IMPORT_PREFIX}${nextOriginalId++}`; + const originalModuleId = `${TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX}${originalModuleSpecifier.slice( + TOP_LEVEL_ORIGINAL_IMPORT_PREFIX.length, + )}`; + const topLevelWrapper = buildTopLevelImportHookSourceWrapper( + topLevelImportHooks, + { + format: isModule ? "esm" : "cjs", + modulePath: normalizedModulePath, + originalModuleSpecifier, + packageName: moduleName, + source: nextCode, + target: topLevelImportHookTarget, + }, + ); + if (topLevelWrapper !== null) { + originalSources.set(originalModuleId, nextCode); + originalDirectories.set(originalModuleId, dirname(filePath)); + nextCode = topLevelWrapper; + didPatch = true; + } + // If no version found if (!moduleVersion) { + if (didPatch) { + return { code: nextCode, map: null }; + } console.warn( `No 'package.json' version found for module ${moduleName} at ${moduleDetails.basedir}. Skipping transformation.`, ); @@ -157,13 +231,13 @@ export const unplugin = createUnplugin( if (!transformer) { // No instrumentations match this file - return null; + return didPatch ? { code: nextCode, map: null } : null; } try { // Transform the code const moduleType = isModule ? "esm" : "cjs"; - const result = transformer.transform(code, moduleType); + const result = transformer.transform(nextCode, moduleType); const transformedCode = result.code.replace( /const \{tracingChannel: ([A-Za-z_$][\w$]*)\} = ([A-Za-z_$][\w$]*);/g, "const $1 = $2.tracingChannel;", diff --git a/js/src/auto-instrumentations/bundler/webpack-loader.ts b/js/src/auto-instrumentations/bundler/webpack-loader.ts index c218c21a6..bb72bb0e3 100644 --- a/js/src/auto-instrumentations/bundler/webpack-loader.ts +++ b/js/src/auto-instrumentations/bundler/webpack-loader.ts @@ -27,6 +27,15 @@ import { readFileSync } from "fs"; import moduleDetailsFromPath from "module-details-from-path"; import { getDefaultInstrumentationConfigs } from "../configs/all"; import { type LegacyBundlerPluginOptions } from "./plugin"; +import { applySpecialCasePatch } from "../loader/special-case-patches"; +import { + buildTopLevelImportHookSourceWrapper, + getDefaultTopLevelImportHooks, + type TopLevelImportHookTarget, +} from "../loader/top-level-export-patches"; +import { readDisabledInstrumentationEnvConfig } from "../../instrumentation/config"; + +const TOP_LEVEL_ORIGINAL_QUERY = "braintrust-top-level-original"; /** * Helper function to get module version from package.json @@ -94,11 +103,15 @@ function codeTransformerLoader( const callback = this.async(); const options: LegacyBundlerPluginOptions = this.getOptions() ?? {}; const resourcePath: string = this.resourcePath; + const resourceQuery: string = this.resourceQuery ?? ""; // Skip virtual modules (e.g. Next.js loaders pass query-string URLs with no real path) if (!resourcePath) { return callback(null, code, inputSourceMap); } + if (resourceQuery.includes(TOP_LEVEL_ORIGINAL_QUERY)) { + return callback(null, code, inputSourceMap); + } // Determine if this is an ES module using multiple methods for accurate detection const ext = extname(resourcePath); @@ -122,12 +135,54 @@ function codeTransformerLoader( const moduleName = moduleDetails.name; const moduleVersion = getModuleVersion(moduleDetails.basedir); - if (!moduleVersion) { - return callback(null, code, inputSourceMap); - } - // Normalize the module path for Windows compatibility (WASM transformer expects forward slashes) const normalizedModulePath = moduleDetails.path.replace(/\\/g, "/"); + const moduleType: ModuleType = isModule ? "esm" : "cjs"; + const target: TopLevelImportHookTarget = + options.browser === true ? "browser" : "node"; + const disabledIntegrationConfig = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig, + target, + }); + + let nextCode = code; + let didPatch = false; + + if (options.browser !== true) { + const patched = applySpecialCasePatch({ + format: moduleType, + modulePath: normalizedModulePath, + packageName: moduleName, + source: nextCode, + }); + if (patched !== null) { + nextCode = patched; + didPatch = true; + } + } + + const topLevelWrapper = buildTopLevelImportHookSourceWrapper( + topLevelImportHooks, + { + format: moduleType, + modulePath: normalizedModulePath, + originalModuleSpecifier: `${resourcePath}?${TOP_LEVEL_ORIGINAL_QUERY}`, + packageName: moduleName, + source: nextCode, + target, + }, + ); + if (topLevelWrapper !== null) { + nextCode = topLevelWrapper; + didPatch = true; + } + + if (!moduleVersion) { + return callback(null, nextCode, inputSourceMap); + } const matcher = getMatcher(options); const transformer = matcher.getTransformer( @@ -137,19 +192,18 @@ function codeTransformerLoader( ); if (!transformer) { - return callback(null, code, inputSourceMap); + return callback(null, nextCode, inputSourceMap); } try { - const moduleType: ModuleType = isModule ? "esm" : "cjs"; - const result = transformer.transform(code, moduleType); + const result = transformer.transform(nextCode, moduleType); callback(null, result.code, result.map ?? undefined); } catch (error) { console.warn( `[code-transformer-loader] Error transforming ${resourcePath}:`, error, ); - callback(null, code, inputSourceMap); + callback(null, didPatch ? nextCode : code, inputSourceMap); } } diff --git a/js/src/auto-instrumentations/configs/all.ts b/js/src/auto-instrumentations/configs/all.ts index b4642974a..f28905c91 100644 --- a/js/src/auto-instrumentations/configs/all.ts +++ b/js/src/auto-instrumentations/configs/all.ts @@ -96,12 +96,9 @@ const defaultInstrumentationConfigGroups: readonly InstrumentationConfigGroup[] configs: flueConfigs, }, // Note: `@mastra/core` is not listed here because its instrumentation - // doesn't go through the AST `code-transformer` matcher — Mastra's - // content-hashed chunks make `filePath`-based matching too brittle. - // Instead it's handled by the source-replacement entry in - // `loader/special-case-patches.ts`, which both the runtime loader - // (`hook.mjs` → `cjs-patch.ts`/`esm-hook.mts`) and the bundler plugin - // (`bundler/plugin.ts`) call. The `mastra` env-var disable still works. + // doesn't go through the AST `code-transformer` matcher. It is handled by + // the internal top-level import hook registry in + // `loader/top-level-export-patches.ts`. ]; export function getDefaultInstrumentationConfigs({ diff --git a/js/src/auto-instrumentations/hook.mts b/js/src/auto-instrumentations/hook.mts index 10626cebd..74580334e 100644 --- a/js/src/auto-instrumentations/hook.mts +++ b/js/src/auto-instrumentations/hook.mts @@ -13,7 +13,7 @@ * - CJS modules: Transformed via ModulePatch monkey-patching Module._compile */ -import { register } from "node:module"; +import { register as registerModule } from "node:module"; import { isInstrumentationIntegrationDisabled, readDisabledInstrumentationEnvConfig, @@ -23,6 +23,28 @@ import { installMastraExporterFactory } from "./loader/mastra-observability-patc import { getDefaultAutoInstrumentationConfigs } from "./configs/all.js"; import { ModulePatch } from "./loader/cjs-patch.js"; import { patchTracingChannel } from "./patch-tracing-channel.js"; +import registerState from "./import-in-the-middle/lib/register.mjs"; +import { createHook as createImportInTheMiddleHook } from "./import-in-the-middle/create-hook.mjs"; +import { + getDefaultTopLevelImportHooks, + installTopLevelImportHookRunner, +} from "./loader/top-level-export-patches.js"; +import { installNodeTopLevelExportPatches } from "./loader/top-level-export-patches-node.js"; + +const BRAINTRUST_IITM_LOADER_PARAM = "braintrust-iitm-loader"; +const registryImportUrl = getCanonicalHookUrl(import.meta.url); +const asyncImportHookUrl = getImportInTheMiddleLoaderUrl(registryImportUrl); +const isImportInTheMiddleLoader = hasImportInTheMiddleLoaderParam( + import.meta.url, +); +const importInTheMiddleHook = createImportInTheMiddleHook(import.meta, { + registerUrl: registryImportUrl, +}); + +export const initialize = importInTheMiddleHook.initialize; +export const resolve = importInTheMiddleHook.resolve; +export const load = importInTheMiddleHook.load; +export const register = registerState.register; const state = ((globalThis as any)[ Symbol.for("braintrust.applyAutoInstrumentation") @@ -32,13 +54,13 @@ const alreadyApplied = state.applied; // Patch diagnostics_channel.tracePromise to handle APIPromise correctly. // MUST be done here (before any SDK code runs) to fix Anthropic APIPromise incompatibility. // Construct the module path dynamically to prevent build from stripping "node:" prefix. -if (!alreadyApplied) { +if (!isImportInTheMiddleLoader && !alreadyApplied) { const dcPath = ["node", "diagnostics_channel"].join(":"); const dc: any = await import(/* @vite-ignore */ dcPath as any); patchTracingChannel(dc.tracingChannel); } -if (!alreadyApplied) { +if (!isImportInTheMiddleLoader && !alreadyApplied) { const allConfigs = getDefaultAutoInstrumentationConfigs(); // Expose the Mastra exporter factory on globalThis so the loader patches @@ -52,8 +74,19 @@ if (!alreadyApplied) { installMastraExporterFactory(() => new BraintrustObservabilityExporter()); } + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: disabled, + target: "node", + }); + installTopLevelImportHookRunner(topLevelImportHooks); + installNodeTopLevelExportPatches({ + asyncImportHookUrl, + hooks: topLevelImportHooks, + registryImportUrl, + }); + // 1. Register ESM loader for ESM modules - register("./loader/esm-hook.mjs", { + registerModule("./loader/esm-hook.mjs", { parentURL: import.meta.url, data: { instrumentations: allConfigs }, } as any); @@ -82,3 +115,24 @@ if (!alreadyApplied) { } } } + +function hasImportInTheMiddleLoaderParam(url: string): boolean { + try { + return new URL(url).searchParams.has(BRAINTRUST_IITM_LOADER_PARAM); + } catch { + return false; + } +} + +function getCanonicalHookUrl(url: string): string { + const parsed = new URL(url); + parsed.searchParams.delete(BRAINTRUST_IITM_LOADER_PARAM); + parsed.hash = ""; + return parsed.href; +} + +function getImportInTheMiddleLoaderUrl(canonicalUrl: string): string { + const parsed = new URL(canonicalUrl); + parsed.searchParams.set(BRAINTRUST_IITM_LOADER_PARAM, "true"); + return parsed.href; +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts new file mode 100644 index 000000000..e39efd8d5 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts @@ -0,0 +1,81 @@ +import type { MessagePort } from "node:worker_threads"; + +export type LoaderAttributes = Readonly>; +export type LoaderContext = { + conditions?: readonly string[]; + format?: string; + importAssertions?: LoaderAttributes; + importAttributes?: LoaderAttributes; + parentURL?: string; + [key: string]: unknown; +}; +export type LoadSource = + | string + | ArrayBuffer + | NodeJS.ArrayBufferView + | null + | undefined; +export type LoadResult = { + format?: string; + shortCircuit?: boolean; + source?: LoadSource; +}; +export type ResolveResult = { + format?: string; + shortCircuit?: boolean; + url: string; +}; +export type HookData = { + addHookMessagePort?: MessagePort; + exclude?: never; + include?: readonly string[]; + shouldInclude?: never; +}; +export type AsyncLoadFunction = ( + url: string, + context: LoaderContext, +) => LoadResult | Promise; +export type SyncLoadFunction = ( + url: string, + context: LoaderContext, +) => LoadResult; +export type AsyncResolveFunction = ( + specifier: string, + context: LoaderContext, +) => ResolveResult | Promise; +export type SyncResolveFunction = ( + specifier: string, + context: LoaderContext, +) => ResolveResult; + +export interface ImportInTheMiddleHook { + applyOptions(data: HookData): void; + initialize(data?: HookData): Promise; + load( + url: string, + context: LoaderContext, + parentLoad: AsyncLoadFunction, + ): Promise; + loadSync( + url: string, + context: LoaderContext, + nextLoad: SyncLoadFunction, + ): LoadResult; + resolve( + specifier: string, + context: LoaderContext, + parentResolve: AsyncResolveFunction, + ): Promise; + resolveSync( + specifier: string, + context: LoaderContext, + nextResolve: SyncResolveFunction, + ): ResolveResult; +} + +export function createHook( + meta: { url: string }, + options?: { registerUrl?: string }, +): ImportInTheMiddleHook; + +export function supportsSyncHooks(): boolean; diff --git a/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mts similarity index 75% rename from js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs rename to js/src/auto-instrumentations/import-in-the-middle/create-hook.mts index 3ae4d53c3..f56d92312 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs +++ b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mts @@ -2,27 +2,83 @@ // // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. -import { URL, fileURLToPath } from "url"; -import { inspect } from "util"; -import { builtinModules } from "module"; -import registerState from "./lib/register.js"; +import { builtinModules } from "node:module"; +import { URL, fileURLToPath } from "node:url"; +import { inspect } from "node:util"; +import type { MessagePort } from "node:worker_threads"; +import registerState from "./lib/register.mjs"; import { getExports, hasModuleExportsCJSDefault } from "./lib/get-exports.mjs"; import { RESOLVE, driveSync, driveAsync } from "./lib/io.mjs"; - -const specifiers = new Map(); +import type { + LoaderContext, + LoaderOperation, + LoadResult, + ResolveOperation, + ResolveResult, +} from "./lib/io.mjs"; + +const specifiers = new Map(); const isWin = process.platform === "win32"; // FIXME: Typescript extensions are added temporarily until we find a better // way of supporting arbitrary extensions const EXTENSION_RE = /\.(js|mjs|cjs|ts|mts|cts)$/; -const HANDLED_FORMATS = new Set(["builtin", "module", "commonjs"]); +const HANDLED_FORMATS = new Set(["builtin", "module", "commonjs"]); const TRACE_WARNINGS = process.execArgv.includes("--trace-warnings"); // process.versions.node is always "major.minor.patch" (nightlies add a suffix // the regex ignores). const [, NODE_MAJOR, NODE_MINOR, NODE_PATCH] = process.versions.node .match(/^(\d+)\.(\d+)\.(\d+)/) - .map(Number); + ?.map(Number) ?? [0, 0, 0, 0]; + +type LoaderMeta = { url: string }; +type HookOptions = { registerUrl?: string }; +type HookData = { + addHookMessagePort?: MessagePort; + exclude?: never; + include?: readonly string[]; + shouldInclude?: never; +}; +type AsyncLoadFunction = ( + url: string, + context: LoaderContext, +) => LoadResult | Promise; +type SyncLoadFunction = (url: string, context: LoaderContext) => LoadResult; +type AsyncResolveFunction = ( + specifier: string, + context: LoaderContext, +) => ResolveResult | Promise; +type SyncResolveFunction = ( + specifier: string, + context: LoaderContext, +) => ResolveResult; +type SetterMap = Map; + +export interface ImportInTheMiddleHook { + applyOptions(data: HookData): void; + initialize(data?: HookData): Promise; + load( + url: string, + context: LoaderContext, + parentLoad: AsyncLoadFunction, + ): Promise; + loadSync( + url: string, + context: LoaderContext, + nextLoad: SyncLoadFunction, + ): LoadResult; + resolve( + specifier: string, + context: LoaderContext, + parentResolve: AsyncResolveFunction, + ): Promise; + resolveSync( + specifier: string, + context: LoaderContext, + nextResolve: SyncResolveFunction, + ): ResolveResult; +} /** * Whether the running Node.js can correctly run the synchronous loader via @@ -38,7 +94,7 @@ const [, NODE_MAJOR, NODE_MINOR, NODE_PATCH] = process.versions.node * * @returns {boolean} */ -export function supportsSyncHooks() { +export function supportsSyncHooks(): boolean { if (NODE_MAJOR >= 26) return true; if (NODE_MAJOR === 25) return NODE_MINOR >= 1; if (NODE_MAJOR === 24) @@ -48,7 +104,7 @@ export function supportsSyncHooks() { return false; } -function hasIitm(url) { +function hasIitm(url: unknown): boolean { // Fast path: avoid URL parsing on the hot path when there's clearly no iitm. if (typeof url !== "string" || url.indexOf("iitm") === -1) { return false; @@ -60,18 +116,18 @@ function hasIitm(url) { } } -function isIitm(url, meta) { +function isIitm(url: string, meta: LoaderMeta): boolean { return ( url === meta.url || url === meta.url.replace("hook.mjs", "create-hook.mjs") ); } -function deleteIitm(url) { +function deleteIitm(url: string): string { // Fast path: avoid URL parsing / try-catch on bare specifiers and normal file URLs. if (typeof url !== "string" || url.indexOf("iitm") === -1) { return url; } - let resultUrl; + let resultUrl: string; const stackTraceLimit = Error.stackTraceLimit; try { Error.stackTraceLimit = 0; @@ -101,11 +157,11 @@ function deleteIitm(url) { * @param {string} line * @returns {boolean} */ -function isStarExportLine(line) { +function isStarExportLine(line: string): boolean { return /^\* from /.test(line); } -function isBareSpecifier(specifier) { +function isBareSpecifier(specifier: string): boolean { // Relative and absolute paths are not bare specifiers. if (specifier.startsWith(".") || specifier.startsWith("/")) { return false; @@ -124,19 +180,24 @@ function isBareSpecifier(specifier) { // eslint-disable-next-line no-new new URL(specifier); return false; - } catch (err) { + } catch { return true; } finally { Error.stackTraceLimit = stackTraceLimit; } } -function emitWarning(err) { +function emitWarning(err: unknown): void { // Unfortunately, process.emitWarning does not output the full error // with error.cause like console.warn does so we need to inspect it when // tracing warnings - const warnMessage = TRACE_WARNINGS ? inspect(err) : err; - process.emitWarning(warnMessage); + if (TRACE_WARNINGS) { + process.emitWarning(inspect(err)); + } else if (err instanceof Error) { + process.emitWarning(err); + } else { + process.emitWarning(String(err)); + } } /** @@ -149,7 +210,7 @@ function emitWarning(err) { * @param {string} srcUrl The URL of the module the export belongs to. * @returns {string} */ -function buildSetter(n, srcUrl) { +function buildSetter(n: string, srcUrl: string): string { const variableName = `$${n.replace(/[^a-zA-Z0-9_$]/g, "_")}`; const objectKey = JSON.stringify(n); const reExportedName = n === "default" ? n : objectKey; @@ -214,12 +275,24 @@ function buildSetter(n, srcUrl) { * operations and ultimately returns the shimmed setters for all the exports * from the module and any transitive export all modules. */ -function* processModule({ srcUrl, context, excludeDefault = false }) { +function* processModule({ + srcUrl, + context, + excludeDefault = false, +}: { + context: LoaderContext; + excludeDefault?: boolean; + srcUrl: string; +}): Generator { const exportNames = yield* getExports(srcUrl, context); - const starExports = new Set(); - const setters = new Map(); - - const addSetter = (name, setter, isStarExport = false) => { + const starExports = new Set(); + const setters = new Map(); + + const addSetter = ( + name: string, + setter: string, + isStarExport = false, + ): void => { if (setters.has(name)) { if (isStarExport) { // If there's already a matching star export, delete it @@ -258,7 +331,7 @@ function* processModule({ srcUrl, context, excludeDefault = false }) { } if (isStarExportLine(n) === true) { - const [, modFile] = n.split("* from "); + const modFile = n.slice("* from ".length); // Relative paths need to be resolved relative to the parent module const newSpecifier = isBareSpecifier(modFile) @@ -267,7 +340,12 @@ function* processModule({ srcUrl, context, excludeDefault = false }) { // We need to resolve bare specifiers to a full URL. We also need to // resolve all sub-modules to get the `format`. We can't rely on the // parent's `format` to know if this sub-module is ESM or CJS! - const result = yield [RESOLVE, newSpecifier, { parentURL: srcUrl }]; + const resolveOperation: ResolveOperation = [ + RESOLVE, + newSpecifier, + { parentURL: srcUrl }, + ]; + const result = (yield resolveOperation) as ResolveResult; const subSetters = yield* processModule({ srcUrl: result.url, @@ -286,16 +364,25 @@ function* processModule({ srcUrl, context, excludeDefault = false }) { return setters; } -function addIitm(url) { +function addIitm(url: string): string { const urlObj = new URL(url); urlObj.searchParams.set("iitm", "true"); return urlObj.href; } -export function createHook(meta) { - let cachedResolve; - const iitmURL = new URL("lib/register.js", meta.url).toString(); - const includeModules = new Set(); +export function createHook( + meta: LoaderMeta, + options: HookOptions = {}, +): ImportInTheMiddleHook { + let cachedAsyncResolve: AsyncResolveFunction | undefined; + let cachedSyncResolve: SyncResolveFunction | undefined; + const iitmURL = + options.registerUrl ?? + new URL( + meta.url.endsWith(".mts") ? "lib/register.mts" : "lib/register.mjs", + meta.url, + ).toString(); + const includeModules = new Set(); // Track CJS module URLs that IITM has wrapped. On Node 24+, CJS modules loaded // via loadCJSModule (in an ESM import chain) have their require() calls for @@ -303,9 +390,9 @@ export function createHook(meta) { // intercept those require() calls and return an ESM namespace object instead // of the native CJS module value (e.g. EventEmitter constructor), breaking // patterns like `class App extends require('events') {}`. - const cjsInIitmChain = new Set(); + const cjsInIitmChain = new Set(); - function addExplicitIncludes(modules) { + function addExplicitIncludes(modules: unknown): void { if (!Array.isArray(modules)) { return; } @@ -323,14 +410,14 @@ export function createHook(meta) { } } - function defaultShouldInclude(url, specifier) { + function defaultShouldInclude(url: string, specifier: string): boolean { const modules = includeModules.size > 0 ? includeModules : registerState.hookedModules; if (!modules || modules.size === 0) { return false; } - let resultPath; + let resultPath: string | undefined; if (url.startsWith("file:")) { const stackTraceLimit = Error.stackTraceLimit; Error.stackTraceLimit = 0; @@ -339,7 +426,7 @@ export function createHook(meta) { } catch {} Error.stackTraceLimit = stackTraceLimit; } - function match(each) { + function match(each: string): boolean { return ( each === specifier || each === url || @@ -358,7 +445,7 @@ export function createHook(meta) { return false; } - function applyOptions(data) { + function applyOptions(data: HookData): void { if (data.exclude || data.shouldInclude) { throw new Error( "Braintrust import-in-the-middle only supports explicit Hook([...]) module interception", @@ -367,24 +454,28 @@ export function createHook(meta) { addExplicitIncludes(data.include); - if (data.addHookMessagePort) { - data.addHookMessagePort - .on("message", (modules) => { + const { addHookMessagePort } = data; + if (addHookMessagePort) { + addHookMessagePort + .on("message", (modules: unknown) => { addExplicitIncludes(modules); - data.addHookMessagePort.postMessage("ack"); + addHookMessagePort.postMessage("ack"); }) .unref(); } } - async function initialize(data) { - if (global.__import_in_the_middle_initialized__) { + async function initialize(data?: HookData): Promise { + const globalState = globalThis as typeof globalThis & { + __import_in_the_middle_initialized__?: boolean; + }; + if (globalState.__import_in_the_middle_initialized__) { process.emitWarning( "The 'import-in-the-middle' hook has already been initialized", ); } - global.__import_in_the_middle_initialized__ = true; + globalState.__import_in_the_middle_initialized__ = true; if (data) { applyOptions(data); @@ -395,7 +486,12 @@ export function createHook(meta) { // once the parent loader has turned the specifier into a resolved URL. The // only difference between the asynchronous and synchronous hooks is whether // that resolution was awaited, so all the wrapping decisions live here. - function finishResolve(result, specifier, context, parentURL) { + function finishResolve( + result: ResolveResult, + specifier: string, + context: LoaderContext, + parentURL: string, + ): ResolveResult { // Do not wrap the entrypoint module. Many CLIs check whether they are the // "main" module (e.g. require.main === module). Wrapping changes how they // are evaluated, and can make them exit without doing anything. @@ -479,8 +575,12 @@ export function createHook(meta) { }; } - async function resolve(specifier, context, parentResolve) { - cachedResolve = parentResolve; + async function resolve( + specifier: string, + context: LoaderContext, + parentResolve: AsyncResolveFunction, + ): Promise { + cachedAsyncResolve = parentResolve; // See https://github.com/nodejs/import-in-the-middle/pull/76. if (specifier === iitmURL) { @@ -495,7 +595,10 @@ export function createHook(meta) { if (isWin && parentURL.indexOf("file:node") === 0) { context.parentURL = ""; } - const result = await parentResolve(newSpecifier, context); + const result = (await parentResolve( + newSpecifier, + context, + )) as ResolveResult; return finishResolve(result, specifier, context, parentURL); } @@ -504,8 +607,12 @@ export function createHook(meta) { // synchronous `nextResolve` returns its result directly. We stash it so the // synchronous `load` hook can resolve star re-exports later, mirroring how // `resolve` caches `parentResolve`. - function resolveSync(specifier, context, nextResolve) { - cachedResolve = nextResolve; + function resolveSync( + specifier: string, + context: LoaderContext, + nextResolve: SyncResolveFunction, + ): ResolveResult { + cachedSyncResolve = nextResolve; if (specifier === iitmURL) { return { @@ -519,7 +626,7 @@ export function createHook(meta) { if (isWin && parentURL.indexOf("file:node") === 0) { context.parentURL = ""; } - const result = nextResolve(newSpecifier, context); + const result = nextResolve(newSpecifier, context) as ResolveResult; return finishResolve(result, specifier, context, parentURL); } @@ -527,7 +634,11 @@ export function createHook(meta) { // Builds the wrapper module source that re-exports the real module through // iitm's proxy. Pure string generation shared by the asynchronous and // synchronous `load` paths. - function buildWrapperSource(realUrl, setters, originalSpecifier) { + function buildWrapperSource( + realUrl: string, + setters: SetterMap, + originalSpecifier: string | undefined, + ): string { return ` import { register } from '${iitmURL}' import * as namespace from ${JSON.stringify(realUrl)} @@ -603,7 +714,12 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci // succeeds: free the specifier entry early, and remember CJS modules so their // transitive require() chain bypasses iitm (see `load`). Returns the wrapper // module source. - function onWrapSuccess(realUrl, context, originalSpecifier, setters) { + function onWrapSuccess( + realUrl: string, + context: LoaderContext, + originalSpecifier: string | undefined, + setters: SetterMap, + ): string { specifiers.delete(realUrl); // context.format is set to 'commonjs' by getCjsExports during processModule. if (context.format === "commonjs") { @@ -617,22 +733,41 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci // (it just can't be Hook'ed) rather than taking down the whole app. We free // the specifier entry to avoid a leak, and log because a failure here is // usually an iitm bug and would otherwise be very tricky to debug. - function onWrapFailure(realUrl, cause) { + function onWrapFailure(realUrl: string, cause: unknown): void { specifiers.delete(realUrl); - const err = new Error(`'import-in-the-middle' failed to wrap '${realUrl}'`); - err.cause = cause; + const err = new Error( + `'import-in-the-middle' failed to wrap '${realUrl}'`, + { + cause, + }, + ); emitWarning(err); } - async function getSource(url, context, parentGetSource) { + async function getSource( + url: string, + context: LoaderContext, + parentGetSource: AsyncLoadFunction, + ): Promise { if (hasIitm(url)) { const realUrl = deleteIitm(url); const originalSpecifier = specifiers.get(realUrl); try { + const resolveForWrap = cachedAsyncResolve; const setters = await driveAsync( processModule({ srcUrl: realUrl, context }), - { resolve: cachedResolve, load: parentGetSource }, + { + load: async (loadUrl, loadContext) => + (await parentGetSource(loadUrl, loadContext)) as LoadResult, + resolve: resolveForWrap + ? async (specifier, resolveContext) => + (await resolveForWrap( + specifier, + resolveContext, + )) as ResolveResult + : undefined, + }, ); return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters), @@ -644,21 +779,26 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci } } - return parentGetSource(url, context); + return (await parentGetSource(url, context)) as LoadResult; } // Synchronous counterpart to `getSource`, for `module.registerHooks`. Drives // `processModule` straight through; all bookkeeping and source generation is // shared with `getSource`. - function getSourceSync(url, context, nextLoad) { + function getSourceSync( + url: string, + context: LoaderContext, + nextLoad: SyncLoadFunction, + ): LoadResult { if (hasIitm(url)) { const realUrl = deleteIitm(url); const originalSpecifier = specifiers.get(realUrl); try { const setters = driveSync(processModule({ srcUrl: realUrl, context }), { - resolve: cachedResolve, - load: nextLoad, + load: (loadUrl, loadContext) => + nextLoad(loadUrl, loadContext) as LoadResult, + resolve: cachedSyncResolve, }); return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters), @@ -669,10 +809,14 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci } } - return nextLoad(url, context); + return nextLoad(url, context) as LoadResult; } - async function load(url, context, parentLoad) { + async function load( + url: string, + context: LoaderContext, + parentLoad: AsyncLoadFunction, + ): Promise { if (hasIitm(url)) { const result = await getSource(url, context, parentLoad); // If wrapping failed, `getSource()` may have fallen back to `parentLoad`, @@ -686,7 +830,7 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci } // Fall back to the parent loader with the original (non-iitm) URL. - return parentLoad(deleteIitm(url), context); + return (await parentLoad(deleteIitm(url), context)) as LoadResult; } // On Node 22+, when a CJS module is loaded through the ESM translator and @@ -697,7 +841,7 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci // hook-provided source for CJS modules in the synchronous require chain, // forcing Node to use its native CJS loader which handles this correctly. if (cjsInIitmChain.has(url)) { - const result = await parentLoad(url, context); + const result = (await parentLoad(url, context)) as LoadResult; if (result.format === "commonjs" && result.source != null) { return { format: result.format, @@ -707,13 +851,17 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci return result; } - return parentLoad(url, context); + return (await parentLoad(url, context)) as LoadResult; } // Synchronous counterpart to `load`, for `module.registerHooks`. Mirrors the // async `load` exactly — wrapping via `getSourceSync` and applying the same // CJS-in-iitm-chain source stripping — only without awaiting. - function loadSync(url, context, nextLoad) { + function loadSync( + url: string, + context: LoaderContext, + nextLoad: SyncLoadFunction, + ): LoadResult { if (hasIitm(url)) { const result = getSourceSync(url, context, nextLoad); // If wrapping failed, `getSourceSync()` may have fallen back to `nextLoad`, @@ -727,11 +875,11 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci } // Fall back to the parent loader with the original (non-iitm) URL. - return nextLoad(deleteIitm(url), context); + return nextLoad(deleteIitm(url), context) as LoadResult; } if (cjsInIitmChain.has(url)) { - const result = nextLoad(url, context); + const result = nextLoad(url, context) as LoadResult; if (result.format === "commonjs" && result.source != null) { return { format: result.format, @@ -741,7 +889,7 @@ register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpeci return result; } - return nextLoad(url, context); + return nextLoad(url, context) as LoadResult; } return { initialize, load, resolve, resolveSync, loadSync, applyOptions }; diff --git a/js/src/auto-instrumentations/import-in-the-middle/hook.mjs b/js/src/auto-instrumentations/import-in-the-middle/hook.mts similarity index 100% rename from js/src/auto-instrumentations/import-in-the-middle/hook.mjs rename to js/src/auto-instrumentations/import-in-the-middle/hook.mts diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.d.ts b/js/src/auto-instrumentations/import-in-the-middle/index.d.ts index 03364febe..7de09c013 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/index.d.ts +++ b/js/src/auto-instrumentations/import-in-the-middle/index.d.ts @@ -1,25 +1,44 @@ // Forked from import-in-the-middle@3.2.0. Modified by Braintrust. -export type Namespace = { [key: string]: any }; -export type HookFn = ( - exported: Namespace, +import type { MessagePort } from "node:worker_threads"; + +export type Namespace = Record; +export type HookFn = ( + exported: Exported, name: string, - baseDir: string | void, -) => any; + baseDir?: string, +) => Result; -export declare class Hook { - constructor(modules: string[], hookFn: HookFn); +export interface Hook { unhook(): void; } +export interface HookConstructor { + new ( + modules: readonly string[], + hookFn: HookFn, + ): Hook; + ( + modules: readonly string[], + hookFn: HookFn, + ): Hook; +} + +export declare const Hook: HookConstructor; export default Hook; -type CreateAddHookMessageChannelReturn = { +export type HookRegisterData = { + addHookMessagePort: MessagePort; + include: string[]; +}; + +type CreateAddHookMessageChannelReturn = { addHookMessagePort: MessagePort; waitForAllMessagesAcknowledged: () => Promise; - registerOptions: { data?: Data; transferList?: any[] }; + registerOptions: { + data: HookRegisterData; + transferList: [MessagePort]; + }; }; -export declare function createAddHookMessageChannel< - Data = any, ->(): CreateAddHookMessageChannelReturn; +export declare function createAddHookMessageChannel(): CreateAddHookMessageChannelReturn; diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.js b/js/src/auto-instrumentations/import-in-the-middle/index.mts similarity index 58% rename from js/src/auto-instrumentations/import-in-the-middle/index.js rename to js/src/auto-instrumentations/import-in-the-middle/index.mts index 26c5e47bb..625a43658 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/index.js +++ b/js/src/auto-instrumentations/import-in-the-middle/index.mts @@ -2,10 +2,14 @@ // // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. -const moduleDetailsFromPath = require("module-details-from-path"); -const { isBuiltin } = require("module"); -const { fileURLToPath } = require("url"); -const { MessageChannel } = require("worker_threads"); +import moduleDetailsFromPath from "module-details-from-path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import { MessageChannel } from "node:worker_threads"; +import registerState, { + type ImportHook, + type Namespace, +} from "./lib/register.mjs"; const { addHookedModules, @@ -13,48 +17,81 @@ const { importHooks, specifiers, toHook, -} = require("./lib/register"); +} = registerState; -function addHook(hook) { +export type HookFn = ( + exported: Namespace, + name: string, + baseDir?: string, +) => unknown; + +interface HookInstance { + _iitmHook: ImportHook; + _modules: string[]; +} + +interface HookConstructor { + new (modules: unknown, hookFn: unknown): HookInstance; + (modules: unknown, hookFn: unknown): HookInstance; + prototype: HookInstance & { unhook(): void }; +} + +let sendModulesToLoader: ((modules: string[]) => void) | undefined; + +function addHook(hook: ImportHook): void { importHooks.push(hook); toHook.forEach(([name, namespace, specifier]) => hook(name, namespace, specifier), ); } -function removeHook(hook) { +function removeHook(hook: ImportHook): void { const index = importHooks.indexOf(hook); if (index > -1) { importHooks.splice(index, 1); } } -function callHookFn(hookFn, namespace, name, baseDir) { +function callHookFn( + hookFn: HookFn, + namespace: Namespace, + name: string, + baseDir?: string, +): void { const newDefault = hookFn(namespace, name, baseDir); if (newDefault && newDefault !== namespace && "default" in namespace) { namespace.default = newDefault; } } -function normalizeModules(modules) { +function normalizeModules(modules: unknown): string[] { if (!Array.isArray(modules) || modules.length === 0) { throw new TypeError( "Braintrust import-in-the-middle requires a non-empty modules array", ); } + const normalized: string[] = []; for (const each of modules) { if (typeof each !== "string") { throw new TypeError( "Braintrust import-in-the-middle only supports string module names or file URLs", ); } + normalized.push(each); } - return modules; + return normalized; } -function moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) { +function moduleMatches( + matchArg: string, + name: string, + filePath: string | undefined, + specifier: string | undefined, + baseDir: string | undefined, + loadUrl: string, +): boolean { if (filePath && matchArg === filePath) { return true; } @@ -69,17 +106,15 @@ function moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) { // Keep the top-level package check from upstream, but do not support the // broad internals mode. Internal files must be listed explicitly. - return matchArg === name && baseDir.endsWith(specifiers.get(loadUrl)); + return matchArg === name && baseDir.endsWith(String(specifiers.get(loadUrl))); } -let sendModulesToLoader; - -function createAddHookMessageChannel() { +export function createAddHookMessageChannel() { const { port1, port2 } = new MessageChannel(); let pendingAckCount = 0; - let resolveFn; + let resolveFn: (() => void) | undefined; - sendModulesToLoader = (modules) => { + sendModulesToLoader = (modules: string[]) => { pendingAckCount++; port1.postMessage(modules); }; @@ -94,16 +129,16 @@ function createAddHookMessageChannel() { }) .unref(); - function waitForAllMessagesAcknowledged() { + function waitForAllMessagesAcknowledged(): Promise { const timer = setInterval(() => {}, 1000); - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveFn = resolve; }).then(() => { clearInterval(timer); }); if (pendingAckCount === 0) { - resolveFn(); + resolveFn?.(); } return promise; @@ -111,7 +146,7 @@ function createAddHookMessageChannel() { const addHookMessagePort = port2; const registerOptions = { - data: { addHookMessagePort, include: [] }, + data: { addHookMessagePort, include: [] as string[] }, transferList: [addHookMessagePort], }; @@ -122,26 +157,33 @@ function createAddHookMessageChannel() { }; } -function Hook(modules, hookFn) { - if (this instanceof Hook === false) return new Hook(modules, hookFn); +const Hook: HookConstructor = function ( + this: HookInstance | undefined, + modules: unknown, + hookFn: unknown, +): HookInstance | void { + if (!this || !(this instanceof Hook)) { + return new Hook(modules, hookFn); + } - modules = normalizeModules(modules); + const normalizedModules = normalizeModules(modules); if (typeof hookFn !== "function") { throw new TypeError( "Braintrust import-in-the-middle requires a hook function", ); } + const importHookFn = hookFn as HookFn; - addHookedModules(modules); + addHookedModules(normalizedModules); if (sendModulesToLoader) { - sendModulesToLoader(modules); + sendModulesToLoader(normalizedModules); } - this._modules = modules; + this._modules = normalizedModules; this._iitmHook = (name, namespace, specifier) => { const loadUrl = name; - let filePath; - let baseDir; + let filePath: string | undefined; + let baseDir: string | undefined; if (loadUrl.startsWith("node:")) { const unprefixed = name.slice(5); @@ -154,7 +196,7 @@ function Hook(modules, hookFn) { try { filePath = fileURLToPath(name); name = filePath; - } catch (e) {} + } catch {} Error.stackTraceLimit = stackTraceLimit; if (filePath) { @@ -166,12 +208,12 @@ function Hook(modules, hookFn) { } } - for (const matchArg of modules) { + for (const matchArg of normalizedModules) { if ( moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) ) { callHookFn( - hookFn, + importHookFn, namespace, filePath && matchArg === filePath ? filePath : matchArg, baseDir, @@ -181,13 +223,12 @@ function Hook(modules, hookFn) { }; addHook(this._iitmHook); -} +} as HookConstructor; -Hook.prototype.unhook = function () { +Hook.prototype.unhook = function (this: HookInstance): void { removeHook(this._iitmHook); deleteHookedModules(this._modules); }; -module.exports = Hook; -module.exports.Hook = Hook; -module.exports.createAddHookMessageChannel = createAddHookMessageChannel; +export { Hook }; +export default Hook; diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs deleted file mode 100644 index f01cfc6a6..000000000 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs +++ /dev/null @@ -1,116 +0,0 @@ -"use strict"; - -import { Parser } from "acorn"; -import { importAttributesOrAssertions } from "acorn-import-attributes"; - -const acornOpts = { - ecmaVersion: "latest", - sourceType: "module", -}; - -const parser = Parser.extend(importAttributesOrAssertions); - -function warn(txt) { - process.emitWarning(txt, "get-esm-exports"); -} - -/** - * Utilizes an AST parser to interpret ESM source code and build a list of - * exported identifiers. In the baseline case, the list of identifiers will be - * the simple identifier names as written in the source code of the module. - * However, there is a special case: - * - * When an `export * from './foo.js'` line is encountered it is rewritten - * as `* from ./foo.js`. This allows the interpreting code to recognize a - * transitive export and recursively parse the indicated module. The returned - * identifier list will have "* from ./foo.js" as an item. - * - * @param {object} params - * @param {string} params.moduleSource The source code of the module to parse - * and interpret. - * - * @returns {Set} The identifiers exported by the module along with any - * custom directives. - */ -export default function getEsmExports(moduleSource) { - const exportedNames = new Set(); - const tree = parser.parse(moduleSource, acornOpts); - for (const node of tree.body) { - if (!node.type.startsWith("Export")) continue; - switch (node.type) { - case "ExportNamedDeclaration": - if (node.declaration) { - parseDeclaration(node, exportedNames); - } else { - parseSpecifiers(node, exportedNames); - } - break; - - case "ExportDefaultDeclaration": { - exportedNames.add("default"); - break; - } - - case "ExportAllDeclaration": - if (node.exported) { - exportedNames.add(node.exported.name); - } else { - exportedNames.add(`* from ${node.source.value}`); - } - break; - default: - warn("unrecognized export type: " + node.type); - } - } - return exportedNames; -} - -function parseDeclaration(node, exportedNames) { - switch (node.declaration.type) { - case "FunctionDeclaration": - exportedNames.add(node.declaration.id.name); - break; - case "VariableDeclaration": - for (const varDecl of node.declaration.declarations) { - parseVariableDeclaration(varDecl, exportedNames); - } - break; - case "ClassDeclaration": - exportedNames.add(node.declaration.id.name); - break; - default: - warn("unknown declaration type: " + node.delcaration.type); - } -} - -function parseVariableDeclaration(node, exportedNames) { - switch (node.id.type) { - case "Identifier": - exportedNames.add(node.id.name); - break; - case "ObjectPattern": - for (const prop of node.id.properties) { - exportedNames.add(prop.value.name); - } - break; - case "ArrayPattern": - for (const elem of node.id.elements) { - exportedNames.add(elem.name); - } - break; - default: - warn("unknown variable declaration type: " + node.id.type); - } -} - -function parseSpecifiers(node, exportedNames) { - for (const specifier of node.specifiers) { - if (specifier.exported.type === "Identifier") { - exportedNames.add(specifier.exported.name); - } else if (specifier.exported.type === "Literal") { - exportedNames.add(specifier.exported.value); - } else { - warn("unrecognized specifier type: " + specifier.exported.type); - } - } -} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mts b/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mts new file mode 100644 index 000000000..9c390c3ba --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mts @@ -0,0 +1,228 @@ +"use strict"; + +import { Parser } from "acorn"; +import { importAttributesOrAssertions } from "acorn-import-attributes"; +import type { + ArrayPattern, + AssignmentPattern, + AssignmentProperty, + ExportAllDeclaration, + ExportDefaultDeclaration, + ExportNamedDeclaration, + ExportSpecifier, + Identifier, + Literal, + ModuleDeclaration, + ObjectPattern, + Pattern, + Program, + RestElement, + Statement, + VariableDeclarator, +} from "acorn"; + +const acornOpts = { + ecmaVersion: "latest" as const, + sourceType: "module" as const, +}; + +const parser = Parser.extend(importAttributesOrAssertions); +type ExportDeclaration = + | ExportAllDeclaration + | ExportDefaultDeclaration + | ExportNamedDeclaration; + +function warn(txt: string): void { + process.emitWarning(txt, "get-esm-exports"); +} + +function isExportDeclaration( + node: Statement | ModuleDeclaration, +): node is ExportDeclaration { + return ( + node.type === "ExportAllDeclaration" || + node.type === "ExportDefaultDeclaration" || + node.type === "ExportNamedDeclaration" + ); +} + +function getLiteralString(node: Literal): string | undefined { + return typeof node.value === "string" ? node.value : undefined; +} + +function getExportedName(node: Identifier | Literal): string | undefined { + return node.type === "Identifier" ? node.name : getLiteralString(node); +} + +/** + * Utilizes an AST parser to interpret ESM source code and build a list of + * exported identifiers. In the baseline case, the list of identifiers will be + * the simple identifier names as written in the source code of the module. + * However, there is a special case: + * + * When an `export * from './foo.js'` line is encountered it is rewritten + * as `* from ./foo.js`. This allows the interpreting code to recognize a + * transitive export and recursively parse the indicated module. The returned + * identifier list will have "* from ./foo.js" as an item. + * + * @param {object} params + * @param {string} params.moduleSource The source code of the module to parse + * and interpret. + * + * @returns {Set} The identifiers exported by the module along with any + * custom directives. + */ +export default function getEsmExports(moduleSource: string): Set { + const exportedNames = new Set(); + const tree = parser.parse(moduleSource, acornOpts) as Program; + for (const node of tree.body) { + if (!isExportDeclaration(node)) continue; + switch (node.type) { + case "ExportNamedDeclaration": + if (node.declaration) { + parseDeclaration(node, exportedNames); + } else { + parseSpecifiers(node, exportedNames); + } + break; + + case "ExportDefaultDeclaration": { + exportedNames.add("default"); + break; + } + + case "ExportAllDeclaration": + if (node.exported) { + const exportedName = getExportedName(node.exported); + if (exportedName) { + exportedNames.add(exportedName); + } else { + warn("unrecognized export-all name type: " + node.exported.type); + } + } else { + const source = getLiteralString(node.source); + if (source) { + exportedNames.add(`* from ${source}`); + } else { + warn("unrecognized export-all source type: " + node.source.type); + } + } + break; + } + } + return exportedNames; +} + +function parseDeclaration( + node: ExportNamedDeclaration, + exportedNames: Set, +): void { + const { declaration } = node; + if (!declaration) return; + + switch (declaration.type) { + case "FunctionDeclaration": + exportedNames.add(declaration.id.name); + break; + case "VariableDeclaration": + for (const varDecl of declaration.declarations) { + parseVariableDeclaration(varDecl, exportedNames); + } + break; + case "ClassDeclaration": + exportedNames.add(declaration.id.name); + break; + } +} + +function parseVariableDeclaration( + node: VariableDeclarator, + exportedNames: Set, +): void { + parsePattern(node.id, exportedNames); +} + +function parsePattern(node: Pattern, exportedNames: Set): void { + switch (node.type) { + case "Identifier": + exportedNames.add(node.name); + break; + case "ObjectPattern": + parseObjectPattern(node, exportedNames); + break; + case "ArrayPattern": + parseArrayPattern(node, exportedNames); + break; + case "RestElement": + parseRestElement(node, exportedNames); + break; + case "AssignmentPattern": + parseAssignmentPattern(node, exportedNames); + break; + default: + warn("unknown variable declaration type: " + node.type); + } +} + +function parseObjectPattern( + node: ObjectPattern, + exportedNames: Set, +): void { + for (const property of node.properties) { + if (property.type === "RestElement") { + parseRestElement(property, exportedNames); + } else { + parseAssignmentProperty(property, exportedNames); + } + } +} + +function parseAssignmentProperty( + node: AssignmentProperty, + exportedNames: Set, +): void { + parsePattern(node.value, exportedNames); +} + +function parseArrayPattern( + node: ArrayPattern, + exportedNames: Set, +): void { + for (const element of node.elements) { + if (element) { + parsePattern(element, exportedNames); + } + } +} + +function parseRestElement(node: RestElement, exportedNames: Set): void { + parsePattern(node.argument, exportedNames); +} + +function parseAssignmentPattern( + node: AssignmentPattern, + exportedNames: Set, +): void { + parsePattern(node.left, exportedNames); +} + +function parseSpecifiers( + node: ExportNamedDeclaration, + exportedNames: Set, +): void { + for (const specifier of node.specifiers) { + parseSpecifier(specifier, exportedNames); + } +} + +function parseSpecifier( + specifier: ExportSpecifier, + exportedNames: Set, +): void { + const exportedName = getExportedName(specifier.exported); + if (exportedName) { + exportedNames.add(exportedName); + } else { + warn("unrecognized specifier type: " + specifier.exported.type); + } +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts similarity index 73% rename from js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs rename to js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts index 70920ce77..80d911173 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts @@ -2,11 +2,17 @@ import getEsmExports from "./get-esm-exports.mjs"; import { parse as parseCjs, initSync } from "cjs-module-lexer"; -import { readFileSync, existsSync } from "fs"; -import { builtinModules, createRequire } from "module"; -import { fileURLToPath, pathToFileURL } from "url"; -import { dirname, join } from "path"; +import { existsSync, readFileSync } from "node:fs"; +import { builtinModules, createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { LOAD } from "./io.mjs"; +import type { + LoaderContext, + LoaderOperation, + LoadOperation, + LoadResult, +} from "./io.mjs"; const nodeMajor = Number(process.versions.node.split(".")[0]); export const hasModuleExportsCJSDefault = nodeMajor >= 23; @@ -24,11 +30,22 @@ function ensureParserInitialized() { } } -function addDefault(arr) { +type NodeRequire = ReturnType; +type ExportGenerator = Generator, LoadResult>; +type PackageImportBranch = { + default?: unknown; + node?: unknown; +}; +type PackageImportObject = PackageImportBranch & { + import?: unknown; + require?: unknown; +}; + +function addDefault(arr: Iterable): Set { return new Set(["default", ...arr]); } -function hasEsmSyntax(source) { +function hasEsmSyntax(source: string): boolean { // Lightweight scan (no full parse) to determine if the *source code* // contains ESM-specific syntax. This is used only when: // - the loader chain didn't tell us a `format`, and @@ -41,14 +58,14 @@ function hasEsmSyntax(source) { // in CJS as an expression. if (source.indexOf("import") === -1) return false; - const isIdentCharCode = (code) => + const isIdentCharCode = (code: number) => (code >= 48 && code <= 57) || // 0-9 (code >= 65 && code <= 90) || // A-Z (code >= 97 && code <= 122) || // a-z code === 95 || // _ code === 36; // $ - const skipWhitespace = (idx) => { + const skipWhitespace = (idx: number) => { while (idx < source.length) { const c = source.charCodeAt(idx); // space, tab, cr, lf @@ -150,9 +167,60 @@ function hasEsmSyntax(source) { } // Cached exports for Node built-in modules -const BUILT_INS = new Map(); +const BUILT_INS = new Map>(); -let require; +let require: NodeRequire | undefined; + +function getRequire(): NodeRequire { + if (!require) { + require = createRequire(pathToFileURL(process.execPath)); + } + return require; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function getStringProperty( + value: Record, + property: string, +): string | undefined { + const result = value[property]; + return typeof result === "string" ? result : undefined; +} + +function getImportBranch(value: unknown): PackageImportBranch | undefined { + return isRecord(value) ? value : undefined; +} + +function getPackageImportTarget(imports: unknown): string | undefined { + if (typeof imports === "string") { + return imports; + } + + if (!isRecord(imports)) { + return undefined; + } + + const conditionalImports = imports as PackageImportObject; + const requireExport = getImportBranch(conditionalImports.require); + const importExport = getImportBranch(conditionalImports.import); + + if (requireExport || importExport) { + return ( + (requireExport && getStringProperty(requireExport, "node")) || + (requireExport && getStringProperty(requireExport, "default")) || + (importExport && getStringProperty(importExport, "node")) || + (importExport && getStringProperty(importExport, "default")) + ); + } + + return ( + getStringProperty(conditionalImports, "node") || + getStringProperty(conditionalImports, "default") + ); +} // Returns a builtin's exports object. `process.getBuiltinModule` (Node >= // 20.16 / >= 22.3) bypasses registered loader hooks; `require` does not. Under @@ -161,23 +229,20 @@ let require; // the native module. The off-thread `module.register` loader runs `require` on // the loader thread where the hooks aren't installed, so the fallback stays // correct on older Node that lacks getBuiltinModule. -function loadBuiltin(name) { +function loadBuiltin(name: string): unknown { if (typeof process.getBuiltinModule === "function") { return process.getBuiltinModule(name); } - if (!require) { - require = createRequire(import.meta.url); - } - return require(name); + return getRequire()(name); } -function getExportsForNodeBuiltIn(name) { +function getExportsForNodeBuiltIn(name: string): Set { let exports = BUILT_INS.get(name); if (!exports) { // get all properties both enumerable and non-enumerable exports = new Set( - addDefault(Object.getOwnPropertyNames(loadBuiltin(name))), + addDefault(Object.getOwnPropertyNames(loadBuiltin(name) as object)), ); // added in node 23 as alias for default in cjs modules if (hasModuleExportsCJSDefault) { @@ -198,7 +263,10 @@ const urlsBeingProcessed = new Set(); // Guard against circular imports. * @param {URL|string} fromUrl The url from which the search starts from * @returns array with url and resolvedExport */ -function resolvePackageImports(specifier, fromUrl) { +function resolvePackageImports( + specifier: string, + fromUrl: URL | string, +): [URL | string, string] | null { try { const fromPath = fileURLToPath(fromUrl); let currentDir = dirname(fromPath); @@ -208,29 +276,16 @@ function resolvePackageImports(specifier, fromUrl) { const packageJsonPath = join(currentDir, "package.json"); if (existsSync(packageJsonPath)) { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); - if (packageJson.imports && packageJson.imports[specifier]) { - const imports = packageJson.imports[specifier]; - - // Look for path inside packageJson - let resolvedExport; - if (imports && typeof imports === "object") { - const requireExport = imports.require; - const importExport = imports.import; - // look for the possibility of require and import which is standard for CJS/ESM - if (requireExport || importExport) { - // trying to resolve based on order of importance - resolvedExport = - requireExport.node || - requireExport.default || - importExport.node || - importExport.default; - } else if (imports.node || imports.default) { - resolvedExport = imports.node || imports.default; - } - } else if (typeof imports === "string") { - resolvedExport = imports; - } + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf8"), + ) as unknown; + const packageImports = + isRecord(packageJson) && isRecord(packageJson.imports) + ? packageJson.imports[specifier] + : undefined; + + if (packageImports) { + const resolvedExport = getPackageImportTarget(packageImports); if (resolvedExport) { const url = resolvedExport.startsWith(".") @@ -251,7 +306,11 @@ function resolvePackageImports(specifier, fromUrl) { return null; } -function* getCjsExports(url, context, source) { +function* getCjsExports( + url: string, + context: LoaderContext, + source: string, +): ExportGenerator { if (urlsBeingProcessed.has(url)) { return new Set(); } @@ -274,7 +333,7 @@ function* getCjsExports(url, context, source) { // resolution scoped to this iteration: a `#`-import rewrites both the // base URL and the specifier, and that rewrite must not leak into the // next re-export. - let reUrl = url; + let reUrl: string | URL = url; let reSpecifier = reexport === "." ? "./" : reexport; // Entries in the import field should always start with # @@ -284,11 +343,8 @@ function* getCjsExports(url, context, source) { [reUrl, reSpecifier] = resolved; } - if (!require) { - require = createRequire(import.meta.url); - } const newUrl = pathToFileURL( - require.resolve(reSpecifier, { + getRequire().resolve(reSpecifier, { paths: [dirname(fileURLToPath(reUrl))], }), ).href; @@ -335,11 +391,15 @@ function* getCjsExports(url, context, source) { * Please see {@link getEsmExports} for caveats on special identifiers that may * be included in the result set. */ -export function* getExports(url, context) { +export function* getExports( + url: string, + context: LoaderContext, +): ExportGenerator { // `[LOAD, ...]` gives us the possibility of getting the source from an // upstream loader. This doesn't always work though, so later on we fall back // to reading it from disk. - const parentCtx = yield [LOAD, url, context]; + const loadOperation: LoadOperation = [LOAD, url, context]; + const parentCtx = yield loadOperation; let source = parentCtx.source; const format = parentCtx.format; @@ -373,32 +433,32 @@ export function* getExports(url, context) { source = readFileSync(fileURLToPath(url), "utf8"); } + const moduleSource = source as string; + try { if (format === "module") { - return getEsmExports(source); + return getEsmExports(moduleSource); } if (format === "commonjs") { - return yield* getCjsExports(url, context, source); + return yield* getCjsExports(url, context, moduleSource); } // At this point our `format` is either undefined or not known by us. Fall // back to parsing as ESM/CJS. - const esmExports = getEsmExports(source); + const esmExports = getEsmExports(moduleSource); if (!esmExports.size) { // If there's strong evidence this is ESM (static import/import.meta), // prefer returning the empty ESM export set over incorrectly treating it // as CJS. - if (!hasEsmSyntax(source)) { + if (!hasEsmSyntax(moduleSource)) { // It might be possible to get here if the format // isn't set at first and yet we have an ESM module with no exports. - return yield* getCjsExports(url, context, source); + return yield* getCjsExports(url, context, moduleSource); } } return esmExports; } catch (cause) { - const err = new Error(`Failed to parse '${url}'`); - err.cause = cause; - throw err; + throw new Error(`Failed to parse '${url}'`, { cause }); } } diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs deleted file mode 100644 index aac1f3fb0..000000000 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs +++ /dev/null @@ -1,78 +0,0 @@ -"use strict"; - -// The export-collection logic (resolving star re-exports, reading source, -// parsing exports) is identical whether `import-in-the-middle` runs as an -// off-thread loader (`module.register`, asynchronous `nextResolve`/`nextLoad`) -// or as an in-thread synchronous loader (`module.registerHooks`). To keep a -// single implementation of that logic — instead of two copies that drift — it -// is written as "sans-io" generators that `yield` the I/O they need and let a -// driver fulfil it. The async driver awaits; the sync driver calls straight -// through. Everything between the yields is shared. - -// Operation kinds a loader generator may yield. Each is `[KIND, ...args]`. -export const LOAD = 0; // [LOAD, url, context] -> resolves to { source, format } -export const RESOLVE = 1; // [RESOLVE, specifier, context] -> resolves to { url, format } - -function runOp(op, io) { - if (op[0] === RESOLVE) { - return io.resolve(op[1], op[2]); - } - return io.load(op[1], op[2]); -} - -/** - * Drives a loader generator to completion, fulfilling each yielded I/O - * operation synchronously. Used with `module.registerHooks`, whose - * `nextResolve`/`nextLoad` return their result directly. - * - * Errors from I/O are thrown back into the generator (via `gen.throw`) so its - * `try`/`finally` blocks run exactly as they would for an `await` rejection. - * - * @template T - * @param {Generator} gen - * @param {{ load: Function, resolve?: Function }} io - * @returns {T} - */ -export function driveSync(gen, io) { - let next = gen.next(); - while (next.done === false) { - let result; - let error; - let threw = false; - try { - result = runOp(next.value, io); - } catch (err) { - threw = true; - error = err; - } - next = threw ? gen.throw(error) : gen.next(result); - } - return next.value; -} - -/** - * Drives a loader generator to completion, awaiting each yielded I/O - * operation. Used with the off-thread `module.register` loader, whose - * `nextResolve`/`nextLoad` are asynchronous. - * - * @template T - * @param {Generator} gen - * @param {{ load: Function, resolve?: Function }} io - * @returns {Promise} - */ -export async function driveAsync(gen, io) { - let next = gen.next(); - while (next.done === false) { - let result; - let error; - let threw = false; - try { - result = await runOp(next.value, io); - } catch (err) { - threw = true; - error = err; - } - next = threw ? gen.throw(error) : gen.next(result); - } - return next.value; -} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/io.mts b/js/src/auto-instrumentations/import-in-the-middle/lib/io.mts new file mode 100644 index 000000000..e52fe9326 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/io.mts @@ -0,0 +1,128 @@ +"use strict"; + +// The export-collection logic (resolving star re-exports, reading source, +// parsing exports) is identical whether `import-in-the-middle` runs as an +// off-thread loader (`module.register`, asynchronous `nextResolve`/`nextLoad`) +// or as an in-thread synchronous loader (`module.registerHooks`). To keep a +// single implementation of that logic — instead of two copies that drift — it +// is written as "sans-io" generators that `yield` the I/O they need and let a +// driver fulfil it. The async driver awaits; the sync driver calls straight +// through. Everything between the yields is shared. + +// Operation kinds a loader generator may yield. Each is `[KIND, ...args]`. +export const LOAD = 0; // [LOAD, url, context] -> resolves to { source, format } +export const RESOLVE = 1; // [RESOLVE, specifier, context] -> resolves to { url, format } + +export type LoaderAttributes = Record; +export type LoaderContext = { + conditions?: string[]; + format?: string; + importAssertions?: LoaderAttributes; + importAttributes?: LoaderAttributes; + parentURL?: string; + [key: string]: unknown; +}; +export type LoadSource = + | string + | ArrayBuffer + | NodeJS.ArrayBufferView + | null + | undefined; +export type LoadResult = { + format?: string; + shortCircuit?: boolean; + source?: LoadSource; +}; +export type ResolveResult = { + format?: string; + shortCircuit?: boolean; + url: string; +}; +export type LoadOperation = [typeof LOAD, string, LoaderContext]; +export type ResolveOperation = [typeof RESOLVE, string, LoaderContext]; +export type LoaderOperation = LoadOperation | ResolveOperation; +export type SyncLoaderIo = { + load: (url: string, context: LoaderContext) => LoadResult; + resolve?: (specifier: string, context: LoaderContext) => ResolveResult; +}; +export type AsyncLoaderIo = { + load: (url: string, context: LoaderContext) => Promise; + resolve?: ( + specifier: string, + context: LoaderContext, + ) => Promise; +}; + +function runOp( + op: LoaderOperation, + io: SyncLoaderIo, +): LoadResult | ResolveResult; +function runOp( + op: LoaderOperation, + io: AsyncLoaderIo, +): Promise; +function runOp( + op: LoaderOperation, + io: SyncLoaderIo | AsyncLoaderIo, +): LoadResult | ResolveResult | Promise { + if (op[0] === RESOLVE) { + if (!io.resolve) { + throw new Error("resolve operation yielded without a resolve function"); + } + return io.resolve(op[1], op[2]); + } + return io.load(op[1], op[2]); +} + +/** + * Drives a loader generator to completion, fulfilling each yielded I/O + * operation synchronously. Used with `module.registerHooks`, whose + * `nextResolve`/`nextLoad` return their result directly. + * + * Errors from I/O are thrown back into the generator (via `gen.throw`) so its + * `try`/`finally` blocks run exactly as they would for an `await` rejection. + * + * @template T + * @param {Generator} gen + * @param {SyncLoaderIo} io + * @returns {T} + */ +export function driveSync( + gen: Generator, + io: SyncLoaderIo, +): T { + let next = gen.next(); + while (next.done === false) { + try { + next = gen.next(runOp(next.value, io)); + } catch (err) { + next = gen.throw(err); + } + } + return next.value as T; +} + +/** + * Drives a loader generator to completion, awaiting each yielded I/O + * operation. Used with the off-thread `module.register` loader, whose + * `nextResolve`/`nextLoad` are asynchronous. + * + * @template T + * @param {Generator} gen + * @param {AsyncLoaderIo} io + * @returns {Promise} + */ +export async function driveAsync( + gen: Generator, + io: AsyncLoaderIo, +): Promise { + let next = gen.next(); + while (next.done === false) { + try { + next = gen.next(await runOp(next.value, io)); + } catch (err) { + next = gen.throw(err); + } + } + return next.value as T; +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts b/js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts new file mode 100644 index 000000000..9abbdb348 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts @@ -0,0 +1,23 @@ +export type Namespace = Record; + +export function register( + name: string, + namespace: Namespace, + set: Record boolean>, + get: Record unknown>, + specifier?: string, +): void; + +declare const registerState: { + addHookedModules(modules: readonly string[]): void; + deleteHookedModules(modules: readonly string[]): void; + hookedModules: Set; + importHooks: Array< + (name: string, namespace: Namespace, specifier?: string) => void + >; + register: typeof register; + specifiers: Map; + toHook: Array<[name: string, namespace: Namespace, specifier?: string]>; +}; + +export default registerState; diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/register.js b/js/src/auto-instrumentations/import-in-the-middle/lib/register.mts similarity index 51% rename from js/src/auto-instrumentations/import-in-the-middle/lib/register.js rename to js/src/auto-instrumentations/import-in-the-middle/lib/register.mts index 08eecf643..9ffee8f79 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/register.js +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/register.mts @@ -2,15 +2,50 @@ // // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. -const importHooks = []; // TODO should this be a Set? -const setters = new WeakMap(); -const getters = new WeakMap(); -const specifiers = new Map(); -const toHook = []; -const hookedModuleCounts = new Map(); -const hookedModules = new Set(); +export type Namespace = Record; +export type ExportSetter = (value: unknown) => boolean; +export type ExportGetter = () => unknown; +export type ImportHook = ( + name: string, + namespace: Namespace, + specifier?: string, +) => void; -const proxyHandler = { +interface RegisterState { + getters: WeakMap>; + hookedModuleCounts: Map; + hookedModules: Set; + importHooks: ImportHook[]; + setters: WeakMap>; + specifiers: Map; + toHook: Array<[name: string, namespace: Namespace, specifier?: string]>; +} + +const stateKey = Symbol.for("braintrust.importInTheMiddle.registerState"); +const stateGlobal = globalThis as typeof globalThis & + Record; + +const state: RegisterState = (stateGlobal[stateKey] ??= { + getters: new WeakMap(), + hookedModuleCounts: new Map(), + hookedModules: new Set(), + importHooks: [], // TODO should this be a Set? + setters: new WeakMap(), + specifiers: new Map(), + toHook: [], +}); + +const { + getters, + hookedModuleCounts, + hookedModules, + importHooks, + setters, + specifiers, + toHook, +} = state; + +const proxyHandler: ProxyHandler = { set(target, name, value) { const set = setters.get(target); const setter = set && set[name]; @@ -27,7 +62,7 @@ const proxyHandler = { return "Module"; } - const getter = getters.get(target)[name]; + const getter = getters.get(target)?.[name]; if (typeof getter === "function") { return getter(); @@ -50,7 +85,13 @@ const proxyHandler = { }, }; -function register(name, namespace, set, get, specifier) { +export function register( + name: string, + namespace: Namespace, + set: Record, + get: Record, + specifier?: string, +): void { specifiers.set(name, specifier); setters.set(namespace, set); getters.set(namespace, get); @@ -59,7 +100,7 @@ function register(name, namespace, set, get, specifier) { toHook.push([name, proxy, specifier]); } -function addHookedModules(modules) { +function addHookedModules(modules: readonly string[]): void { for (const each of modules) { const nextCount = (hookedModuleCounts.get(each) || 0) + 1; hookedModuleCounts.set(each, nextCount); @@ -67,7 +108,7 @@ function addHookedModules(modules) { } } -function deleteHookedModules(modules) { +function deleteHookedModules(modules: readonly string[]): void { for (const each of modules) { const nextCount = (hookedModuleCounts.get(each) || 0) - 1; if (nextCount > 0) { @@ -79,10 +120,12 @@ function deleteHookedModules(modules) { } } -exports.register = register; -exports.addHookedModules = addHookedModules; -exports.deleteHookedModules = deleteHookedModules; -exports.hookedModules = hookedModules; -exports.importHooks = importHooks; -exports.specifiers = specifiers; -exports.toHook = toHook; +export default { + addHookedModules, + deleteHookedModules, + hookedModules, + importHooks, + register, + specifiers, + toHook, +}; diff --git a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts similarity index 78% rename from js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs rename to js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts index d1e96b9af..2748fb4d9 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs +++ b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts @@ -1,9 +1,13 @@ -import * as module from "module"; +import * as module from "node:module"; import { createHook, supportsSyncHooks } from "./create-hook.mjs"; export { supportsSyncHooks }; const hook = createHook(import.meta); +type RegisterHooks = (hooks: { + load: typeof hook.loadSync; + resolve: typeof hook.resolveSync; +}) => void; let registered = false; @@ -46,5 +50,16 @@ export function register() { } registered = true; - module.registerHooks({ resolve: hook.resolveSync, load: hook.loadSync }); + const registerHooks = ( + module as typeof module & { + registerHooks?: RegisterHooks; + } + ).registerHooks; + if (typeof registerHooks !== "function") { + throw new Error( + "'import-in-the-middle' synchronous hooks require module.registerHooks", + ); + } + + registerHooks({ resolve: hook.resolveSync, load: hook.loadSync }); } diff --git a/js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts b/js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts new file mode 100644 index 000000000..7b1f47778 --- /dev/null +++ b/js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts @@ -0,0 +1,158 @@ +import moduleDetailsFromPath from "module-details-from-path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import registerState from "../import-in-the-middle/lib/register.mjs"; + +type Namespace = Record; +type HookFn = (exported: Namespace, name: string, baseDir?: string) => unknown; + +const { + addHookedModules, + deleteHookedModules, + importHooks, + specifiers, + toHook, +} = registerState; + +function addHook( + hook: (name: string, namespace: Namespace, specifier?: string) => void, +) { + importHooks.push(hook); + toHook.forEach(([name, namespace, specifier]) => + hook(name, namespace, specifier), + ); +} + +function removeHook( + hook: (name: string, namespace: Namespace, specifier?: string) => void, +) { + const index = importHooks.indexOf(hook); + if (index > -1) { + importHooks.splice(index, 1); + } +} + +function callHookFn( + hookFn: HookFn, + namespace: Namespace, + name: string, + baseDir?: string, +): void { + const newDefault = hookFn(namespace, name, baseDir); + if (newDefault && newDefault !== namespace && "default" in namespace) { + namespace.default = newDefault; + } +} + +function normalizeModules(modules: string[]): string[] { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust import-in-the-middle requires a non-empty modules array", + ); + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust import-in-the-middle only supports string module names or file URLs", + ); + } + } + + return modules; +} + +function moduleMatches( + matchArg: string, + name: string, + filePath: string | undefined, + specifier: string | undefined, + baseDir: string | undefined, + loadUrl: string, +): boolean { + if (filePath && matchArg === filePath) { + return true; + } + + if (matchArg === specifier || matchArg === loadUrl || matchArg === name) { + return true; + } + + if (!baseDir) { + return false; + } + + // Keep the top-level package check from upstream, but do not support the + // broad internals mode. Internal files must be listed explicitly. + return matchArg === name && baseDir.endsWith(specifiers.get(loadUrl) ?? ""); +} + +export default class Hook { + private readonly modules: string[]; + private readonly iitmHook: ( + name: string, + namespace: Namespace, + specifier?: string, + ) => void; + + constructor(modules: string[], hookFn: HookFn) { + modules = normalizeModules(modules); + if (typeof hookFn !== "function") { + throw new TypeError( + "Braintrust import-in-the-middle requires a hook function", + ); + } + + addHookedModules(modules); + + this.modules = modules; + this.iitmHook = (name, namespace, specifier) => { + const loadUrl = name; + let filePath: string | undefined; + let baseDir: string | undefined; + + if (loadUrl.startsWith("node:")) { + const unprefixed = name.slice(5); + if (isBuiltin(unprefixed)) { + name = unprefixed; + } + } else if (loadUrl.startsWith("file://")) { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + try { + filePath = fileURLToPath(name); + name = filePath; + } catch {} + Error.stackTraceLimit = stackTraceLimit; + + if (filePath) { + const details = moduleDetailsFromPath(filePath); + if (details) { + name = details.name; + baseDir = details.basedir; + } + } + } + + for (const matchArg of modules) { + if ( + moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) + ) { + callHookFn( + hookFn, + namespace, + filePath && matchArg === filePath ? filePath : matchArg, + baseDir, + ); + } + } + }; + + addHook(this.iitmHook); + } + + unhook(): void { + removeHook(this.iitmHook); + deleteHookedModules(this.modules); + } +} diff --git a/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts b/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts index cda12afdf..0abc7d319 100644 --- a/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts +++ b/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts @@ -1,117 +1,176 @@ import { describe, expect, it } from "vitest"; import { - classifyMastraTarget, - patchMastraSource, + installMastraExporterFactory, + patchMastraExports, } from "./mastra-observability-patch"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -describe("classifyMastraTarget", () => { - it("identifies @mastra/core main and submodule entries", () => { - expect(classifyMastraTarget("@mastra/core", "dist/index.js")).toBe("core"); - expect(classifyMastraTarget("@mastra/core", "dist/index.cjs")).toBe("core"); - expect(classifyMastraTarget("@mastra/core", "dist/mastra/index.js")).toBe( - "core", - ); - expect(classifyMastraTarget("@mastra/core", "dist/mastra/index.cjs")).toBe( - "core", - ); - }); +const fixturesDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../../tests/auto-instrumentations/fixtures", +); +const mastraCoreBaseDir = join(fixturesDir, "node_modules/@mastra/core"); - it("identifies @mastra/observability entry", () => { - expect(classifyMastraTarget("@mastra/observability", "dist/index.js")).toBe( - "observability", - ); - expect( - classifyMastraTarget("@mastra/observability", "dist/index.cjs"), - ).toBe("observability"); - }); +describe("patchMastraExports — runtime @mastra/observability export", () => { + it("wraps Observability and appends the Braintrust exporter", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); - it("returns null for unrelated paths", () => { - expect( - classifyMastraTarget("@mastra/core", "dist/agent/index.js"), - ).toBeNull(); - expect( - classifyMastraTarget("@mastra/core", "dist/chunk-XYZ.js"), - ).toBeNull(); - expect(classifyMastraTarget("openai", "dist/index.js")).toBeNull(); - }); -}); + class Observability { + public config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + const namespace = { Observability }; + + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); -describe("patchMastraSource — @mastra/core ESM entry", () => { - it("rewrites a thin re-export into a Proxy-wrapped class", () => { - const original = `export { Mastra } from './chunk-PLCLLPJL.js';\n`; - const patched = patchMastraSource(original, "core", "esm"); - - // The rewritten source must import the original class from the same chunk - expect(patched).toContain( - `import { Mastra as __braintrustOrigMastra } from "./chunk-PLCLLPJL.js"`, - ); - // It must wrap in a Proxy with a `construct` trap - expect(patched).toContain("new Proxy(__braintrustOrigMastra,"); - expect(patched).toContain("construct(target, args, newTarget)"); - // It must re-export `Mastra` so consumers' bindings are unchanged - expect(patched).toContain("export { Mastra }"); - // It must pull Observability via createRequire so it resolves from the - // user's node_modules tree (not our SDK's) - expect(patched).toContain("createRequire"); - expect(patched).toContain(`__braintrustRequire("@mastra/observability")`); + const instance = new namespace.Observability({ + configs: { + default: { + exporters: [{ name: "other" }], + serviceName: "service", + }, + }, + }); + + expect(instance.config).toEqual({ + configs: { + default: { + exporters: [{ name: "other" }, { name: "braintrust" }], + serviceName: "service", + }, + }, + }); }); - it("returns the original source when the re-export shape doesn't match", () => { - const original = `// arbitrary code that doesn't re-export Mastra\n`; - const patched = patchMastraSource(original, "core", "esm"); - expect(patched).toBe(original); + it("does not duplicate an existing Braintrust exporter", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Observability { + public config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + const namespace = { Observability }; + + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); + + const instance = new namespace.Observability({ + configs: { + default: { + exporters: [{ name: "braintrust" }], + serviceName: "service", + }, + }, + }); + + expect(instance.config).toEqual({ + configs: { + default: { + exporters: [{ name: "braintrust" }], + serviceName: "service", + }, + }, + }); }); - it("preserves the exact chunk path Mastra references", () => { - const original = `export { Mastra } from '../chunk-DIFFERENT.js';\n`; - const patched = patchMastraSource(original, "core", "esm"); - expect(patched).toContain("../chunk-DIFFERENT.js"); + it("creates a default config when none is provided", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Observability { + public config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + const namespace = { Observability }; + + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); + + const instance = new namespace.Observability(undefined); + + expect(instance.config).toEqual({ + configs: { + default: { + exporters: [{ name: "braintrust" }], + serviceName: "mastra", + }, + }, + }); }); }); -describe("patchMastraSource — @mastra/core CJS entry", () => { - it("rewrites the require + defineProperty shape into a Proxy", () => { - const original = `'use strict'; -var chunkVOP4TUHG_cjs = require('./chunk-VOP4TUHG.cjs'); -Object.defineProperty(exports, "Mastra", { - enumerable: true, - get: function () { return chunkVOP4TUHG_cjs.Mastra; } -}); -`; - const patched = patchMastraSource(original, "core", "cjs"); - - expect(patched).toContain(`require("./chunk-VOP4TUHG.cjs")`); - expect(patched).toContain(`require("@mastra/observability")`); - expect(patched).toContain("__braintrustChunk.Mastra"); - expect(patched).toContain("new Proxy("); - expect(patched).toContain(`Object.defineProperty(exports, "Mastra"`); +describe("patchMastraExports — runtime @mastra/core export", () => { + it("wraps Mastra and injects default Observability when config has none", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Mastra { + public observability: any; + constructor(config: any = {}) { + this.observability = config.observability; + } + } + const namespace = { Mastra }; + + patchMastraExports(namespace, { + baseDir: mastraCoreBaseDir, + moduleName: "@mastra/core", + }); + patchMastraExports(namespace, { + baseDir: mastraCoreBaseDir, + moduleName: "@mastra/core", + }); + + const instance = new namespace.Mastra({}); + + expect(instance.observability).toBeTruthy(); + expect( + instance.observability.config.configs.default.exporters.map( + (exporter: { name: string }) => exporter.name, + ), + ).toEqual(["braintrust"]); }); -}); -describe("patchMastraSource — @mastra/observability entry", () => { - it("appends a Proxy wrap, leaving the original source intact above", () => { - const original = `var Observability = class extends MastraBase {};\nexport { Observability };\n`; - const patched = patchMastraSource(original, "observability", "esm"); - - // Original source still starts the file - expect(patched.startsWith(original)).toBe(true); - // Append wraps the Observability binding via Proxy - expect(patched).toContain("function __braintrustWrapObservability"); - expect(patched).toContain( - `if (typeof Observability === "undefined") return`, - ); - expect(patched).toContain("new Proxy(__OriginalObservability"); - expect(patched).toContain("factory()"); - expect(patched).toContain("__braintrustWrapped"); + it("preserves a user-provided observability config", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Mastra { + public observability: any; + constructor(config: any = {}) { + this.observability = config.observability; + } + } + const namespace = { Mastra }; + const observability = { user: true }; + + patchMastraExports(namespace, { + baseDir: mastraCoreBaseDir, + moduleName: "@mastra/core", + }); + + const instance = new namespace.Mastra({ observability }); + + expect(instance.observability).toBe(observability); }); - it("doesn't depend on chunk path extraction for observability", () => { - // The Observability entry is one big inline file, not a re-export. - // patchMastraSource should still produce a valid append even when no - // chunk-shaped pattern is present in the source. - const original = `// big inline bundle\nvar Observability = class {};\nexport { Observability };\n`; - const patched = patchMastraSource(original, "observability", "esm"); - expect(patched).not.toBe(original); - expect(patched.length).toBeGreaterThan(original.length); + it("is a no-op when @mastra/observability is unavailable", () => { + class Mastra {} + const namespace = { Mastra }; + + patchMastraExports(namespace, { + baseDir: dirname(fileURLToPath(import.meta.url)), + moduleName: "@mastra/core", + }); + + expect(namespace.Mastra).toBe(Mastra); }); }); diff --git a/js/src/auto-instrumentations/loader/mastra-observability-patch.ts b/js/src/auto-instrumentations/loader/mastra-observability-patch.ts index 95b8f1aa1..22879923e 100644 --- a/js/src/auto-instrumentations/loader/mastra-observability-patch.ts +++ b/js/src/auto-instrumentations/loader/mastra-observability-patch.ts @@ -1,46 +1,14 @@ /** - * Runtime patches that auto-install the Braintrust observability exporter for - * Mastra users. + * Runtime hook implementation for Mastra top-level exports. * - * Two entry shapes need handling, and they're each different enough that we - * pattern-match them separately rather than trying to share one strategy: - * - * 1. **`@mastra/core` `Mastra` entries** (`dist/index.{js,cjs}` and - * `dist/mastra/index.{js,cjs}`) are thin re-exports from a content-hashed - * chunk that changes filename every release: - * - * // dist/index.js - * export { Mastra } from "./chunk-PLCLLPJL.js"; - * - * The entry filename itself is stable (pinned by the package's `exports` - * map), so we patch it — but a re-export has no local binding to mutate, - * so we have to rewrite the line into an explicit import + Proxy + export. - * That means extracting the chunk path with a regex. The regex pattern is - * stable; only the chunk hash inside the matched string changes. - * - * 2. **`@mastra/observability` `Observability` entry** (`dist/index.{js,cjs}`) - * inlines its class declaration in the entry itself: - * - * var Observability = class extends MastraBase { ... }; - * export { ..., Observability, ... }; - * - * Because the local `Observability` is a `var` binding, we can *append* a - * wrap (reassign `Observability` to a Proxy) and ESM's live-binding - * semantics propagate it to importers. No source rewrite needed, no chunk - * path involved. - * - * In both cases the generated wrapper is a `Proxy` with a `construct` trap. - * On the Mastra side the trap injects an `Observability` instance into the - * config when the user didn't pass one (via a factory registered on - * `globalThis` by `hook.mjs`). On the Observability side the trap walks the - * `configs` map and appends our exporter to any instance config that doesn't - * already have one with `name === "braintrust"`. - * - * The patch is a no-op if the regex doesn't match (e.g., Mastra restructures - * its build) or if our globalThis factory isn't present (e.g., user disabled - * via `BRAINTRUST_DISABLE_INSTRUMENTATION=mastra`). + * The IITM/RITM adapters pass real module export objects here. Bundler/source + * adapters generate wrapper modules that build the same mutable namespace + * facade, call the shared hook runner, and re-export the updated bindings. */ +import { createRequire } from "node:module"; +import { join } from "node:path"; + /** * Name of the `globalThis` property the generated patches read to look up the * Braintrust exporter factory at runtime. Set by `hook.mjs` (loader path) and @@ -65,224 +33,247 @@ export function installMastraExporterFactory(factory: () => unknown): void { const MASTRA_CORE_PACKAGE = "@mastra/core"; const MASTRA_OBSERVABILITY_PACKAGE = "@mastra/observability"; +const MASTRA_CORE_MASTRA_SPECIFIER = "@mastra/core/mastra"; +const MASTRA_RUNTIME_WRAPPED = Symbol.for( + "braintrust.mastra.runtime-export-wrapped", +); -// Entrypoints pinned by each package's `exports` map. Both Mastra entries -// (`dist/index.js` and `dist/mastra/index.js`) re-export `Mastra` from a -// chunk, so they both need the rewrite treatment. -const MASTRA_CORE_ENTRY_PATHS = new Set([ - "dist/index.js", - "dist/index.cjs", - "dist/mastra/index.js", - "dist/mastra/index.cjs", -]); -const MASTRA_OBSERVABILITY_ENTRY_PATHS = new Set([ - "dist/index.js", - "dist/index.cjs", -]); +export interface MastraRuntimePatchContext { + moduleName: string; + baseDir?: string; + resolutionBase?: string; +} -type MastraTargetKind = "core" | "observability"; -export type MastraModuleFormat = "esm" | "cjs"; +type RuntimeConstructor = new (...args: unknown[]) => unknown; -export function classifyMastraTarget( - packageName: string, - modulePath: string, -): MastraTargetKind | null { +export function patchMastraExports( + exportsValue: T, + context: MastraRuntimePatchContext, +): T { if ( - packageName === MASTRA_CORE_PACKAGE && - MASTRA_CORE_ENTRY_PATHS.has(modulePath) + context.moduleName === MASTRA_CORE_PACKAGE || + context.moduleName === MASTRA_CORE_MASTRA_SPECIFIER ) { - return "core"; + return patchMastraCoreExports(exportsValue, context) as T; + } else if (context.moduleName === MASTRA_OBSERVABILITY_PACKAGE) { + return patchMastraObservabilityExports(exportsValue) as T; } - if ( - packageName === MASTRA_OBSERVABILITY_PACKAGE && - MASTRA_OBSERVABILITY_ENTRY_PATHS.has(modulePath) - ) { - return "observability"; + return exportsValue; +} + +function patchMastraCoreExports( + exportsValue: unknown, + context: MastraRuntimePatchContext, +): unknown { + if (!exportsValue || typeof exportsValue !== "object") return exportsValue; + + const namespace = exportsValue as Record; + const Mastra = asRuntimeConstructor(namespace.Mastra); + if (!Mastra || isRuntimeWrapped(Mastra)) return exportsValue; + + const Observability = loadObservabilityClass(context); + if (!Observability) return exportsValue; + + const wrapped = new Proxy(Mastra, { + construct(target, args, newTarget) { + const firstArg = args[0]; + if ( + (!firstArg || + typeof firstArg !== "object" || + !("observability" in firstArg) || + !(firstArg as { observability?: unknown }).observability) && + Observability + ) { + try { + const observability = new Observability({ + configs: { default: { serviceName: "mastra" } }, + }); + const nextConfig = + firstArg && typeof firstArg === "object" + ? { ...(firstArg as Record), observability } + : { observability }; + return Reflect.construct( + target, + [nextConfig, ...args.slice(1)], + newTarget, + ); + } catch { + // Mastra will keep its own fallback behavior if constructing + // Observability fails. + } + } + return Reflect.construct(target, args, newTarget); + }, + }); + markRuntimeWrapped(wrapped); + + if (setNamespaceExport(namespace, "Mastra", wrapped)) { + return exportsValue; } - return null; + return cloneNamespaceWithExport(namespace, "Mastra", wrapped); } -// Extract the chunk path from a Mastra entry source. -// ESM shape: `export { Mastra } from "../chunk-XYZ.js";` -// CJS shape: `var chunkXYZ_cjs = require("../chunk-XYZ.cjs"); ...` -// Returns null when the shape isn't what we expect — caller falls through and -// emits the original source unchanged so user code keeps working. -function extractChunkPath(source: string): string | null { - const esmMatch = source.match( - /export\s*\{\s*Mastra(?:\s+as\s+\w+)?\s*\}\s*from\s*['"]([^'"]+)['"]/, - ); - if (esmMatch) return esmMatch[1]; +function patchMastraObservabilityExports(exportsValue: unknown): unknown { + if (!exportsValue || typeof exportsValue !== "object") return exportsValue; - const cjsMatch = source.match( - /require\s*\(\s*['"]([^'"]+chunk-[^'"]+)['"]\s*\)/, - ); - if (cjsMatch) return cjsMatch[1]; + const namespace = exportsValue as Record; + const Observability = asRuntimeConstructor(namespace.Observability); + if (!Observability || isRuntimeWrapped(Observability)) { + return exportsValue; + } + + const wrapped = new Proxy(Observability, { + construct(target, args, newTarget) { + const nextArgs = args.slice(); + nextArgs[0] = ensureBraintrustExporter(nextArgs[0]); + return Reflect.construct(target, nextArgs, newTarget); + }, + }); + markRuntimeWrapped(wrapped); - return null; + if (setNamespaceExport(namespace, "Observability", wrapped)) { + return exportsValue; + } + return cloneNamespaceWithExport(namespace, "Observability", wrapped); +} + +function loadObservabilityClass( + context: MastraRuntimePatchContext, +): RuntimeConstructor | undefined { + try { + const requireFromMastra = createRequire( + context.resolutionBase ?? + (context.baseDir + ? join(context.baseDir, "package.json") + : join(process.cwd(), "package.json")), + ); + const observability = requireFromMastra(MASTRA_OBSERVABILITY_PACKAGE); + patchMastraObservabilityExports(observability); + return asRuntimeConstructor(observability?.Observability); + } catch { + return undefined; + } } -// All wrapper templates below are emitted as runtime JS in the target module's -// scope. They never reference Braintrust SDK code directly — instead they look -// up a factory function on `globalThis` (registered by `hook.mjs`). That keeps -// the patch independent of how the user's module graph resolves `braintrust`, -// and lets us cleanly no-op when the user disables our integration via env. +function ensureBraintrustExporter(rawConfig: unknown): unknown { + try { + const factory = (globalThis as Record)[ + MASTRA_EXPORTER_FACTORY_GLOBAL + ]; + if (typeof factory !== "function") return rawConfig; -const EXPORTER_FACTORY_KEY = JSON.stringify(MASTRA_EXPORTER_FACTORY_GLOBAL); + const config = + rawConfig && typeof rawConfig === "object" + ? (rawConfig as Record) + : {}; + const configsIn = + config.configs && typeof config.configs === "object" + ? (config.configs as Record) + : undefined; + const configsOut: Record = {}; + let hadEntries = false; -// Construct-trap body shared between ESM and CJS wrappers. The wrapper loads -// `@mastra/observability` at module-evaluation time from the Mastra entry's -// own location (so the resolver finds the user's `node_modules`, not our -// SDK's). When the user constructs `new Mastra(...)` without an observability -// config, the trap injects a default `Observability` — which, because it -// loaded through our patched ESM hook / CJS patch, already auto-installs the -// Braintrust exporter via its own constructor wrap. -const MASTRA_PROXY_HANDLER_BODY = ` -{ - construct(target, args, newTarget) { - var firstArg = args[0]; - if ( - (!firstArg || typeof firstArg !== "object" || !firstArg.observability) && - __braintrustObservabilityClass - ) { - try { - // serviceName is required by Mastra's Observability validator; pass - // something sensible by default. Users who want a different name - // should construct Observability themselves. - var observability = new __braintrustObservabilityClass({ - configs: { default: { serviceName: "mastra" } }, - }); - args = args.slice(); - args[0] = Object.assign({}, firstArg, { observability: observability }); - } catch (e) { - // Fall through. Mastra will use its own NoOp; user code still works, - // just without auto-instrumentation. + if (configsIn) { + for (const [name, rawInstanceConfig] of Object.entries(configsIn)) { + hadEntries = true; + const instanceConfig = + rawInstanceConfig && typeof rawInstanceConfig === "object" + ? (rawInstanceConfig as Record) + : {}; + const existing = Array.isArray(instanceConfig.exporters) + ? instanceConfig.exporters + : []; + const hasOurs = existing.some( + (exporter) => + exporter && + typeof exporter === "object" && + (exporter as { name?: unknown }).name === "braintrust", + ); + configsOut[name] = { + ...instanceConfig, + exporters: hasOurs ? existing : [...existing, factory()], + }; } } - return Reflect.construct(target, args, newTarget); - }, -}`; -function buildMastraEsmWrapper(chunkPath: string): string { - const chunk = JSON.stringify(chunkPath); - return `import { Mastra as __braintrustOrigMastra } from ${chunk}; -import { createRequire as __braintrustCreateRequire } from "node:module"; + if (!hadEntries) { + configsOut.default = { + serviceName: "mastra", + exporters: [factory()], + }; + } -let __braintrustObservabilityClass = null; -try { - // Resolve @mastra/observability relative to this module (the Mastra entry), - // so it's looked up from the user's node_modules tree. - const __braintrustRequire = __braintrustCreateRequire(import.meta.url); - __braintrustObservabilityClass = - __braintrustRequire("@mastra/observability").Observability; -} catch (e) { - // @mastra/observability isn't installed; the construct trap will skip the - // auto-construct branch and Mastra falls back to its own NoOp. -} -const Mastra = new Proxy(__braintrustOrigMastra, ${MASTRA_PROXY_HANDLER_BODY}); -export { Mastra }; -`; + return { ...config, configs: configsOut }; + } catch { + return rawConfig; + } } -function buildMastraCjsWrapper(chunkPath: string): string { - const chunk = JSON.stringify(chunkPath); - return `'use strict'; -const __braintrustChunk = require(${chunk}); - -let __braintrustObservabilityClass = null; -try { - __braintrustObservabilityClass = require("@mastra/observability").Observability; -} catch (e) { - // @mastra/observability isn't installed; same fallback as the ESM wrapper. +function asRuntimeConstructor(value: unknown): RuntimeConstructor | undefined { + return typeof value === "function" + ? (value as RuntimeConstructor) + : undefined; } -const __braintrustWrappedMastra = new Proxy( - __braintrustChunk.Mastra, - ${MASTRA_PROXY_HANDLER_BODY}, -); -Object.defineProperty(exports, "Mastra", { - enumerable: true, - configurable: true, - get: function () { return __braintrustWrappedMastra; } -}); -`; +function isRuntimeWrapped(value: RuntimeConstructor): boolean { + return ( + (value as unknown as Record)[MASTRA_RUNTIME_WRAPPED] === + true + ); } -// Appended to the end of `@mastra/observability/dist/index.{js,cjs}`. The -// entry declares `var Observability = class extends MastraBase { ... }`, -// which means we can reassign the binding from inside the same module after -// the class is created and the export line has run. ESM live-binding -// semantics make external importers see the new value; CJS lookups go -// through `exports.Observability`, which we redefine to mirror the wrap. -const OBSERVABILITY_APPEND_BODY = ` -;(function __braintrustWrapObservability() { - // Top-level so we can both read and reassign the var binding the original - // entry declared. - if (typeof Observability === "undefined") return; - if (Observability.__braintrustWrapped) return; - function __braintrustEnsureExporter(rawConfig) { - try { - var factory = globalThis[${EXPORTER_FACTORY_KEY}]; - if (typeof factory !== "function") return rawConfig; - var config = rawConfig && typeof rawConfig === "object" ? rawConfig : {}; - var configsIn = config.configs && typeof config.configs === "object" ? config.configs : null; - var configsOut = {}; - var hadEntries = false; - if (configsIn) { - for (var name in configsIn) { - if (!Object.prototype.hasOwnProperty.call(configsIn, name)) continue; - hadEntries = true; - var inst = configsIn[name] || {}; - var existing = Array.isArray(inst.exporters) ? inst.exporters : []; - var hasOurs = existing.some(function (e) { return e && e.name === "braintrust"; }); - configsOut[name] = Object.assign({}, inst, { - exporters: hasOurs ? existing : existing.concat([factory()]), - }); - } - } - if (!hadEntries) { - configsOut.default = { - serviceName: "mastra", - exporters: [factory()], - }; - } - return Object.assign({}, config, { configs: configsOut }); - } catch (e) { - return rawConfig; - } +function markRuntimeWrapped(value: RuntimeConstructor): void { + try { + Object.defineProperty(value, MASTRA_RUNTIME_WRAPPED, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + // Best effort only. Idempotence still holds through the export assignment. } - var __OriginalObservability = Observability; - Observability = new Proxy(__OriginalObservability, { - construct: function (target, args, newTarget) { - var nextArgs = args.slice(); - nextArgs[0] = __braintrustEnsureExporter(nextArgs[0]); - return Reflect.construct(target, nextArgs, newTarget); - }, - }); - Observability.__braintrustWrapped = true; - if (typeof exports !== "undefined" && exports && typeof exports === "object") { - try { - Object.defineProperty(exports, "Observability", { - enumerable: true, - configurable: true, - get: function () { return Observability; }, - }); - } catch (e) {} +} + +function setNamespaceExport( + namespace: Record, + key: string, + value: RuntimeConstructor, +): boolean { + try { + namespace[key] = value; + if (namespace[key] === value) return true; + } catch { + // Try defineProperty below. } -})(); -`; -export function patchMastraSource( - source: string, - target: MastraTargetKind, - format: MastraModuleFormat, -): string { - if (target === "core") { - const chunkPath = extractChunkPath(source); - if (!chunkPath) return source; - return format === "esm" - ? buildMastraEsmWrapper(chunkPath) - : buildMastraCjsWrapper(chunkPath); + try { + Object.defineProperty(namespace, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + return namespace[key] === value; + } catch { + return false; } - // Observability: append-wrap, leaving original source intact above. - return source + OBSERVABILITY_APPEND_BODY; +} + +function cloneNamespaceWithExport( + namespace: Record, + key: string, + value: RuntimeConstructor, +): Record { + return Object.defineProperties( + Object.create(Object.getPrototypeOf(namespace)), + { + ...Object.getOwnPropertyDescriptors(namespace), + [key]: { + configurable: true, + enumerable: true, + value, + writable: true, + }, + }, + ); } diff --git a/js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts b/js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts new file mode 100644 index 000000000..7dbc4d854 --- /dev/null +++ b/js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts @@ -0,0 +1,266 @@ +import moduleDetailsFromPath from "module-details-from-path"; +import Module, { builtinModules, createRequire } from "node:module"; +import { parse as parsePath } from "node:path"; +import { pathToFileURL } from "node:url"; + +type OnRequireFn = (exports: T, name: string, basedir?: string) => T; +type GetBuiltinModuleFn = (this: unknown, id: string) => unknown; +type ModuleWithInternals = typeof Module & { + _cache: Record) | undefined>; + _resolveFilename(id: string, parent: NodeJS.Module): string; +}; + +let builtinModulesSet: Set | undefined; +const ModuleInternals = Module as ModuleWithInternals; +const requireForResolve = createRequire( + pathToFileURL(process.argv[1] ?? process.cwd()).href, +); + +const isCore = + typeof Module.isBuiltin === "function" + ? Module.isBuiltin + : (moduleName: string) => { + if (moduleName.startsWith("node:")) { + return true; + } + + builtinModulesSet ??= new Set(builtinModules); + return builtinModulesSet.has(moduleName); + }; + +const normalize = /([/\\]index)?(\.js|\.cjs)?$/; + +class ExportsCache { + private readonly localCache = new Map(); + private readonly kRitmExports = Symbol("RitmExports"); + + has(filename: string, isBuiltin: boolean): boolean { + if (this.localCache.has(filename)) { + return true; + } else if (!isBuiltin) { + const mod = ModuleInternals._cache[filename] as + | (NodeJS.Module & Record) + | undefined; + return !!(mod && this.kRitmExports in mod); + } else { + return false; + } + } + + get(filename: string, isBuiltin: boolean): unknown { + const cachedExports = this.localCache.get(filename); + if (cachedExports !== undefined) { + return cachedExports; + } else if (!isBuiltin) { + const mod = ModuleInternals._cache[filename] as + | (NodeJS.Module & Record) + | undefined; + return mod && mod[this.kRitmExports]; + } + } + + set(filename: string, exports: unknown, isBuiltin: boolean): void { + if (isBuiltin) { + this.localCache.set(filename, exports); + } else if (filename in ModuleInternals._cache) { + ( + ModuleInternals._cache[filename] as NodeJS.Module & + Record + )[this.kRitmExports] = exports; + } else { + this.localCache.set(filename, exports); + } + } +} + +function normalizeModules(modules: string[]): string[] { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust require-in-the-middle requires a non-empty modules array", + ); + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust require-in-the-middle only supports string module names or absolute paths", + ); + } + } + + return modules; +} + +export default class Hook { + private readonly cache = new ExportsCache(); + private readonly modules: string[]; + private readonly onrequire: OnRequireFn; + private unhooked = false; + private readonly origRequire = Module.prototype.require; + private readonly origGetBuiltinModule: GetBuiltinModuleFn | undefined = ( + process as unknown as { getBuiltinModule?: GetBuiltinModuleFn } + ).getBuiltinModule; + + constructor(modules: string[], onrequire: OnRequireFn) { + modules = normalizeModules(modules); + if (typeof onrequire !== "function") { + throw new TypeError( + "Braintrust require-in-the-middle requires an onrequire function", + ); + } + + if (typeof ModuleInternals._resolveFilename !== "function") { + throw new Error( + `Expected Module._resolveFilename to be a function, got ${typeof ModuleInternals._resolveFilename}`, + ); + } + + this.modules = modules; + this.onrequire = onrequire; + const self = this; + const patching = new Set(); + + Module.prototype.require = function (id: string) { + if (self.unhooked === true) { + return self.origRequire.apply(this, arguments as any); + } + + return patchedRequire.call(this, arguments, false); + }; + + if (typeof this.origGetBuiltinModule === "function") { + ( + process as unknown as { getBuiltinModule: GetBuiltinModuleFn } + ).getBuiltinModule = function (id: string) { + if (self.unhooked === true) { + return self.origGetBuiltinModule!.apply(process, arguments as any); + } + + return patchedRequire.call( + process as unknown as NodeJS.Module, + arguments, + true, + ); + }; + } + + function patchedRequire( + this: NodeJS.Module, + args: IArguments, + coreOnly: boolean, + ): unknown { + const id = args[0] as string; + const core = isCore(id); + let filename: string; + if (core) { + filename = id; + if (id.startsWith("node:")) { + const idWithoutPrefix = id.slice(5); + if (isCore(idWithoutPrefix)) { + filename = idWithoutPrefix; + } + } + } else if (coreOnly) { + return self.origGetBuiltinModule!.call(process, id); + } else { + try { + filename = ModuleInternals._resolveFilename(id, this); + } catch { + return self.origRequire.apply(this, args as any); + } + } + + if (self.cache.has(filename, core) === true) { + return self.cache.get(filename, core); + } + + const isPatching = patching.has(filename); + if (isPatching === false) { + patching.add(filename); + } + + const exports = coreOnly + ? self.origGetBuiltinModule!.call(process, id) + : self.origRequire.apply(this, args as any); + + if (isPatching === true) { + return exports; + } + + patching.delete(filename); + + let moduleName: string; + let basedir: string | undefined; + + if (core === true) { + if (modules.includes(filename) === false) { + return exports; + } + moduleName = filename; + } else if (modules.includes(filename)) { + const parsedPath = parsePath(filename); + moduleName = parsedPath.name; + basedir = parsedPath.dir; + } else { + const stat = moduleDetailsFromPath(filename); + if (!stat) { + return exports; + } + moduleName = stat.name; + basedir = stat.basedir; + + const fullModuleName = resolveModuleName(stat); + let matchFound = false; + if (!id.startsWith(".") && modules.includes(id)) { + moduleName = id; + matchFound = true; + } + + if ( + !modules.includes(moduleName) && + !modules.includes(fullModuleName) + ) { + return exports; + } + + if (modules.includes(fullModuleName) && fullModuleName !== moduleName) { + moduleName = fullModuleName; + matchFound = true; + } + + if (!matchFound) { + let res: string; + try { + res = requireForResolve.resolve(moduleName, { paths: [basedir] }); + } catch { + self.cache.set(filename, exports, core); + return exports; + } + + if (res !== filename) { + self.cache.set(filename, exports, core); + return exports; + } + } + } + + const patchedExports = onrequire(exports, moduleName, basedir); + self.cache.set(filename, patchedExports, core); + return patchedExports; + } + } + + unhook(): void { + this.unhooked = true; + Module.prototype.require = this.origRequire; + if (typeof this.origGetBuiltinModule === "function") { + ( + process as unknown as { getBuiltinModule: GetBuiltinModuleFn } + ).getBuiltinModule = this.origGetBuiltinModule; + } + } +} + +function resolveModuleName(stat: { name: string; path: string }): string { + return `${stat.name}/${stat.path.replace(normalize, "")}`; +} diff --git a/js/src/auto-instrumentations/loader/special-case-patches.ts b/js/src/auto-instrumentations/loader/special-case-patches.ts index 4b984c158..f5af894dd 100644 --- a/js/src/auto-instrumentations/loader/special-case-patches.ts +++ b/js/src/auto-instrumentations/loader/special-case-patches.ts @@ -21,23 +21,11 @@ * .parse()` doesn't double-read the response body. Removable once OpenAI * stops sharing the same `APIPromise` between `create()` and * `_thenUnwrap()`. - * - `@mastra/core` and `@mastra/observability` entries: Mastra ships - * code-split bundles with content-hashed chunk filenames, so we patch - * the stable submodule entries to install the - * `BraintrustObservabilityExporter` automatically. Removable when Mastra - * adopts a NPM-installable Braintrust exporter package directly, or when - * `import-in-the-middle` is reliable enough across Node versions to use - * for the same job. */ -import { - classifyMastraTarget, - patchMastraSource, - type MastraModuleFormat, -} from "./mastra-observability-patch.js"; import { OPENAI_API_PROMISE_PATCH } from "./openai-api-promise-patch.js"; -type SpecialCaseFormat = MastraModuleFormat; +export type SpecialCaseFormat = "esm" | "cjs"; interface SpecialCaseInput { packageName: string; @@ -62,16 +50,6 @@ export function applySpecialCasePatch(input: SpecialCaseInput): string | null { return input.source + OPENAI_API_PROMISE_PATCH; } - // Mastra: rewrite the stable submodule entries (@mastra/core) or append a - // Proxy wrap to the inline class binding (@mastra/observability). - const mastraTarget = classifyMastraTarget( - input.packageName, - input.modulePath, - ); - if (mastraTarget) { - return patchMastraSource(input.source, mastraTarget, input.format); - } - return null; } @@ -87,8 +65,5 @@ export function isSpecialCaseTarget( if (packageName === "openai" && modulePath.includes("api-promise")) { return true; } - if (classifyMastraTarget(packageName, modulePath) !== null) { - return true; - } return false; } diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts b/js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts new file mode 100644 index 000000000..6bb7ad72b --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + installNodeTopLevelExportPatches, + type NodeTopLevelExportPatchRuntime, +} from "./top-level-export-patches-node"; +import { createHook as createImportInTheMiddleHook } from "../import-in-the-middle/create-hook.mjs"; +import type { TopLevelImportHook } from "./top-level-export-patches"; + +describe("installNodeTopLevelExportPatches", () => { + it("uses in-process registerHooks when synchronous hooks are supported", () => { + const events: string[] = []; + const importHook = { + initialize(data: unknown) { + events.push(`initialize:${JSON.stringify(data)}`); + return Promise.resolve(); + }, + async load() {}, + loadSync() {}, + async resolve() {}, + resolveSync() {}, + }; + + installNodeTopLevelExportPatches({ + asyncImportHookUrl: + "file:///braintrust/hook.mjs?braintrust-iitm-loader=true", + hooks: [fakeHook()], + registryImportUrl: "file:///braintrust/hook.mjs", + runtime: fakeRuntime(events, { + createImportHook(meta, options) { + events.push(`create:${meta.url}:${options.registerUrl}`); + return importHook; + }, + register() { + events.push("register"); + }, + registerHooks(hooks) { + events.push( + `registerHooks:${typeof hooks.resolve}:${typeof hooks.load}`, + ); + }, + supportsSyncHooks: () => true, + }), + }); + + expect(events).toEqual([ + "create:file:///braintrust/hook.mjs:file:///braintrust/hook.mjs", + 'initialize:{"include":["pkg"]}', + "registerHooks:function:function", + "importHook:pkg", + "importHookPatched:patched", + "requireHook:pkg", + "requireHookPatched:patched", + ]); + }); + + it("falls back to self-registering hook.mjs as the async loader", () => { + const events: string[] = []; + const asyncImportHookUrl = + "file:///braintrust/hook.mjs?braintrust-iitm-loader=true"; + + installNodeTopLevelExportPatches({ + asyncImportHookUrl, + hooks: [fakeHook()], + registryImportUrl: "file:///braintrust/hook.mjs", + runtime: fakeRuntime(events, { + register(specifier, options) { + events.push(`register:${specifier}:${JSON.stringify(options)}`); + }, + registerHooks() { + events.push("registerHooks"); + }, + supportsSyncHooks: () => false, + }), + }); + + expect(events).toEqual([ + `register:${asyncImportHookUrl}:{"data":{"include":["pkg"]}}`, + "importHook:pkg", + "importHookPatched:patched", + "requireHook:pkg", + "requireHookPatched:patched", + ]); + }); +}); + +describe("merged import-in-the-middle wrapper generation", () => { + it("imports register from the canonical Braintrust hook URL", async () => { + const hook = createImportInTheMiddleHook( + { url: "file:///braintrust/hook.mjs?braintrust-iitm-loader=true" }, + { registerUrl: "file:///braintrust/hook.mjs" }, + ); + await hook.initialize({ include: ["target"] }); + + const resolved = (await hook.resolve( + "target", + { conditions: ["import"], parentURL: "file:///app.mjs" }, + async () => ({ format: "module", url: "file:///target.mjs" }), + )) as { url: string }; + const loaded = (await hook.load( + resolved.url, + { format: "module" }, + async () => ({ + format: "module", + source: 'export const value = "original";', + }), + )) as { source: string }; + + expect(loaded.source).toContain( + "import { register } from 'file:///braintrust/hook.mjs'", + ); + expect(loaded.source).not.toContain("lib/register.js"); + }); +}); + +function fakeHook(): TopLevelImportHook { + return { + hook(exportsValue) { + (exportsValue as { value: string }).value = "patched"; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }; +} + +function fakeRuntime( + events: string[], + { + createImportHook, + register, + registerHooks, + supportsSyncHooks, + }: { + createImportHook?: NodeTopLevelExportPatchRuntime["createImportHook"]; + register?: (specifier: string, options?: unknown) => void; + registerHooks?: (hooks: { load: unknown; resolve: unknown }) => void; + supportsSyncHooks: () => boolean; + }, +): Partial { + class FakeImportHook { + constructor( + modules: string[], + hookFn: (exportsValue: unknown, name: string) => unknown, + ) { + events.push(`importHook:${modules.join(",")}`); + const namespace = { value: "original" }; + hookFn(namespace, "pkg"); + events.push(`importHookPatched:${namespace.value}`); + } + } + class FakeRequireHook { + constructor( + modules: string[], + hookFn: (exportsValue: unknown, name: string) => unknown, + ) { + events.push(`requireHook:${modules.join(",")}`); + const namespace = { value: "original" }; + hookFn(namespace, "pkg"); + events.push(`requireHookPatched:${namespace.value}`); + } + } + return { + createImportHook: + createImportHook ?? + (() => ({ + initialize: () => Promise.resolve(), + async load() {}, + loadSync() {}, + async resolve() {}, + resolveSync() {}, + })), + importHookConstructor: FakeImportHook, + moduleApi: { + register: + register ?? + (() => { + events.push("register"); + }), + registerHooks, + }, + requireHookConstructor: FakeRequireHook, + supportsSyncHooks, + }; +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts b/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts new file mode 100644 index 000000000..d7d262fea --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts @@ -0,0 +1,108 @@ +import * as module from "node:module"; +import { debugLogger } from "../../debug-logger"; +import { + createHook as createImportInTheMiddleHook, + supportsSyncHooks, + type ImportInTheMiddleHook as ImportInTheMiddleHookApi, +} from "../import-in-the-middle/create-hook.mjs"; +import ImportInTheMiddleRuntimeHook from "./import-in-the-middle-runtime.js"; +import RequireInTheMiddleHook from "./require-in-the-middle-runtime.js"; +import { + getTopLevelImportHookSpecifiers, + runTopLevelImportHooks, + type TopLevelImportHook, +} from "./top-level-export-patches.js"; + +export interface InstallNodeTopLevelExportPatchesOptions { + hooks: readonly TopLevelImportHook[]; + asyncImportHookUrl: string; + registryImportUrl: string; + runtime?: Partial; +} + +type HookCallback = ( + exportsValue: unknown, + name: string, + baseDir?: string, +) => unknown; + +type HookConstructor = new (modules: string[], hookFn: HookCallback) => unknown; + +interface NodeModuleApi { + register: (specifier: string, options?: unknown) => void; + registerHooks?: (hooks: { load: unknown; resolve: unknown }) => void; +} + +export interface NodeTopLevelExportPatchRuntime { + createImportHook: ( + meta: { url: string }, + options: { registerUrl: string }, + ) => ImportInTheMiddleHookApi; + importHookConstructor: HookConstructor; + moduleApi: NodeModuleApi; + requireHookConstructor: HookConstructor; + supportsSyncHooks: () => boolean; +} + +export function installNodeTopLevelExportPatches({ + asyncImportHookUrl, + hooks, + registryImportUrl, + runtime, +}: InstallNodeTopLevelExportPatchesOptions): void { + const specifiers = getTopLevelImportHookSpecifiers(hooks); + if (specifiers.length === 0) return; + + const effectiveRuntime = getRuntime(runtime); + const hookCallback: HookCallback = (exportsValue, name, baseDir) => + runTopLevelImportHooks(hooks, exportsValue, { + baseDir, + moduleName: name, + }); + + try { + if ( + effectiveRuntime.moduleApi.registerHooks && + effectiveRuntime.supportsSyncHooks() + ) { + const importHook = effectiveRuntime.createImportHook( + { url: registryImportUrl }, + { registerUrl: registryImportUrl }, + ); + void importHook.initialize({ include: specifiers }); + effectiveRuntime.moduleApi.registerHooks({ + load: importHook.loadSync, + resolve: importHook.resolveSync, + }); + } else { + effectiveRuntime.moduleApi.register(asyncImportHookUrl, { + data: { include: specifiers }, + }); + } + + new effectiveRuntime.importHookConstructor(specifiers, hookCallback); + } catch (err) { + debugLogger.warn("Failed to install ESM top-level import hooks", err); + } + + try { + new effectiveRuntime.requireHookConstructor(specifiers, hookCallback); + } catch (err) { + debugLogger.warn("Failed to install CJS top-level import hooks", err); + } +} + +function getRuntime( + overrides: Partial | undefined, +): NodeTopLevelExportPatchRuntime { + return { + createImportHook: createImportInTheMiddleHook, + importHookConstructor: + ImportInTheMiddleRuntimeHook as unknown as HookConstructor, + moduleApi: module as unknown as NodeModuleApi, + requireHookConstructor: + RequireInTheMiddleHook as unknown as HookConstructor, + supportsSyncHooks, + ...overrides, + }; +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches.test.ts b/js/src/auto-instrumentations/loader/top-level-export-patches.test.ts new file mode 100644 index 000000000..f9e8db3e2 --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + buildTopLevelImportHookSourceWrapper, + getDefaultTopLevelImportHooks, + getTopLevelImportHookSpecifiers, + runTopLevelImportHooks, + type TopLevelImportHook, +} from "./top-level-export-patches"; + +describe("top-level import hook registry", () => { + it("returns the Mastra runtime specifiers for node targets", () => { + const hooks = getDefaultTopLevelImportHooks({ target: "node" }); + + expect(getTopLevelImportHookSpecifiers(hooks)).toEqual([ + "@mastra/core", + "@mastra/core/mastra", + "@mastra/observability", + ]); + }); + + it("filters hooks by disabled integration config", () => { + const hooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: { mastra: false }, + target: "node", + }); + + expect(hooks).toEqual([]); + }); + + it("does not include Mastra for browser targets", () => { + const hooks = getDefaultTopLevelImportHooks({ target: "browser" }); + + expect(hooks).toEqual([]); + }); + + it("runs hook callbacks against a mutable namespace", () => { + const hooks: TopLevelImportHook[] = [ + { + hook(exportsValue) { + exportsValue.value = "patched"; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }, + ]; + const namespace = { value: "original" }; + + const result = runTopLevelImportHooks(hooks, namespace, { + moduleName: "pkg", + }); + + expect(result).toBe(namespace); + expect(namespace.value).toBe("patched"); + }); + + it("uses a returned replacement namespace when a hook provides one", () => { + const replacement = { value: "replacement" }; + const hooks: TopLevelImportHook[] = [ + { + hook() { + return replacement; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }, + ]; + + const result = runTopLevelImportHooks( + hooks, + { value: "original" }, + { moduleName: "pkg" }, + ); + + expect(result).toBe(replacement); + }); + + it("passes runtime resolution context through hook callbacks", () => { + let seen: + | { baseDir: string | undefined; resolutionBase: string | undefined } + | undefined; + const hooks: TopLevelImportHook[] = [ + { + hook(_exportsValue, _name, baseDir, resolutionBase) { + seen = { baseDir, resolutionBase }; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }, + ]; + + runTopLevelImportHooks( + hooks, + { value: "original" }, + { + baseDir: "/tmp/pkg", + moduleName: "pkg", + resolutionBase: "file:///tmp/app/bundle.mjs", + }, + ); + + expect(seen).toEqual({ + baseDir: "/tmp/pkg", + resolutionBase: "file:///tmp/app/bundle.mjs", + }); + }); + + it("generates an ESM wrapper that calls the shared hook runner", () => { + const wrapper = buildTopLevelImportHookSourceWrapper([fakeHook("node")], { + format: "esm", + modulePath: "index.js", + originalModuleSpecifier: "braintrust-top-level-original:0", + packageName: "pkg", + source: `export { value } from "./value.js";`, + target: "node", + }); + + expect(wrapper).toContain( + `import * as __braintrustOriginal from "braintrust-top-level-original:0"`, + ); + expect(wrapper).toContain("__braintrustTopLevelImportHookRunner"); + expect(wrapper).toContain( + `export * from "braintrust-top-level-original:0"`, + ); + expect(wrapper).toContain("import.meta.url"); + expect(wrapper).toContain(" as value"); + }); + + it("generates a CJS wrapper that calls the shared hook runner", () => { + const wrapper = buildTopLevelImportHookSourceWrapper([fakeHook("node")], { + format: "cjs", + modulePath: "index.cjs", + originalModuleSpecifier: "/tmp/pkg/index.cjs?braintrust-original", + packageName: "pkg", + source: `exports.value = "original";`, + target: "node", + }); + + expect(wrapper).toContain( + `const __braintrustOriginal = require("/tmp/pkg/index.cjs?braintrust-original")`, + ); + expect(wrapper).toContain("__braintrustTopLevelImportHookRunner"); + expect(wrapper).toContain("typeof __filename"); + expect(wrapper).toContain(`Object.defineProperty(exports, "value"`); + }); + + it("supports browser-safe hook descriptors while Mastra remains node-only", () => { + const wrapper = buildTopLevelImportHookSourceWrapper( + [fakeHook("browser")], + { + format: "esm", + modulePath: "index.js", + originalModuleSpecifier: "braintrust-top-level-original:1", + packageName: "pkg", + source: `export const value = "original";`, + target: "browser", + }, + ); + + expect(wrapper).toContain("__braintrustTopLevelImportHookRunner"); + expect(getDefaultTopLevelImportHooks({ target: "browser" })).toEqual([]); + }); +}); + +function fakeHook(target: "node" | "browser"): TopLevelImportHook { + return { + hook(exportsValue) { + exportsValue.value = "patched"; + }, + integrations: ["mastra"], + sourceTargets: [ + { + exportNames: ["value"], + modulePaths: + target === "node" ? ["index.js", "index.cjs"] : ["index.js"], + packageName: "pkg", + specifier: "pkg", + }, + ], + specifiers: ["pkg"], + targets: [target], + }; +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches.ts b/js/src/auto-instrumentations/loader/top-level-export-patches.ts new file mode 100644 index 000000000..76a21be69 --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches.ts @@ -0,0 +1,456 @@ +import type { InstrumentationIntegrationsConfig } from "../../instrumentation/config"; +import { isInstrumentationIntegrationDisabled } from "../../instrumentation/config"; +import { patchMastraExports } from "./mastra-observability-patch.js"; + +export type TopLevelImportHookTarget = "node" | "browser"; +export type TopLevelImportHookFormat = "esm" | "cjs"; +export type MutableExportNamespace = Record; + +export interface TopLevelImportHookSourceTarget { + packageName: string; + modulePaths: readonly string[]; + specifier: string; + exportNames: readonly string[]; +} + +export interface TopLevelImportHook { + integrations: readonly (keyof InstrumentationIntegrationsConfig)[]; + specifiers: readonly string[]; + targets: readonly TopLevelImportHookTarget[]; + sourceTargets?: readonly TopLevelImportHookSourceTarget[]; + hook( + exports: MutableExportNamespace, + name: string, + baseDir?: string, + resolutionBase?: string, + ): unknown | void; +} + +export interface TopLevelImportHookContext { + moduleName: string; + baseDir?: string; + resolutionBase?: string; +} + +export interface TopLevelImportHookSourceWrapperInput { + baseDir?: string; + format: TopLevelImportHookFormat; + modulePath: string; + originalModuleSpecifier: string; + packageName: string; + source: string; + target: TopLevelImportHookTarget; +} + +const TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL = + "__braintrustTopLevelImportHookRunner"; + +export function getDefaultTopLevelImportHooks({ + disabledIntegrationConfig, + target, +}: { + disabledIntegrationConfig?: InstrumentationIntegrationsConfig; + target: TopLevelImportHookTarget; +}): TopLevelImportHook[] { + return defaultTopLevelImportHooks.filter( + (hook) => + hook.targets.includes(target) && + !isInstrumentationIntegrationDisabled( + disabledIntegrationConfig, + ...hook.integrations, + ), + ); +} + +export function getTopLevelImportHookSpecifiers( + hooks: readonly TopLevelImportHook[], +): string[] { + return [...new Set(hooks.flatMap((hook) => hook.specifiers))]; +} + +export function runTopLevelImportHooks( + hooks: readonly TopLevelImportHook[], + exportsValue: unknown, + context: TopLevelImportHookContext, +): unknown { + let nextExports = exportsValue; + for (const hook of hooks) { + if (!matchesRuntimeHook(hook, context.moduleName)) { + continue; + } + + try { + const patched = hook.hook( + asMutableNamespace(nextExports), + context.moduleName, + context.baseDir, + context.resolutionBase, + ); + if (patched !== undefined) { + nextExports = patched; + } + } catch { + // Hook failures must never escape into user module evaluation. + } + } + return nextExports; +} + +export function installTopLevelImportHookRunner( + hooks: readonly TopLevelImportHook[], +): void { + Object.defineProperty(globalThis, TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL, { + configurable: true, + enumerable: false, + value( + exportsValue: unknown, + name: string, + baseDir?: string, + resolutionBase?: string, + ) { + return runTopLevelImportHooks(hooks, exportsValue, { + baseDir, + moduleName: name, + resolutionBase, + }); + }, + writable: true, + }); +} + +export function buildTopLevelImportHookSourceWrapper( + hooks: readonly TopLevelImportHook[], + input: TopLevelImportHookSourceWrapperInput, +): string | null { + const sourceTargets = hooks + .filter((hook) => hook.targets.includes(input.target)) + .flatMap((hook) => hook.sourceTargets ?? []) + .filter( + (sourceTarget) => + sourceTarget.packageName === input.packageName && + sourceTarget.modulePaths.includes(input.modulePath), + ); + + if (sourceTargets.length === 0) { + return null; + } + + const specifier = sourceTargets[0].specifier; + const exportNames = [ + ...new Set([ + ...sourceTargets.flatMap((target) => target.exportNames), + ...collectStaticExportNames(input.source, input.format), + ]), + ].sort((a, b) => Number(a === "default") - Number(b === "default")); + + if (exportNames.length === 0) { + return null; + } + + return input.format === "esm" + ? buildEsmSourceWrapper({ + baseDir: input.baseDir, + exportNames, + moduleName: specifier, + originalModuleSpecifier: input.originalModuleSpecifier, + }) + : buildCjsSourceWrapper({ + baseDir: input.baseDir, + exportNames, + moduleName: specifier, + originalModuleSpecifier: input.originalModuleSpecifier, + }); +} + +export { + getDefaultTopLevelImportHooks as getDefaultTopLevelExportPatches, + getTopLevelImportHookSpecifiers as getTopLevelExportPatchSpecifiers, + runTopLevelImportHooks as applyTopLevelExportRuntimePatches, +}; + +export type { + TopLevelImportHook as TopLevelExportPatch, + TopLevelImportHookContext as TopLevelExportPatchContext, + TopLevelImportHookFormat as TopLevelExportPatchFormat, + TopLevelImportHookTarget as TopLevelExportPatchTarget, +}; + +function matchesRuntimeHook( + hook: TopLevelImportHook, + moduleName: string, +): boolean { + return hook.specifiers.includes(moduleName); +} + +function asMutableNamespace(exportsValue: unknown): MutableExportNamespace { + if (exportsValue && typeof exportsValue === "object") { + return exportsValue as MutableExportNamespace; + } + + return { default: exportsValue }; +} + +function collectStaticExportNames( + source: string, + format: TopLevelImportHookFormat, +): string[] { + if (format === "cjs") { + const names = new Set(); + for (const match of source.matchAll( + /\bexports\.([A-Za-z_$][\w$]*)\s*=|\bmodule\.exports\.([A-Za-z_$][\w$]*)\s*=|Object\.defineProperty\s*\(\s*exports\s*,\s*["']([^"']+)["']/g, + )) { + names.add(match[1] ?? match[2] ?? match[3]); + } + return [...names]; + } + + const names = new Set(); + + for (const match of source.matchAll( + /\bexport\s+(?:async\s+)?(?:class|function|const|let|var)\s+([A-Za-z_$][\w$]*)/g, + )) { + names.add(match[1]); + } + + if (/\bexport\s+default\b/.test(source)) { + names.add("default"); + } + + for (const match of source.matchAll( + /\bexport\s*\{([^}]+)\}(?:\s*from\s*["'][^"']+["'])?/g, + )) { + for (const part of match[1].split(",")) { + const trimmed = part.trim(); + if (!trimmed || trimmed.startsWith("type ")) continue; + const aliasMatch = trimmed.match(/\bas\s+([A-Za-z_$][\w$]*|default)$/); + const directMatch = trimmed.match(/^([A-Za-z_$][\w$]*|default)$/); + const name = aliasMatch?.[1] ?? directMatch?.[1]; + if (name) names.add(name); + } + } + + return [...names]; +} + +function buildEsmSourceWrapper({ + baseDir, + exportNames, + moduleName, + originalModuleSpecifier, +}: { + baseDir?: string; + exportNames: readonly string[]; + moduleName: string; + originalModuleSpecifier: string; +}): string { + const locals = exportNames.map((name, index) => ({ + exportName: name, + localName: + name === "default" + ? "__braintrustDefaultExport" + : toSafeLocalName(name, index), + })); + + return `import * as __braintrustOriginal from ${JSON.stringify(originalModuleSpecifier)}; + +${locals + .map( + ({ exportName, localName }) => + `let ${localName} = __braintrustOriginal[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + +const __braintrustExports = Object.create(null); +${locals.map(({ exportName, localName }) => buildMutableExportDescriptor(exportName, localName)).join("\n")} + +try { + const __braintrustHookRunner = globalThis[${JSON.stringify(TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL)}]; + if (typeof __braintrustHookRunner === "function") { + const __braintrustPatched = __braintrustHookRunner( + __braintrustExports, + ${JSON.stringify(moduleName)}, + ${JSON.stringify(baseDir)}, + import.meta.url, + ); + if (__braintrustPatched && __braintrustPatched !== __braintrustExports) { +${locals + .map( + ({ exportName, localName }) => + ` if (Object.prototype.hasOwnProperty.call(__braintrustPatched, ${JSON.stringify(exportName)})) ${localName} = __braintrustPatched[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + } + } +} catch (e) { + // Top-level import hooks are best-effort in bundled output. +} + +export * from ${JSON.stringify(originalModuleSpecifier)}; +${buildEsmExports(locals)} +`; +} + +function buildCjsSourceWrapper({ + baseDir, + exportNames, + moduleName, + originalModuleSpecifier, +}: { + baseDir?: string; + exportNames: readonly string[]; + moduleName: string; + originalModuleSpecifier: string; +}): string { + const locals = exportNames.map((name, index) => ({ + exportName: name, + localName: + name === "default" + ? "__braintrustDefaultExport" + : toSafeLocalName(name, index), + })); + const cjsRequire = "require"; + + return `"use strict"; +const __braintrustOriginal = ${cjsRequire}(${JSON.stringify(originalModuleSpecifier)}); +const __braintrustPatchedExportNames = new Set(${JSON.stringify(exportNames)}); +try { + const __braintrustOriginalDescriptors = Object.getOwnPropertyDescriptors(__braintrustOriginal); +${locals.map(({ exportName }) => ` delete __braintrustOriginalDescriptors[${JSON.stringify(exportName)}];`).join("\n")} + Object.defineProperties(exports, __braintrustOriginalDescriptors); +} catch (e) { + for (const __braintrustKey in __braintrustOriginal) { + if (!__braintrustPatchedExportNames.has(__braintrustKey)) { + exports[__braintrustKey] = __braintrustOriginal[__braintrustKey]; + } + } +} + +${locals + .map( + ({ exportName, localName }) => + `let ${localName} = __braintrustOriginal[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + +const __braintrustExports = Object.create(null); +${locals.map(({ exportName, localName }) => buildMutableExportDescriptor(exportName, localName)).join("\n")} + +try { + const __braintrustHookRunner = globalThis[${JSON.stringify(TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL)}]; + if (typeof __braintrustHookRunner === "function") { + const __braintrustPatched = __braintrustHookRunner( + __braintrustExports, + ${JSON.stringify(moduleName)}, + ${JSON.stringify(baseDir)}, + typeof __filename === "string" ? __filename : undefined, + ); + if (__braintrustPatched && __braintrustPatched !== __braintrustExports) { +${locals + .map( + ({ exportName, localName }) => + ` if (Object.prototype.hasOwnProperty.call(__braintrustPatched, ${JSON.stringify(exportName)})) ${localName} = __braintrustPatched[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + } + } +} catch (e) { + // Top-level import hooks are best-effort in bundled output. +} + +${locals.map(({ exportName, localName }) => buildCjsExportDescriptor(exportName, localName)).join("\n")} +`; +} + +function buildMutableExportDescriptor( + exportName: string, + localName: string, +): string { + return `Object.defineProperty(__braintrustExports, ${JSON.stringify(exportName)}, { + configurable: true, + enumerable: true, + get() { return ${localName}; }, + set(value) { ${localName} = value; return true; }, +});`; +} + +function buildEsmExports( + locals: readonly { exportName: string; localName: string }[], +): string { + const named = locals.filter(({ exportName }) => exportName !== "default"); + const defaultExport = locals.find( + ({ exportName }) => exportName === "default", + ); + const lines = named.length + ? [ + `export { ${named.map(({ localName, exportName }) => (localName === exportName ? exportName : `${localName} as ${exportName}`)).join(", ")} };`, + ] + : []; + if (defaultExport) { + lines.push(`export { ${defaultExport.localName} as default };`); + } + return lines.join("\n"); +} + +function buildCjsExportDescriptor( + exportName: string, + localName: string, +): string { + return `Object.defineProperty(exports, ${JSON.stringify(exportName)}, { + configurable: true, + enumerable: true, + get() { return ${localName}; }, +});`; +} + +const MASTRA_CORE_ENTRY_PATHS = [ + "dist/index.js", + "dist/index.cjs", + "dist/mastra/index.js", + "dist/mastra/index.cjs", +]; +const MASTRA_OBSERVABILITY_ENTRY_PATHS = ["dist/index.js", "dist/index.cjs"]; + +const mastraTopLevelImportHook: TopLevelImportHook = { + hook(exportsValue, name, baseDir, resolutionBase) { + return patchMastraExports(exportsValue, { + baseDir, + moduleName: name, + resolutionBase, + }); + }, + integrations: ["mastra"], + sourceTargets: [ + { + exportNames: ["Mastra"], + modulePaths: MASTRA_CORE_ENTRY_PATHS.filter((path) => + path.startsWith("dist/index."), + ), + packageName: "@mastra/core", + specifier: "@mastra/core", + }, + { + exportNames: ["Mastra"], + modulePaths: MASTRA_CORE_ENTRY_PATHS.filter((path) => + path.startsWith("dist/mastra/"), + ), + packageName: "@mastra/core", + specifier: "@mastra/core/mastra", + }, + { + exportNames: ["Observability"], + modulePaths: MASTRA_OBSERVABILITY_ENTRY_PATHS, + packageName: "@mastra/observability", + specifier: "@mastra/observability", + }, + ], + specifiers: ["@mastra/core", "@mastra/core/mastra", "@mastra/observability"], + targets: ["node"], +}; + +const defaultTopLevelImportHooks: readonly TopLevelImportHook[] = [ + mastraTopLevelImportHook, +]; + +function toSafeLocalName(exportName: string, index: number): string { + return `__braintrust_export_${index}_${exportName.replace(/\W/g, "_")}`; +} diff --git a/js/src/auto-instrumentations/orchestrion-js/transforms.ts b/js/src/auto-instrumentations/orchestrion-js/transforms.ts index 15d4bfd1b..92a1adfaa 100644 --- a/js/src/auto-instrumentations/orchestrion-js/transforms.ts +++ b/js/src/auto-instrumentations/orchestrion-js/transforms.ts @@ -249,7 +249,7 @@ function wrap( block.body.unshift(...common); - esquery.query(block, "[id.name=__apm$wrapped]")[0].init = node; + (esquery.query(block, "[id.name=__apm$wrapped]")[0] as AnyNode).init = node; return block; } diff --git a/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts b/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts index 2d0407a33..c510f8cc6 100644 --- a/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts +++ b/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts @@ -3,20 +3,15 @@ * licensed under Apache-2.0. Modified by Braintrust. */ -declare module "esquery" { - const esquery: { - parse(selector: string): unknown; - traverse( - ast: unknown, - selector: unknown, - visitor: (node: any, parent: any, ancestry: any[]) => void, - ): void; - query(ast: unknown, selector: string): any[]; - }; - export = esquery; -} - declare module "semifies" { function semifies(version: string, range: string): boolean; export = semifies; } + +declare module "acorn-import-attributes" { + import type { Parser } from "acorn"; + + export function importAttributesOrAssertions( + BaseParser: typeof Parser, + ): typeof Parser; +} diff --git a/js/src/auto-instrumentations/require-in-the-middle/index.d.ts b/js/src/auto-instrumentations/require-in-the-middle/index.d.ts new file mode 100644 index 000000000..8b9acde2f --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/index.d.ts @@ -0,0 +1,23 @@ +export type OnRequireFn = ( + exports: Exports, + name: string, + basedir?: string, +) => PatchedExports; + +export interface Hook { + unhook(): void; +} + +export interface HookConstructor { + new ( + modules: readonly string[], + onrequire: OnRequireFn, + ): Hook; + ( + modules: readonly string[], + onrequire: OnRequireFn, + ): Hook; +} + +export declare const Hook: HookConstructor; +export default Hook; diff --git a/js/src/auto-instrumentations/require-in-the-middle/index.js b/js/src/auto-instrumentations/require-in-the-middle/index.js deleted file mode 100644 index 3888f5d51..000000000 --- a/js/src/auto-instrumentations/require-in-the-middle/index.js +++ /dev/null @@ -1,252 +0,0 @@ -"use strict"; - -const path = require("path"); -const Module = require("module"); -const moduleDetailsFromPath = require("module-details-from-path"); - -module.exports = Hook; -module.exports.Hook = Hook; - -let builtinModules; - -let isCore; -if (Module.isBuiltin) { - isCore = Module.isBuiltin; -} else if (Module.builtinModules) { - isCore = (moduleName) => { - if (moduleName.startsWith("node:")) { - return true; - } - - if (builtinModules === undefined) { - builtinModules = new Set(Module.builtinModules); - } - - return builtinModules.has(moduleName); - }; -} else { - throw new Error( - "Braintrust require-in-the-middle requires Node.js >=v9.3.0 or >=v8.10.0", - ); -} - -const normalize = /([/\\]index)?(\.js|\.cjs)?$/; - -class ExportsCache { - constructor() { - this._localCache = new Map(); - this._kRitmExports = Symbol("RitmExports"); - } - - has(filename, isBuiltin) { - if (this._localCache.has(filename)) { - return true; - } else if (!isBuiltin) { - const mod = require.cache[filename]; - return !!(mod && this._kRitmExports in mod); - } else { - return false; - } - } - - get(filename, isBuiltin) { - const cachedExports = this._localCache.get(filename); - if (cachedExports !== undefined) { - return cachedExports; - } else if (!isBuiltin) { - const mod = require.cache[filename]; - return mod && mod[this._kRitmExports]; - } - } - - set(filename, exports, isBuiltin) { - if (isBuiltin) { - this._localCache.set(filename, exports); - } else if (filename in require.cache) { - require.cache[filename][this._kRitmExports] = exports; - } else { - this._localCache.set(filename, exports); - } - } -} - -function normalizeModules(modules) { - if (!Array.isArray(modules) || modules.length === 0) { - throw new TypeError( - "Braintrust require-in-the-middle requires a non-empty modules array", - ); - } - - for (const each of modules) { - if (typeof each !== "string") { - throw new TypeError( - "Braintrust require-in-the-middle only supports string module names or absolute paths", - ); - } - } - - return modules; -} - -function Hook(modules, onrequire) { - if (this instanceof Hook === false) return new Hook(modules, onrequire); - - modules = normalizeModules(modules); - if (typeof onrequire !== "function") { - throw new TypeError( - "Braintrust require-in-the-middle requires an onrequire function", - ); - } - - if (typeof Module._resolveFilename !== "function") { - throw new Error( - `Expected Module._resolveFilename to be a function, got ${typeof Module._resolveFilename}`, - ); - } - - this._cache = new ExportsCache(); - this._unhooked = false; - this._origRequire = Module.prototype.require; - - const self = this; - const patching = new Set(); - - this._require = Module.prototype.require = function (id) { - if (self._unhooked === true) { - return self._origRequire.apply(this, arguments); - } - - return patchedRequire.call(this, arguments, false); - }; - - if (typeof process.getBuiltinModule === "function") { - this._origGetBuiltinModule = process.getBuiltinModule; - this._getBuiltinModule = process.getBuiltinModule = function (id) { - if (self._unhooked === true) { - return self._origGetBuiltinModule.apply(this, arguments); - } - - return patchedRequire.call(this, arguments, true); - }; - } - - function patchedRequire(args, coreOnly) { - const id = args[0]; - const core = isCore(id); - let filename; - if (core) { - filename = id; - if (id.startsWith("node:")) { - const idWithoutPrefix = id.slice(5); - if (isCore(idWithoutPrefix)) { - filename = idWithoutPrefix; - } - } - } else if (coreOnly) { - return self._origGetBuiltinModule.apply(this, args); - } else { - try { - filename = Module._resolveFilename(id, this); - } catch (resolveErr) { - return self._origRequire.apply(this, args); - } - } - - if (self._cache.has(filename, core) === true) { - return self._cache.get(filename, core); - } - - const isPatching = patching.has(filename); - if (isPatching === false) { - patching.add(filename); - } - - const exports = coreOnly - ? self._origGetBuiltinModule.apply(this, args) - : self._origRequire.apply(this, args); - - if (isPatching === true) { - return exports; - } - - patching.delete(filename); - - let moduleName; - let basedir; - - if (core === true) { - if (modules.includes(filename) === false) { - return exports; - } - moduleName = filename; - } else if (modules.includes(filename)) { - const parsedPath = path.parse(filename); - moduleName = parsedPath.name; - basedir = parsedPath.dir; - } else { - const stat = moduleDetailsFromPath(filename); - if (stat === undefined) { - return exports; - } - moduleName = stat.name; - basedir = stat.basedir; - - const fullModuleName = resolveModuleName(stat); - let matchFound = false; - if (!id.startsWith(".") && modules.includes(id)) { - moduleName = id; - matchFound = true; - } - - if (!modules.includes(moduleName) && !modules.includes(fullModuleName)) { - return exports; - } - - if (modules.includes(fullModuleName) && fullModuleName !== moduleName) { - moduleName = fullModuleName; - matchFound = true; - } - - if (!matchFound) { - let res; - try { - res = require.resolve(moduleName, { paths: [basedir] }); - } catch (e) { - self._cache.set(filename, exports, core); - return exports; - } - - if (res !== filename) { - self._cache.set(filename, exports, core); - return exports; - } - } - } - - self._cache.set(filename, exports, core); - const patchedExports = onrequire(exports, moduleName, basedir); - self._cache.set(filename, patchedExports, core); - - return patchedExports; - } -} - -Hook.prototype.unhook = function () { - this._unhooked = true; - - if (this._require === Module.prototype.require) { - Module.prototype.require = this._origRequire; - } - - if (process.getBuiltinModule !== undefined) { - if (this._getBuiltinModule === process.getBuiltinModule) { - process.getBuiltinModule = this._origGetBuiltinModule; - } - } -}; - -function resolveModuleName(stat) { - const normalizedPath = - path.sep !== "/" ? stat.path.split(path.sep).join("/") : stat.path; - return path.posix.join(stat.name, normalizedPath).replace(normalize, ""); -} diff --git a/js/src/auto-instrumentations/require-in-the-middle/index.ts b/js/src/auto-instrumentations/require-in-the-middle/index.ts new file mode 100644 index 000000000..704cf5514 --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/index.ts @@ -0,0 +1,321 @@ +"use strict"; + +import Module from "node:module"; +import moduleDetailsFromPath from "module-details-from-path"; +import path from "node:path"; + +type OnRequireFn = ( + exports: unknown, + name: string, + basedir?: string, +) => unknown; +type GetBuiltinModuleFn = (this: unknown, id: string) => unknown; +type ProcessWithGetBuiltinModule = Omit & { + getBuiltinModule?: GetBuiltinModuleFn; +}; +type ModuleWithInternals = typeof Module & { + _resolveFilename(id: string, parent: NodeJS.Module): string; +}; +type CachedModule = NodeJS.Module & Record; +type HookedRequire = (this: NodeJS.Module, id: string) => unknown; + +interface HookInstance { + _cache: ExportsCache; + _getBuiltinModule?: GetBuiltinModuleFn; + _origGetBuiltinModule?: GetBuiltinModuleFn; + _origRequire: HookedRequire; + _require: HookedRequire; + _unhooked: boolean; +} + +interface HookConstructor { + new (modules: unknown, onrequire: unknown): HookInstance; + (modules: unknown, onrequire: unknown): HookInstance; + prototype: HookInstance & { unhook(): void }; +} + +let builtinModules: Set | undefined; + +const ModuleInternals = Module as ModuleWithInternals; +let isCore: (moduleName: string) => boolean; +if (Module.isBuiltin) { + isCore = Module.isBuiltin; +} else if (Module.builtinModules) { + isCore = (moduleName: string) => { + if (moduleName.startsWith("node:")) { + return true; + } + + if (builtinModules === undefined) { + builtinModules = new Set(Module.builtinModules); + } + + return builtinModules.has(moduleName); + }; +} else { + throw new Error( + "Braintrust require-in-the-middle requires Node.js >=v9.3.0 or >=v8.10.0", + ); +} + +const normalize = /([/\\]index)?(\.js|\.cjs)?$/; + +class ExportsCache { + private readonly localCache = new Map(); + private readonly kRitmExports = Symbol("RitmExports"); + + has(filename: string, isBuiltin: boolean): boolean { + if (this.localCache.has(filename)) { + return true; + } else if (!isBuiltin) { + const mod = require.cache[filename] as CachedModule | undefined; + return !!(mod && this.kRitmExports in mod); + } else { + return false; + } + } + + get(filename: string, isBuiltin: boolean): unknown { + const cachedExports = this.localCache.get(filename); + if (cachedExports !== undefined) { + return cachedExports; + } else if (!isBuiltin) { + const mod = require.cache[filename] as CachedModule | undefined; + return mod && mod[this.kRitmExports]; + } + } + + set(filename: string, exports: unknown, isBuiltin: boolean): void { + if (isBuiltin) { + this.localCache.set(filename, exports); + } else if (filename in require.cache) { + (require.cache[filename] as CachedModule)[this.kRitmExports] = exports; + } else { + this.localCache.set(filename, exports); + } + } +} + +function normalizeModules(modules: unknown): string[] { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust require-in-the-middle requires a non-empty modules array", + ); + } + + const normalized: string[] = []; + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust require-in-the-middle only supports string module names or absolute paths", + ); + } + normalized.push(each); + } + + return normalized; +} + +const Hook: HookConstructor = function ( + this: HookInstance | undefined, + modules: unknown, + onrequire: unknown, +): HookInstance | void { + if (!this || !(this instanceof Hook)) { + return new Hook(modules, onrequire); + } + + const normalizedModules = normalizeModules(modules); + if (typeof onrequire !== "function") { + throw new TypeError( + "Braintrust require-in-the-middle requires an onrequire function", + ); + } + const onRequireFn = onrequire as OnRequireFn; + + if (typeof ModuleInternals._resolveFilename !== "function") { + throw new Error( + `Expected Module._resolveFilename to be a function, got ${typeof ModuleInternals._resolveFilename}`, + ); + } + + this._cache = new ExportsCache(); + this._unhooked = false; + this._origRequire = Module.prototype.require; + + const self = this; + const patching = new Set(); + + this._require = Module.prototype.require = function ( + this: NodeJS.Module, + id: string, + ) { + if (self._unhooked === true) { + return self._origRequire.call(this, id); + } + + return patchedRequire.call(this, id, false); + }; + + const processWithGetBuiltinModule: ProcessWithGetBuiltinModule = process; + if (typeof processWithGetBuiltinModule.getBuiltinModule === "function") { + this._origGetBuiltinModule = processWithGetBuiltinModule.getBuiltinModule; + this._getBuiltinModule = processWithGetBuiltinModule.getBuiltinModule = + function (this: unknown, id: string) { + if (self._unhooked === true) { + return getOrigGetBuiltinModule().call(this, id); + } + + return patchedRequire.call(this as NodeJS.Module, id, true); + }; + } + + function getOrigGetBuiltinModule(): GetBuiltinModuleFn { + if (!self._origGetBuiltinModule) { + throw new Error( + "Expected process.getBuiltinModule to be captured before patching builtins", + ); + } + return self._origGetBuiltinModule; + } + + function patchedRequire( + this: NodeJS.Module, + id: string, + coreOnly: boolean, + ): unknown { + const core = isCore(id); + let filename: string; + if (core) { + filename = id; + if (id.startsWith("node:")) { + const idWithoutPrefix = id.slice(5); + if (isCore(idWithoutPrefix)) { + filename = idWithoutPrefix; + } + } + } else if (coreOnly) { + return getOrigGetBuiltinModule().call(this, id); + } else { + try { + filename = ModuleInternals._resolveFilename(id, this); + } catch { + return self._origRequire.call(this, id); + } + } + + if (self._cache.has(filename, core) === true) { + return self._cache.get(filename, core); + } + + const isPatching = patching.has(filename); + if (isPatching === false) { + patching.add(filename); + } + + const exports = coreOnly + ? getOrigGetBuiltinModule().call(this, id) + : self._origRequire.call(this, id); + + if (isPatching === true) { + return exports; + } + + patching.delete(filename); + + let moduleName: string; + let basedir: string | undefined; + + if (core === true) { + if (normalizedModules.includes(filename) === false) { + return exports; + } + moduleName = filename; + } else if (normalizedModules.includes(filename)) { + const parsedPath = path.parse(filename); + moduleName = parsedPath.name; + basedir = parsedPath.dir; + } else { + const stat = moduleDetailsFromPath(filename); + if (stat === undefined || stat === null) { + return exports; + } + moduleName = stat.name; + basedir = stat.basedir; + + const fullModuleName = resolveModuleName(stat); + let matchFound = false; + if (!id.startsWith(".") && normalizedModules.includes(id)) { + moduleName = id; + matchFound = true; + } + + if ( + !normalizedModules.includes(moduleName) && + !normalizedModules.includes(fullModuleName) + ) { + return exports; + } + + if ( + normalizedModules.includes(fullModuleName) && + fullModuleName !== moduleName + ) { + moduleName = fullModuleName; + matchFound = true; + } + + if (!matchFound) { + let res: string; + try { + res = require.resolve(moduleName, { paths: [basedir] }); + } catch { + self._cache.set(filename, exports, core); + return exports; + } + + if (res !== filename) { + self._cache.set(filename, exports, core); + return exports; + } + } + } + + self._cache.set(filename, exports, core); + const patchedExports = onRequireFn(exports, moduleName, basedir); + self._cache.set(filename, patchedExports, core); + + return patchedExports; + } +} as HookConstructor; + +Hook.prototype.unhook = function (this: HookInstance): void { + this._unhooked = true; + + if (this._require === Module.prototype.require) { + Module.prototype.require = this._origRequire; + } + + const processWithGetBuiltinModule: ProcessWithGetBuiltinModule = process; + if (processWithGetBuiltinModule.getBuiltinModule !== undefined) { + if ( + this._getBuiltinModule === processWithGetBuiltinModule.getBuiltinModule + ) { + if (this._origGetBuiltinModule) { + processWithGetBuiltinModule.getBuiltinModule = + this._origGetBuiltinModule; + } + } + } +}; + +module.exports = Hook; +module.exports.Hook = Hook; + +function resolveModuleName(stat: { name: string; path: string }): string { + const normalizedPath = + path.sep !== "/" ? stat.path.split(path.sep).join("/") : stat.path; + return path.posix.join(stat.name, normalizedPath).replace(normalize, ""); +} + +export {}; diff --git a/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts b/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts index 7c7658f9e..c98ac6157 100644 --- a/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts +++ b/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts @@ -1,10 +1,22 @@ -export interface HookOptions { - internals?: boolean; -} - -export type OnRequireFn = (exports: T, name: string, basedir?: string) => T; +export type OnRequireFn = ( + exports: Exports, + name: string, + basedir?: string, +) => PatchedExports; -export class Hook { - constructor(modules: string[], onrequire: OnRequireFn); +export interface Hook { unhook(): void; } + +export interface HookConstructor { + new ( + modules: readonly string[], + onrequire: OnRequireFn, + ): Hook; + ( + modules: readonly string[], + onrequire: OnRequireFn, + ): Hook; +} + +export declare const Hook: HookConstructor; diff --git a/js/src/instrumentation/braintrust-plugin.ts b/js/src/instrumentation/braintrust-plugin.ts index a1fe0e352..dc6c51e29 100644 --- a/js/src/instrumentation/braintrust-plugin.ts +++ b/js/src/instrumentation/braintrust-plugin.ts @@ -202,9 +202,8 @@ export class BraintrustPlugin extends BasePlugin { // Mastra is intentionally not wired here: `@mastra/core` ships its own // ObservabilityExporter contract, and `BraintrustObservabilityExporter` - // (wrappers/mastra.ts) is auto-installed by the loader patch in - // `auto-instrumentations/loader/mastra-observability-patch.ts` rather than - // by a BasePlugin / tracingChannel subscription. + // (wrappers/mastra.ts) is auto-installed by the top-level import hook + // registry rather than by a BasePlugin / tracingChannel subscription. } protected onDisable(): void { diff --git a/js/src/node/apply-auto-instrumentation-entry.ts b/js/src/node/apply-auto-instrumentation-entry.ts index 0328688dd..d30bb43d3 100644 --- a/js/src/node/apply-auto-instrumentation-entry.ts +++ b/js/src/node/apply-auto-instrumentation-entry.ts @@ -3,7 +3,18 @@ import { register } from "node:module"; import { pathToFileURL } from "node:url"; import { getDefaultAutoInstrumentationConfigs } from "../auto-instrumentations/configs/all"; import { ModulePatch } from "../auto-instrumentations/loader/cjs-patch"; +import { installMastraExporterFactory } from "../auto-instrumentations/loader/mastra-observability-patch"; +import { + getDefaultTopLevelImportHooks, + installTopLevelImportHookRunner, +} from "../auto-instrumentations/loader/top-level-export-patches"; +import { installNodeTopLevelExportPatches } from "../auto-instrumentations/loader/top-level-export-patches-node"; import { patchTracingChannel } from "../auto-instrumentations/patch-tracing-channel"; +import { + isInstrumentationIntegrationDisabled, + readDisabledInstrumentationEnvConfig, +} from "../instrumentation/config"; +import { BraintrustObservabilityExporter } from "../wrappers/mastra"; interface ApplyAutoInstrumentationState { applied?: boolean; @@ -33,8 +44,29 @@ if (!state.applied) { patchTracingChannel(diagnostics_channel.tracingChannel); const allConfigs = getDefaultAutoInstrumentationConfigs(); + const disabled = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + if (!isInstrumentationIntegrationDisabled(disabled, "mastra")) { + installMastraExporterFactory(() => new BraintrustObservabilityExporter()); + } const currentModuleUrl = getCurrentModuleUrl(); + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: disabled, + target: "node", + }); + const autoInstrumentationHookUrl = + getAutoInstrumentationHookUrl(currentModuleUrl); + installTopLevelImportHookRunner(topLevelImportHooks); + installNodeTopLevelExportPatches({ + asyncImportHookUrl: getImportInTheMiddleLoaderUrl( + autoInstrumentationHookUrl, + ), + hooks: topLevelImportHooks, + registryImportUrl: autoInstrumentationHookUrl, + }); + register("./auto-instrumentations/loader/esm-hook.mjs", { parentURL: currentModuleUrl, data: { instrumentations: allConfigs }, @@ -71,4 +103,14 @@ function getCurrentModuleUrl(): string { return pathToFileURL(process.argv[1] ?? process.cwd()).href; } +function getAutoInstrumentationHookUrl(currentModuleUrl: string): string { + return new URL("./auto-instrumentations/hook.mjs", currentModuleUrl).href; +} + +function getImportInTheMiddleLoaderUrl(canonicalUrl: string): string { + const parsed = new URL(canonicalUrl); + parsed.searchParams.set("braintrust-iitm-loader", "true"); + return parsed.href; +} + export {}; diff --git a/js/src/node/config.ts b/js/src/node/config.ts index 09c27b807..cb5e7a034 100644 --- a/js/src/node/config.ts +++ b/js/src/node/config.ts @@ -20,6 +20,10 @@ import { readDisabledInstrumentationEnvConfig, } from "../instrumentation/config"; import { installMastraExporterFactory } from "../auto-instrumentations/loader/mastra-observability-patch"; +import { + getDefaultTopLevelImportHooks, + installTopLevelImportHookRunner, +} from "../auto-instrumentations/loader/top-level-export-patches"; import { BraintrustObservabilityExporter } from "../wrappers/mastra"; const BRAINTRUST_ENV_SEARCH_PARENT_LIMIT = 64; @@ -136,15 +140,19 @@ export function configureNode() { _internalSetInitialState(); - // The Mastra patches rewritten by the bundler plugin and the loader hook - // both look up an exporter factory on `globalThis` at runtime. Register it - // here too so a bundled app (where neither hook.mjs nor the loader runs - // against `@mastra/core`) still gets the exporter installed when its code - // imports `braintrust`. The loader path (hook.mjs) also calls this; the - // `??=` makes double-registration a no-op. + // Bundled wrapper modules call the top-level import hook runner through + // `globalThis`, and Mastra's hook uses the exporter factory below. Register + // both when the app imports `braintrust`; the loader path (hook.mjs) does + // the same setup for zero-line instrumentation. const disabled = readDisabledInstrumentationEnvConfig( iso.getEnv("BRAINTRUST_DISABLE_INSTRUMENTATION"), ).integrations; + installTopLevelImportHookRunner( + getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: disabled, + target: "node", + }), + ); if (!isInstrumentationIntegrationDisabled(disabled, "mastra")) { installMastraExporterFactory(() => new BraintrustObservabilityExporter()); } diff --git a/js/src/wrappers/mastra.ts b/js/src/wrappers/mastra.ts index 9946a9643..6542c1cc3 100644 --- a/js/src/wrappers/mastra.ts +++ b/js/src/wrappers/mastra.ts @@ -10,9 +10,9 @@ * Two integration paths: * - **Manual**: `new Mastra({ observability: new Observability({ configs: { * default: { exporters: [new BraintrustObservabilityExporter()] } } }) })` - * - **Auto** (under `node --import braintrust/hook.mjs`): the loader patches - * `@mastra/core`'s `dist/mastra/index.{js,cjs}` to wrap `Mastra` so it - * calls `defaultInstance.registerExporter(exporter)` after construction. + * - **Auto** (under `node --import braintrust/hook.mjs`): the top-level + * import hook registry wraps Mastra's exported constructors so a default + * Observability config receives this exporter automatically. * * Minimum supported Mastra version: 1.20.0 (when `Mastra.prototype.register` * `Exporter` and `ObservabilityInstance.registerExporter` were added). The @@ -370,7 +370,7 @@ function logExporterError(err: unknown): void { * * - **Auto-instrumentation**: run your app with * `node --import braintrust/hook.mjs`. The loader installs - * `BraintrustObservabilityExporter` into every `new Mastra(...)` + * `BraintrustObservabilityExporter` into Mastra Observability configs * automatically. * - **Manual wiring**: pass the exporter yourself: * diff --git a/js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs b/js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs new file mode 100644 index 000000000..3ad020cf0 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs @@ -0,0 +1,15 @@ +import { parentPort } from "node:worker_threads"; + +const imported = await import(process.env.BRAINTRUST_QUERY_HOOK_URL); +const state = globalThis[Symbol.for("braintrust.applyAutoInstrumentation")]; + +parentPort?.postMessage({ + result: { + applied: state?.applied === true, + hasInitialize: typeof imported.initialize === "function", + hasLoad: typeof imported.load === "function", + hasRegister: typeof imported.register === "function", + hasResolve: typeof imported.resolve === "function", + }, + type: "hook-query-result", +}); diff --git a/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json new file mode 100644 index 000000000..84b0d6964 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mastra/core", + "version": "1.26.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./mastra": { + "import": "./dist/mastra/index.js", + "require": "./dist/mastra/index.cjs" + } + } +} diff --git a/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json new file mode 100644 index 000000000..ed647752e --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mastra/observability", + "version": "1.26.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + } +} diff --git a/js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs b/js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs new file mode 100644 index 000000000..94c9ab6c0 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs @@ -0,0 +1,3 @@ +import "braintrust/apply-auto-instrumentation"; + +await import("./test-mastra-top-level-esm.mjs"); diff --git a/js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js b/js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js new file mode 100644 index 000000000..26a50077c --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js @@ -0,0 +1,3 @@ +import { Mastra } from "@mastra/core"; + +new Mastra({}); diff --git a/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs new file mode 100644 index 000000000..0e761531f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs @@ -0,0 +1,14 @@ +const { parentPort } = require("node:worker_threads"); +const { Mastra } = require("@mastra/core"); + +const mastra = new Mastra({}); +parentPort?.postMessage({ + result: { + exporters: + mastra.observability?.config?.configs?.default?.exporters?.map( + (exporter) => exporter.name, + ) ?? [], + hasObservability: Boolean(mastra.observability), + }, + type: "mastra-result", +}); diff --git a/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs new file mode 100644 index 000000000..e60a93761 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs @@ -0,0 +1,14 @@ +import { parentPort } from "node:worker_threads"; +import { Mastra } from "@mastra/core"; + +const mastra = new Mastra({}); +parentPort?.postMessage({ + result: { + exporters: + mastra.observability?.config?.configs?.default?.exporters?.map( + (exporter) => exporter.name, + ) ?? [], + hasObservability: Boolean(mastra.observability), + }, + type: "mastra-result", +}); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs index e6d69f304..b56411050 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs @@ -2,9 +2,11 @@ import assert from "node:assert"; import getLabel, { foo } from "hook-target"; import getUnhookedLabel, { foo as unhookedFoo } from "unhooked-target"; import cjsTarget from "cjs-hook-target"; +import { nestedValue } from "cjs-reexport-target"; assert.equal(foo, 57); assert.equal(getLabel(), "patched"); assert.equal(unhookedFoo, 10); assert.equal(getUnhookedLabel(), "untouched"); assert.equal(cjsTarget.value, 8); +assert.equal(nestedValue, "nested"); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs index 9e7377fed..dddc487e4 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs @@ -1,35 +1,39 @@ import assert from "node:assert"; import { register } from "node:module"; +import { fileURLToPath } from "node:url"; import { Hook, createAddHookMessageChannel, -} from "../../../../src/auto-instrumentations/import-in-the-middle/index.js"; +} from "../../../../src/auto-instrumentations/import-in-the-middle/index.mts"; const hookUrl = new URL( - "../../../../src/auto-instrumentations/import-in-the-middle/hook.mjs", + "../../../../src/auto-instrumentations/import-in-the-middle/hook.mts", import.meta.url, ); const { addHookMessagePort, registerOptions, waitForAllMessagesAcknowledged } = createAddHookMessageChannel(); -register(hookUrl.href, import.meta.url, registerOptions); +register(fileURLToPath(hookUrl), import.meta.url, registerOptions); globalThis.__braintrustIitmAsyncHookCalls = 0; -const hook = new Hook(["hook-target", "cjs-hook-target"], (exports, name) => { - globalThis.__braintrustIitmAsyncHookCalls++; - if (name === "hook-target") { - exports.foo += 15; - exports.default = () => "patched"; - } - if (name === "cjs-hook-target") { - exports.default.value = 8; - } -}); +const hook = new Hook( + ["hook-target", "cjs-hook-target", "cjs-reexport-target"], + (exports, name) => { + globalThis.__braintrustIitmAsyncHookCalls++; + if (name === "hook-target") { + exports.foo += 15; + exports.default = () => "patched"; + } + if (name === "cjs-hook-target") { + exports.default.value = 8; + } + }, +); await waitForAllMessagesAcknowledged(); process.on("exit", () => { - assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 2); + assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 3); hook.unhook(); addHookMessagePort.close(); }); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs index 9f4bb0012..f57366832 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs @@ -1,10 +1,10 @@ import assert from "node:assert"; import { createRequire } from "node:module"; -import { Hook } from "../../../../src/auto-instrumentations/import-in-the-middle/index.js"; +import { Hook } from "../../../../src/auto-instrumentations/import-in-the-middle/index.mts"; import { register, supportsSyncHooks, -} from "../../../../src/auto-instrumentations/import-in-the-middle/register-hooks.mjs"; +} from "../../../../src/auto-instrumentations/import-in-the-middle/register-hooks.mts"; if (!supportsSyncHooks()) { process.exit(0); @@ -14,7 +14,7 @@ register(); let calls = 0; const hook = new Hook( - ["hook-target", "cjs-hook-target", "fs"], + ["hook-target", "cjs-hook-target", "cjs-reexport-target", "fs"], (exports, name) => { calls++; if (name === "hook-target") { @@ -33,6 +33,7 @@ const hook = new Hook( const target = await import("hook-target"); const other = await import("unhooked-target"); const cjsTarget = await import("cjs-hook-target"); +const cjsReexportTarget = await import("cjs-reexport-target"); const fs = await import("node:fs"); assert.equal(target.foo, 57); @@ -40,11 +41,13 @@ assert.equal(target.default(), "patched"); assert.equal(other.foo, 10); assert.equal(other.default(), "untouched"); assert.equal(cjsTarget.default.value, 8); +assert.equal(cjsReexportTarget.nestedValue, "nested"); +assert.equal(cjsReexportTarget.rootValue, undefined); assert.equal(fs.existsSync("/definitely/not/a/real/path"), true); const require = createRequire(import.meta.url); const requiredFs = require("fs"); assert.equal(Object.isExtensible(requiredFs), true); -assert.equal(calls, 3); +assert.equal(calls, 4); hook.unhook(); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs new file mode 100644 index 000000000..a8b25ed8f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs @@ -0,0 +1 @@ +exports.rootValue = "root"; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json new file mode 100644 index 000000000..dbeb183ab --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-reexport-leaf", + "version": "1.0.0", + "main": "./index.cjs" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs new file mode 100644 index 000000000..22730cdc1 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs @@ -0,0 +1 @@ +module.exports = require("cjs-reexport-leaf"); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs new file mode 100644 index 000000000..673c5564d --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs @@ -0,0 +1 @@ +exports.nestedValue = "nested"; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json new file mode 100644 index 000000000..dbeb183ab --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-reexport-leaf", + "version": "1.0.0", + "main": "./index.cjs" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json new file mode 100644 index 000000000..3e6393aca --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json @@ -0,0 +1,8 @@ +{ + "name": "cjs-reexport-target", + "version": "1.0.0", + "main": "./index.cjs", + "exports": { + ".": "./index.cjs" + } +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs index f3ff3de96..7ee8d6d87 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs @@ -1,7 +1,7 @@ const assert = require("node:assert"); const { Hook, -} = require("../../../../src/auto-instrumentations/require-in-the-middle"); +} = require("../../../../src/auto-instrumentations/require-in-the-middle/index.ts"); let calls = 0; const hook = new Hook(["ritm-target"], (exports, name) => { diff --git a/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts b/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts index 551c0804f..5727db474 100644 --- a/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts +++ b/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts @@ -40,15 +40,20 @@ function runNode({ args: string[]; cwd: string; }): Promise { + const nodeArgs = ["--import", "tsx", ...args]; const normalizedArgs = process.platform === "win32" - ? args.map((arg, index) => { - if (index > 0 && args[index - 1] === "--import") { + ? nodeArgs.map((arg, index) => { + if ( + index > 0 && + nodeArgs[index - 1] === "--import" && + path.isAbsolute(arg) + ) { return pathToFileURL(path.resolve(arg)).href; } return arg; }) - : args; + : nodeArgs; return new Promise((resolve, reject) => { const child = spawn(process.execPath, normalizedArgs, { diff --git a/js/tests/auto-instrumentations/loader-hook.test.ts b/js/tests/auto-instrumentations/loader-hook.test.ts index 51a20be41..bccae5dcd 100644 --- a/js/tests/auto-instrumentations/loader-hook.test.ts +++ b/js/tests/auto-instrumentations/loader-hook.test.ts @@ -20,6 +20,10 @@ const helperPromisePath = path.join( fixturesDir, "test-api-promise-preservation.mjs", ); +const importHookQueryModePath = path.join( + fixturesDir, + "import-hook-query-mode.mjs", +); const runtimeApplyAutoSideEffectEsmPath = path.join( fixturesDir, "runtime-apply-auto-side-effect-esm.mjs", @@ -28,6 +32,18 @@ const runtimeApplyAutoSideEffectCjsPath = path.join( fixturesDir, "runtime-apply-auto-side-effect-cjs.cjs", ); +const mastraTopLevelEsmPath = path.join( + fixturesDir, + "test-mastra-top-level-esm.mjs", +); +const mastraTopLevelCjsPath = path.join( + fixturesDir, + "test-mastra-top-level-cjs.cjs", +); +const runtimeApplyAutoMastraTopLevelEsmPath = path.join( + fixturesDir, + "runtime-apply-auto-mastra-top-level-esm.mjs", +); interface TestResult { events: { start: any[]; end: any[]; error: any[] }; @@ -83,6 +99,74 @@ describe("Unified Loader Hook Integration Tests", () => { expect(result.withResponseOk).toBe(true); expect(result.constructorName).toBe("HelperPromise"); }); + + it("should expose import hook exports in query mode without bootstrapping", async () => { + const result = await runWithWorkerMessage<{ + applied: boolean; + hasInitialize: boolean; + hasLoad: boolean; + hasRegister: boolean; + hasResolve: boolean; + }>({ + env: { + BRAINTRUST_QUERY_HOOK_URL: `${pathToFileURL(hookPath).href}?braintrust-iitm-loader=true`, + }, + execArgv: [], + messageType: "hook-query-result", + script: importHookQueryModePath, + }); + + expect(result).toEqual({ + applied: false, + hasInitialize: true, + hasLoad: true, + hasRegister: true, + hasResolve: true, + }); + }); + + it("should patch Mastra top-level ESM exports", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + execArgv: ["--import", hookPath], + messageType: "mastra-result", + script: mastraTopLevelEsmPath, + }); + + expect(result.hasObservability).toBe(true); + expect(result.exporters).toEqual(["braintrust"]); + }); + + it("should patch Mastra top-level CJS exports", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + execArgv: ["--import", hookPath], + messageType: "mastra-result", + script: mastraTopLevelCjsPath, + }); + + expect(result.hasObservability).toBe(true); + expect(result.exporters).toEqual(["braintrust"]); + }); + + it("should respect Mastra disable config for top-level export patches", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + env: { BRAINTRUST_DISABLE_INSTRUMENTATION: "mastra" }, + execArgv: ["--import", hookPath], + messageType: "mastra-result", + script: mastraTopLevelEsmPath, + }); + + expect(result.hasObservability).toBe(false); + expect(result.exporters).toEqual([]); + }); }); describe("apply-auto-instrumentation side-effect runtime setup", () => { @@ -126,6 +210,20 @@ describe("Unified Loader Hook Integration Tests", () => { expect(result.events.start.length).toBe(1); expect(result.events.end.length).toBe(1); }); + + it("should apply Mastra top-level export patches through the side-effect export", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + execArgv: [], + messageType: "mastra-result", + script: runtimeApplyAutoMastraTopLevelEsmPath, + }); + + expect(result.hasObservability).toBe(true); + expect(result.exporters).toEqual(["braintrust"]); + }); }); }); diff --git a/js/tests/auto-instrumentations/transformation.test.ts b/js/tests/auto-instrumentations/transformation.test.ts index fc402096b..99f86b1c9 100644 --- a/js/tests/auto-instrumentations/transformation.test.ts +++ b/js/tests/auto-instrumentations/transformation.test.ts @@ -306,6 +306,42 @@ describe("Orchestrion Transformation Tests", () => { expect(output).not.toContain("TracingChannel"); }); + it("should apply node top-level export patches", async () => { + const { braintrustEsbuildPlugin } = + await import("../../src/auto-instrumentations/bundler/esbuild.js"); + + const entryPoint = path.join(fixturesDir, "test-mastra-bundler.js"); + const outfile = path.join(outputDir, "esbuild-mastra-bundle.js"); + + const result = await esbuild.build({ + absWorkingDir: fixturesDir, + bundle: true, + entryPoints: [entryPoint], + format: "esm", + logLevel: "error", + outfile, + platform: "node", + plugins: [braintrustEsbuildPlugin()], + preserveSymlinks: true, + write: true, + }); + + expect(result.errors).toHaveLength(0); + expect(fs.existsSync(outfile)).toBe(true); + + const output = fs.readFileSync(outfile, "utf-8"); + + expect(output).toContain("__braintrustTopLevelImportHookRunner"); + expect(output).toContain("__braintrustExports"); + expect(output.replace(/\\/g, "/")).not.toContain( + JSON.stringify( + path + .join(fixturesDir, "node_modules", "@mastra", "core") + .replace(/\\/g, "/"), + ), + ); + }); + it("should bundle dc-browser module when useDiagnosticChannelCompatShim is true", async () => { const { braintrustEsbuildPlugin } = await import("../../src/auto-instrumentations/bundler/esbuild.js"); @@ -342,6 +378,69 @@ describe("Orchestrion Transformation Tests", () => { // Should NOT import from external diagnostics_channel expect(output).not.toMatch(/from\s+["']diagnostics_channel["']/); }); + + it("should skip node-only top-level export patches for browser bundles", async () => { + const { braintrustEsbuildPlugin } = + await import("../../src/auto-instrumentations/bundler/esbuild.js"); + + const entryPoint = path.join(fixturesDir, "test-mastra-bundler.js"); + const outfile = path.join(outputDir, "esbuild-mastra-browser-bundle.js"); + + const result = await esbuild.build({ + absWorkingDir: fixturesDir, + bundle: true, + entryPoints: [entryPoint], + format: "esm", + logLevel: "error", + outfile, + platform: "browser", + plugins: [ + braintrustEsbuildPlugin({ useDiagnosticChannelCompatShim: true }), + ], + preserveSymlinks: true, + write: true, + }); + + expect(result.errors).toHaveLength(0); + expect(fs.existsSync(outfile)).toBe(true); + + const output = fs.readFileSync(outfile, "utf-8"); + + expect(output).not.toContain("__braintrustTopLevelImportHookRunner"); + expect(output).not.toContain("__braintrustOriginal"); + }); + + it("should keep legacy default bundles browser-safe for top-level export patches", async () => { + const { esbuildPlugin } = + await import("../../src/auto-instrumentations/bundler/esbuild.js"); + + const entryPoint = path.join(fixturesDir, "test-mastra-bundler.js"); + const outfile = path.join( + outputDir, + "esbuild-mastra-legacy-default-bundle.js", + ); + + const result = await esbuild.build({ + absWorkingDir: fixturesDir, + bundle: true, + entryPoints: [entryPoint], + format: "esm", + logLevel: "error", + outfile, + platform: "browser", + plugins: [esbuildPlugin()], + preserveSymlinks: true, + write: true, + }); + + expect(result.errors).toHaveLength(0); + expect(fs.existsSync(outfile)).toBe(true); + + const output = fs.readFileSync(outfile, "utf-8"); + + expect(output).not.toContain("__braintrustTopLevelImportHookRunner"); + expect(output).not.toContain("__braintrustOriginal"); + }); }); describe("vite", () => { @@ -570,6 +669,37 @@ describe("Orchestrion Transformation Tests", () => { expect(output).toContain("orchestrion:openai:chat.completions.create"); }); + it("should apply top-level export patches (turbopack loader-only mode)", async () => { + const { errors, output } = await runWebpackWithLoader({ + entry: path.join(fixturesDir, "test-mastra-bundler.js"), + output: { + path: outputDir, + filename: "turbopack-mastra-bundle.js", + library: { type: "module" }, + }, + experiments: { outputModule: true }, + mode: "development", + resolve: { modules: [nodeModulesDir, "node_modules"] }, + externals: { "node:module": "module node:module" }, + module: { + rules: [ + { + use: [ + { + loader: webpackLoaderPath, + options: { browser: false }, + }, + ], + }, + ], + }, + }); + + expect(errors).toHaveLength(0); + expect(output).toContain("__braintrustTopLevelImportHookRunner"); + expect(output).toContain("__braintrustExports"); + }); + it("should bundle dc-browser polyfill when browser: true (turbopack loader-only mode)", async () => { const { errors, output } = await runWebpackWithLoader({ entry: path.join(fixturesDir, "test-app.js"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce6d6ea1c..309371333 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,9 +341,6 @@ importers: '@next/env': specifier: ^14.2.3 version: 14.2.3 - '@types/estree': - specifier: ^1.0.8 - version: 1.0.9 '@vercel/functions': specifier: ^1.0.2 version: 1.0.2 @@ -450,6 +447,9 @@ importers: '@types/cors': specifier: ^2.8.17 version: 2.8.17 + '@types/esquery': + specifier: ^1.5.4 + version: 1.5.4 '@types/express': specifier: ^5.0.0 version: 5.0.1 @@ -1795,6 +1795,9 @@ packages: '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/esquery@1.5.4': + resolution: {integrity: sha512-yYO4Q8H+KJHKW1rEeSzHxcZi90durqYgWVfnh5K6ZADVBjBv2e1NEveYX5yT2bffgN7RqzH3k9930m+i2yBoMA==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -5519,6 +5522,10 @@ snapshots: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 + '@types/esquery@1.5.4': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {}