Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/bundler-plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@
"@types/node": "^18.6.3",
"@types/webpack": "npm:@types/webpack@^4",
"premove": "^4.0.0",
"rolldown": "^1.0.0",
"rolldown": "1.1.2",
"rolldown-1-2-5": "npm:rolldown@1.2.5",
"vitest": "^3.2.7",
"webpack": "5.104.1"
},
Expand Down
69 changes: 69 additions & 0 deletions packages/bundler-plugins/src/rollup/debug-id-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { stringToUUID } from '../core';

export const ROLLDOWN_DEBUG_ID_PLACEHOLDER = 'SENTRY_DEBUG_ID_PLACEHOLDER_00000000';

type GeneratedChunk = {
type: 'chunk';
fileName: string;
code: string;
};

type GeneratedAsset = {
type: 'asset';
fileName: string;
};

export type GeneratedBundle = Record<string, GeneratedChunk | GeneratedAsset>;

const SENTRY_DEBUG_ID_IDENTIFIER = '_sentryDebugIdIdentifier';
const SENTRY_DEBUG_ID_IDENTIFIER_PREFIX = 'sentry-dbid-';

export function hasExistingDebugID(code: string): boolean {
const chunkStartSnippet = code.slice(0, 6000);
const chunkEndSnippet = code.slice(-500);

return chunkStartSnippet.includes(SENTRY_DEBUG_ID_IDENTIFIER) || chunkEndSnippet.includes('//# debugId=');
}

export function getDebugIdForChunk(code: string, isRolldown: boolean): string {
return isRolldown ? ROLLDOWN_DEBUG_ID_PLACEHOLDER : stringToUUID(code);
}

function replaceAt(code: string, start: number, search: string, replacement: string): string {
return `${code.slice(0, start)}${replacement}${code.slice(start + search.length)}`;
}

export function finalizeRolldownDebugIds(bundle: GeneratedBundle): void {
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type !== 'chunk') {
continue;
}

const identifier = `${SENTRY_DEBUG_ID_IDENTIFIER_PREFIX}${ROLLDOWN_DEBUG_ID_PLACEHOLDER}`;
const identifierPropertyStart = output.code.indexOf(SENTRY_DEBUG_ID_IDENTIFIER);
const identifierStart = output.code.indexOf(
identifier,
identifierPropertyStart + SENTRY_DEBUG_ID_IDENTIFIER.length,
);
if (identifierStart === -1) {
continue;
}

const identifierPlaceholderStart = identifierStart + SENTRY_DEBUG_ID_IDENTIFIER_PREFIX.length;
const debugIdsPlaceholderStart = output.code.lastIndexOf(ROLLDOWN_DEBUG_ID_PLACEHOLDER, identifierStart - 1);
if (debugIdsPlaceholderStart === -1) {
throw new Error(`Failed to locate the Sentry debug ID placeholder for chunk \`${fileName}\`.`);
}

// Including the final filename disambiguates otherwise identical chunks. The fixed-width replacement deliberately
// happens after Rolldown computes [hash], so the emitted filename represents the placeholder-bearing chunk.
const debugId = stringToUUID(JSON.stringify([output.fileName, output.code]));
const codeWithIdentifier = replaceAt(
output.code,
identifierPlaceholderStart,
ROLLDOWN_DEBUG_ID_PLACEHOLDER,
debugId,
);
output.code = replaceAt(codeWithIdentifier, debugIdsPlaceholderStart, ROLLDOWN_DEBUG_ID_PLACEHOLDER, debugId);
}
}
138 changes: 46 additions & 92 deletions packages/bundler-plugins/src/rollup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,97 +6,41 @@ import {
isJsFile,
shouldSkipCodeInjection,
getDebugIdSnippet,
stringToUUID,
COMMENT_USE_STRICT_REGEX,
createDebugIdUploadFunction,
globFiles,
createComponentNameAnnotateHooks,
replaceBooleanFlagsInCode,
CodeInjection,
} from '../core';
import type {
ComponentAnnotationTransformMeta,
ComponentAnnotationTransformResult,
} from '../core/component-annotation-vite';
import type { ComponentAnnotationTransformMeta } from '../core/component-annotation-vite';
import type { SourceMap } from 'magic-string';
import MagicString from 'magic-string';
import * as path from 'node:path';
import { createRequire } from 'node:module';
import {
finalizeRolldownDebugIds,
getDebugIdForChunk,
hasExistingDebugID,
type GeneratedBundle,
} from './debug-id-injection';
import { getRollupMajorVersion } from './rollup-version';
import { createViteAnnotationHooks } from './vite-annotations';

