From a3b9450cd12d18f4ac022893eb08038938d65820 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 22 Oct 2026 10:25:59 -0700 Subject: [PATCH] fix(cloudflare): Bind a default re-export with `var` in the Vite transform The transform rewrote `export { default } from './handler'` to bind the wrapped handler with a `const`. A module in an import cycle that read the entry's default export during evaluation hit the `const` before its initialization, threw a `ReferenceError`, and stopped the Worker from starting. The binding is now a `var`, as `wrapDefaultSpecifier` and `wrapDefaultExport` already use. A cyclic read gets `undefined` and does not throw. It does not get the handler as the original live binding did, because the wrapper is not set until the entry's body runs. The JSDoc states this limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cloudflare/src/vite/transform.ts | 7 +++- .../cloudflare/test/vite/transform.test.ts | 33 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 273cfb16a766..e640de3a45d1 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -604,6 +604,11 @@ function wrapDefaultSpecifier(localName: string, state: TransformState): string /** * `export { default } from './handler'`. The handler may already be wrapped in its own module, * which this transform cannot see, so the emitted guard returns such a handler as-is. + * + * The original re-export is a live binding to the handler module's export, but the wrapper is not + * set until this module's body runs. A circular import that reads the default export before then + * gets `undefined` instead of the handler. The binding is a `var` so that the read does not throw, + * as a `const` would, and stop the Worker from starting. See `wrapDefaultSpecifier`. */ function wrapDefaultReexport( localName: string, @@ -616,7 +621,7 @@ function wrapDefaultReexport( localName === DEFAULT_IMPORT ? `import ${target} from ${sourceLiteral};` : `import { ${localName} as ${target} } from ${sourceLiteral};`, - `const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, ${state.optionsFn}, ${target});`, + `var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, ${state.optionsFn}, ${target});`, ); state.needsImport = true; state.autoWrapped.add(DEFAULT_EXPORT); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 539b66c98961..76129b034923 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -241,7 +241,7 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import __SENTRY_REEXPORT_default__ from './handler';", - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', ].join('\n'), ); @@ -254,12 +254,39 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import { handler as __SENTRY_REEXPORT_default__ } from './handler';", - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', ].join('\n'), ); }); + it('does not throw when a circular import reads a default re-export during module evaluation', () => { + // `b` reads the entry's default export after `./handler.mjs` has run but before the entry has. + const entry = [ + "export { default } from './handler.mjs';", + "import { readDefault } from './b.mjs';", + 'console.log(readDefault());', + ].join('\n'); + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => handler, _INTERNAL_wrapUnlessInstrumented: (wrap, options, handler) => wrap(options, handler) };', + ); + + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-cycle-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync(join(dir, 'handler.mjs'), 'export default { fetch() {} };\n'); + writeFileSync( + join(dir, 'b.mjs'), + "import handler from './a.mjs';\nconst kind = typeof handler;\nexport function readDefault() { return kind; }\n", + ); + // Plain Node: Vitest's module runner does not evaluate import cycles like native ESM does. + expect(execFileSync(process.execPath, [join(dir, 'a.mjs')], { encoding: 'utf8' }).trim()).toBe('undefined'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('leaves sibling specifiers of a default re-export untouched', () => { const code = "export { default, helper } from './handler';"; @@ -267,7 +294,7 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import __SENTRY_REEXPORT_default__ from './handler';", - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__._INTERNAL_wrapUnlessInstrumented(__SENTRY__.withSentry, (env) => ({}), __SENTRY_REEXPORT_default__);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', "export { helper } from './handler';", ].join('\n'),