From 498856a5cc9cc5f05dc251a3bdaaa4082c1f9afa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:18:18 +0000 Subject: [PATCH 1/3] fix(v10/cloudflare): keep default exports intact when auto-instrumenting Backport of #25372. The vinext-specific tests in autoInstrument.test.ts are left out because vinext support (#25000) is not on v10. (cherry picked from commit 6b728c6c0e042e716d11b2d3c39b76672f346ac1) Co-Authored-By: Isaac Schlueter --- packages/cloudflare/src/vite/transform.ts | 43 +++++++- .../test/vite/autoInstrument.test.ts | 12 +-- .../cloudflare/test/vite/transform.test.ts | 102 ++++++++++++++++-- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index ad823b1f00c7..fe30443be433 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -416,14 +416,49 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state return; } - // `export default ` → `const __SENTRY_DEFAULT_EXPORT__ = ` - // MagicString positions are always relative to the original source. - state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = '); - state.ms.append(`\nexport default __SENTRY__.withSentry(${state.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); + // MagicString positions are always relative to the original source. Only the `export default` + // keywords are replaced, so parentheses around the expression stay. + const source = state.ms.original; + const keywordsEnd = node.start + source.slice(node.start, decl.start).lastIndexOf('default') + 'default'.length; + const declaredName = + decl.type === 'ClassDeclaration' || decl.type === 'FunctionDeclaration' + ? (decl as ClassDeclarationNode).id?.name + : undefined; + + // As with `export { handler as default }`, the export is set in two steps: bound as-is where the + // original statement set it, then rebound at the end of the module to the wrapper, which sees + // methods added in between. See `wrapDefaultSpecifier`. + let wrapped = '__SENTRY_DEFAULT_EXPORT__'; + if (declaredName) { + // `export default class Handler {}`: the declaration stays, so `Handler` stays bound in the module. + state.ms.remove(node.start, decl.start); + state.ms.appendLeft(node.end, `\nvar __SENTRY_DEFAULT_EXPORT__ = ${declaredName};`); + wrapped = declaredName; + } else if (isAnonymousFunctionOrClass(decl)) { + // As a property value, the class or function gets the name `default`, as `export default` gives it. + const hasSemicolon = source[node.end - 1] === ';'; + state.ms.overwrite(node.start, keywordsEnd, 'var __SENTRY_DEFAULT_EXPORT__ = { default:'); + state.ms.appendLeft(hasSemicolon ? node.end - 1 : node.end, hasSemicolon ? ' }.default' : ' }.default;'); + } else { + state.ms.overwrite(node.start, keywordsEnd, 'var __SENTRY_DEFAULT_EXPORT__ ='); + } + state.ms.appendLeft(node.end, '\nexport { __SENTRY_DEFAULT_EXPORT__ as default };'); + state.ms.append(`\n__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${wrapped});`); state.needsImport = true; state.autoWrapped.add(DEFAULT_EXPORT); } +function isAnonymousFunctionOrClass(node: BaseNode): boolean { + if (node.type === 'ArrowFunctionExpression') return true; + const isFunctionOrClass = [ + 'FunctionExpression', + 'FunctionDeclaration', + 'ClassExpression', + 'ClassDeclaration', + ].includes(node.type); + return isFunctionOrClass && !(node as ClassDeclarationNode).id; +} + function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void { const decl = node.declaration; diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index cb70d3533b80..379589698688 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -428,9 +428,9 @@ describe('wranglerConfigPath option', () => { expect(result.code).toBe( [ "import * as __SENTRY__ from '@sentry/cloudflare';", - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', - 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', - '', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', ].join('\n'), ); }); @@ -506,9 +506,9 @@ describe('package specifier as wrangler `main`', () => { expect(result.code).toBe( [ "import * as __SENTRY__ from '@sentry/cloudflare';", - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', - 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', - '', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', ].join('\n'), ); }); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 5779fc489ce1..4396d936ff1d 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -52,7 +52,7 @@ describe('default export wrapping', () => { const result = transform(code, ctx)!; expect(result).toBeDefined(); expect(result.code).toContain("import * as __SENTRY__ from '@sentry/cloudflare'"); - expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ = handler'); + expect(result.code).toContain('var __SENTRY_DEFAULT_EXPORT__ = handler'); expect(result.code).toContain('__SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__)'); expect(result.code).not.toContain('export default handler'); expect(result.map).toBeDefined(); @@ -62,7 +62,7 @@ describe('default export wrapping', () => { const code = 'export default { fetch() { return new Response("ok"); } };'; const result = transform(code, ctx)!; expect(result).toBeDefined(); - expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ ='); + expect(result.code).toContain('var __SENTRY_DEFAULT_EXPORT__ ='); expect(result.code).toContain('__SENTRY__.withSentry('); }); @@ -286,6 +286,94 @@ describe('default export wrapping', () => { expect(result.code).toContain('tracesSampleRate: 1.0'); }); + /** + * Writes `files` to a temp dir, with `a.mjs` transformed and `withSentry` stubbed to mark each + * method it finds as instrumented, and returns what `node ` prints. + */ + function runWithEntry(files: Record, entry: string, transformEntry = true): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-default-')); + try { + for (const [name, code] of Object.entries(files)) { + const out = + name === 'a.mjs' && transformEntry + ? transform(code, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => { for (const key of Object.keys(handler)) handler[key].instrumented = true; return handler; } };', + ) + : code; + writeFileSync(join(dir, name), out); + } + return execFileSync(process.execPath, [join(dir, entry)], { encoding: 'utf8' }).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it.each([ + 'export default class { fetch() {} }', + 'export default function () {}', + 'export default () => {};', + 'export default (class { fetch() {} });', + 'export default class Handler { fetch() {} }', + 'export default function handler() {}', + ])('keeps the name of the default export of `%s`', code => { + const files = { 'a.mjs': code, 'b.mjs': "import handler from './a.mjs';\nconsole.log(handler.name);" }; + expect(runWithEntry(files, 'b.mjs')).toBe(runWithEntry(files, 'b.mjs', false)); + }); + + it('wraps a parenthesized default export', () => { + const code = ['const handler = { fetch() {} };', 'export default (handler);'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = (handler);', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__);', + ].join('\n'), + ); + }); + + it('keeps the module binding of a named default class', () => { + const code = ['export default class Handler { fetch() {} }', 'Handler.ready = true;'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'class Handler { fetch() {} }', + 'var __SENTRY_DEFAULT_EXPORT__ = Handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'Handler.ready = true;', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), Handler);', + ].join('\n'), + ); + }); + + it('wraps a method added to the default export after `export default`', () => { + const files = { + 'a.mjs': ['const handler = {};', 'export default handler;', 'handler.fetch = function () {};'].join('\n'), + 'main.mjs': "import handler from './a.mjs';\nconsole.log(String(handler.fetch.instrumented));", + }; + + expect(runWithEntry(files, 'main.mjs')).toBe('true'); + }); + + it('sets the default export where the original statement set it', () => { + // `b` reads the default export of `a` while `a` is still evaluating, after its `export default`. + const files = { + 'a.mjs': [ + "import { readDefault } from './b.mjs';", + 'export default { fetch() {} };', + 'export const seen = readDefault();', + ].join('\n'), + 'b.mjs': "import handler from './a.mjs';\nexport function readDefault() { return typeof handler.fetch; }", + 'main.mjs': "import { seen } from './a.mjs';\nconsole.log(seen);", + }; + + expect(runWithEntry(files, 'main.mjs')).toBe('function'); + }); + it('skips when already wrapped with withSentry', () => { const code = [ "import { withSentry } from '@sentry/cloudflare';", @@ -926,9 +1014,9 @@ describe('agent class wrapping', () => { 'class __SENTRY_ORIGINAL_MyDO__ extends DurableObject {}', 'export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_ORIGINAL_MyDO__);', '', - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() {} };', - 'export default __SENTRY__.withSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_DEFAULT_EXPORT__);', - '', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() {} };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_DEFAULT_EXPORT__);', ].join('\n'), ); }); @@ -1034,8 +1122,8 @@ describe('combined transforms', () => { expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); // Default export wrapped - expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ ='); - expect(result.code).toContain('export default __SENTRY__.withSentry('); + expect(result.code).toContain('var __SENTRY_DEFAULT_EXPORT__ ='); + expect(result.code).toContain('__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry('); // Single import const importCount = (result.code.match(/import \* as __SENTRY__/g) ?? []).length; From 73375f51079d221c9b6b2a8f777d71553e28e9b7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:43:24 +0000 Subject: [PATCH 2/3] fix(v10/cloudflare): Skip comments when locating the `default` keyword A comment between `export default` and the expression that contained the word `default` moved the overwrite past the keyword and produced invalid syntax. --- packages/cloudflare/src/vite/transform.ts | 13 ++++++++++++- packages/cloudflare/test/vite/transform.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index fe30443be433..2af1a65a7cda 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -419,7 +419,7 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // MagicString positions are always relative to the original source. Only the `export default` // keywords are replaced, so parentheses around the expression stay. const source = state.ms.original; - const keywordsEnd = node.start + source.slice(node.start, decl.start).lastIndexOf('default') + 'default'.length; + const keywordsEnd = exportDefaultKeywordsEnd(source, node.start); const declaredName = decl.type === 'ClassDeclaration' || decl.type === 'FunctionDeclaration' ? (decl as ClassDeclarationNode).id?.name @@ -448,6 +448,17 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state state.autoWrapped.add(DEFAULT_EXPORT); } +/** + * Offset just past the `default` keyword of the `export default` statement at `start`. Comments can + * sit between `export`, `default` and the expression, and may themselves contain the word `default`. + */ +function exportDefaultKeywordsEnd(source: string, start: number): number { + const keywords = /export(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*default/y; + keywords.lastIndex = start; + keywords.exec(source); + return keywords.lastIndex; +} + function isAnonymousFunctionOrClass(node: BaseNode): boolean { if (node.type === 'ArrowFunctionExpression') return true; const isFunctionOrClass = [ diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 4396d936ff1d..7be7180d47b3 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -335,6 +335,19 @@ describe('default export wrapping', () => { ); }); + it('wraps a default export with a comment that mentions `default`', () => { + const code = 'export /* the default */ default /* default handler */ { fetch() {} };'; + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var __SENTRY_DEFAULT_EXPORT__ = /* default handler */ { fetch() {} };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__);', + ].join('\n'), + ); + }); + it('keeps the module binding of a named default class', () => { const code = ['export default class Handler { fetch() {} }', 'Handler.ready = true;'].join('\n'); From cdd70584c16177349d44a85a92a91b912313b991 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:03:02 +0000 Subject: [PATCH 3/3] fix(v10/cloudflare): Scan for the `default` keyword without a backtracking regex CodeQL flagged the comment-skipping regex as prone to exponential backtracking. A linear scan over whitespace and comments finds the same offset. --- packages/cloudflare/src/vite/transform.ts | 17 +++++++++++++---- packages/cloudflare/test/vite/transform.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 2af1a65a7cda..43e975782a2d 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -453,10 +453,19 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state * sit between `export`, `default` and the expression, and may themselves contain the word `default`. */ function exportDefaultKeywordsEnd(source: string, start: number): number { - const keywords = /export(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*default/y; - keywords.lastIndex = start; - keywords.exec(source); - return keywords.lastIndex; + let index = start + 'export'.length; + while (index < source.length && !source.startsWith('default', index)) { + if (source.startsWith('//', index)) { + const lineEnd = source.indexOf('\n', index); + index = lineEnd === -1 ? source.length : lineEnd; + } else if (source.startsWith('/*', index)) { + const commentEnd = source.indexOf('*/', index + 2); + index = commentEnd === -1 ? source.length : commentEnd + 2; + } else { + index++; + } + } + return index + 'default'.length; } function isAnonymousFunctionOrClass(node: BaseNode): boolean { diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 7be7180d47b3..9fe5018a832d 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -348,6 +348,20 @@ describe('default export wrapping', () => { ); }); + it('wraps a default export with a line comment that mentions `default`', () => { + const code = ['export // the default', 'default // default handler', '{ fetch() {} };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var __SENTRY_DEFAULT_EXPORT__ = // default handler', + '{ fetch() {} };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__);', + ].join('\n'), + ); + }); + it('keeps the module binding of a named default class', () => { const code = ['export default class Handler { fetch() {} }', 'Handler.ready = true;'].join('\n');