Skip to content
Closed
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
17 changes: 9 additions & 8 deletions packages/bundler-plugins/src/esbuild/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,19 +227,20 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {

return {
pluginName,
path: resolvedPath,
// Treat the generated proxy as dependency code without changing the user's entry point.
path: path.join(
path.dirname(resolvedPath),
'node_modules',
'@sentry',
'esbuild-plugin',
path.basename(resolvedPath),
),
namespace: 'sentry-debug-id-proxy',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Proxy namespace missing onLoad handler

High Severity

The debug ID wrapper is now resolved into the sentry-debug-id-proxy namespace, but the loader that emits that wrapper still listens on the default file namespace. esbuild will not load the proxy, so debug ID injection fails and the build errors when sourcemaps are enabled.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5e08cb3. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

onLoad({ filter: /.*/ }) has no namespace filter, so it handles sentry-debug-id-proxy too. esbuild only restricts the callback when a namespace is provided (docs).

pluginData: {
isDebugIdProxy: true,
originalPath: args.path,
originalResolveDir: args.resolveDir,
},
// We need to add a suffix here, otherwise esbuild will mark the entrypoint as resolved and won't traverse
// the module tree any further down past the proxy module because we're essentially creating a dependency
// loop back to the proxy module.
// By setting a suffix we're telling esbuild that the entrypoint and proxy module are two different things,
// making it re-resolve the entrypoint when it is imported from the proxy module.
// Super confusing? Yes. Works? Apparently... Let's see.
suffix: '?sentryDebugIdProxy=true',
};
});

Expand Down
151 changes: 151 additions & 0 deletions packages/bundler-plugins/test/esbuild/entry-exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runInNewContext } from 'node:vm';
import * as esbuild from 'esbuild';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { sentryEsbuildPlugin } from '../../src/esbuild';

describe('entry point exports', () => {
let tmpDir: string;
let entry: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-esbuild-entry-exports-'));
entry = path.join(tmpDir, 'entry.js');
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it.each([
['an empty ES module', 'export {};', { default: undefined }],
['a script without exports', '', { default: {} }],
['named exports', 'export const answer = 42;', { answer: 42, default: undefined }],
['a default export', 'export default 42;', { default: 42 }],
['named and default exports', 'export const answer = 42; export default 7;', { answer: 42, default: 7 }],
['CommonJS exports', 'module.exports = { answer: 42 };', { default: { answer: 42 } }],
])('preserves %s without warnings', async (_name, source, expectedExports) => {
fs.writeFileSync(entry, `globalThis.entryRuns = (globalThis.entryRuns || 0) + 1;\n${source}`);

const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
format: 'cjs',
write: false,
logLevel: 'silent',
plugins: [sentryEsbuildPlugin({ telemetry: false, silent: true, sourcemaps: { disable: 'disable-upload' } })],
});

expect(result.warnings).toEqual([]);
const output = result.outputFiles[0]!.text;
expect(output).toContain('_sentryDebugIds');

const module = { exports: {} };
const context = { module, entryRuns: 0 };
runInNewContext(output, context);
expect(module.exports).toMatchObject(expectedExports);
expect(context.entryRuns).toBe(1);
});

it('removes unused named exports when a downstream bundle imports only the default', async () => {
const marker = 'UNUSED_EXPORT_PAYLOAD_';
fs.writeFileSync(
entry,
`globalThis.entryRuns = (globalThis.entryRuns || 0) + 1;
export const unused = ${JSON.stringify(marker + 'x'.repeat(10000))};
export default 42;`,
);
const library = path.join(tmpDir, 'library.js');

const libraryResult = await esbuild.build({
entryPoints: [entry],
outfile: library,
bundle: true,
minify: true,
format: 'esm',
logLevel: 'silent',
plugins: [
sentryEsbuildPlugin({
telemetry: false,
silent: true,
release: { inject: false },
sourcemaps: { disable: 'disable-upload' },
}),
],
});
expect(libraryResult.warnings).toEqual([]);
expect(fs.readFileSync(library, 'utf8')).toContain(marker);

const consumerResult = await esbuild.build({
stdin: {
contents: 'import value from "./library.js"; globalThis.result = value;',
resolveDir: tmpDir,
},
bundle: true,
minify: true,
format: 'iife',
write: false,
logLevel: 'silent',
});
expect(consumerResult.warnings).toEqual([]);
const output = consumerResult.outputFiles[0]!.text;
expect(output).not.toContain(marker);
expect(output).toContain('_sentryDebugIds');

const context = { result: undefined, entryRuns: 0 };
runInNewContext(output, context);
expect(context.result).toBe(42);
expect(context.entryRuns).toBe(1);
});

it('still reports missing exports accessed by user code', async () => {
fs.writeFileSync(path.join(tmpDir, 'named.js'), 'export const answer = 42;');
fs.writeFileSync(
entry,
'import * as named from "./named.js"; globalThis.value = named.missing; export default 42;',
);

const result = await esbuild.build({
entryPoints: [entry],
absWorkingDir: tmpDir,
bundle: true,
write: false,
logLevel: 'silent',
plugins: [sentryEsbuildPlugin({ telemetry: false, silent: true, sourcemaps: { disable: 'disable-upload' } })],
});

expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toMatchObject({
id: 'import-is-undefined',
location: { file: 'entry.js' },
});
});

it('preserves output paths with multiple entry points and an explicit outbase', async () => {
for (const name of ['one', 'two']) {
const dir = path.join(tmpDir, 'src', name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.js'), 'export default 42;');
}

const result = await esbuild.build({
absWorkingDir: tmpDir,
entryPoints: ['src/one/index.js', 'src/two/index.js'],
outbase: 'src',
outdir: 'dist',
bundle: true,
format: 'esm',
write: false,
logLevel: 'silent',
plugins: [sentryEsbuildPlugin({ telemetry: false, silent: true, sourcemaps: { disable: 'disable-upload' } })],
});

expect(result.warnings).toEqual([]);
expect(result.outputFiles.map(file => path.relative(tmpDir, file.path)).sort()).toEqual([
path.join('dist', 'one', 'index.js'),
path.join('dist', 'two', 'index.js'),
]);
});
});
Loading