diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index 4b51eea9a053..b5e73dcfbc05 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -321,6 +321,25 @@ export function registerDiagnosticsChannelInjection(): void { const hookSpecifier = hookFound ? pathToFileURL(hookPath).href : '@sentry/server-runtime-injection/hook'; + if (hookFound) { + // Only `build/esm/package.json` marks the hook and its chunks as ESM. Deployments can drop + // that file, and Vercel disables syntax detection, so Node then parses the hook as CommonJS + // and fails on its `export`s. + // + // This registers a small loader hook before the real one. Its `load` sets + // `format: 'module'` for every URL inside our ESM build directory and passes all other URLs + // through unchanged. Hooks registered earlier also apply when Node loads hook modules + // registered later, so the real hook and its chunks load as ESM even without the marker. + // The loader is inlined as a `data:` URL because Node always loads those as ESM, so it + // cannot fail the same way and needs no file of its own. + const esmDirUrl = `${pathToFileURL(dirname(hookPath)).href}/`; + mod.register( + `data:text/javascript,${encodeURIComponent( + `const d=${JSON.stringify(esmDirUrl)};export async function load(u,c,n){return n(u,u.startsWith(d)?{...c,format:'module'}:c)}`, + )}`, + ); + } + mod.register(hookSpecifier, { parentURL, data: { instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS, diagnosticsPort }, diff --git a/packages/server-runtime-injection/test/esmMarker.test.ts b/packages/server-runtime-injection/test/esmMarker.test.ts new file mode 100644 index 000000000000..0fe5445f3107 --- /dev/null +++ b/packages/server-runtime-injection/test/esmMarker.test.ts @@ -0,0 +1,42 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +/** + * Deployments can drop `build/esm/package.json` (the only `"type": "module"` marker for the hook), + * and Vercel runs Node with syntax detection disabled, so the loader thread then parses the hook as + * CommonJS. Runs against the built files, so it needs the package built. + * + * The copy sits inside this package so `@sentry/*` still resolves from its `node_modules`. + */ +const copyDir = mkdtempSync(join(__dirname, '.esm-marker-')); +cpSync(join(__dirname, '..', 'build'), join(copyDir, 'build'), { recursive: true }); +rmSync(join(copyDir, 'build', 'esm', 'package.json')); + +afterAll(() => { + rmSync(copyDir, { recursive: true, force: true }); +}); + +describe('without the build/esm package.json marker', () => { + it('registers the ESM loader hook', { timeout: 30_000 }, () => { + // Removing `registerHooks` forces the `Module.register()` path, the one that loads the hook on + // Node < 24.13, whatever Node version runs the test. + const script = ` + require('node:module').registerHooks = undefined; + const { registerDiagnosticsChannelInjection } = require(${JSON.stringify(join(copyDir, 'build', 'cjs', 'register.js'))}); + registerDiagnosticsChannelInjection(); + import('data:text/javascript,export default 1').then(() => { + const marker = globalThis.__SENTRY__?.__SENTRY_ORCHESTRION__ ?? globalThis.__SENTRY_ORCHESTRION__; + process.stdout.write(JSON.stringify(marker)); + }); + `; + + const { stdout, stderr } = spawnSync(process.execPath, ['--no-experimental-detect-module', '-e', script], { + encoding: 'utf8', + }); + + expect(stderr).not.toContain('Failed to register diagnostics-channel injection hooks'); + expect(JSON.parse(stdout)).toEqual({ runtime: [] }); + }); +});