diff --git a/packages/bundler-plugin-core/src/index.ts b/packages/bundler-plugin-core/src/index.ts index a59eeca0..bc350184 100644 --- a/packages/bundler-plugin-core/src/index.ts +++ b/packages/bundler-plugin-core/src/index.ts @@ -206,44 +206,80 @@ type RenderChunkHook = ( code: string, chunk: { fileName: string; + facadeModuleId?: string | null; } ) => { code: string; map: SourceMap; } | null; +/** + * 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 { + const cleanFileName = stripQueryAndHashFromPath(fileName); + return [".js", ".mjs", ".cjs"].some((ext) => cleanFileName.endsWith(ext)); +} + +/** + * Checks if a chunk should be skipped for code injection + * + * This is necessary to handle Vite's MPA (multi-page application) mode where + * HTML entry points create "facade" chunks that should not contain injected code. + * See: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/829 + * + * @param code - The chunk's code content + * @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 { + // Skip empty chunks - these are placeholder chunks that should be optimized away + if (code.trim().length === 0) { + return true; + } + + // Skip HTML facade chunks + // They only contain import statements and should not have Sentry code injected + if (facadeModuleId && stripQueryAndHashFromPath(facadeModuleId).endsWith(".html")) { + return true; + } + + return false; +} + export function createRollupReleaseInjectionHooks(injectionCode: string): { renderChunk: RenderChunkHook; } { return { - renderChunk(code: string, chunk: { fileName: string }) { - if ( - // chunks could be any file (html, md, ...) - [".js", ".mjs", ".cjs"].some((ending) => - stripQueryAndHashFromPath(chunk.fileName).endsWith(ending) - ) - ) { - const ms = new MagicString(code, { filename: chunk.fileName }); + renderChunk(code: string, chunk: { fileName: string; facadeModuleId?: string | null }) { + if (!isJsFile(chunk.fileName)) { + return null; // returning null means not modifying the chunk at all + } - const match = code.match(COMMENT_USE_STRICT_REGEX)?.[0]; + // Skip empty chunks and HTML facade chunks (Vite MPA) + if (shouldSkipCodeInjection(code, chunk.facadeModuleId)) { + return null; + } - if (match) { - // Add injected code after any comments or "use strict" at the beginning of the bundle. - ms.appendLeft(match.length, injectionCode); - } 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(injectionCode); - } + const ms = new MagicString(code, { filename: chunk.fileName }); - return { - code: ms.toString(), - map: ms.generateMap({ file: chunk.fileName, hires: "boundary" }), - }; + 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, injectionCode); } else { - return null; // returning null means not modifying the chunk at all + // 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(injectionCode); } + + return { + code: ms.toString(), + map: ms.generateMap({ file: chunk.fileName, hires: "boundary" }), + }; }, }; } @@ -262,48 +298,48 @@ export function createRollupDebugIdInjectionHooks(): { renderChunk: RenderChunkHook; } { return { - renderChunk(code: string, chunk: { fileName: string }) { + renderChunk(code: string, chunk: { fileName: string; facadeModuleId?: string | 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; + } + + // 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 ( - // chunks could be any file (html, md, ...) - [".js", ".mjs", ".cjs"].some((ending) => - stripQueryAndHashFromPath(chunk.fileName).endsWith(ending) - ) + chunkStartSnippet.includes("_sentryDebugIdIdentifier") || + chunkEndSnippet.includes("//# debugId=") ) { - // 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 null; // Debug ID already present, skip injection - } - - const debugId = stringToUUID(code); // generate a deterministic debug ID - const codeToInject = getDebugIdSnippet(debugId); + return null; // Debug ID already present, skip injection + } - const ms = new MagicString(code, { filename: chunk.fileName }); + const debugId = stringToUUID(code); // generate a deterministic debug ID + const codeToInject = getDebugIdSnippet(debugId); - const match = code.match(COMMENT_USE_STRICT_REGEX)?.[0]; + const ms = new MagicString(code, { filename: chunk.fileName }); - if (match) { - // Add injected code after any comments or "use strict" at the beginning of the bundle. - ms.appendLeft(match.length, codeToInject); - } 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(codeToInject); - } + const match = code.match(COMMENT_USE_STRICT_REGEX)?.[0]; - return { - code: ms.toString(), - map: ms.generateMap({ file: chunk.fileName, hires: "boundary" }), - }; + if (match) { + // Add injected code after any comments or "use strict" at the beginning of the bundle. + ms.appendLeft(match.length, codeToInject); } else { - return null; // returning null means not modifying the chunk at all + // 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(codeToInject); } + + return { + code: ms.toString(), + map: ms.generateMap({ file: chunk.fileName, hires: "boundary" }), + }; }, }; } @@ -312,34 +348,34 @@ export function createRollupModuleMetadataInjectionHooks(injectionCode: string): renderChunk: RenderChunkHook; } { return { - renderChunk(code: string, chunk: { fileName: string }) { - if ( - // chunks could be any file (html, md, ...) - [".js", ".mjs", ".cjs"].some((ending) => - stripQueryAndHashFromPath(chunk.fileName).endsWith(ending) - ) - ) { - const ms = new MagicString(code, { filename: chunk.fileName }); + renderChunk(code: string, chunk: { fileName: string; facadeModuleId?: string | null }) { + if (!isJsFile(chunk.fileName)) { + return null; // returning null means not modifying the chunk at all + } - const match = code.match(COMMENT_USE_STRICT_REGEX)?.[0]; + // Skip empty chunks and HTML facade chunks (Vite MPA) + if (shouldSkipCodeInjection(code, chunk.facadeModuleId)) { + return null; + } - if (match) { - // Add injected code after any comments or "use strict" at the beginning of the bundle. - ms.appendLeft(match.length, injectionCode); - } 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(injectionCode); - } + const ms = new MagicString(code, { filename: chunk.fileName }); - return { - code: ms.toString(), - map: ms.generateMap({ file: chunk.fileName, hires: "boundary" }), - }; + 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, injectionCode); } else { - return null; // returning null means not modifying the chunk at all + // 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(injectionCode); } + + return { + code: ms.toString(), + map: ms.generateMap({ file: chunk.fileName, hires: "boundary" }), + }; }, }; } diff --git a/packages/integration-tests/fixtures/vite-mpa-extra-modules/build-vite-with-plugin.ts b/packages/integration-tests/fixtures/vite-mpa-extra-modules/build-vite-with-plugin.ts new file mode 100644 index 00000000..fc9b426d --- /dev/null +++ b/packages/integration-tests/fixtures/vite-mpa-extra-modules/build-vite-with-plugin.ts @@ -0,0 +1,28 @@ +import { sentryVitePlugin } from "@sentry/vite-plugin"; +import * as path from "path"; +import * as vite from "vite"; + +const inputDir = path.join(__dirname, "input"); + +void vite.build({ + clearScreen: false, + root: inputDir, + build: { + sourcemap: true, + outDir: path.join(__dirname, "out", "with-plugin"), + emptyOutDir: true, + rollupOptions: { + input: { + index: path.join(inputDir, "index.html"), + page1: path.join(inputDir, "page1.html"), + page2: path.join(inputDir, "page2.html"), + }, + }, + }, + plugins: [ + sentryVitePlugin({ + telemetry: false, + // Empty options - the issue says options don't affect the results + }), + ], +}); diff --git a/packages/integration-tests/fixtures/vite-mpa-extra-modules/build-vite-without-plugin.ts b/packages/integration-tests/fixtures/vite-mpa-extra-modules/build-vite-without-plugin.ts new file mode 100644 index 00000000..85112284 --- /dev/null +++ b/packages/integration-tests/fixtures/vite-mpa-extra-modules/build-vite-without-plugin.ts @@ -0,0 +1,22 @@ +import * as path from "path"; +import * as vite from "vite"; + +const inputDir = path.join(__dirname, "input"); + +void vite.build({ + clearScreen: false, + root: inputDir, + build: { + sourcemap: true, + outDir: path.join(__dirname, "out", "without-plugin"), + emptyOutDir: true, + rollupOptions: { + input: { + index: path.join(inputDir, "index.html"), + page1: path.join(inputDir, "page1.html"), + page2: path.join(inputDir, "page2.html"), + }, + }, + }, + plugins: [], +}); diff --git a/packages/integration-tests/fixtures/vite-mpa-extra-modules/input/index.html b/packages/integration-tests/fixtures/vite-mpa-extra-modules/input/index.html new file mode 100644 index 00000000..ae984747 --- /dev/null +++ b/packages/integration-tests/fixtures/vite-mpa-extra-modules/input/index.html @@ -0,0 +1,11 @@ + + +
+ +