diff --git a/js/package.json b/js/package.json index 573969f9f..78f0850fd 100644 --- a/js/package.json +++ b/js/package.json @@ -144,7 +144,7 @@ "scripts": { "postinstall": "node ./scripts/install.js", "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup", - "check:typings": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "check:typings": "tsc --noEmit -p tsconfig.vendor-hooks.json && tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "watch": "tsup --watch", "clean": "rm -r dist/* && rm -r dev/dist/* && rm -r util/dist/*", "docs": "typedoc --options typedoc.json src/node/index.ts", @@ -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/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..26994c5a9 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,88 @@ // // 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"; +import type { register as registerGeneratedWrapper } from "./lib/register.mjs"; + +const generatedWrapperRegister: typeof registerGeneratedWrapper | undefined = + undefined; +void generatedWrapperRegister; + +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 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 ResolveFunction = AsyncResolveFunction | SyncResolveFunction; +type SetterMap = Map; + +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 +99,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 +109,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 +121,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 +162,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 +185,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 +215,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 +280,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 +336,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 +345,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 +369,19 @@ 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): ImportInTheMiddleHook { + let cachedResolve: ResolveFunction | undefined; + const iitmURL = 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 +389,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 +409,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 +425,7 @@ export function createHook(meta) { } catch {} Error.stackTraceLimit = stackTraceLimit; } - function match(each) { + function match(each: string): boolean { return ( each === specifier || each === url || @@ -358,7 +444,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 +453,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 +485,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,7 +574,11 @@ export function createHook(meta) { }; } - async function resolve(specifier, context, parentResolve) { + async function resolve( + specifier: string, + context: LoaderContext, + parentResolve: AsyncResolveFunction, + ): Promise { cachedResolve = parentResolve; // See https://github.com/nodejs/import-in-the-middle/pull/76. @@ -495,7 +594,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,7 +606,11 @@ 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) { + function resolveSync( + specifier: string, + context: LoaderContext, + nextResolve: SyncResolveFunction, + ): ResolveResult { cachedResolve = nextResolve; if (specifier === iitmURL) { @@ -519,7 +625,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 +633,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 +713,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 +732,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 = cachedResolve; 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 +778,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: cachedResolve as SyncResolveFunction | undefined, }); return { source: onWrapSuccess(realUrl, context, originalSpecifier, setters), @@ -669,10 +808,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 +829,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 +840,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 +850,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 +874,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 +888,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 deleted file mode 100644 index 03364febe..000000000 --- a/js/src/auto-instrumentations/import-in-the-middle/index.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Forked from import-in-the-middle@3.2.0. Modified by Braintrust. - -export type Namespace = { [key: string]: any }; -export type HookFn = ( - exported: Namespace, - name: string, - baseDir: string | void, -) => any; - -export declare class Hook { - constructor(modules: string[], hookFn: HookFn); - unhook(): void; -} - -export default Hook; - -type CreateAddHookMessageChannelReturn = { - addHookMessagePort: MessagePort; - waitForAllMessagesAcknowledged: () => Promise; - registerOptions: { data?: Data; transferList?: any[] }; -}; - -export declare function createAddHookMessageChannel< - Data = any, ->(): CreateAddHookMessageChannelReturn; diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.js b/js/src/auto-instrumentations/import-in-the-middle/index.js deleted file mode 100644 index 26c5e47bb..000000000 --- a/js/src/auto-instrumentations/import-in-the-middle/index.js +++ /dev/null @@ -1,193 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License. -// -// 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"); - -const { - addHookedModules, - deleteHookedModules, - importHooks, - specifiers, - toHook, -} = require("./lib/register"); - -function addHook(hook) { - importHooks.push(hook); - toHook.forEach(([name, namespace, specifier]) => - hook(name, namespace, specifier), - ); -} - -function removeHook(hook) { - const index = importHooks.indexOf(hook); - if (index > -1) { - importHooks.splice(index, 1); - } -} - -function callHookFn(hookFn, namespace, name, baseDir) { - const newDefault = hookFn(namespace, name, baseDir); - if (newDefault && newDefault !== namespace && "default" in namespace) { - namespace.default = newDefault; - } -} - -function normalizeModules(modules) { - 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, name, filePath, specifier, baseDir, loadUrl) { - 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)); -} - -let sendModulesToLoader; - -function createAddHookMessageChannel() { - const { port1, port2 } = new MessageChannel(); - let pendingAckCount = 0; - let resolveFn; - - sendModulesToLoader = (modules) => { - pendingAckCount++; - port1.postMessage(modules); - }; - - port1 - .on("message", () => { - pendingAckCount--; - - if (resolveFn && pendingAckCount <= 0) { - resolveFn(); - } - }) - .unref(); - - function waitForAllMessagesAcknowledged() { - const timer = setInterval(() => {}, 1000); - const promise = new Promise((resolve) => { - resolveFn = resolve; - }).then(() => { - clearInterval(timer); - }); - - if (pendingAckCount === 0) { - resolveFn(); - } - - return promise; - } - - const addHookMessagePort = port2; - const registerOptions = { - data: { addHookMessagePort, include: [] }, - transferList: [addHookMessagePort], - }; - - return { - registerOptions, - addHookMessagePort, - waitForAllMessagesAcknowledged, - }; -} - -function Hook(modules, hookFn) { - if (this instanceof Hook === false) return new Hook(modules, hookFn); - - modules = normalizeModules(modules); - if (typeof hookFn !== "function") { - throw new TypeError( - "Braintrust import-in-the-middle requires a hook function", - ); - } - - addHookedModules(modules); - if (sendModulesToLoader) { - sendModulesToLoader(modules); - } - - this._modules = modules; - this._iitmHook = (name, namespace, specifier) => { - const loadUrl = name; - let filePath; - let baseDir; - - 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 (e) {} - 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); -} - -Hook.prototype.unhook = function () { - removeHook(this._iitmHook); - deleteHookedModules(this._modules); -}; - -module.exports = Hook; -module.exports.Hook = Hook; -module.exports.createAddHookMessageChannel = createAddHookMessageChannel; diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.mts b/js/src/auto-instrumentations/import-in-the-middle/index.mts new file mode 100644 index 000000000..fc33d5722 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/index.mts @@ -0,0 +1,263 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License. +// +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. + +import moduleDetailsFromPath from "module-details-from-path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import { MessageChannel, type MessagePort } from "node:worker_threads"; +import registerState, { + type ImportHook, + type Namespace, +} from "./lib/register.mjs"; + +const { + addHookedModules, + deleteHookedModules, + importHooks, + specifiers, + toHook, +} = registerState; + +type HookFn = ( + exported: Exported, + name: string, + baseDir?: string, +) => Result; + +interface Hook { + unhook(): void; +} + +interface HookConstructor { + new ( + modules: readonly string[], + hookFn: HookFn, + ): Hook; + ( + modules: readonly string[], + hookFn: HookFn, + ): Hook; +} + +type HookRegisterData = { + addHookMessagePort: MessagePort; + include: string[]; +}; + +type CreateAddHookMessageChannelReturn = { + addHookMessagePort: MessagePort; + waitForAllMessagesAcknowledged: () => Promise; + registerOptions: { + data: HookRegisterData; + transferList: [MessagePort]; + }; +}; + +interface HookInstance { + _iitmHook: ImportHook; + _modules: string[]; +} + +interface HookImplementationConstructor { + 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: ImportHook): 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: 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 normalized; +} + +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(String(specifiers.get(loadUrl))); +} + +export function createAddHookMessageChannel(): CreateAddHookMessageChannelReturn { + const { port1, port2 } = new MessageChannel(); + let pendingAckCount = 0; + let resolveFn: (() => void) | undefined; + + sendModulesToLoader = (modules: string[]) => { + pendingAckCount++; + port1.postMessage(modules); + }; + + port1 + .on("message", () => { + pendingAckCount--; + + if (resolveFn && pendingAckCount <= 0) { + resolveFn(); + } + }) + .unref(); + + function waitForAllMessagesAcknowledged(): Promise { + const timer = setInterval(() => {}, 1000); + const promise = new Promise((resolve) => { + resolveFn = resolve; + }).then(() => { + clearInterval(timer); + }); + + if (pendingAckCount === 0) { + resolveFn?.(); + } + + return promise; + } + + const addHookMessagePort = port2; + const registerOptions: CreateAddHookMessageChannelReturn["registerOptions"] = + { + data: { addHookMessagePort, include: [] as string[] }, + transferList: [addHookMessagePort], + }; + + return { + registerOptions, + addHookMessagePort, + waitForAllMessagesAcknowledged, + }; +} + +const HookImpl: HookImplementationConstructor = function ( + this: HookInstance | undefined, + modules: unknown, + hookFn: unknown, +): HookInstance | void { + if (!this || !(this instanceof HookImpl)) { + return new HookImpl(modules, hookFn); + } + + 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(normalizedModules); + if (sendModulesToLoader) { + sendModulesToLoader(normalizedModules); + } + + this._modules = normalizedModules; + 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 normalizedModules) { + if ( + moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) + ) { + callHookFn( + importHookFn, + namespace, + filePath && matchArg === filePath ? filePath : matchArg, + baseDir, + ); + } + } + }; + + addHook(this._iitmHook); +} as HookImplementationConstructor; + +HookImpl.prototype.unhook = function (this: HookInstance): void { + removeHook(this._iitmHook); + deleteHookedModules(this._modules); +}; + +export const Hook = HookImpl as unknown as HookConstructor; 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..906ae8f15 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mts @@ -0,0 +1,226 @@ +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 78% 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..2f066c46b 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 @@ -1,12 +1,16 @@ -"use strict"; - 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 +28,20 @@ function ensureParserInitialized() { } } -function addDefault(arr) { +type NodeRequire = ReturnType; +type ExportGenerator = Generator, LoadResult>; +type PackageImportObject = { + default?: string; + import?: { default?: string; node?: string }; + node?: string; + require?: { default?: string; node?: string }; +}; + +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 +54,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 +163,20 @@ 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(import.meta.url); + } + return require; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} // Returns a builtin's exports object. `process.getBuiltinModule` (Node >= // 20.16 / >= 22.3) bypasses registered loader hooks; `require` does not. Under @@ -161,23 +185,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 +219,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,25 +232,29 @@ function resolvePackageImports(specifier, fromUrl) { const packageJsonPath = join(currentDir, "package.json"); if (existsSync(packageJsonPath)) { - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf8"), + ) as { imports?: Record }; 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; + let resolvedExport: string | undefined; + if (isRecord(imports)) { + const conditionalImports = imports as PackageImportObject; + const requireExport = conditionalImports.require; + const importExport = conditionalImports.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; + requireExport?.node || + requireExport?.default || + importExport?.node || + importExport?.default; + } else if (conditionalImports.node || conditionalImports.default) { + resolvedExport = + conditionalImports.node || conditionalImports.default; } } else if (typeof imports === "string") { resolvedExport = imports; @@ -251,7 +279,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 +306,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 +316,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 +364,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 +406,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..dd95ee372 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/io.mts @@ -0,0 +1,126 @@ +// 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; +type SyncLoaderIo = { + load: (url: string, context: LoaderContext) => LoadResult; + resolve?: (specifier: string, context: LoaderContext) => ResolveResult; +}; +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.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..8d1b26ac7 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; +type ExportSetter = (value: unknown) => boolean; +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.d.ts b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.d.ts deleted file mode 100644 index 64976895a..000000000 --- a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Forked from import-in-the-middle@3.2.0. Modified by Braintrust. - -export declare function supportsSyncHooks(): boolean; -export declare function register(): void; 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/import-in-the-middle/tsconfig.json b/js/src/auto-instrumentations/import-in-the-middle/tsconfig.json new file mode 100644 index 000000000..61106cb94 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.vendor-hooks.json", + "include": [ + "../types/module-details-from-path.d.ts", + "../orchestrion-js/vendor-deps.d.ts", + "**/*.mts" + ] +} 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.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..3769e8b70 --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/index.ts @@ -0,0 +1,320 @@ +import Module from "node:module"; +import moduleDetailsFromPath from "module-details-from-path"; +import path from "node:path"; + +type OnRequireFn = ( + exports: Exports, + name: string, + basedir?: string, +) => PatchedExports; + +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 HookImplementationConstructor { + 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: HookImplementationConstructor = 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 as string] }); + } 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 HookImplementationConstructor; + +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/tsconfig.json b/js/src/auto-instrumentations/require-in-the-middle/tsconfig.json new file mode 100644 index 000000000..1046b1ae6 --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.vendor-hooks.json", + "include": ["../types/module-details-from-path.d.ts", "**/*.ts"] +} 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 deleted file mode 100644 index 7c7658f9e..000000000 --- a/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface HookOptions { - internals?: boolean; -} - -export type OnRequireFn = (exports: T, name: string, basedir?: string) => T; - -export class Hook { - constructor(modules: string[], onrequire: OnRequireFn); - unhook(): void; -} 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..7d48dcf18 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 @@ -3,10 +3,10 @@ import { register } from "node:module"; 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 } = 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..05b1cf2ba 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); 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/typecheck.d.ts b/js/tests/typecheck.d.ts index 5cb6f1a5a..67e21c726 100644 --- a/js/tests/typecheck.d.ts +++ b/js/tests/typecheck.d.ts @@ -1,3 +1,2 @@ -declare module "esquery"; declare module "module-details-from-path"; declare module "semifies"; diff --git a/js/tsconfig.vendor-hooks.json b/js/tsconfig.vendor-hooks.json new file mode 100644 index 000000000..79c106eea --- /dev/null +++ b/js/tsconfig.vendor-hooks.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/auto-instrumentations/types/module-details-from-path.d.ts", + "src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts", + "src/auto-instrumentations/import-in-the-middle/**/*.mts", + "src/auto-instrumentations/require-in-the-middle/**/*.ts" + ], + "exclude": ["node_modules/**", "**/dist/**"] +} 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': {}