// The subset of Rollup's `TransformResult` that this plugin's `transform`
// hook actually returns. Defined locally instead of imported from `rollup`
// because `rollup` is an optional dependency.
type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined;

type ViteModule = {
parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise<unknown>;
};

type ViteParseAstAsync = NonNullable<ViteModule['parseAstAsync']>;
type ViteAnnotationHooks = {
transform(
code: string,
id: string,
meta?: ComponentAnnotationTransformMeta,
): Promise<ComponentAnnotationTransformResult>;
type RenderChunkPluginContext = {
meta?: {
rolldownVersion?: string;
};
};

let viteParseAstAsyncPromise: Promise<ViteParseAstAsync | null> | undefined;
type GenerateBundlePluginContext = RenderChunkPluginContext;

const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/;

function hasExistingDebugID(code: string): boolean {
// Check if a debug ID has already been injected to avoid duplicate injection (e.g. by another plugin or Sentry CLI)
const chunkStartSnippet = code.slice(0, 6000);
const chunkEndSnippet = code.slice(-500);

if (chunkStartSnippet.includes('_sentryDebugIdIdentifier') || chunkEndSnippet.includes('//# debugId=')) {
return true; // Debug ID already present, skip injection
}

return false;
}

function getRollupMajorVersion(): string | undefined {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Rollup already transpiles this for us
const req = createRequire(import.meta.url);
const rollup = req('rollup') as { VERSION?: string };
return rollup.VERSION?.split('.')[0];
} catch {
// do nothing, we'll just not report a version
}

return undefined;
}

function getViteParseAstAsync(): Promise<ViteParseAstAsync | null> {
if (!viteParseAstAsyncPromise) {
viteParseAstAsyncPromise = Promise.resolve()
.then(async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Vite is an optional runtime peer for this package
const viteModule = createRequire(import.meta.url)('vite') as ViteModule;

if (typeof viteModule.parseAstAsync !== 'function') {
return null;
}

try {
await viteModule.parseAstAsync('const x = <div />;', { lang: 'tsx' });
} catch {
return null;
}

return viteModule.parseAstAsync;
})
.catch(() => null);
}

return viteParseAstAsyncPromise;
}

/**
* @ignore - this is the internal plugin factory function only used for the Vite plugin!
*/
Expand Down Expand Up @@ -165,25 +109,7 @@ export function _rollupPluginInternal(
buildTool === 'vite' &&
buildToolMajorVersion === '8' &&
!options.reactComponentAnnotation?._experimentalInjectIntoHtml
? (() => {
let viteAnnotationHooksPromise: Promise<ViteAnnotationHooks> | undefined;

return {
transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) {
if (!viteAnnotationHooksPromise) {
viteAnnotationHooksPromise = import('../core/component-annotation-vite').then(
({ createViteComponentNameAnnotateHooks }) =>
createViteComponentNameAnnotateHooks(
options.reactComponentAnnotation?.ignoredComponents || [],
getViteParseAstAsync,
),
);
}

return viteAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta));
},
};
})()
? createViteAnnotationHooks(options.reactComponentAnnotation?.ignoredComponents || [])
: undefined;

const transformReplace = Object.keys(replacementValues).length > 0;
Expand Down Expand Up @@ -230,6 +156,7 @@ export function _rollupPluginInternal(
}

