From 2a473399af9e0bbca10a60872fe5d9187183dba0 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 29 Jan 2026 18:30:52 +0000 Subject: [PATCH] PoC: Regular Rollup Plugin --- packages/bundler-plugin-core/src/index.ts | 44 +-- packages/bundler-plugin-core/src/utils.ts | 2 +- .../test/__snapshots__/utils.test.ts.snap | 8 +- .../bundler-plugin-core/test/utils.test.ts | 8 +- packages/rollup-plugin/src/index.ts | 250 ++++++++++++++---- .../rollup-plugin/test/public-api.test.ts | 21 +- 6 files changed, 241 insertions(+), 92 deletions(-) diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index a31e4215..e282f625 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -16,7 +16,7 @@ import { Options, SentrySDKBuildFlags } from "./types"; import { CodeInjection, containsOnlyImports, - generateGlobalInjectorCode, + generateReleaseInjectorCode, generateModuleMetadataInjectorCode, replaceBooleanFlagsInCode, stringToUUID, @@ -118,7 +118,7 @@ export function sentryUnpluginFactory({ "No release name provided. Will not inject release. Please set the `release.name` option to identify your release." ); } else { - const code = generateGlobalInjectorCode({ + const code = generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, }); @@ -227,7 +227,7 @@ export function sentryCliBinaryExists(): boolean { // We need to be careful not to inject the snippet before any `"use strict";`s. // As an additional complication `"use strict";`s may come after any number of comments. -const COMMENT_USE_STRICT_REGEX = +export const COMMENT_USE_STRICT_REGEX = // Note: CodeQL complains that this regex potentially has n^2 runtime. This likely won't affect realistic files. /^(?:\s*|\/\*(?:.|\r|\n)*?\*\/|\/\/.*[\n\r])*(?:"[^"]*";|'[^']*';)?/; @@ -253,7 +253,7 @@ type RenderChunkHook = ( * Checks if a file is a JavaScript file based on its extension. * Handles query strings and hashes in the filename. */ -function isJsFile(fileName: string): boolean { +export function isJsFile(fileName: string): boolean { const cleanFileName = stripQueryAndHashFromPath(fileName); return [".js", ".mjs", ".cjs"].some((ext) => cleanFileName.endsWith(ext)); } @@ -272,7 +272,10 @@ function isJsFile(fileName: string): boolean { * @param facadeModuleId - The facade module ID (if any) - HTML files create facade chunks * @returns true if the chunk should be skipped */ -function shouldSkipCodeInjection(code: string, facadeModuleId: string | null | undefined): boolean { +export function shouldSkipCodeInjection( + code: string, + facadeModuleId: string | null | undefined +): boolean { // Skip empty chunks - these are placeholder chunks that should be optimized away if (code.trim().length === 0) { return true; @@ -369,6 +372,19 @@ export function createRollupInjectionHooks( }; } +export function globFiles(outputDir: string): Promise { + return glob( + ["/**/*.js", "/**/*.mjs", "/**/*.cjs", "/**/*.js.map", "/**/*.mjs.map", "/**/*.cjs.map"].map( + (q) => `${q}?(\\?*)?(#*)` + ), // We want to allow query and hashes strings at the end of files + { + root: outputDir, + absolute: true, + nodir: true, + } + ); +} + export function createRollupDebugIdUploadHooks( upload: (buildArtifacts: string[]) => Promise, _logger: Logger, @@ -388,21 +404,7 @@ export function createRollupDebugIdUploadHooks( try { if (outputOptions.dir) { const outputDir = outputOptions.dir; - const buildArtifacts = await glob( - [ - "/**/*.js", - "/**/*.mjs", - "/**/*.cjs", - "/**/*.js.map", - "/**/*.mjs.map", - "/**/*.cjs.map", - ].map((q) => `${q}?(\\?*)?(#*)`), // We want to allow query and hashes strings at the end of files - { - root: outputDir, - absolute: true, - nodir: true, - } - ); + const buildArtifacts = await globFiles(outputDir); await upload(buildArtifacts); } else if (outputOptions.file) { await upload([outputOptions.file]); @@ -492,3 +494,5 @@ export type { Logger } from "./logger"; export type { Options, SentrySDKBuildFlags } from "./types"; export { CodeInjection, replaceBooleanFlagsInCode, stringToUUID } from "./utils"; export { createSentryBuildPluginManager } from "./build-plugin-manager"; +export { generateReleaseInjectorCode, generateModuleMetadataInjectorCode } from "./utils"; +export { createDebugIdUploadFunction } from "./debug-id-upload"; diff --git a/packages/bundler-plugin-core/src/utils.ts b/packages/bundler-plugin-core/src/utils.ts index 9726895e..45d98ab4 100644 --- a/packages/bundler-plugin-core/src/utils.ts +++ b/packages/bundler-plugin-core/src/utils.ts @@ -305,7 +305,7 @@ export function determineReleaseName(): string | undefined { * Generates code for the global injector which is responsible for setting the global * `SENTRY_RELEASE` & `SENTRY_BUILD_INFO` variables. */ -export function generateGlobalInjectorCode({ +export function generateReleaseInjectorCode({ release, injectBuildInformation, }: { diff --git a/packages/bundler-plugin-core/test/__snapshots__/utils.test.ts.snap b/packages/bundler-plugin-core/test/__snapshots__/utils.test.ts.snap index 1bacbe62..0597ebce 100644 --- a/packages/bundler-plugin-core/test/__snapshots__/utils.test.ts.snap +++ b/packages/bundler-plugin-core/test/__snapshots__/utils.test.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`generateGlobalInjectorCode generates code with release 1`] = `"!function(){try{var e=\\"undefined\\"!=typeof window?window:\\"undefined\\"!=typeof global?global:\\"undefined\\"!=typeof globalThis?globalThis:\\"undefined\\"!=typeof self?self:{};e.SENTRY_RELEASE={id:\\"1.2.3\\"};}catch(e){}}();"`; - -exports[`generateGlobalInjectorCode generates code with release and build information 1`] = `"!function(){try{var e=\\"undefined\\"!=typeof window?window:\\"undefined\\"!=typeof global?global:\\"undefined\\"!=typeof globalThis?globalThis:\\"undefined\\"!=typeof self?self:{};e.SENTRY_RELEASE={id:\\"1.2.3\\"};e.SENTRY_BUILD_INFO={\\"deps\\":[\\"myDep\\",\\"rollup\\"],\\"depsVersions\\":{\\"rollup\\":3},\\"nodeVersion\\":18};}catch(e){}}();"`; - exports[`generateModuleMetadataInjectorCode generates code with empty metadata object 1`] = `"!function(){try{var e=\\"undefined\\"!=typeof window?window:\\"undefined\\"!=typeof global?global:\\"undefined\\"!=typeof globalThis?globalThis:\\"undefined\\"!=typeof self?self:{};e._sentryModuleMetadata=e._sentryModuleMetadata||{},e._sentryModuleMetadata[(new e.Error).stack]=function(e){for(var n=1;n { +describe("generateReleaseInjectorCode", () => { it("generates code with release", () => { - const generatedCode = generateGlobalInjectorCode({ + const generatedCode = generateReleaseInjectorCode({ release: "1.2.3", injectBuildInformation: false, }); @@ -244,7 +244,7 @@ describe("generateGlobalInjectorCode", () => { }) ); - const generatedCode = generateGlobalInjectorCode({ + const generatedCode = generateReleaseInjectorCode({ release: "1.2.3", injectBuildInformation: true, }); diff --git a/packages/rollup-plugin/src/index.ts b/packages/rollup-plugin/src/index.ts index f29ac5f0..21993770 100644 --- a/packages/rollup-plugin/src/index.ts +++ b/packages/rollup-plugin/src/index.ts @@ -1,62 +1,218 @@ import { - CodeInjection, - sentryUnpluginFactory, + createSentryBuildPluginManager, + generateReleaseInjectorCode, + generateModuleMetadataInjectorCode, + isJsFile, + shouldSkipCodeInjection, Options, - createRollupInjectionHooks, - createRollupDebugIdUploadHooks, - SentrySDKBuildFlags, - createRollupBundleSizeOptimizationHooks, + getDebugIdSnippet, + stringToUUID, + COMMENT_USE_STRICT_REGEX, + createDebugIdUploadFunction, + globFiles, createComponentNameAnnotateHooks, - Logger, + replaceBooleanFlagsInCode, + CodeInjection, } from "@sentry/bundler-plugin-core"; -import type { UnpluginOptions } from "unplugin"; +import MagicString, { SourceMap } from "magic-string"; +import type { TransformHook } from "rollup"; +import * as path from "path"; -function rollupComponentNameAnnotatePlugin( - ignoredComponents: string[], - injectIntoHtml: boolean -): UnpluginOptions { - return { - name: "sentry-rollup-component-name-annotate-plugin", - rollup: createComponentNameAnnotateHooks(ignoredComponents, injectIntoHtml), - }; -} +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); -function rollupInjectionPlugin(injectionCode: CodeInjection, debugIds: boolean): UnpluginOptions { - return { - name: "sentry-rollup-injection-plugin", - rollup: createRollupInjectionHooks(injectionCode, debugIds), - }; -} + if ( + chunkStartSnippet.includes("_sentryDebugIdIdentifier") || + chunkEndSnippet.includes("//# debugId=") + ) { + return true; // Debug ID already present, skip injection + } -function rollupDebugIdUploadPlugin( - upload: (buildArtifacts: string[]) => Promise, - logger: Logger, - createDependencyOnBuildArtifacts: () => () => void -): UnpluginOptions { - return { - name: "sentry-rollup-debug-id-upload-plugin", - rollup: createRollupDebugIdUploadHooks(upload, logger, createDependencyOnBuildArtifacts), - }; + return false; } -function rollupBundleSizeOptimizationsPlugin( - replacementValues: SentrySDKBuildFlags -): UnpluginOptions { +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function sentryRollupPlugin(userOptions: Options = {}) { + const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { + loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? "[sentry-rollup-plugin]", + buildTool: "rollup", + }); + + const { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues: replacementValues, + bundleMetadata, + createDependencyOnBuildArtifacts, + } = sentryBuildPluginManager; + + if (options.disable) { + return { + name: "sentry-noop-plugin", + }; + } + + if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { + logger.warn( + "Running Sentry plugin from within a `node_modules` folder. Some features may not work." + ); + } + + const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + const sourcemapsEnabled = options.sourcemaps?.disable !== true; + const staticInjectionCode = new CodeInjection(); + + if (!options.release.inject) { + logger.debug( + "Release injection disabled via `release.inject` option. Will not inject release." + ); + } else if (!options.release.name) { + logger.debug( + "No release name provided. Will not inject release. Please set the `release.name` option to identify your release." + ); + } else { + staticInjectionCode.append( + generateReleaseInjectorCode({ + release: options.release.name, + injectBuildInformation: options._experiments.injectBuildInformation || false, + }) + ); + } + + if (Object.keys(bundleMetadata).length > 0) { + staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + } + + const transformAnnotations = options.reactComponentAnnotation?.enabled + ? createComponentNameAnnotateHooks( + options.reactComponentAnnotation?.ignoredComponents || [], + !!options.reactComponentAnnotation?._experimentalInjectIntoHtml + ) + : undefined; + + const transformReplace = Object.keys(replacementValues).length > 0; + const shouldTransform = transformAnnotations || transformReplace; + + function buildStart(): void { + void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { + // Telemetry failures are acceptable + }); + } + + function transform(code: string, id: string): ReturnType { + // Component annotations are only in user code and boolean flag replacements are + // only in Sentry code. If we successfully add annotations, we can return early. + + if (transformAnnotations?.transform) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TS complains about 'this' + const result = transformAnnotations.transform(code, id); + if (result) { + return result; + } + } + + if (transformReplace) { + return replaceBooleanFlagsInCode(code, replacementValues); + } + + return null; + } + + function renderChunk( + code: string, + chunk: { fileName: string; facadeModuleId?: string | null } + ): { + code: string; + map: SourceMap; + } | null { + if (!isJsFile(chunk.fileName)) { + return null; // returning null means not modifying the chunk at all + } + + // Skip empty chunks and HTML facade chunks (Vite MPA) + if (shouldSkipCodeInjection(code, chunk.facadeModuleId)) { + return null; + } + + const injectCode = staticInjectionCode.clone(); + + if (sourcemapsEnabled && !hasExistingDebugID(code)) { + const debugId = stringToUUID(code); // generate a deterministic debug ID + injectCode.append(getDebugIdSnippet(debugId)); + } + + if (injectCode.isEmpty()) { + return null; + } + + const ms = new MagicString(code, { filename: chunk.fileName }); + + const match = code.match(COMMENT_USE_STRICT_REGEX)?.[0]; + + if (match) { + // Add injected code after any comments or "use strict" at the beginning of the bundle. + ms.appendLeft(match.length, injectCode.code()); + } else { + // ms.replace() doesn't work when there is an empty string match (which happens if + // there is neither, a comment, nor a "use strict" at the top of the chunk) so we + // need this special case here. + ms.prepend(injectCode.code()); + } + + return { + code: ms.toString(), + map: ms.generateMap({ file: chunk.fileName, hires: "boundary" as unknown as undefined }), + }; + } + + async function writeBundle( + outputOptions: { dir?: string; file?: string }, + bundle: { [fileName: string]: unknown } + ): Promise { + if (!sourcemapsEnabled) { + return; + } + + try { + await sentryBuildPluginManager.createRelease(); + + if (outputOptions.dir) { + const outputDir = outputOptions.dir; + const buildArtifacts = await globFiles(outputDir); + await upload(buildArtifacts); + } else if (outputOptions.file) { + await upload([outputOptions.file]); + } else { + const buildArtifacts = Object.keys(bundle).map((asset) => path.join(path.resolve(), asset)); + await upload(buildArtifacts); + } + } finally { + freeGlobalDependencyOnBuildArtifacts(); + await sentryBuildPluginManager.deleteArtifacts(); + } + } + + if (shouldTransform) { + return { + name: "sentry-rollup-plugin", + buildStart, + transform, + renderChunk, + writeBundle, + }; + } + return { - name: "sentry-rollup-bundle-size-optimizations-plugin", - rollup: createRollupBundleSizeOptimizationHooks(replacementValues), + name: "sentry-rollup-plugin", + buildStart, + renderChunk, + writeBundle, }; } -const sentryUnplugin = sentryUnpluginFactory({ - injectionPlugin: rollupInjectionPlugin, - componentNameAnnotatePlugin: rollupComponentNameAnnotatePlugin, - debugIdUploadPlugin: rollupDebugIdUploadPlugin, - bundleSizeOptimizationsPlugin: rollupBundleSizeOptimizationsPlugin, -}); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryRollupPlugin: (options?: Options) => any = sentryUnplugin.rollup; - export type { Options as SentryRollupPluginOptions } from "@sentry/bundler-plugin-core"; export { sentryCliBinaryExists } from "@sentry/bundler-plugin-core"; diff --git a/packages/rollup-plugin/test/public-api.test.ts b/packages/rollup-plugin/test/public-api.test.ts index ced22fbc..b4e72418 100644 --- a/packages/rollup-plugin/test/public-api.test.ts +++ b/packages/rollup-plugin/test/public-api.test.ts @@ -1,4 +1,3 @@ -import { Plugin } from "rollup"; import { sentryRollupPlugin } from "../src"; test("Rollup plugin should exist", () => { @@ -11,25 +10,15 @@ describe("sentryRollupPlugin", () => { jest.clearAllMocks(); }); - it("returns an array of rollup plugins", () => { - const plugins = sentryRollupPlugin({ + it("returns a single rollup plugin", () => { + const plugin = sentryRollupPlugin({ authToken: "test-token", org: "test-org", project: "test-project", - }) as Plugin[]; + }); - expect(Array.isArray(plugins)).toBe(true); + expect(Array.isArray(plugin)).not.toBe(true); - const pluginNames = plugins.map((plugin) => plugin.name); - - expect(pluginNames).toEqual( - expect.arrayContaining([ - "sentry-telemetry-plugin", - "sentry-release-management-plugin", - "sentry-rollup-injection-plugin", - "sentry-rollup-debug-id-upload-plugin", - "sentry-file-deletion-plugin", - ]) - ); + expect(plugin.name).toBe("sentry-rollup-plugin"); }); });