Skip to content

Commit a10178e

Browse files
committed
feat(node): Add registerModuleWrapper utility
1 parent 499f042 commit a10178e

File tree

8 files changed

+793
-2
lines changed

8 files changed

+793
-2
lines changed

packages/node-core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@
115115
"dependencies": {
116116
"@sentry/core": "10.48.0",
117117
"@sentry/opentelemetry": "10.48.0",
118-
"import-in-the-middle": "^3.0.0"
118+
"import-in-the-middle": "^3.0.0",
119+
"require-in-the-middle": "^7.5.0"
119120
},
120121
"devDependencies": {
121122
"@opentelemetry/api": "^1.9.1",

packages/node-core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export { processSessionIntegration } from './integrations/processSession';
2525

2626
export type { OpenTelemetryServerRuntimeOptions } from './types';
2727

28+
export { registerModuleWrapper } from './module-wrapper';
29+
2830
export {
2931
// This needs exporting so the NodeClient can be used without calling init
3032
setOpenTelemetryContextAsyncContextStrategy as setNodeAsyncContextStrategy,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Module wrapper utilities for patching Node.js modules.
3+
*
4+
* This provides a Sentry-owned alternative to OTel's registerInstrumentations(),
5+
* allowing module patching without requiring the full OTel instrumentation infrastructure.
6+
*/
7+
8+
import { Hook } from 'import-in-the-middle';
9+
import { satisfies } from './semver';
10+
import { RequireInTheMiddleSingleton, type OnRequireFn } from './singleton';
11+
import { extractPackageVersion } from './version';
12+
13+
export type { OnRequireFn };
14+
export { satisfies } from './semver';
15+
export { extractPackageVersion } from './version';
16+
17+
/** Store for module options, keyed by module name */
18+
const MODULE_OPTIONS = new Map<string, unknown>();
19+
20+
/** Options for file-level patching within a module */
21+
export interface ModuleWrapperFileOptions<TOptions = unknown> {
22+
/** Relative path within the package (e.g., 'lib/client.js') */
23+
name: string;
24+
/** Semver ranges for supported versions of the file */
25+
supportedVersions: string[];
26+
/** Function to patch the file's exports. Use getOptions() to access current options at runtime. */
27+
patch: (exports: unknown, getOptions: () => TOptions | undefined, version?: string) => unknown;
28+
}
29+
30+
/** Options for registering a module wrapper */
31+
export interface ModuleWrapperOptions<TOptions = unknown> {
32+
/** Module name to wrap (e.g., 'express', 'pg', '@prisma/client') */
33+
moduleName: string;
34+
/** Semver ranges for supported versions (e.g., ['>=4.0.0 <5.0.0']) */
35+
supportedVersions: string[];
36+
/** Function to patch the module's exports. Use getOptions() to access current options at runtime. */
37+
patch: (moduleExports: unknown, getOptions: () => TOptions | undefined, version?: string) => unknown;
38+
/** Optional array of specific files within the module to patch */
39+
files?: ModuleWrapperFileOptions<TOptions>[];
40+
/** Optional configuration options that can be updated on subsequent calls */
41+
options?: TOptions;
42+
}
43+
44+
/**
45+
* Register a module wrapper to patch a module when it's required/imported.
46+
*
47+
* This sets up hooks for both CommonJS (via require-in-the-middle) and
48+
* ESM (via import-in-the-middle) module loading.
49+
*
50+
* Calling this multiple times for the same module is safe:
51+
* - The wrapping/hooking only happens once (first call)
52+
* - Options are always updated (subsequent calls replace options)
53+
* - Use `getOptions()` in your patch function to access current options at runtime
54+
*
55+
* @param wrapperOptions - Configuration for the module wrapper
56+
*
57+
* @example
58+
* ```ts
59+
* registerModuleWrapper({
60+
* moduleName: 'express',
61+
* supportedVersions: ['>=4.0.0 <6.0.0'],
62+
* options: { customOption: true },
63+
* patch: (moduleExports, getOptions, version) => {
64+
* // getOptions() returns the current options at runtime
65+
* patchExpressModule(moduleExports, getOptions);
66+
* return moduleExports;
67+
* },
68+
* });
69+
* ```
70+
*/
71+
export function registerModuleWrapper<TOptions = unknown>(wrapperOptions: ModuleWrapperOptions<TOptions>): void {
72+
const { moduleName, supportedVersions, patch, files, options } = wrapperOptions;
73+
74+
// Always update the stored options (even if already registered)
75+
MODULE_OPTIONS.set(moduleName, options);
76+
77+
// If already registered, skip the wrapping - options have been updated above
78+
if (MODULE_OPTIONS.has(moduleName) && options === undefined) {
79+
// This means we've registered before but this call has no new options
80+
// Still skip re-registration
81+
return;
82+
}
83+
84+
// Create a getter that retrieves current options at runtime
85+
const getOptions = () => MODULE_OPTIONS.get(moduleName) as TOptions;
86+
87+
// Create the onRequire handler for CJS
88+
const onRequire: OnRequireFn = (exports, name, basedir) => {
89+
// Check if this is the main module or a file within it
90+
const isMainModule = name === moduleName;
91+
92+
if (isMainModule) {
93+
// Main module - check version and patch
94+
const version = extractPackageVersion(basedir);
95+
if (isVersionSupported(version, supportedVersions)) {
96+
return patch(exports, getOptions, version);
97+
}
98+
} else if (files) {
99+
// Check if this is one of the specified files
100+
for (const file of files) {
101+
const expectedPath = `${moduleName}/${file.name}`;
102+
if (name === expectedPath || name.endsWith(`/${expectedPath}`)) {
103+
const version = extractPackageVersion(basedir);
104+
if (isVersionSupported(version, file.supportedVersions)) {
105+
return file.patch(exports, getOptions, version);
106+
}
107+
}
108+
}
109+
}
110+
111+
return exports;
112+
};
113+
114+
// Register with CJS singleton (require-in-the-middle)
115+
const ritmSingleton = RequireInTheMiddleSingleton.getInstance();
116+
ritmSingleton.register(moduleName, onRequire);
117+
118+
// Register file hooks with the singleton as well
119+
if (files) {
120+
for (const file of files) {
121+
const filePath = `${moduleName}/${file.name}`;
122+
ritmSingleton.register(filePath, onRequire);
123+
}
124+
}
125+
126+
// Register with ESM (import-in-the-middle)
127+
// The ESM loader must be initialized before this (via initializeEsmLoader())
128+
const moduleNames = [moduleName];
129+
if (files) {
130+
for (const file of files) {
131+
moduleNames.push(`${moduleName}/${file.name}`);
132+
}
133+
}
134+
135+
new Hook(moduleNames, { internals: true }, (exports, name, basedir) => {
136+
// Convert void to undefined for compatibility
137+
const baseDirectory = basedir || undefined;
138+
const isMainModule = name === moduleName;
139+
140+
if (isMainModule) {
141+
const version = extractPackageVersion(baseDirectory);
142+
if (isVersionSupported(version, supportedVersions)) {
143+
return patch(exports, getOptions, version);
144+
}
145+
} else if (files) {
146+
for (const file of files) {
147+
const expectedPath = `${moduleName}/${file.name}`;
148+
if (name === expectedPath || name.endsWith(`/${expectedPath}`)) {
149+
const version = extractPackageVersion(baseDirectory);
150+
if (isVersionSupported(version, file.supportedVersions)) {
151+
return file.patch(exports, getOptions, version);
152+
}
153+
}
154+
}
155+
}
156+
157+
return exports;
158+
});
159+
}
160+
161+
/**
162+
* Check if a version is supported by the given semver ranges.
163+
*
164+
* @param version - The version to check (or undefined if not available)
165+
* @param supportedVersions - Array of semver range strings
166+
* @returns true if the version is supported
167+
*/
168+
function isVersionSupported(version: string | undefined, supportedVersions: string[]): boolean {
169+
// If no version is available (e.g., core modules), we allow patching
170+
if (!version) {
171+
return true;
172+
}
173+
174+
// Check if the version satisfies any of the supported ranges
175+
for (const range of supportedVersions) {
176+
if (satisfies(version, range)) {
177+
return true;
178+
}
179+
}
180+
181+
return false;
182+
}

0 commit comments

Comments
 (0)