diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index d4c5123e91e4..d03b8656ffc1 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -84,6 +84,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n return false; } +export interface ChunkGraphNode { + fileName: string; + isEntry?: boolean; + imports?: string[]; +} + +const initialChunksCache = new WeakMap, Set>(); + +function getInitialChunks(chunks: Record): Set { + const cached = initialChunksCache.get(chunks); + if (cached) { + return cached; + } + + const initial = new Set(); + const queue = Object.values(chunks).filter(chunk => chunk.isEntry); + + for (let chunk = queue.shift(); chunk; chunk = queue.shift()) { + if (initial.has(chunk.fileName)) { + continue; + } + initial.add(chunk.fileName); + for (const imported of chunk.imports ?? []) { + const importedChunk = chunks[imported]; + if (importedChunk) { + queue.push(importedChunk); + } + } + } + + initialChunksCache.set(chunks, initial); + return initial; +} + +/** + * Whether a chunk loads at startup: it is an entry chunk, or an entry chunk + * reaches it through static imports. Chunks that only dynamic imports reach + * load later, after startup code already ran. + * + * Without the chunk graph every chunk counts as initial. + */ +export function isInitialChunk( + chunk: Pick, + chunks: Record | undefined, +): boolean { + if (chunk.isEntry || !chunks) { + return true; + } + + return getInitialChunks(chunks).has(chunk.fileName); +} + export { globFiles } from './glob'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index b07ed36f4514..1771bff95fd0 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -1,9 +1,12 @@ -import type { Options } from '../core'; +/* oxlint-disable max-lines */ + +import type { ChunkGraphNode, Options } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, generateModuleMetadataInjectorCode, isJsFile, + isInitialChunk, shouldSkipCodeInjection, getDebugIdSnippet, stringToUUID, @@ -141,7 +144,8 @@ export function _rollupPluginInternal( const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -150,7 +154,7 @@ export function _rollupPluginInternal( 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -159,7 +163,7 @@ export function _rollupPluginInternal( } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -239,9 +243,9 @@ export function _rollupPluginInternal( function renderChunk( code: string, - chunk: { fileName: string; facadeModuleId?: string | null }, + chunk: { fileName: string; facadeModuleId?: string | null; isEntry?: boolean }, _?: unknown, - meta?: { magicString?: MagicString }, + meta?: { magicString?: MagicString; chunks?: Record }, ): { code: string; map?: SourceMap; @@ -255,7 +259,13 @@ export function _rollupPluginInternal( return null; } - const injectCode = staticInjectionCode.clone(); + const injectCode = new CodeInjection(); + + if (isInitialChunk(chunk, meta?.chunks)) { + injectCode.append(releaseInjectionCode); + } + + injectCode.append(chunkInjectionCode); if (sourcemapsEnabled && !hasExistingDebugID(code)) { const debugId = stringToUUID(code); // generate a deterministic debug ID diff --git a/packages/bundler-plugins/src/webpack/webpack4and5.ts b/packages/bundler-plugins/src/webpack/webpack4and5.ts index 42c635e37bb2..8e00bb93f29c 100644 --- a/packages/bundler-plugins/src/webpack/webpack4and5.ts +++ b/packages/bundler-plugins/src/webpack/webpack4and5.ts @@ -41,6 +41,7 @@ try { interface BannerPluginCallbackArg { chunk?: { hash?: string; + canBeInitial?: () => boolean; contentHash?: { javascript?: string; }; @@ -208,7 +209,8 @@ export function sentryWebpackPluginFactory({ } const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -217,7 +219,7 @@ export function sentryWebpackPluginFactory({ 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -226,7 +228,7 @@ export function sentryWebpackPluginFactory({ } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -249,7 +251,7 @@ export function sentryWebpackPluginFactory({ const DefinePlugin = compiler?.webpack?.DefinePlugin || UnsafeDefinePlugin; // Add BannerPlugin for code injection (release, metadata, debug IDs) - if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { + if (!releaseInjectionCode.isEmpty() || !chunkInjectionCode.isEmpty() || sourcemapsEnabled) { if (!BannerPlugin) { logger.warn( 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', @@ -261,7 +263,11 @@ export function sentryWebpackPluginFactory({ raw: true, include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, banner: (arg?: BannerPluginCallbackArg) => { - const codeToInject = staticInjectionCode.clone(); + const codeToInject = new CodeInjection(); + if (arg?.chunk?.canBeInitial?.() ?? true) { + codeToInject.append(releaseInjectionCode); + } + codeToInject.append(chunkInjectionCode); if (sourcemapsEnabled) { const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; const debugId = hash ? stringToUUID(hash) : randomUUID(); diff --git a/packages/bundler-plugins/test/core/index.test.ts b/packages/bundler-plugins/test/core/index.test.ts index 7ccc24723bcf..e62daa8211a0 100644 --- a/packages/bundler-plugins/test/core/index.test.ts +++ b/packages/bundler-plugins/test/core/index.test.ts @@ -1,4 +1,4 @@ -import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core'; +import { createComponentNameAnnotateHooks, getDebugIdSnippet, isInitialChunk } from '../../src/core'; import { containsOnlyImports } from '../../src/core/utils'; import { describe, it, expect } from 'vitest'; @@ -38,6 +38,31 @@ describe('getDebugIdSnippet', () => { }); }); +describe('isInitialChunk', () => { + const chunks = { + 'entry.js': { fileName: 'entry.js', isEntry: true, imports: ['shared.js', 'external'] }, + 'shared.js': { fileName: 'shared.js', imports: ['vendor.js'] }, + 'vendor.js': { fileName: 'vendor.js', imports: ['shared.js'] }, + 'route.js': { fileName: 'route.js', imports: ['route-shared.js'] }, + 'route-shared.js': { fileName: 'route-shared.js', imports: [] }, + }; + + it('marks entry chunks and chunks they reach through static imports as initial', () => { + expect(isInitialChunk(chunks['entry.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['shared.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['vendor.js'], chunks)).toBe(true); + }); + + it('does not mark chunks that only dynamic imports reach as initial', () => { + expect(isInitialChunk(chunks['route.js'], chunks)).toBe(false); + expect(isInitialChunk(chunks['route-shared.js'], chunks)).toBe(false); + }); + + it('treats every chunk as initial without a chunk graph', () => { + expect(isInitialChunk({ fileName: 'route.js' }, undefined)).toBe(true); + }); +}); + describe('containsOnlyImports', () => { describe('should return true (import-only code)', () => { it.each([ diff --git a/packages/bundler-plugins/test/rollup/release-injection.test.ts b/packages/bundler-plugins/test/rollup/release-injection.test.ts new file mode 100644 index 000000000000..af240efc39f0 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/release-injection.test.ts @@ -0,0 +1,108 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } from 'rollup'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; +import { sentryVitePlugin } from '../../src/vite'; + +const supportsRolldown = Number(process.versions.node.split('.')[0]) >= 20; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function readChunks(outDir: string): Record { + const chunks: Record = {}; + for (const fileName of fs.readdirSync(outDir)) { + if (fileName.endsWith('.js')) { + chunks[fileName] = fs.readFileSync(path.join(outDir, fileName), 'utf8'); + } + } + return chunks; +} + +function findChunk(chunks: Record, content: string): string { + const match = Object.entries(chunks).find(([, code]) => code.includes(content)); + expect(match, `no chunk contains ${content}`).toBeDefined(); + return match?.[1] ?? ''; +} + +describe('release injection', () => { + let tmpDir: string; + let entries: string[]; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-release-injection-')); + entries = [path.join(tmpDir, 'entry.js'), path.join(tmpDir, 'second.js')]; + fs.writeFileSync( + entries[0] ?? '', + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport("./route.js");\n', + ); + fs.writeFileSync(entries[1] ?? '', 'import { shared } from "./shared.js";\nconsole.log("second", shared);\n'); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into chunks that load at startup (rollup)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entries, + plugins: [sentryRollupPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); + + it.skipIf(!supportsRolldown)( + 'injects the release only into chunks that load at startup (rolldown / vite)', + async () => { + const { rolldown } = await import('rolldown'); + const outDir = path.join(tmpDir, 'dist'); + const build = await rolldown({ + input: entries, + plugins: sentryVitePlugin({ telemetry: false, release: { name: '1.2.3', create: false } }), + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }, + ); +}); diff --git a/packages/bundler-plugins/test/webpack/release-injection.test.ts b/packages/bundler-plugins/test/webpack/release-injection.test.ts new file mode 100644 index 000000000000..55ee52f5a944 --- /dev/null +++ b/packages/bundler-plugins/test/webpack/release-injection.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { webpack } from 'webpack'; +import type { Configuration, Stats } from 'webpack'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function build(config: Configuration): Promise { + return new Promise((resolve, reject) => { + webpack(config, (err, stats) => { + if (err) { + return reject(err); + } + if (!stats || stats.hasErrors()) { + return reject(new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats); + }); + }); +} + +describe('release injection', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-release-injection-')); + fs.writeFileSync( + path.join(tmpDir, 'entry.js'), + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport(/* webpackChunkName: "route" */ "./route.js");\n', + ); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into initial chunks', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + output: { path: outDir, filename: '[name].js', chunkFilename: '[name].js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + + const mainChunk = fs.readFileSync(path.join(outDir, 'main.js'), 'utf8'); + const routeChunk = fs.readFileSync(path.join(outDir, 'route.js'), 'utf8'); + + expect(mainChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(mainChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +});