Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/bundler-plugins/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n
return false;
}

export interface ChunkGraphNode {
fileName: string;
isEntry?: boolean;
imports?: string[];
}

const initialChunksCache = new WeakMap<Record<string, ChunkGraphNode>, Set<string>>();

function getInitialChunks(chunks: Record<string, ChunkGraphNode>): Set<string> {
const cached = initialChunksCache.get(chunks);
if (cached) {
return cached;
}

const initial = new Set<string>();
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<ChunkGraphNode, 'fileName' | 'isEntry'>,
chunks: Record<string, ChunkGraphNode> | undefined,
): boolean {
if (chunk.isEntry || !chunks) {
return true;
}

return getInitialChunks(chunks).has(chunk.fileName);
}

export { globFiles } from './glob';
export { getCodeInjectionPosition } from './get-code-injection-position';

Expand Down
22 changes: 15 additions & 7 deletions packages/bundler-plugins/src/rollup/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Options } from '../core';
import type { ChunkGraphNode, Options } from '../core';
import {
createSentryBuildPluginManager,
generateReleaseInjectorCode,
generateModuleMetadataInjectorCode,
isJsFile,
isInitialChunk,
shouldSkipCodeInjection,
getDebugIdSnippet,
createDebugIdUploadFunction,
Expand Down Expand Up @@ -115,7 +116,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.');
Expand All @@ -124,7 +126,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,
Expand All @@ -133,7 +135,7 @@ export function _rollupPluginInternal(
}

if (Object.keys(bundleMetadata).length > 0) {
staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
}

const transformAnnotations = options.reactComponentAnnotation?.enabled
Expand Down Expand Up @@ -181,9 +183,9 @@ export function _rollupPluginInternal(
function renderChunk(
this: PluginContext | undefined,
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<string, ChunkGraphNode> },
): {
code: string;
map?: SourceMap;
Expand All @@ -197,7 +199,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 = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion);
Expand Down
16 changes: 11 additions & 5 deletions packages/bundler-plugins/src/webpack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ type WebpackCompilation = {
files: Iterable<string>;
hash?: string;
contentHash?: { javascript?: string };
canBeInitial?: () => boolean;
}>;
outputOptions: {
path?: string;
Expand Down Expand Up @@ -198,7 +199,8 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
}

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.');
Expand All @@ -207,7 +209,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
'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,
Expand All @@ -216,7 +218,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
}

if (Object.keys(bundleMetadata).length > 0) {
staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
}

const transformAnnotations = options.reactComponentAnnotation?.enabled
Expand All @@ -230,7 +232,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
const transformReplace = Object.keys(replacementValues).length > 0;

function addCodeInjection(compiler: WebpackCompiler): void {
if (staticInjectionCode.isEmpty() && !sourcemapsEnabled) {
if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) {
return;
}

Expand Down Expand Up @@ -267,7 +269,11 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})

const sourceContents = source.source();
const code = typeof sourceContents === 'string' ? sourceContents : Buffer.from(sourceContents).toString();
const codeToInject = staticInjectionCode.clone();
const codeToInject = new CodeInjection();
if (chunk.canBeInitial?.() ?? true) {
codeToInject.append(releaseInjectionCode);
}
codeToInject.append(chunkInjectionCode);
if (sourcemapsEnabled) {
const hash = chunk.contentHash?.javascript ?? chunk.hash;
codeToInject.append(getDebugIdSnippet(hash ? stringToUUID(hash) : randomUUID()));
Expand Down
27 changes: 26 additions & 1 deletion packages/bundler-plugins/test/core/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core';
import { createComponentNameAnnotateHooks, getDebugIdSnippet, isInitialChunk } from '../../src/core';
import { getOxcParseAstAsync } from '../../src/core/component-annotation-oxc';
import { containsOnlyImports } from '../../src/core/utils';
import { describe, it, expect, vi } from 'vitest';
Expand Down Expand Up @@ -63,6 +63,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([
Expand Down
103 changes: 103 additions & 0 deletions packages/bundler-plugins/test/rollup/release-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { rollup } from 'rollup';
import { rolldown } from 'rolldown';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { sentryRollupPlugin } from '../../src/rollup';
import { sentryVitePlugin } from '../../src/vite';

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<string, string> {
const chunks: Record<string, string> = {};
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<string, string>, 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('injects the release only into chunks that load at startup (rolldown / vite)', async () => {
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);
});
});
66 changes: 66 additions & 0 deletions packages/bundler-plugins/test/webpack/release-injection.test.ts
Original file line number Diff line number Diff line change
@@ -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<Stats> {
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);
});
});
Loading