Skip to content

Commit 12e0fce

Browse files
JPeer264claude
andcommitted
fix(server-runtime-injection): Keep ES modules working on Deno
Deno's module hooks report no `format` for an ES module, so the orchestrion transform treated it as CommonJS and injected a `require()` that throws when the module loads. This broke `@sentry/node` on Deno for the ESM builds of libraries it instruments, such as the AI SDKs and `postgres`. The Deno load hook now restores the format of `.mjs` files and of `.js` files in a `"type": "module"` package, the same way it already did for JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c4ddeec commit 12e0fce

2 files changed

Lines changed: 139 additions & 17 deletions

File tree

‎packages/server-runtime-injection/src/register.ts‎

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core';
2-
import { existsSync } from 'node:fs';
2+
import { existsSync, readFileSync } from 'node:fs';
33
import * as Module from 'node:module';
44
import { dirname, join } from 'node:path';
55
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -28,20 +28,63 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean {
2828
return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13);
2929
}
3030

31+
/** `"type"` of the nearest `package.json`, keyed by the directory the lookup started in. */
32+
const packageTypeByDir = new Map<string, string | undefined>();
33+
34+
function getPackageType(dir: string): string | undefined {
35+
if (packageTypeByDir.has(dir)) {
36+
return packageTypeByDir.get(dir);
37+
}
38+
39+
let type: string | undefined;
40+
const packageJsonPath = join(dir, 'package.json');
41+
if (existsSync(packageJsonPath)) {
42+
try {
43+
type = (JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { type?: string }).type;
44+
} catch {
45+
type = undefined;
46+
}
47+
} else if (dirname(dir) !== dir) {
48+
type = getPackageType(dirname(dir));
49+
}
50+
51+
packageTypeByDir.set(dir, type);
52+
return type;
53+
}
54+
55+
/** The `format` Node would report for `url`, for the formats Deno leaves out. */
56+
function getMissingDenoFormat(url: string): string | undefined {
57+
if (url.endsWith('.json')) {
58+
return 'json';
59+
}
60+
if (url.endsWith('.mjs')) {
61+
return 'module';
62+
}
63+
if (url.startsWith('file:') && url.endsWith('.js') && getPackageType(dirname(fileURLToPath(url))) === 'module') {
64+
return 'module';
65+
}
66+
return undefined;
67+
}
68+
3169
/**
32-
* Deno's `nextLoad` reports no `format` for a `.json` file, where Node reports `'json'`. With any
33-
* load hook installed, Deno's CJS loader then compiles the JSON as JavaScript and `require()` of it
34-
* throws `SyntaxError: Unexpected token ':'`. Restoring the format is enough, and only Deno needs
35-
* it: on Node the format is never missing.
70+
* Deno's `nextLoad` reports no `format` for a `.json` file or an ES module, where Node reports
71+
* `'json'` or `'module'`. Without the format, Deno's CJS loader compiles JSON as JavaScript
72+
* (`SyntaxError: Unexpected token ':'`), and the transform treats an ES module as CommonJS and
73+
* injects a `require()` into it (`ReferenceError: require is not defined`). The format is restored
74+
* on the `nextLoad` result, so the transform sees it too. Only Deno needs this.
3675
*/
37-
function withDenoJsonFormat(loadHook: Function): Function {
38-
return (url: string, context: unknown, nextLoad: Function) => {
39-
const result = loadHook(url, context, nextLoad) as { format?: string };
40-
if (result?.format === undefined && url.endsWith('.json')) {
41-
result.format = 'json';
42-
}
43-
return result;
44-
};
76+
function withDenoFormats(loadHook: Function): Function {
77+
return (url: string, context: unknown, nextLoad: Function) =>
78+
loadHook(url, context, (nextUrl: string, nextContext: unknown) => {
79+
const result = nextLoad(nextUrl, nextContext) as { format?: string | null } | undefined;
80+
if (result && result.format == null) {
81+
const format = getMissingDenoFormat(nextUrl);
82+
if (format) {
83+
result.format = format;
84+
}
85+
}
86+
return result;
87+
});
4588
}
4689

4790
/**
@@ -181,7 +224,7 @@ export function registerDiagnosticsChannelInjection(): void {
181224
try {
182225
if (typeof mod.registerHooks === 'function' && stableSyncHooks) {
183226
initialize({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS });
184-
mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoJsonFormat(load) : load });
227+
mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoFormats(load) : load });
185228
debug.log('Registered diagnostics-channel injection via Module.registerHooks()');
186229
} else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) {
187230
// `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0

‎packages/server-runtime-injection/test/register.test.ts‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
import type * as SentryCore from '@sentry/core';
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
23
import type * as NodeModule from 'node:module';
3-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { pathToFileURL } from 'node:url';
7+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
8+
9+
type LoadResult = { format?: string | null };
10+
type LoadHook = (url: string, context: unknown, nextLoad: (url: string, context: unknown) => LoadResult) => unknown;
411

512
// The registration installs real Node module hooks, which we neither want nor need here. Stub the
613
// tracing-hooks surface so the tests can drive the diagnostics callback directly, and neuter
714
// `node:module`'s hook installers: on Node 24.13+/26 the stable-sync-hooks path would otherwise call
815
// the real `Module.registerHooks({ resolve, load })` with the mocked (undefined-returning) callbacks,
916
// leaving a broken resolve hook installed process-wide that crashes vitest's next dynamic `import()`.
17+
const registerHooksMock = vi.fn<(options: { load: LoadHook; resolve: unknown }) => void>();
1018
vi.mock('node:module', async importOriginal => {
1119
const actual = await importOriginal<typeof NodeModule>();
12-
return { ...actual, registerHooks: vi.fn(), register: vi.fn() };
20+
return {
21+
...actual,
22+
registerHooks: (options: { load: LoadHook; resolve: unknown }) => registerHooksMock(options),
23+
register: vi.fn(),
24+
};
1325
});
1426

1527
const setDiagnosticsHookMock = vi.fn<(cb: DiagnosticsCallback) => void>();
@@ -21,9 +33,10 @@ vi.mock('@apm-js-collab/tracing-hooks', () => ({
2133
patch(): void {}
2234
},
2335
}));
36+
const loadMock = vi.fn<LoadHook>();
2437
vi.mock('@apm-js-collab/tracing-hooks/hook-sync.mjs', () => ({
2538
initialize: vi.fn(),
26-
load: vi.fn(),
39+
load: (...args: Parameters<LoadHook>) => loadMock(...args),
2740
resolve: vi.fn(),
2841
createDiagnosticsPort: vi.fn(),
2942
}));
@@ -192,3 +205,69 @@ describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection',
192205
expect(setDiagnosticsHookMock).toHaveBeenCalledTimes(1);
193206
});
194207
});
208+
209+
describe('registerDiagnosticsChannelInjection - Deno module formats', () => {
210+
let fixtureDir: string;
211+
let registerDiagnosticsChannelInjection: typeof RegisterModule.registerDiagnosticsChannelInjection;
212+
let loadHook: LoadHook;
213+
214+
beforeAll(() => {
215+
fixtureDir = mkdtempSync(join(tmpdir(), 'sentry-deno-formats-'));
216+
mkdirSync(join(fixtureDir, 'esm-package', 'lib'), { recursive: true });
217+
writeFileSync(join(fixtureDir, 'esm-package', 'package.json'), JSON.stringify({ type: 'module' }));
218+
writeFileSync(join(fixtureDir, 'esm-package', 'lib', 'index.js'), 'export default 1;');
219+
mkdirSync(join(fixtureDir, 'cjs-package'), { recursive: true });
220+
writeFileSync(join(fixtureDir, 'cjs-package', 'package.json'), JSON.stringify({}));
221+
writeFileSync(join(fixtureDir, 'cjs-package', 'index.js'), 'module.exports = 1;');
222+
});
223+
224+
afterAll(() => {
225+
rmSync(fixtureDir, { recursive: true, force: true });
226+
});
227+
228+
beforeEach(async () => {
229+
delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__;
230+
(globalThis as { Deno?: unknown }).Deno = { version: { deno: '2.8.3' } };
231+
vi.resetModules();
232+
registerHooksMock.mockClear();
233+
// The transform reads the format from what `nextLoad` returns, so the stub forwards to it.
234+
loadMock.mockImplementation((url, context, nextLoad) => nextLoad(url, context));
235+
236+
({ registerDiagnosticsChannelInjection } = await import('../src/register'));
237+
registerDiagnosticsChannelInjection();
238+
239+
const [options] = registerHooksMock.mock.lastCall ?? [];
240+
if (!options) {
241+
throw new Error('registerDiagnosticsChannelInjection() did not call Module.registerHooks()');
242+
}
243+
loadHook = options.load;
244+
});
245+
246+
afterEach(() => {
247+
delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__;
248+
delete (globalThis as { Deno?: unknown }).Deno;
249+
loadMock.mockReset();
250+
});
251+
252+
it.each([
253+
['a `.js` file in a `"type": "module"` package', 'esm-package/lib/index.js', 'module'],
254+
['an `.mjs` file', 'cjs-package/other.mjs', 'module'],
255+
['a `.json` file', 'cjs-package/package.json', 'json'],
256+
])('restores the format Deno leaves out for %s', (_label, file, format) => {
257+
const url = pathToFileURL(join(fixtureDir, file)).href;
258+
259+
expect(loadHook(url, {}, () => ({ format: null }))).toEqual({ format });
260+
});
261+
262+
it('keeps the format missing for a `.js` file in a package without `"type": "module"`', () => {
263+
const url = pathToFileURL(join(fixtureDir, 'cjs-package', 'index.js')).href;
264+
265+
expect(loadHook(url, {}, () => ({ format: null }))).toEqual({ format: null });
266+
});
267+
268+
it('keeps a format that Deno reports', () => {
269+
const url = pathToFileURL(join(fixtureDir, 'esm-package', 'lib', 'index.js')).href;
270+
271+
expect(loadHook(url, {}, () => ({ format: 'commonjs' }))).toEqual({ format: 'commonjs' });
272+
});
273+
});

0 commit comments

Comments
 (0)