function renderChunk(
this: RenderChunkPluginContext | undefined,
code: string,
chunk: { fileName: string; facadeModuleId?: string | null },
_?: unknown,
Expand All @@ -250,7 +177,7 @@ export function _rollupPluginInternal(
const injectCode = staticInjectionCode.clone();

if (sourcemapsEnabled && !hasExistingDebugID(code)) {
const debugId = stringToUUID(code); // generate a deterministic debug ID
const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion);
injectCode.append(getDebugIdSnippet(debugId));
}

Expand Down Expand Up @@ -280,10 +207,25 @@ export function _rollupPluginInternal(

return {
code: ms.toString(),
map: ms.generateMap({ file: chunk.fileName, hires: 'boundary' as unknown as undefined }),
map: ms.generateMap({
file: chunk.fileName,
hires: 'boundary' as unknown as undefined,
}),
};
}

function generateBundle(
this: GenerateBundlePluginContext | undefined,
_outputOptions: unknown,
bundle: GeneratedBundle,
): void {
if (!this?.meta?.rolldownVersion) {
return;
}

finalizeRolldownDebugIds(bundle);
}

async function writeBundle(
outputOptions: { dir?: string; file?: string },
bundle: { [fileName: string]: unknown },
Expand All @@ -302,7 +244,9 @@ export function _rollupPluginInternal(
'/**/*.mjs.map',
'/**/*.cjs.map',
].map(q => `${q}?(\\?*)?(#*)`); // We want to allow query and hash strings at the end of files
const buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, { root: outputDir });
const buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, {
root: outputDir,
});
await upload(buildArtifacts);
} else if (outputOptions.file) {
await upload([outputOptions.file]);
Expand All @@ -318,6 +262,14 @@ export function _rollupPluginInternal(
}

const name = `sentry-${buildTool}-plugin`;
function createGenerateBundleHook() {
if (buildTool === 'vite' && buildToolMajorVersion === '8') {
return { order: 'post' as const, handler: generateBundle };
}

return generateBundle;
}
const generateBundleHook = createGenerateBundleHook();

if (shouldTransform) {
const transformHook =
Expand All @@ -333,6 +285,7 @@ export function _rollupPluginInternal(
buildStart,
transform: transformHook,
renderChunk,
generateBundle: generateBundleHook,
writeBundle,
};
}
Expand All @@ -341,6 +294,7 @@ export function _rollupPluginInternal(
name,
buildStart,
renderChunk,
generateBundle: generateBundleHook,
writeBundle,
};
}
Expand Down
13 changes: 13 additions & 0 deletions packages/bundler-plugins/src/rollup/rollup-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createRequire } from 'node:module';

export function getRollupMajorVersion(): string | undefined {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Rollup already transpiles this for us
const req = createRequire(import.meta.url);
const rollup = req('rollup') as { VERSION?: string };
return rollup.VERSION?.split('.')[0];
} catch {
return undefined;
}
}
63 changes: 63 additions & 0 deletions packages/bundler-plugins/src/rollup/vite-annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { createRequire } from 'node:module';
import type {
ComponentAnnotationTransformMeta,
ComponentAnnotationTransformResult,
} from '../core/component-annotation-vite';

type ViteModule = {
parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise<unknown>;
};

type ViteParseAstAsync = NonNullable<ViteModule['parseAstAsync']>;

type ViteAnnotationHooks = {
transform(
code: string,
id: string,
meta?: ComponentAnnotationTransformMeta,
): Promise<ComponentAnnotationTransformResult>;
};

let viteParseAstAsyncPromise: Promise<ViteParseAstAsync | null> | undefined;

export function getViteParseAstAsync(): Promise<ViteParseAstAsync | null> {
if (!viteParseAstAsyncPromise) {
viteParseAstAsyncPromise = Promise.resolve()
.then(async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Vite is an optional runtime peer for this package
const viteModule = createRequire(import.meta.url)('vite') as ViteModule;

if (typeof viteModule.parseAstAsync !== 'function') {
return null;
}

try {
await viteModule.parseAstAsync('const x = <div />;', { lang: 'tsx' });
} catch {
return null;
}

return viteModule.parseAstAsync;
})
.catch(() => null);
}

return viteParseAstAsyncPromise;
}

export function createViteAnnotationHooks(ignoredComponents: string[]): ViteAnnotationHooks {
let hooksPromise: Promise<ViteAnnotationHooks> | undefined;

return {
transform(code, id, meta) {
if (!hooksPromise) {
hooksPromise = import('../core/component-annotation-vite').then(({ createViteComponentNameAnnotateHooks }) =>
createViteComponentNameAnnotateHooks(ignoredComponents, getViteParseAstAsync),
);
}

return hooksPromise.then(hooks => hooks.transform(code, id, meta));
},
};
}
Loading