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
196 changes: 116 additions & 80 deletions packages/bundler-plugin-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
};
},
};
}
Expand All @@ -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" }),
};
},
};
}
Expand All @@ -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];

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with '//' and with many repetitions of '*///'.
Copy link
Member

@Lms24 Lms24 Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is theoretically a valid concern though we had that regexp match before as well. If you find an easy way to reduce the runtime of the regexp, it would be great. Otherwise not the end of the world to leave it as-is. I don't see a clear ReDoS vector here, unless a code file already includes a malicious string. Though this is still not really DoS, given the plugin runs at build time 😅

Copy link
Collaborator Author

@logaretm logaretm Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yea i didn't pay attention to those since we had them before. I will take a look and will create a new PR for it if I find a way to avoid blocking this.

Thanks for the callout!


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" }),
};
},
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}),
],
});
Original file line number Diff line number Diff line change
@@ -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: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Index Page</title>
</head>
<body>
<h1>Index Page - No Scripts</h1>
<!-- This page has no scripts -->
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Page 1</title>
</head>
<body>
<h1>Page 1 - With Shared Module</h1>
<script type="module" src="./shared-module.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Page 2</title>
</head>
<body>
<h1>Page 2 - With Shared Module</h1>
<script type="module" src="./shared-module.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// This is a shared module that is used by multiple HTML pages
export function greet(name) {
// eslint-disable-next-line no-console
console.log(`Hello, ${String(name)}!`);
}

export const VERSION = "1.0.0";

// Side effect: greet on load
greet("World");
Loading
Loading