diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 5307ad48336b..273cfb16a766 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -393,14 +393,49 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state } } - // `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 9f987f6ea78a..7212a8fe55c8 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -76,8 +76,9 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { expect(resolve).toHaveBeenCalledWith('@sentry/nextjs/cloudflare', join(dir, 'src/index.ts')); expect(result.code).toBe( "import * as __SENTRY__ from '@sentry/nextjs/cloudflare';\n" + - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + - 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);\n', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + + 'export { __SENTRY_DEFAULT_EXPORT__ as default };\n' + + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', ); }); @@ -98,8 +99,9 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { expect(result.code).toBe( "import * as __SENTRY__ from '@sentry/cloudflare';\n" + - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + - 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);\n', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + + 'export { __SENTRY_DEFAULT_EXPORT__ as default };\n' + + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', ); }); @@ -118,8 +120,9 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { expect(result.code).toBe( "import * as __SENTRY__ from '@sentry/cloudflare';\n" + - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + - 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);\n', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + + 'export { __SENTRY_DEFAULT_EXPORT__ as default };\n' + + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', ); }); @@ -142,8 +145,9 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { expect(result.code).toBe( "import * as __SENTRY__ from '@sentry/cloudflare';\n" + - 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + - 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);\n', + 'var __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };\n' + + 'export { __SENTRY_DEFAULT_EXPORT__ as default };\n' + + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', ); }); @@ -518,9 +522,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'), ); }); @@ -596,9 +600,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 8b3cd2ec207f..539b66c98961 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';", @@ -932,9 +1020,9 @@ describe('agent class wrapping', () => { 'const __SENTRY_WRAPPED_MyDO__ = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({ dsn: env.SENTRY_DSN }), MyDO);', 'export { __SENTRY_WRAPPED_MyDO__ as 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'), ); }); @@ -1044,8 +1132,8 @@ describe('combined transforms', () => { expect(result.code).toContain('export { __SENTRY_WRAPPED_MyDO__ as MyDO };'); // 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;