diff --git a/js/NOTICE b/js/NOTICE index 4a357d5a7..ce3a2f4da 100644 --- a/js/NOTICE +++ b/js/NOTICE @@ -10,7 +10,7 @@ This product includes software developed at Datadog ((); const isWin = process.platform === "win32"; const IITM_QUERY_PARAM = "braintrust_iitm"; +const STAR_CYCLE_DEPTH = 100; // 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", + "module-typescript", + "commonjs-typescript", +]); 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) ?? [0, 0, 0, 0]; - type LoaderMeta = { url: string }; type HookData = { addHookMessagePort?: MessagePort; @@ -51,6 +52,12 @@ type SyncResolveFunction = ( context: LoaderContext, ) => ResolveResult; type SetterMap = Map; +type OriginNamespaceMap = Map; +type ProcessModuleResult = { + originNamespaces?: OriginNamespaceMap; + origins?: Map; + setters: SetterMap; +}; interface ImportInTheMiddleHook { applyOptions(data: HookData): void; @@ -77,30 +84,6 @@ interface ImportInTheMiddleHook { ): ResolveResult; } -/** - * Whether the running Node.js can correctly run the synchronous loader via - * `module.registerHooks`. - * - * `module.registerHooks` exists since v22.15, but its synchronous load hook - * rejected the nullish CommonJS `source` the loader returns for `require()`s - * pulled into the ESM graph (throwing `ERR_INVALID_RETURN_PROPERTY_VALUE`) until - * https://github.com/nodejs/node/pull/59929, released in 22.22.3, 24.11.1, - * 25.1.0 and 26.0.0. Earlier 24.x (<= 24.11.0) and 25.0.0 ship `registerHooks` - * but predate the fix, so the synchronous loader must fall back to the - * asynchronous one there. - * - * @returns {boolean} - */ -export function supportsSyncHooks(): boolean { - if (NODE_MAJOR >= 26) return true; - if (NODE_MAJOR === 25) return NODE_MINOR >= 1; - if (NODE_MAJOR === 24) - return NODE_MINOR > 11 || (NODE_MINOR === 11 && NODE_PATCH >= 1); - if (NODE_MAJOR === 22) - return NODE_MINOR > 22 || (NODE_MINOR === 22 && NODE_PATCH >= 3); - return false; -} - function hasIitm(url: unknown): boolean { // Fast path: avoid URL parsing on the hot path when our marker is absent. if (typeof url !== "string" || url.indexOf(IITM_QUERY_PARAM) === -1) { @@ -207,17 +190,14 @@ function emitWarning(err: unknown): void { * @param {string} srcUrl The URL of the module the export belongs to. * @returns {string} */ -function buildSetter(n: string, srcUrl: string): string { +function buildSetter(n: string, srcUrl: string, namespaceVar: string): string { const variableName = `$${n.replace(/[^a-zA-Z0-9_$]/g, "_")}`; const objectKey = JSON.stringify(n); const reExportedName = n === "default" ? n : objectKey; - // For the module.exports synthetic export (Node 23+), fall back to $default - // when namespace['module.exports'] is not exposed by the native ESM namespace - // (builtins don't expose it). This ensures the IITM hook proxy returns the - // actual CJS value (e.g. EventEmitter) when an instrumentor reads - // capturedExports['module.exports'], rather than undefined. - const moduleExportsFallback = n === "module.exports" ? " ?? $default" : ""; + // Fall back to namespace.default for the module.exports synthetic export, + // which builtins don't expose on the native ESM namespace. + const useFallback = n === "module.exports"; const reExportLine = n === "module.exports" && @@ -225,32 +205,9 @@ function buildSetter(n: string, srcUrl: string): string { ? "" : `export { ${variableName} as ${reExportedName} }`; - return ` - let ${variableName} - __overridden[${objectKey}] = false - let ${variableName}Defer = false - try { - ${variableName} = _[${objectKey}] = namespace[${objectKey}]${moduleExportsFallback} - } catch (err) { - if (!(err instanceof ReferenceError)) throw err - ${variableName}Defer = true - } - - if (${variableName}Defer || ${variableName} === undefined) { - __pending.push(__makeUpdater( - ${objectKey}, - () => namespace[${objectKey}]${moduleExportsFallback}, - (v) => { ${variableName} = _[${objectKey}] = v } - )) - } - ${reExportLine} - set[${objectKey}] = (v) => { - __overridden[${objectKey}] = true - ${variableName} = v - return true - } - get[${objectKey}] = () => ${variableName} - `; + return `let ${variableName} +__binder.bind(${objectKey}, ${namespaceVar}, v => { ${variableName} = v }, () => ${variableName}, ${useFallback}) +${reExportLine}`; } /** @@ -276,40 +233,59 @@ function* processModule({ srcUrl, context, excludeDefault = false, + depth = 0, + seen, + originNamespaces, }: { context: LoaderContext; + depth?: number; excludeDefault?: boolean; + originNamespaces?: OriginNamespaceMap; + seen?: Set; srcUrl: string; -}): Generator { +}): Generator< + LoaderOperation, + ProcessModuleResult, + LoadResult | ResolveResult +> { const exportNames = yield* getExports(srcUrl, context); - const starExports = new Set(); - const setters = new Map(); + const setters: SetterMap = new Map(); + let starOrigins: Map | undefined; + + const ensureOriginNamespace = (origin: string): string => { + originNamespaces ??= new Map(); + let alias = originNamespaces.get(origin); + if (alias === undefined) { + alias = `__ns${originNamespaces.size}`; + originNamespaces.set(origin, alias); + } + return alias; + }; const addSetter = ( name: string, setter: string, - isStarExport = false, + isStarExport: boolean, + origin: string, ): void => { if (setters.has(name)) { if (isStarExport) { - // If there's already a matching star export, delete it - if (starExports.has(name)) { - setters.delete(name); + if (starOrigins?.has(name)) { + if (starOrigins.get(name) === origin) { + setters.set( + name, + buildSetter(name, origin, ensureOriginNamespace(origin)), + ); + } else { + setters.delete(name); + starOrigins.delete(name); + } } - // and return so this is excluded - return; - } - - // if we already have this export but it is from a * export, overwrite it - if (starExports.has(name)) { - starExports.delete(name); - setters.set(name, setter); } } else { - // Store export * exports so we know they can be overridden by explicit - // named exports if (isStarExport) { - starExports.add(name); + starOrigins ??= new Map(); + starOrigins.set(name, origin); } setters.set(name, setter); @@ -344,21 +320,38 @@ function* processModule({ ]; const result = (yield resolveOperation) as ResolveResult; - const subSetters = yield* processModule({ - srcUrl: result.url, - context: { ...context, format: result.format }, - excludeDefault: true, - }); + starOrigins ??= new Map(); + + if (depth >= STAR_CYCLE_DEPTH) { + seen ??= new Set(); + if (seen.has(result.url)) continue; + seen.add(result.url); + } + + try { + const sub = yield* processModule({ + srcUrl: result.url, + context: { ...context, format: result.format }, + excludeDefault: true, + depth: depth + 1, + seen, + originNamespaces, + }); + + originNamespaces ??= sub.originNamespaces; - for (const [name, setter] of subSetters.entries()) { - addSetter(name, setter, true); + for (const [name, setter] of sub.setters) { + addSetter(name, setter, true, sub.origins?.get(name) ?? result.url); + } + } finally { + seen?.delete(result.url); } } else { - addSetter(n, buildSetter(n, srcUrl)); + addSetter(n, buildSetter(n, srcUrl, "namespace"), false, srcUrl); } } - return setters; + return { setters, origins: starOrigins, originNamespaces }; } function addIitm(url: string): string { @@ -624,75 +617,26 @@ export function createHook(meta: LoaderMeta): ImportInTheMiddleHook { realUrl: string, setters: SetterMap, originalSpecifier: string | undefined, + originNamespaces: OriginNamespaceMap | undefined, ): string { - return ` -import registerState from '${iitmURL}' -import * as namespace from ${JSON.stringify(realUrl)} - -// Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects). -const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } }) -const set = {} -const get = {} -const __overridden = Object.create(null) -let __pending = [] - -function __makeUpdater (key, read, assign) { - return () => { - if (__overridden[key] === true) return true - try { - const v = read() - if (v !== undefined) { - assign(v) - return true + let originImports = ""; + if (originNamespaces !== undefined) { + for (const [originUrl, alias] of originNamespaces) { + originImports += `import * as ${alias} from ${JSON.stringify(originUrl)}\n`; } - return false - } catch (err) { - if (err instanceof ReferenceError) return false - throw err } - } -} -function __flushPendingOnce () { - if (__pending.length === 0) return - const next = [] - for (const fn of __pending) { - // If it still throws ReferenceError, keep it for the (single) next attempt. - if (fn() !== true) next.push(fn) - } - __pending = next -} + return ` +import registerState from '${iitmURL}' +import * as namespace from ${JSON.stringify(realUrl)} +${originImports} +const __binder = new registerState.ModuleBinder() ${Array.from(setters.values()).join("\n")} -if (__pending.length > 0) { - queueMicrotask(() => { - __flushPendingOnce() - - if (__pending.length > 0) { - const __retryDelays = [0, 10, 50] - const __schedulePending = (i) => { - if (__pending.length === 0) return - if (i >= __retryDelays.length) { - // Give up: leave exports as-is to avoid unbounded retries. - __pending = [] - return - } - - const t = setTimeout(() => { - __flushPendingOnce() - __schedulePending(i + 1) - }, __retryDelays[i]) - // Don't keep the process alive just for best-effort retries. - if (t && typeof t.unref === 'function') t.unref() - } - - __schedulePending(0) - } - }) -} +__binder.flush() -registerState.register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)}) +registerState.register(${JSON.stringify(realUrl)}, __binder.namespace, __binder.set, __binder.get, ${JSON.stringify(originalSpecifier)}) `; } @@ -705,13 +649,19 @@ registerState.register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify context: LoaderContext, originalSpecifier: string | undefined, setters: SetterMap, + originNamespaces: OriginNamespaceMap | undefined, ): string { specifiers.delete(realUrl); // context.format is set to 'commonjs' by getCjsExports during processModule. if (context.format === "commonjs") { cjsInIitmChain.add(realUrl); } - return buildWrapperSource(realUrl, setters, originalSpecifier); + return buildWrapperSource( + realUrl, + setters, + originalSpecifier, + originNamespaces, + ); } // Bookkeeping shared by the async and sync wrap paths when `processModule` @@ -741,7 +691,7 @@ registerState.register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify try { const resolveForWrap = cachedAsyncResolve; - const setters = await driveAsync( + const { setters, originNamespaces } = await driveAsync( processModule({ srcUrl: realUrl, context }), { load: async (loadUrl, loadContext) => @@ -756,7 +706,13 @@ registerState.register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify }, ); return { - source: onWrapSuccess(realUrl, context, originalSpecifier, setters), + source: onWrapSuccess( + realUrl, + context, + originalSpecifier, + setters, + originNamespaces, + ), }; } catch (cause) { onWrapFailure(realUrl, cause); @@ -781,13 +737,22 @@ registerState.register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify const originalSpecifier = specifiers.get(realUrl); try { - const setters = driveSync(processModule({ srcUrl: realUrl, context }), { - load: (loadUrl, loadContext) => - nextLoad(loadUrl, loadContext) as LoadResult, - resolve: cachedSyncResolve, - }); + const { setters, originNamespaces } = driveSync( + processModule({ srcUrl: realUrl, context }), + { + load: (loadUrl, loadContext) => + nextLoad(loadUrl, loadContext) as LoadResult, + resolve: cachedSyncResolve, + }, + ); return { - source: onWrapSuccess(realUrl, context, originalSpecifier, setters), + source: onWrapSuccess( + realUrl, + context, + originalSpecifier, + setters, + originNamespaces, + ), }; } catch (cause) { onWrapFailure(realUrl, cause); diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.mts b/js/src/auto-instrumentations/import-in-the-middle/index.mts index 6c8d5ae7a..34e78da9f 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/index.mts +++ b/js/src/auto-instrumentations/import-in-the-middle/index.mts @@ -28,6 +28,20 @@ interface HookInstance { let sendModulesToLoader: ((modules: string[]) => void) | undefined; +function isTurbopackSpecifier( + specifier: string | undefined, + baseDir: string, +): boolean { + const usingTurbopack = + process.env.TURBOPACK ?? process.argv.includes("--turbo"); + if (!usingTurbopack || !specifier) return false; + + const hashIndex = specifier.lastIndexOf("-"); + if (hashIndex === -1) return false; + + return baseDir.endsWith(specifier.slice(0, hashIndex)); +} + function addHook(hook: ImportHook): void { importHooks.push(hook); toHook.forEach(([name, namespace, specifier]) => @@ -76,7 +90,12 @@ function moduleMatches( // 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))); + const originalSpecifier = specifiers.get(loadUrl); + return ( + matchArg === name && + (baseDir.endsWith(String(originalSpecifier)) || + isTurbopackSpecifier(originalSpecifier, baseDir)) + ); } export function createAddHookMessageChannel() { 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 index 906ae8f15..3f6137282 100644 --- 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 @@ -1,226 +1,96 @@ -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"; +"use strict"; -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"); -} +import { readFileSync } from "node:fs"; +import { createRequire, Module } from "node:module"; +import { initSync, parse as parseWasm } from "es-module-lexer"; -function isExportDeclaration( - node: Statement | ModuleDeclaration, -): node is ExportDeclaration { - return ( - node.type === "ExportAllDeclaration" || - node.type === "ExportDefaultDeclaration" || - node.type === "ExportNamedDeclaration" - ); -} +const require = createRequire(import.meta.url); -function getLiteralString(node: Literal): string | undefined { - return typeof node.value === "string" ? node.value : undefined; +type LexerImport = { + n: string; + se: number; + ss: number; +}; +type LexerExport = { + n: string; +}; +type LexerParseResult = readonly [ + readonly LexerImport[], + readonly LexerExport[], + unknown, + boolean, +]; + +const flag = "--disallow-code-generation-from-strings"; +const disallowCodegen = + process.execArgv.includes(flag) || + (process.env.NODE_OPTIONS?.includes(flag) ?? false); + +// initSync compiles the Wasm module up front so parse can run inside +// synchronous loader hooks as well as the off-thread loader. +initSync(); + +let parse: typeof parseWasm = parseWasm; + +if (disallowCodegen) { + parse = loadAsmParse(); } -function getExportedName(node: Identifier | Literal): string | undefined { - return node.type === "Identifier" ? node.name : getLiteralString(node); +function loadAsmParse(): typeof parseWasm { + const asmPath = require.resolve("es-module-lexer/js"); + const source = + readFileSync(asmPath, "utf8").replace( + "export function parse", + "function parse", + ) + "\nmodule.exports = { parse }\n"; + const mod = new Module(asmPath) as NodeJS.Module & { + _compile(source: string, filename: string): void; + exports: { parse: typeof parseWasm }; + }; + mod.filename = asmPath; + mod._compile(source, asmPath); + return mod.exports.parse; } -/** - * 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; - } +function decodeExportName(name: string): string { + const first = name.charCodeAt(0); + if ( + first === 0x22 /* " */ || + first === 0x27 /* ' */ || + !name.includes("\\") + ) { + return name; } - 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; + try { + return JSON.parse(`"${name}"`) as string; + } catch { + return name; } } -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); +// es-module-lexer reports a bare `export * from ` only as an import with no +// matching export entry, so match the statement text to rebuild the transitive marker. +const GAP = String.raw`(?:\s|/\*[^]*?\*/|//[^\n]*\n)*`; +const STAR_REEXPORT = new RegExp(`^export${GAP}\\*${GAP}from`); + +export function lexEsm(moduleSource: string): { + exportNames: Set; + hasModuleSyntax: boolean; +} { + const exportNames = new Set(); + const [imports, exports, , hasModuleSyntax] = parse( + moduleSource, + ) as LexerParseResult; + + for (const exported of exports) { + exportNames.add(decodeExportName(exported.n)); } -} -function parseObjectPattern( - node: ObjectPattern, - exportedNames: Set, -): void { - for (const property of node.properties) { - if (property.type === "RestElement") { - parseRestElement(property, exportedNames); - } else { - parseAssignmentProperty(property, exportedNames); + for (const imported of imports) { + if (STAR_REEXPORT.test(moduleSource.slice(imported.ss, imported.se))) { + exportNames.add(`* from ${imported.n}`); } } -} - -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); - } + return { exportNames, hasModuleSyntax }; } diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts index 2b9854ec2..126d2d642 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mts @@ -1,4 +1,4 @@ -import getEsmExports from "./get-esm-exports.mjs"; +import { lexEsm } from "./get-esm-exports.mjs"; import { parse as parseCjs, initSync } from "cjs-module-lexer"; import { existsSync, readFileSync } from "node:fs"; import { builtinModules, createRequire } from "node:module"; @@ -15,6 +15,19 @@ import type { const nodeMajor = Number(process.versions.node.split(".")[0]); export const hasModuleExportsCJSDefault = nodeMajor >= 23; +type StripTypeScriptTypes = ( + source: string, + options: { mode: "strip" }, +) => string; + +const stripTypeScriptTypes = ( + process as NodeJS.Process & { + getBuiltinModule?: (name: "module") => { + stripTypeScriptTypes?: StripTypeScriptTypes; + }; + } +).getBuiltinModule?.("module").stripTypeScriptTypes; + let parserInitialized = false; // The CJS export scanner is backed by WebAssembly. `initSync` compiles it @@ -43,129 +56,9 @@ function addDefault(arr: Iterable): Set { return new Set(["default", ...arr]); } -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 - // - `getEsmExports()` found no exports. - // - // Notes: - // - We ignore comments and strings to reduce false positives. - // - We treat `import.meta` and static `import ...` as ESM. - // - We do NOT treat `import(` (dynamic import) as ESM because it is allowed - // in CJS as an expression. - if (source.indexOf("import") === -1) return false; - - 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: number) => { - while (idx < source.length) { - const c = source.charCodeAt(idx); - // space, tab, cr, lf - if (c !== 32 && c !== 9 && c !== 13 && c !== 10) break; - idx++; - } - return idx; - }; - - let i = 0; - while (i < source.length) { - const ch = source[i]; - - // Line comment - if (ch === "/" && source[i + 1] === "/") { - i += 2; - while (i < source.length && source[i] !== "\n") i++; - continue; - } - - // Block comment - if (ch === "/" && source[i + 1] === "*") { - i += 2; - while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) - i++; - i += 2; - continue; - } - - // Strings: '...' or "..." - if (ch === "'" || ch === '"') { - const quote = ch; - i++; - while (i < source.length) { - const c = source[i]; - if (c === "\\") { - i += 2; - continue; - } - if (c === quote) { - i++; - break; - } - i++; - } - continue; - } - - // Template strings: `...` - if (ch === "`") { - i++; - while (i < source.length) { - const c = source[i]; - if (c === "\\") { - i += 2; - continue; - } - if (c === "`") { - i++; - break; - } - i++; - } - continue; - } - - // Keyword scan (word-boundary): import - if (ch === "i") { - const prev = source.charCodeAt(i - 1); - if (i > 0 && isIdentCharCode(prev)) { - i++; - continue; - } - - if (source.startsWith("import", i)) { - const next = source.charCodeAt(i + 6); - if (isIdentCharCode(next)) { - i++; - continue; - } - - const j = skipWhitespace(i + 6); - // `import.meta` is ESM-only - if (source[j] === ".") return true; - // `import(` is dynamic import, allowed in CJS - if (source[j] === "(") { - i = j + 1; - continue; - } - // Otherwise assume it's a static import form - return true; - } - } - - i++; - } - - return false; -} - // Cached exports for Node built-in modules const BUILT_INS = new Map>(); +const esmExportsCache = new Map }>(); let require: NodeRequire | undefined; @@ -386,13 +279,18 @@ function* getCjsExports( * * @returns {Generator>} A generator that yields I/O * operations and ultimately returns the identifiers exported by the module. - * Please see {@link getEsmExports} for caveats on special identifiers that may + * Please see {@link lexEsm} for caveats on special identifiers that may * be included in the result set. */ export function* getExports( url: string, context: LoaderContext, ): ExportGenerator { + const cached = esmExportsCache.get(url); + if (cached !== undefined) { + return cached.exportNames; + } + // `[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. @@ -431,31 +329,38 @@ export function* getExports( source = readFileSync(fileURLToPath(url), "utf8"); } - const moduleSource = source as string; - try { - if (format === "module") { - return getEsmExports(moduleSource); + let moduleSource = source as string; + let moduleFormat = format; + if ( + moduleFormat === "module-typescript" || + moduleFormat === "commonjs-typescript" + ) { + if (stripTypeScriptTypes !== undefined) { + moduleSource = stripTypeScriptTypes(moduleSource, { mode: "strip" }); + } + moduleFormat = + moduleFormat === "module-typescript" ? "module" : "commonjs"; } - if (format === "commonjs") { + if (moduleFormat === "commonjs") { 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(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(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, moduleSource); - } + const { exportNames, hasModuleSyntax } = lexEsm(moduleSource); + + if (moduleFormat === "module") { + esmExportsCache.set(url, { exportNames }); + return exportNames; + } + + // At this point our `format` is either undefined or not known by us. When + // there are no exports and no ESM syntax, fall back to CommonJS detection. + if (!exportNames.size && !hasModuleSyntax) { + return yield* getCjsExports(url, context, moduleSource); } - return esmExports; + esmExportsCache.set(url, { exportNames }); + return exportNames; } catch (cause) { throw new Error(`Failed to parse '${url}'`, { cause }); } diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/register.mts b/js/src/auto-instrumentations/import-in-the-middle/lib/register.mts index 7da542679..f804f66fe 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/register.mts +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/register.mts @@ -100,6 +100,103 @@ function register( toHook.push([name, proxy, specifier]); } +// Delays (ms) for re-reading exports that were still in their temporal dead zone +// when the wrapper first ran. Retried on a microtask first, then at these +// intervals; unref'd so best-effort retries never hold the process open. +const RETRY_DELAYS = [0, 10, 50]; + +class ModuleBinder { + namespace: Namespace = Object.create(null, { + [Symbol.toStringTag]: { value: "Module" }, + }); + set: Record = {}; + get: Record = {}; + #overridden: Record = Object.create(null); + #pending: Array<() => boolean> = []; + + bind( + key: string, + source: Namespace, + write: (value: unknown) => void, + read: () => unknown, + useFallback: boolean, + ): void { + const readSource = useFallback + ? () => source[key] ?? source.default + : () => source[key]; + this.#overridden[key] = false; + let deferred = false; + try { + const value = readSource(); + write(value); + this.namespace[key] = value; + } catch (error) { + if (!(error instanceof ReferenceError)) throw error; + deferred = true; + } + if (deferred || read() === undefined) { + this.#pending.push(this.#makeUpdater(key, readSource, write)); + } + this.set[key] = (value) => { + this.#overridden[key] = true; + write(value); + return true; + }; + this.get[key] = read; + } + + #makeUpdater( + key: string, + readSource: () => unknown, + write: (value: unknown) => void, + ): () => boolean { + return () => { + if (this.#overridden[key] === true) return true; + try { + const value = readSource(); + if (value !== undefined) { + write(value); + this.namespace[key] = value; + return true; + } + return false; + } catch (error) { + if (error instanceof ReferenceError) return false; + throw error; + } + }; + } + + #flushOnce(): void { + const next = []; + for (const updater of this.#pending) { + if (updater() !== true) next.push(updater); + } + this.#pending = next; + } + + flush(): void { + if (this.#pending.length === 0) return; + queueMicrotask(() => { + this.#flushOnce(); + this.#scheduleRetry(0); + }); + } + + #scheduleRetry(attempt: number): void { + if (this.#pending.length === 0) return; + if (attempt >= RETRY_DELAYS.length) { + this.#pending = []; + return; + } + const timer = setTimeout(() => { + this.#flushOnce(); + this.#scheduleRetry(attempt + 1); + }, RETRY_DELAYS[attempt]); + timer.unref?.(); + } +} + function addHookedModules(modules: readonly string[]): void { for (const each of modules) { const nextCount = (hookedModuleCounts.get(each) || 0) + 1; @@ -125,6 +222,7 @@ export default { deleteHookedModules, hookedModules, importHooks, + ModuleBinder, register, specifiers, toHook, diff --git a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts index 2748fb4d9..57e5edefb 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts +++ b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mts @@ -1,5 +1,6 @@ import * as module from "node:module"; -import { createHook, supportsSyncHooks } from "./create-hook.mjs"; +import { createHook } from "./create-hook.mjs"; +import { supportsSyncHooks } from "./supports-sync-hooks.mjs"; export { supportsSyncHooks }; diff --git a/js/src/auto-instrumentations/import-in-the-middle/supports-sync-hooks.mts b/js/src/auto-instrumentations/import-in-the-middle/supports-sync-hooks.mts new file mode 100644 index 000000000..e228d2183 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/supports-sync-hooks.mts @@ -0,0 +1,37 @@ +// 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. + +// This module intentionally has no imports. Consumers can branch on sync hook +// support without loading the parser dependencies used by create-hook.mts. + +const version = process.versions.node; +const NODE_MAJOR = parseInt(version, 10); +let NODE_MINOR: number | undefined; +let NODE_PATCH: number | undefined; + +function readMinorAndPatch(): void { + const firstDot = version.indexOf("."); + const secondDot = version.indexOf(".", firstDot + 1); + NODE_MINOR = parseInt(version.slice(firstDot + 1, secondDot), 10); + NODE_PATCH = parseInt(version.slice(secondDot + 1), 10); +} + +/** + * Whether the running Node.js can correctly run the synchronous loader via + * `module.registerHooks`. + * + * `module.registerHooks` exists since v22.15, but its synchronous load hook + * rejected the nullish CommonJS `source` the loader returns for `require()`s + * pulled into the ESM graph until nodejs/node#59929. + */ +export function supportsSyncHooks(): boolean { + if (NODE_MAJOR >= 26) return true; + if (NODE_MAJOR < 22 || NODE_MAJOR === 23) return false; + + readMinorAndPatch(); + if (NODE_MAJOR === 25) return NODE_MINOR! >= 1; + if (NODE_MAJOR === 24) + return NODE_MINOR! > 11 || (NODE_MINOR === 11 && NODE_PATCH! >= 1); + return NODE_MINOR! > 22 || (NODE_MINOR === 22 && NODE_PATCH! >= 3); +} 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 c510f8cc6..ccf0f9f97 100644 --- a/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts +++ b/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts @@ -7,11 +7,3 @@ 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/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs index 09dbc6e45..d30656dff 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 @@ -3,6 +3,10 @@ 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"; +import { fromA, fromB } from "circular-star-target"; +import { val } from "same-source-target"; +import cjsTypescriptTarget from "./typescript-cjs-hook.cts"; +import { alpha, beta, gamma, Delta } from "./typescript-hook.mts"; import { sawIitmParam } from "./query-param-target.mjs?iitm=true"; assert.equal(foo, 57); @@ -11,4 +15,13 @@ assert.equal(unhookedFoo, 10); assert.equal(getUnhookedLabel(), "untouched"); assert.equal(cjsTarget.value, 8); assert.equal(nestedValue, "nested"); +assert.equal(fromA, 11); +assert.equal(fromB, 12); +assert.equal(val, 1); +assert.equal(alpha, 1); +assert.equal(beta, "two"); +assert.equal(gamma(1), 2); +assert.equal(new Delta().value, 3); +assert.equal(cjsTypescriptTarget.epsilon, 5); +assert.equal(cjsTypescriptTarget.zeta({ kind: "square" }), "square"); assert.equal(sawIitmParam, true); 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 5e2c39ef7..5d0ca3631 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,4 +1,5 @@ import assert from "node:assert"; +import { fileURLToPath } from "node:url"; import { register } from "node:module"; import { Hook, @@ -16,7 +17,15 @@ register(hookUrl, import.meta.url, registerOptions); globalThis.__braintrustIitmAsyncHookCalls = 0; const hook = new Hook( - ["hook-target", "cjs-hook-target", "cjs-reexport-target"], + [ + "hook-target", + "cjs-hook-target", + "cjs-reexport-target", + "same-source-target", + "circular-star-target", + fileURLToPath(new URL("./typescript-hook.mts", import.meta.url)), + fileURLToPath(new URL("./typescript-cjs-hook.cts", import.meta.url)), + ], (exports, name) => { globalThis.__braintrustIitmAsyncHookCalls++; if (name === "hook-target") { @@ -26,13 +35,36 @@ const hook = new Hook( if (name === "cjs-hook-target") { exports.default.value = 8; } + if (name === "same-source-target") { + assert.equal(exports.val, 1); + } + if (name === "circular-star-target") { + exports.fromA += 10; + exports.fromB += 10; + } + if (typeof name === "string" && name.endsWith("typescript-hook.mts")) { + assert.deepEqual(Object.keys(exports).sort(), [ + "Delta", + "alpha", + "beta", + "gamma", + ]); + } + if (typeof name === "string" && name.endsWith("typescript-cjs-hook.cts")) { + assert.deepEqual(Object.keys(exports).sort(), [ + "default", + "epsilon", + "module.exports", + "zeta", + ]); + } }, ); await waitForAllMessagesAcknowledged(); process.on("exit", () => { - assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 3); + assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 7); hook.unhook(); addHookMessagePort.close(); }); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-app.mjs new file mode 100644 index 000000000..049a4743d --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-app.mjs @@ -0,0 +1,6 @@ +import assert from "node:assert"; + +import { value } from "virtual-reload-source-per-load"; + +assert.equal(value, 2); +assert.equal(globalThis.__braintrustIitmReloadHookValue, 2); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-loader.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-loader.mjs new file mode 100644 index 000000000..d65203bf7 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-loader.mjs @@ -0,0 +1,25 @@ +const RELOAD_URL = new URL("file:///virtual/reload-source-per-load.mjs").href; + +let loadCount = 0; + +export async function resolve(specifier, context, parentResolve) { + if (specifier === "virtual-reload-source-per-load") { + return { url: RELOAD_URL, format: "module", shortCircuit: true }; + } + if (specifier === RELOAD_URL) { + return { url: specifier, format: "module", shortCircuit: true }; + } + return parentResolve(specifier, context); +} + +export async function load(url, context, parentLoad) { + if (url === RELOAD_URL) { + loadCount++; + return { + format: "module", + source: `export const value = ${loadCount}\n`, + shortCircuit: true, + }; + } + return parentLoad(url, context); +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-setup.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-setup.mjs new file mode 100644 index 000000000..9102ff8c3 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-reload-setup.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert"; +import { register } from "node:module"; +import { + Hook, + createAddHookMessageChannel, +} from "../../../../src/auto-instrumentations/import-in-the-middle/index.mts"; + +const hookUrl = new URL( + "../../../../src/auto-instrumentations/import-in-the-middle/hook.mts", + import.meta.url, +); +const { addHookMessagePort, registerOptions, waitForAllMessagesAcknowledged } = + createAddHookMessageChannel(); + +register(hookUrl, import.meta.url, registerOptions); + +let calls = 0; +const hook = new Hook(["virtual-reload-source-per-load"], (exports) => { + calls++; + globalThis.__braintrustIitmReloadHookValue = exports.value; +}); + +await waitForAllMessagesAcknowledged(); + +process.on("exit", () => { + assert.equal(calls, 1); + 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 432600f46..13f05c9cb 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,5 +1,6 @@ import assert from "node:assert"; import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; import { Hook } from "../../../../src/auto-instrumentations/import-in-the-middle/index.mts"; import { register, @@ -14,7 +15,16 @@ register(); let calls = 0; const hook = new Hook( - ["hook-target", "cjs-hook-target", "cjs-reexport-target", "fs"], + [ + "hook-target", + "cjs-hook-target", + "cjs-reexport-target", + "same-source-target", + "circular-star-target", + "fs", + fileURLToPath(new URL("./typescript-hook.mts", import.meta.url)), + fileURLToPath(new URL("./typescript-cjs-hook.cts", import.meta.url)), + ], (exports, name) => { calls++; if (name === "hook-target") { @@ -27,6 +37,29 @@ const hook = new Hook( if (name === "fs") { exports.existsSync = () => true; } + if (name === "same-source-target") { + assert.equal(exports.val, 1); + } + if (name === "circular-star-target") { + exports.fromA += 10; + exports.fromB += 10; + } + if (typeof name === "string" && name.endsWith("typescript-hook.mts")) { + assert.deepEqual(Object.keys(exports).sort(), [ + "Delta", + "alpha", + "beta", + "gamma", + ]); + } + if (typeof name === "string" && name.endsWith("typescript-cjs-hook.cts")) { + assert.deepEqual(Object.keys(exports).sort(), [ + "default", + "epsilon", + "module.exports", + "zeta", + ]); + } }, ); @@ -34,7 +67,11 @@ 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 circularStarTarget = await import("circular-star-target"); +const sameSourceTarget = await import("same-source-target"); const queryParamTarget = await import("./query-param-target.mjs?iitm=true"); +const typescriptTarget = await import("./typescript-hook.mts"); +const typescriptCjsTarget = await import("./typescript-cjs-hook.cts"); const fs = await import("node:fs"); assert.equal(target.foo, 57); @@ -44,12 +81,21 @@ assert.equal(other.default(), "untouched"); assert.equal(cjsTarget.default.value, 8); assert.equal(cjsReexportTarget.nestedValue, "nested"); assert.equal(cjsReexportTarget.rootValue, undefined); +assert.equal(circularStarTarget.fromA, 11); +assert.equal(circularStarTarget.fromB, 12); +assert.equal(sameSourceTarget.val, 1); assert.equal(queryParamTarget.sawIitmParam, true); +assert.equal(typescriptTarget.alpha, 1); +assert.equal(typescriptTarget.beta, "two"); +assert.equal(typescriptTarget.gamma(1), 2); +assert.equal(new typescriptTarget.Delta().value, 3); +assert.equal(typescriptCjsTarget.default.epsilon, 5); +assert.equal(typescriptCjsTarget.default.zeta({ kind: "square" }), "square"); 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, 4); +assert.equal(calls, 8); hook.unhook(); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/index.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/index.mjs new file mode 100644 index 000000000..efd670d35 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/index.mjs @@ -0,0 +1,3 @@ +export * from "./nested.mjs"; + +export const fromA = 1; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/nested.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/nested.mjs new file mode 100644 index 000000000..02a08746c --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/nested.mjs @@ -0,0 +1,3 @@ +export * from "./index.mjs"; + +export const fromB = 2; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/package.json new file mode 100644 index 000000000..64e03bbcb --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/circular-star-target/package.json @@ -0,0 +1,6 @@ +{ + "name": "circular-star-target", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/index.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/index.mjs new file mode 100644 index 000000000..489332e0f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/index.mjs @@ -0,0 +1,2 @@ +export * from "./leaf.mjs"; +export * from "./middle.mjs"; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/leaf.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/leaf.mjs new file mode 100644 index 000000000..0524557ff --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/leaf.mjs @@ -0,0 +1 @@ +export const val = 1; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/middle.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/middle.mjs new file mode 100644 index 000000000..131057e94 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/middle.mjs @@ -0,0 +1 @@ +export * from "./leaf.mjs"; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/package.json new file mode 100644 index 000000000..c948aa59a --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/same-source-target/package.json @@ -0,0 +1,6 @@ +{ + "name": "same-source-target", + "version": "1.0.0", + "type": "module", + "exports": "./index.mjs" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/typescript-cjs-hook.cts b/js/tests/auto-instrumentations/fixtures/vendor-hooks/typescript-cjs-hook.cts new file mode 100644 index 000000000..0e7232c75 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/typescript-cjs-hook.cts @@ -0,0 +1,11 @@ +interface Shape { + kind: string; +} + +const epsilon: number = 5; + +function zeta(shape: Shape): string { + return shape.kind; +} + +module.exports = { epsilon, zeta }; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/typescript-hook.mts b/js/tests/auto-instrumentations/fixtures/vendor-hooks/typescript-hook.mts new file mode 100644 index 000000000..919e43fc7 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/typescript-hook.mts @@ -0,0 +1,15 @@ +export type OnlyAType = { a: number }; +export interface AlsoAType { + b: string; +} + +export const alpha: number = 1, + beta: string = "two"; + +export function gamma(n: number): number { + return n + alpha; +} + +export class Delta { + value: number = 3; +} 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 4794d3b22..35df2cdc1 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 @@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.join(__dirname, "fixtures", "vendor-hooks"); +const iitmSrc = "../../src/auto-instrumentations/import-in-the-middle/"; describe("vendored import-in-the-middle and require-in-the-middle", () => { it("only wraps explicitly hooked ESM imports through the async loader", async () => { @@ -25,6 +26,136 @@ describe("vendored import-in-the-middle and require-in-the-middle", () => { }); }); + it("does not replay export-scan source for wrapped async loader modules", async () => { + await runNode({ + args: [ + "--experimental-loader", + path.join(fixturesDir, "iitm-reload-loader.mjs"), + "--import", + path.join(fixturesDir, "iitm-reload-setup.mjs"), + path.join(fixturesDir, "iitm-reload-app.mjs"), + ], + cwd: fixturesDir, + }); + }); + + it("collects exports from type-stripped TypeScript formats", async () => { + const stripTypeScriptTypes = ( + process as NodeJS.Process & { + getBuiltinModule?: (name: "module") => { + stripTypeScriptTypes?: unknown; + }; + } + ).getBuiltinModule?.("module").stripTypeScriptTypes; + if (typeof stripTypeScriptTypes !== "function") { + return; + } + + const [{ getExports }, { driveSync }] = await Promise.all([ + import(`${iitmSrc}lib/get-exports.mts`), + import(`${iitmSrc}lib/io.mts`), + ]); + + const esmExports = driveSync( + getExports("file:///virtual/typescript-hook.mts", { + format: "module-typescript", + }), + { + load: () => ({ + format: "module-typescript", + source: ` + export type OnlyAType = { a: number }; + export interface AlsoAType { b: string } + export const alpha: number = 1, beta: string = "two"; + export function gamma(n: number): number { return n + alpha; } + export class Delta { value: number = 3; } + `, + }), + }, + ); + expect([...esmExports].sort()).toEqual(["Delta", "alpha", "beta", "gamma"]); + + const cjsExports = driveSync( + getExports("file:///virtual/typescript-hook.cts", { + format: "commonjs-typescript", + }), + { + load: () => ({ + format: "commonjs-typescript", + source: ` + interface Shape { kind: string } + const epsilon: number = 5; + function zeta(s: Shape): string { return s.kind; } + module.exports = { epsilon, zeta }; + `, + }), + }, + ); + expect([...cjsExports].sort()).toEqual([ + "default", + "epsilon", + "module.exports", + "zeta", + ]); + }); + + it("keeps ModuleBinder state isolated and resolves deferred exports", async () => { + const { default: registerState } = await import( + `${iitmSrc}lib/register.mts` + ); + const { ModuleBinder } = registerState; + + const first = new ModuleBinder(); + const second = new ModuleBinder(); + let firstValue: unknown; + let secondValue: unknown; + + first.bind( + "foo", + { foo: 1 }, + (value: unknown) => { + firstValue = value; + }, + () => firstValue, + false, + ); + second.bind( + "bar", + { bar: 2 }, + (value: unknown) => { + secondValue = value; + }, + () => secondValue, + false, + ); + + expect(firstValue).toBe(1); + expect(secondValue).toBe(2); + expect(Object.keys(first.set)).toEqual(["foo"]); + expect(Object.keys(second.set)).toEqual(["bar"]); + + const deferred = new ModuleBinder(); + const source: { value?: number } = {}; + let value: unknown; + deferred.bind( + "value", + source, + (next: unknown) => { + value = next; + }, + () => value, + false, + ); + source.value = 7; + deferred.flush(); + await Promise.resolve(); + expect(value).toBe(7); + + expect(first.set.foo(42)).toBe(true); + first.flush(); + expect(firstValue).toBe(42); + }); + it("only wraps explicitly hooked CommonJS requires", async () => { await runNode({ args: [path.join(fixturesDir, "ritm-app.cjs")], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 309371333..d963f501b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,12 +344,6 @@ importers: '@vercel/functions': specifier: ^1.0.2 version: 1.0.2 - acorn: - specifier: ^8.16.0 - version: 8.16.0 - acorn-import-attributes: - specifier: ^1.9.5 - version: 1.9.5(acorn@8.16.0) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -377,6 +371,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + es-module-lexer: + specifier: ^2.3.0 + version: 2.3.0 esbuild: specifier: 0.27.4 version: 0.27.4 @@ -2035,11 +2032,6 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-import-attributes@1.9.5: - resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} - peerDependencies: - acorn: ^8 - acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -2460,8 +2452,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -5864,10 +5856,6 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -6214,7 +6202,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.1.0: {} + es-module-lexer@2.3.0: {} es-object-atoms@1.1.1: dependencies: @@ -7973,7 +7961,7 @@ snapshots: '@vitest/snapshot': 4.1.5 '@vitest/spy': 4.1.5 '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 @@ -8001,7 +7989,7 @@ snapshots: '@vitest/snapshot': 4.1.5 '@vitest/spy': 4.1.5 '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 @@ -8029,7 +8017,7 @@ snapshots: '@vitest/snapshot': 4.1.5 '@vitest/spy': 4.1.5 '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 @@ -8057,7 +8045,7 @@ snapshots: '@vitest/snapshot': 4.1.5 '@vitest/spy': 4.1.5 '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 @@ -8106,7 +8094,7 @@ snapshots: browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.21.2 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -8146,7 +8134,7 @@ snapshots: browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.21.2 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1