From 3197ab36440c9f9af5ddf0c0b703ab680fc81e36 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 23 Sep 2026 17:47:52 +0200 Subject: [PATCH] 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 --- .../server-runtime-injection/src/register.ts | 71 +++++++++++++--- .../test/register.test.ts | 85 ++++++++++++++++++- 2 files changed, 139 insertions(+), 17 deletions(-) diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index 38b55efe48d7..d84c2d76ec97 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -1,5 +1,5 @@ import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import * as Module from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -28,20 +28,63 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } +/** `"type"` of the nearest `package.json`, keyed by the directory the lookup started in. */ +const packageTypeByDir = new Map(); + +function getPackageType(dir: string): string | undefined { + if (packageTypeByDir.has(dir)) { + return packageTypeByDir.get(dir); + } + + let type: string | undefined; + const packageJsonPath = join(dir, 'package.json'); + if (existsSync(packageJsonPath)) { + try { + type = (JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { type?: string }).type; + } catch { + type = undefined; + } + } else if (dirname(dir) !== dir) { + type = getPackageType(dirname(dir)); + } + + packageTypeByDir.set(dir, type); + return type; +} + +/** The `format` Node would report for `url`, for the formats Deno leaves out. */ +function getMissingDenoFormat(url: string): string | undefined { + if (url.endsWith('.json')) { + return 'json'; + } + if (url.endsWith('.mjs')) { + return 'module'; + } + if (url.startsWith('file:') && url.endsWith('.js') && getPackageType(dirname(fileURLToPath(url))) === 'module') { + return 'module'; + } + return undefined; +} + /** - * Deno's `nextLoad` reports no `format` for a `.json` file, where Node reports `'json'`. With any - * load hook installed, Deno's CJS loader then compiles the JSON as JavaScript and `require()` of it - * throws `SyntaxError: Unexpected token ':'`. Restoring the format is enough, and only Deno needs - * it: on Node the format is never missing. + * Deno's `nextLoad` reports no `format` for a `.json` file or an ES module, where Node reports + * `'json'` or `'module'`. Without the format, Deno's CJS loader compiles JSON as JavaScript + * (`SyntaxError: Unexpected token ':'`), and the transform treats an ES module as CommonJS and + * injects a `require()` into it (`ReferenceError: require is not defined`). The format is restored + * on the `nextLoad` result, so the transform sees it too. Only Deno needs this. */ -function withDenoJsonFormat(loadHook: Function): Function { - return (url: string, context: unknown, nextLoad: Function) => { - const result = loadHook(url, context, nextLoad) as { format?: string }; - if (result?.format === undefined && url.endsWith('.json')) { - result.format = 'json'; - } - return result; - }; +function withDenoFormats(loadHook: Function): Function { + return (url: string, context: unknown, nextLoad: Function) => + loadHook(url, context, (nextUrl: string, nextContext: unknown) => { + const result = nextLoad(nextUrl, nextContext) as { format?: string | null } | undefined; + if (result && result.format == null) { + const format = getMissingDenoFormat(nextUrl); + if (format) { + result.format = format; + } + } + return result; + }); } /** @@ -181,7 +224,7 @@ export function registerDiagnosticsChannelInjection(): void { try { if (typeof mod.registerHooks === 'function' && stableSyncHooks) { initialize({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }); - mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoJsonFormat(load) : load }); + mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoFormats(load) : load }); debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 diff --git a/packages/server-runtime-injection/test/register.test.ts b/packages/server-runtime-injection/test/register.test.ts index 18307c0073eb..bab7e6c56c8a 100644 --- a/packages/server-runtime-injection/test/register.test.ts +++ b/packages/server-runtime-injection/test/register.test.ts @@ -1,15 +1,27 @@ import type * as SentryCore from '@sentry/core'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import type * as NodeModule from 'node:module'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +type LoadResult = { format?: string | null }; +type LoadHook = (url: string, context: unknown, nextLoad: (url: string, context: unknown) => LoadResult) => unknown; // The registration installs real Node module hooks, which we neither want nor need here. Stub the // tracing-hooks surface so the tests can drive the diagnostics callback directly, and neuter // `node:module`'s hook installers: on Node 24.13+/26 the stable-sync-hooks path would otherwise call // the real `Module.registerHooks({ resolve, load })` with the mocked (undefined-returning) callbacks, // leaving a broken resolve hook installed process-wide that crashes vitest's next dynamic `import()`. +const registerHooksMock = vi.fn<(options: { load: LoadHook; resolve: unknown }) => void>(); vi.mock('node:module', async importOriginal => { const actual = await importOriginal(); - return { ...actual, registerHooks: vi.fn(), register: vi.fn() }; + return { + ...actual, + registerHooks: (options: { load: LoadHook; resolve: unknown }) => registerHooksMock(options), + register: vi.fn(), + }; }); const setDiagnosticsHookMock = vi.fn<(cb: DiagnosticsCallback) => void>(); @@ -21,9 +33,10 @@ vi.mock('@apm-js-collab/tracing-hooks', () => ({ patch(): void {} }, })); +const loadMock = vi.fn(); vi.mock('@apm-js-collab/tracing-hooks/hook-sync.mjs', () => ({ initialize: vi.fn(), - load: vi.fn(), + load: (...args: Parameters) => loadMock(...args), resolve: vi.fn(), createDiagnosticsPort: vi.fn(), })); @@ -192,3 +205,69 @@ describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', expect(setDiagnosticsHookMock).toHaveBeenCalledTimes(1); }); }); + +describe('registerDiagnosticsChannelInjection - Deno module formats', () => { + let fixtureDir: string; + let registerDiagnosticsChannelInjection: typeof RegisterModule.registerDiagnosticsChannelInjection; + let loadHook: LoadHook; + + beforeAll(() => { + fixtureDir = mkdtempSync(join(tmpdir(), 'sentry-deno-formats-')); + mkdirSync(join(fixtureDir, 'esm-package', 'lib'), { recursive: true }); + writeFileSync(join(fixtureDir, 'esm-package', 'package.json'), JSON.stringify({ type: 'module' })); + writeFileSync(join(fixtureDir, 'esm-package', 'lib', 'index.js'), 'export default 1;'); + mkdirSync(join(fixtureDir, 'cjs-package'), { recursive: true }); + writeFileSync(join(fixtureDir, 'cjs-package', 'package.json'), JSON.stringify({})); + writeFileSync(join(fixtureDir, 'cjs-package', 'index.js'), 'module.exports = 1;'); + }); + + afterAll(() => { + rmSync(fixtureDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + (globalThis as { Deno?: unknown }).Deno = { version: { deno: '2.8.3' } }; + vi.resetModules(); + registerHooksMock.mockClear(); + // The transform reads the format from what `nextLoad` returns, so the stub forwards to it. + loadMock.mockImplementation((url, context, nextLoad) => nextLoad(url, context)); + + ({ registerDiagnosticsChannelInjection } = await import('../src/register')); + registerDiagnosticsChannelInjection(); + + const [options] = registerHooksMock.mock.lastCall ?? []; + if (!options) { + throw new Error('registerDiagnosticsChannelInjection() did not call Module.registerHooks()'); + } + loadHook = options.load; + }); + + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + delete (globalThis as { Deno?: unknown }).Deno; + loadMock.mockReset(); + }); + + it.each([ + ['a `.js` file in a `"type": "module"` package', 'esm-package/lib/index.js', 'module'], + ['an `.mjs` file', 'cjs-package/other.mjs', 'module'], + ['a `.json` file', 'cjs-package/package.json', 'json'], + ])('restores the format Deno leaves out for %s', (_label, file, format) => { + const url = pathToFileURL(join(fixtureDir, file)).href; + + expect(loadHook(url, {}, () => ({ format: null }))).toEqual({ format }); + }); + + it('keeps the format missing for a `.js` file in a package without `"type": "module"`', () => { + const url = pathToFileURL(join(fixtureDir, 'cjs-package', 'index.js')).href; + + expect(loadHook(url, {}, () => ({ format: null }))).toEqual({ format: null }); + }); + + it('keeps a format that Deno reports', () => { + const url = pathToFileURL(join(fixtureDir, 'esm-package', 'lib', 'index.js')).href; + + expect(loadHook(url, {}, () => ({ format: 'commonjs' }))).toEqual({ format: 'commonjs' }); + }); +});