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
63 changes: 59 additions & 4 deletions packages/cloudflare/src/vite/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,14 +416,69 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state
return;
}

// `export default <expr>` → `const __SENTRY_DEFAULT_EXPORT__ = <expr>`
// 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 = exportDefaultKeywordsEnd(source, node.start);
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__ =');
Comment thread
cursor[bot] marked this conversation as resolved.
}
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);
}

/**
* 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 {
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 {
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;

Expand Down
12 changes: 6 additions & 6 deletions packages/cloudflare/test/vite/autoInstrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
});
Expand Down Expand Up @@ -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'),
);
});
Expand Down
129 changes: 122 additions & 7 deletions packages/cloudflare/test/vite/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(');
});

Expand Down Expand Up @@ -286,6 +286,121 @@ 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 <entry>` prints.
*/
function runWithEntry(files: Record<string, string>, 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('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('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');

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';",
Expand Down Expand Up @@ -926,9 +1041,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'),
);
});
Expand Down Expand Up @@ -1034,8 +1149,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;
Expand Down
Loading