Repository navigation
fix(server-runtime-injection): Bundle the ESM loader hook into one self-contained file #26271
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e4fca31
a37375e
c18a94a
59917f8
36d574a
1a39557
39a359b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,7 +35,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu | |||||||||||||
| // (Turbopack renames it, producing `.require is not a function` crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing | ||||||||||||||
| // optional/missing) and has no require cycles that depend on lazy evaluation. | ||||||||||||||
| const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto', strictRequires: false }; | ||||||||||||||
| const commonJSPlugin = commonjs(commonJSOptions); | ||||||||||||||
|
|
||||||||||||||
| // Replace `debug` with `src/debug.ts`, which logs through the Sentry logger. `debug` and the | ||||||||||||||
| // `supports-color` it loads read `process.env` at import time, which throws on Deno without env | ||||||||||||||
|
|
@@ -71,6 +70,15 @@ const guardTracingDump = { | |||||||||||||
| }, | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| // The commonjs-converted vendored dependencies import Node builtins as default imports | ||||||||||||||
| // (`require('path')` → default import of `path`), and builtins have no `.default` in CJS — so | ||||||||||||||
| // builtins need `'default'` interop (the module itself is the default export). | ||||||||||||||
| const builtinInterop = id => (id && (id.startsWith('node:') || builtinModules.includes(id)) ? 'default' : 'esModule'); | ||||||||||||||
|
|
||||||||||||||
| // The vendored dependencies import builtins unprefixed (`import … from 'tty'`), which Deno rejects | ||||||||||||||
| // and vite-node (Node 26) misresolves as a relative path. Emit them `node:`-prefixed. | ||||||||||||||
| const builtinPaths = Object.fromEntries(builtinModules.map(m => [m, `node:${m}`])); | ||||||||||||||
|
|
||||||||||||||
| // Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the | ||||||||||||||
| // repo root, so `preserveModules` names our own files `packages/server-runtime-injection/src/...` — | ||||||||||||||
| // strip that prefix to keep the `build/cjs/register.js` layout the `exports` map points at. A vendored | ||||||||||||||
|
|
@@ -107,29 +115,42 @@ const orchestrionRuntimeHooks = [ | |||||||||||||
|
|
||||||||||||||
| export default [ | ||||||||||||||
| ...orchestrionRuntimeHooks, | ||||||||||||||
| // `register.ts` backs `./register` (the Node SDK `require`s it synchronously from `Sentry.init()`). | ||||||||||||||
| ...makeNPMConfigVariants( | ||||||||||||||
| makeBaseNPMConfig({ | ||||||||||||||
| // `register.ts` backs `./register` (the Node SDK `require`s it synchronously from | ||||||||||||||
| // `Sentry.init()`); `hook.mjs` backs `./hook` (the async `Module.register()` target, loaded on | ||||||||||||||
| // Node's ESM loader thread, which cannot resolve bare specifiers into the vendored chunks — but | ||||||||||||||
| // relative imports work, so it shares the ESM build's vendored chunks). `./hook` only maps its | ||||||||||||||
| // `import` condition, so the `build/cjs` copy is unused. | ||||||||||||||
| entrypoints: ['src/register.ts', 'src/hook.mjs'], | ||||||||||||||
| entrypoints: ['src/register.ts'], | ||||||||||||||
| packageSpecificConfig: { | ||||||||||||||
| plugins: [debugShim, guardTracingDump, commonJSPlugin, thirdPartyLicensePlugin], | ||||||||||||||
| plugins: [debugShim, guardTracingDump, commonjs(commonJSOptions), thirdPartyLicensePlugin], | ||||||||||||||
|
mydea marked this conversation as resolved.
Resolved
|
||||||||||||||
| output: { | ||||||||||||||
| exports: 'named', | ||||||||||||||
| preserveModules: true, | ||||||||||||||
| entryFileNames: sanitizedFileNames, | ||||||||||||||
| // The commonjs-converted vendored dependencies import Node builtins as default imports | ||||||||||||||
| // (`require('path')` → default import of `path`), and builtins have no `.default` in CJS — | ||||||||||||||
| // so builtins need `'default'` interop (the module itself is the default export). | ||||||||||||||
| interop: id => (id && (id.startsWith('node:') || builtinModules.includes(id)) ? 'default' : 'esModule'), | ||||||||||||||
| // The vendored dependencies import builtins unprefixed (`import … from 'tty'`), which Deno | ||||||||||||||
| // rejects and vite-node (Node 26) misresolves as a relative path. Emit them `node:`-prefixed. | ||||||||||||||
| paths: Object.fromEntries(builtinModules.map(m => [m, `node:${m}`])), | ||||||||||||||
| interop: builtinInterop, | ||||||||||||||
| paths: builtinPaths, | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
| }), | ||||||||||||||
| ), | ||||||||||||||
| // `hook.mjs` backs `./hook`, the async `Module.register()` target. `register.ts` hands it over by | ||||||||||||||
| // runtime path, so build tracers ship `build/esm/hook.js` but may treat it as an opaque asset | ||||||||||||||
| // and skip its imports (Turbopack does). It is therefore one file that imports only Node builtins. | ||||||||||||||
| // Its vendored dependencies duplicate the ones in the preserved-modules build above. It keeps the | ||||||||||||||
| // real `debug` rather than `debugShim`: `Module.register()` never runs on Deno, and `src/debug.ts` | ||||||||||||||
| // would pull in `@sentry/core`'s ESM build. | ||||||||||||||
| ...makeNPMConfigVariants( | ||||||||||||||
| makeBaseNPMConfig({ | ||||||||||||||
| entrypoints: ['src/hook.mjs'], | ||||||||||||||
| packageSpecificConfig: { | ||||||||||||||
| plugins: [guardTracingDump, commonjs(commonJSOptions), thirdPartyLicensePlugin], | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The hook bundle runs
Reproduced on Node 26.5 with the forced Suggestion: in the hook build only, resolve |
||||||||||||||
| output: { | ||||||||||||||
| exports: 'named', | ||||||||||||||
| preserveModules: false, | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The PR says the package grows "by about 1.6 MB". I packed this branch and compared it with the published 11.12.0:
The new map holds 2.3 MB of Suggestion: fix the number in the PR description. Then decide whether the hook needs a full source map. Node only uses it with |
||||||||||||||
| entryFileNames: 'hook.js', | ||||||||||||||
| interop: builtinInterop, | ||||||||||||||
| paths: builtinPaths, | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
| }), | ||||||||||||||
| { emitCjs: false }, | ||||||||||||||
| ), | ||||||||||||||
| ]; | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { spawnSync } from 'node:child_process'; | ||
| import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; | ||
| import { builtinModules } from 'node:module'; | ||
| import { join } from 'node:path'; | ||
| import { parseModule } from 'meriyah'; | ||
| import { afterAll, describe, expect, it } from 'vitest'; | ||
|
|
||
| /** | ||
| * `register.ts` hands the ESM loader hook to `Module.register()` by runtime path, so build tracers | ||
| * ship `build/esm/hook.js` without following its imports (Turbopack on Next.js does this). Anything | ||
| * the hook imports beyond Node builtins is then missing on the loader thread. Runs against the | ||
| * built files, so it needs the package built. | ||
| */ | ||
| const buildDir = join(__dirname, '..', 'build'); | ||
|
|
||
| function collectSpecifiers(node: unknown, specifiers: string[]): void { | ||
| if (!node || typeof node !== 'object') { | ||
| return; | ||
| } | ||
| if (Array.isArray(node)) { | ||
| node.forEach(child => collectSpecifiers(child, specifiers)); | ||
| return; | ||
| } | ||
| const { type, source } = node as { type?: string; source?: { type?: string; value?: unknown } }; | ||
| if ( | ||
| (type === 'ImportDeclaration' || | ||
| type === 'ExportAllDeclaration' || | ||
| type === 'ExportNamedDeclaration' || | ||
| type === 'ImportExpression') && | ||
| source | ||
| ) { | ||
| specifiers.push(source.type === 'Literal' ? String(source.value) : '<computed>'); | ||
| } | ||
| Object.values(node).forEach(child => collectSpecifiers(child, specifiers)); | ||
| } | ||
|
|
||
| describe('the ESM loader hook', () => { | ||
| it('imports nothing but Node builtins', () => { | ||
| const specifiers: string[] = []; | ||
| collectSpecifiers(parseModule(readFileSync(join(buildDir, 'esm', 'hook.js'), 'utf8')), specifiers); | ||
|
|
||
| const builtins = new Set(builtinModules); | ||
| const nonBuiltins = specifiers.filter(s => !(s.startsWith('node:') && builtins.has(s.slice('node:'.length)))); | ||
|
|
||
| expect(specifiers.length).toBeGreaterThan(0); | ||
| expect(nonBuiltins).toEqual([]); | ||
| }); | ||
|
|
||
| describe('when a deployment ships only the hook from the ESM build', () => { | ||
| // The copy sits inside this package so `register.js` still resolves `@sentry/*` from its | ||
| // `node_modules`. | ||
| const copyDir = mkdtempSync(join(__dirname, '.hook-only-')); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion: create it in |
||
| cpSync(join(buildDir, 'cjs'), join(copyDir, 'build', 'cjs'), { recursive: true }); | ||
| mkdirSync(join(copyDir, 'build', 'esm')); | ||
| cpSync(join(buildDir, 'esm', 'hook.js'), join(copyDir, 'build', 'esm', 'hook.js')); | ||
|
|
||
| afterAll(() => { | ||
| rmSync(copyDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('registers it', { 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], { | ||
|
Comment on lines
+68
to
+74
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion: drop the |
||
| encoding: 'utf8', | ||
| }); | ||
|
|
||
| expect(stderr).not.toContain('Failed to register diagnostics-channel injection hooks'); | ||
| expect(JSON.parse(stdout)).toEqual({ runtime: [] }); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nothing measures the loader-thread code any more
Before, the entry measured
hook.jsplusimport-hook.mjs. Now it measures onlyimport-hook.mjs, andregister.tsreaches the hook throughjoin(), which webpack does not follow. So the 540 kB hook, which everyModule.register()user loads, has no size check at all. If a future bump of@apm-js-collab/*or a new dependency doubles it, CI will not notice. The PR's "81.85 kB vs 82.32 kB" improvement comes from not counting that code, not from loading less.Suggestion: add a second entry, for example
@sentry/server-runtime-injection hook (loader thread)withpath: 'packages/server-runtime-injection/build/esm/hook.js'and the sameignorelist, so changes to that file stay visible. (The bot comment "One or more gzipped bundles increased by more than 500 bytes" does not match the current table, so it is probably from an earlier push.)