From 1c3a46f2fbd72d30b90431ece2811f6046ee23d3 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 13 Oct 2026 19:43:36 -0700 Subject: [PATCH 1/5] fix(cloudflare): auto-instrument when wrangler `main` is a package When wrangler `main` is a package export, such as `"main": "vinext/server/fetch-handler"` or `"main": "@astrojs/cloudflare/entrypoints/server"`, the Vite plugin did not wrap the Worker. The build passed, but the bundle had no Sentry code, and the plugin gave no warning. `unstable_readConfig()` resolved every `main` as a path relative to the wrangler config, so no module matched the entry. The plugin now reads `main` the way `@cloudflare/vite-plugin` does. A value with a JS or TS extension is a file path. Any other value is resolved with Vite, once per environment, from the wrangler config. If it does not resolve, the plugin warns that auto-instrumentation is off. For a package entry, `instrument.server.*` is read from beside the wrangler config. The resolve uses `createIdResolver` from `vite`, so `vite` is now an optional peer dependency. `this.resolve()` in `transform` does not work: Rollup's commonjs resolver loads each module it resolves, so a resolve of the entry from the entry's own transform never finishes. A `resolveId` hook does not work either, because `vite:resolve` resolves bare specifiers before user plugins see them. `createIdResolver` uses each environment's own conditions (for example `workerd`), so it picks the same file that the Cloudflare plugin bundles. Bundled package entries often end in `export { handler as default }`. The transform only wrapped `export default `, so it now also wraps a default export in that form. The new `cloudflare-autoinstrument-package-entry` e2e app installs a local package as the Worker entry. `@sentry/astro` still needs changes before `astro-6-cf-workers` can use a package `main`, so that app stays as it is, and will be fixed in a subsequent PR. Fixes #24522 Fixes JS-3725 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../.gitignore | 4 + .../instrument.server.ts | 10 +++ .../package.json | 28 ++++++ .../playwright.config.ts | 12 +++ .../start-event-proxy.mjs | 6 ++ .../tests/package-entry.test.ts | 33 +++++++ .../vite.config.ts | 7 ++ .../worker-entry/fetch-handler.js | 11 +++ .../worker-entry/package.json | 9 ++ .../wrangler.jsonc | 8 ++ packages/cloudflare/package.json | 4 + .../cloudflare/src/vite/autoInstrument.ts | 86 ++++++++++++++----- .../cloudflare/src/vite/instrumentFile.ts | 10 +-- packages/cloudflare/src/vite/transform.ts | 25 +++++- .../cloudflare/src/vite/wranglerConfig.ts | 30 ++++--- .../test/vite/autoInstrument.test.ts | 83 +++++++++++++++++- .../cloudflare/test/vite/transform.test.ts | 43 ++++++++++ .../test/vite/wranglerConfig.test.ts | 8 ++ 18 files changed, 378 insertions(+), 39 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore new file mode 100644 index 000000000000..3f64191fe034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore @@ -0,0 +1,4 @@ +dist +.wrangler +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts new file mode 100644 index 000000000000..10d0f17fbcdb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The worker entry is a package, so the plugin reads this file from beside +// the wrangler config. +export default defineCloudflareOptions((env: { E2E_TEST_DSN: string }) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json new file mode 100644 index 000000000000..b0875bc0b32c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json @@ -0,0 +1,28 @@ +{ + "name": "cloudflare-autoinstrument-package-entry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "worker-entry": "file:../../test-applications/cloudflare-autoinstrument-package-entry/worker-entry" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts new file mode 100644 index 000000000000..9633de63b272 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts @@ -0,0 +1,12 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 8787, + }, + { + workers: '100%', + retries: 0, + }, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..553dac81dc3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-autoinstrument-package-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts new file mode 100644 index 000000000000..9a1fd7448c8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// The worker entry is a package with no Sentry calls. Any event that arrives +// proves the plugin wrapped the module that wrangler's `main` resolved to. + +test('wraps a package entry with withSentry (options from instrument.server.ts)', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan( + 'cloudflare-autoinstrument-package-entry', + span => getSpanOp(span) === 'http.server' && span.is_segment, + ); + + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + + const span = await spanPromise; + expect(span.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); + // Only `instrument.server.ts` sets this environment. + expect(span.attributes['sentry.environment']?.value).toBe('qa'); +}); + +test('captures an error thrown by a package entry', async ({ baseURL }) => { + const errorPromise = waitForError( + 'cloudflare-autoinstrument-package-entry', + event => event.exception?.values?.[0]?.value === 'Thrown from a package entry', + ); + + const res = await fetch(`${baseURL}/throw`); + expect(res.status).toBe(500); + + const error = await errorPromise; + expect(error.environment).toBe('qa'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js new file mode 100644 index 000000000000..1d8a976634e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js @@ -0,0 +1,11 @@ +// Shaped like bundler output (e.g. `@astrojs/cloudflare`): the default export +// is a specifier, not `export default `. +var fetch_handler_default = { + async fetch(request) { + if (new URL(request.url).pathname === '/throw') { + throw new Error('Thrown from a package entry'); + } + return new Response('ok'); + }, +}; +export { fetch_handler_default as default }; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json new file mode 100644 index 000000000000..08018b205e6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json @@ -0,0 +1,9 @@ +{ + "name": "worker-entry", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./fetch-handler": "./fetch-handler.js" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc new file mode 100644 index 000000000000..4f9bc84616ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-autoinstrument-package-entry", + // A package export, not a file path. The Sentry plugin resolves it with Vite. + "main": "worker-entry/fetch-handler", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c4145caf6c47..27494f3c6059 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -61,12 +61,16 @@ }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x", + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", "wrangler": "^4.x" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { "optional": true }, + "vite": { + "optional": true + }, "wrangler": { "optional": true } diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index c4550f2fa12f..0a725d224a0e 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -1,4 +1,5 @@ -import { basename } from 'node:path'; +import { basename, dirname, isAbsolute } from 'node:path'; +import { createIdResolver, type ResolvedConfig } from 'vite'; import { collectAgentCandidates, detectAgentClasses, type ModuleResolver } from './agentClass'; import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type ProgramBody } from './transform'; @@ -30,14 +31,48 @@ async function resolvesNextjsCloudflare(resolver: ModuleResolver, importer: stri } } +type TransformContext = ModuleResolver & { + warn?(msg: string): void; + environment?: Parameters>[0]; +}; + export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPath?: string } = {}) { let wranglerConfig: WranglerConfig | undefined; let entryFilePath: string | undefined; - - let optionsFn = ENV_FALLBACK_OPTIONS_FN; - let optionsImport: string | undefined; + // A `main` that is not a file path (a package export or virtual module). + let entrySpecifier: string | undefined; + let wranglerConfigPath: string | undefined; + let resolveId: ReturnType | undefined; + // Each environment resolves with its own conditions, so the entry may differ between them. + const resolvedEntries = new Map>(); + + let instrumentFilePath: string | undefined; let isVinext = false; + function resolveEntrySpecifier(ctx: TransformContext, specifier: string): Promise { + const environment = ctx.environment; + if (!environment || !resolveId) return Promise.resolve(undefined); + + let entry = resolvedEntries.get(environment.name); + if (!entry) { + // Resolved from the wrangler config, as wrangler does. Without an importer, Vite's resolver + // tries the specifier as a path below the root first. + entry = resolveId(environment, specifier, wranglerConfigPath) + .catch(() => undefined) + .then(id => { + if (!id) { + ctx.warn?.( + `[sentry] Could not resolve the wrangler \`main\` "${specifier}". Auto-instrumentation disabled.`, + ); + return undefined; + } + return normalizePath(id.replace(/[?#].*$/, '')); + }); + resolvedEntries.set(environment.name, entry); + } + return entry; + } + return { name: 'sentry-cloudflare-auto-instrument', @@ -64,40 +99,43 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa } wranglerConfig = result.config; - if (wranglerConfig.main) { - // `main` is already absolute (wrangler resolves it); just normalize - // separators so the entry-module comparison holds on Windows. - entryFilePath = normalizePath(wranglerConfig.main); - } - - if (entryFilePath) { - const instrumentFilePath = resolveInstrumentFile(entryFilePath); - if (instrumentFilePath) { - const built = buildOptionsImport(entryFilePath, instrumentFilePath); - optionsFn = built.optionsFn; - optionsImport = built.importStmt; - } + const main = wranglerConfig.main; + if (main && isAbsolute(main)) { + // Normalize separators so the entry-module comparison holds on Windows. + entryFilePath = normalizePath(main); + instrumentFilePath = resolveInstrumentFile(dirname(main)); + } else if (main) { + entrySpecifier = main; + wranglerConfigPath = result.configPath; + // Not `this.resolve()`: Rollup's commonjs resolver loads the module it resolves, so a + // resolve of the entry from the entry's own `transform` never finishes. + resolveId = createIdResolver(config as ResolvedConfig); + // A package entry has no project directory of its own. + instrumentFilePath = resolveInstrumentFile(result.configDir); } }, async transform( - this: ModuleResolver & { warn?(msg: string): void; environment?: { name?: string } }, + this: TransformContext, code: string, id: string, ): Promise<{ code: string; map: unknown } | undefined> { - if (!wranglerConfig || !entryFilePath) return undefined; + if (!wranglerConfig || (!entryFilePath && !entrySpecifier)) return undefined; // The worker entry never belongs to the client (browser) environment. // Skipping it keeps a same-basename sibling (e.g. a `src/index.tsx` // client entry next to a `src/index.ts` worker) out of the browser bundle. if (this.environment?.name === 'client') return undefined; + const entryPath = entrySpecifier ? await resolveEntrySpecifier(this, entrySpecifier) : entryFilePath; + if (!entryPath) return undefined; + // Vite may append query/hash params to the module ID. const normalizedId = normalizePath(id.replace(/[?#].*$/, '')); - if (normalizedId !== entryFilePath) { + if (normalizedId !== entryPath) { // Tolerate a differing JS-flavored extension (e.g. `.js` vs `.ts`). - if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryFilePath)) return undefined; - if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryFilePath.replace(JS_EXTENSION_REGEX, '')) { + if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryPath)) return undefined; + if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryPath.replace(JS_EXTENSION_REGEX, '')) { return undefined; } } @@ -145,6 +183,10 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa const sentryModule = isVinext && (await resolvesNextjsCloudflare(this, normalizedId)) ? NEXTJS_CLOUDFLARE_MODULE : undefined; + const { optionsFn, importStmt: optionsImport } = instrumentFilePath + ? buildOptionsImport(entryPath, instrumentFilePath) + : { optionsFn: ENV_FALLBACK_OPTIONS_FN, importStmt: undefined }; + // No registration import is injected here: the orchestrion plugin's // subscribe-injection makes each bundled package self-register its channel // subscriber on the global marker, so wrapping the entry with `withSentry` diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts index ffd79ca0104c..91c8e9c057f5 100644 --- a/packages/cloudflare/src/vite/instrumentFile.ts +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -10,17 +10,17 @@ export const ENV_FALLBACK_OPTIONS_FN = '() => undefined'; const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__'; // Conventional, non-configurable name of the Sentry options module. It is -// looked up next to the worker entry file; its default export is the options +// looked up next to the worker entry file, or next to the wrangler config +// when `main` is not a file path; its default export is the options // callback `(env) => CloudflareOptions`. const INSTRUMENT_FILE_BASENAME = 'instrument.server'; const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs']; /** - * Locate the conventional `instrument.server.*` module sitting next to the - * worker entry file. Returns its absolute path, or `undefined` when absent. + * Locate the conventional `instrument.server.*` module in `dir`. Returns its + * absolute path, or `undefined` when absent. */ -export function resolveInstrumentFile(entryFilePath: string): string | undefined { - const dir = dirname(entryFilePath); +export function resolveInstrumentFile(dir: string): string | undefined { for (const ext of INSTRUMENT_FILE_EXTENSIONS) { const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`); if (existsSync(candidate)) return candidate; diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 3674ed5dd032..517585dc056f 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -503,6 +503,26 @@ function wrapSpecifierExports(node: ExportNamedNode, ctx: TransformContext, stat state.ms.overwrite(node.start, node.end, statements.join('\n')); } +/** + * `export { handler as default }`, the form bundlers emit for a default export. The wrapper is + * declared at the end of the module, where `handler` is sure to be initialized. + */ +function wrapDefaultSpecifier(localName: string, state: TransformState): string | undefined { + if (state.manuallyWrappedLocals.has(localName)) return undefined; + + // Same as `export default Foo`: re-point at the wrapper a named export already made. + const wrappedName = state.wrappedLocals.get(localName); + if (wrappedName) { + state.autoWrapped.add(DEFAULT_EXPORT); + return `${wrappedName} as default`; + } + + state.ms.append(`\nconst __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});\n`); + state.needsImport = true; + state.autoWrapped.add(DEFAULT_EXPORT); + return '__SENTRY_DEFAULT_EXPORT__ as default'; +} + /** * Handle one export specifier, returning the `Wrapped as Exported` pair to emit for a class that * gets wrapped. The import/wrapper statements a cross-module pair depends on are pushed onto @@ -528,7 +548,10 @@ function wrapSpecifier( ? resolveWrapperKind(exportedName, sourceLiteral ? undefined : localName, state) : undefined; - if (!exportedName || !localName || !kind) return undefined; + if (!exportedName || !localName) return undefined; + if (!kind) { + return exportedName === DEFAULT_IMPORT && !sourceLiteral ? wrapDefaultSpecifier(localName, state) : undefined; + } // Without a `from` clause the specifier points at a module-local binding. if (!sourceLiteral) { diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index 9838f0609e46..8e02d2036f30 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -4,10 +4,13 @@ import { type Unstable_Config, unstable_readConfig } from 'wrangler'; /** * The slice of the wrangler configuration the auto-instrument plugin cares - * about. `main` is an absolute path (wrangler resolves it against the config - * file's directory). + * about. */ export interface WranglerConfig { + /** + * An absolute path when `main` names a JS or TS file, otherwise the + * original value (a package export or virtual module) for Vite to resolve. + */ main?: string; durableObjects: Array<{ name: string; className: string }>; workflows: Array<{ name: string; className: string }>; @@ -25,6 +28,9 @@ export interface WranglerConfig { sameWorkerBindings: SameWorkerBinding[]; } +// Same rule as `@cloudflare/vite-plugin`: only these extensions mark `main` as a file path. +const ENTRY_FILE_REGEX = /\.(?:m?[jt]s|[jt]sx)$/; + /** Stands in for a class name where a binding targets the module's default export. */ export const DEFAULT_EXPORT = Symbol('defaultExport'); @@ -42,10 +48,10 @@ export interface SameWorkerBinding { * * We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside * `root` with wrangler's own precedence, since it discovers from `cwd` rather - * than an arbitrary root); wrangler then parses it, flattens the active - * environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute - * path. Durable Object and Workflow bindings are the active environment's, - * matching what the deployed Worker actually binds. + * than an arbitrary root); wrangler then parses it and flattens the active + * environment (honoring `CLOUDFLARE_ENV`). Durable Object and Workflow + * bindings are the active environment's, matching what the deployed Worker + * actually binds. * * Returns `undefined` when no config file is found or it can't be read/parsed * (the caller warns and disables auto-instrumentation rather than failing the @@ -54,7 +60,7 @@ export interface SameWorkerBinding { export function resolveWranglerConfig( root: string, explicitPath?: string, -): { config: WranglerConfig; configDir: string } | undefined { +): { config: WranglerConfig; configPath: string; configDir: string } | undefined { const configPath = explicitPath ? resolve(root, explicitPath) : ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync); @@ -67,20 +73,24 @@ export function resolveWranglerConfig( try { // `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO // migrations) out of the Vite build output. - raw = unstable_readConfig({ config: configPath }, { hideWarnings: true }); + raw = unstable_readConfig({ config: configPath }, { hideWarnings: true, preserveOriginalMain: true }); } catch { return undefined; } + const resolvedConfigPath = raw.configPath ?? configPath; + const configDir = dirname(resolvedConfigPath); + return { config: { - main: raw.main, + main: raw.main && ENTRY_FILE_REGEX.test(raw.main) ? resolve(configDir, raw.main) : raw.main, durableObjects: collectClassBindings(raw.durable_objects?.bindings), workflows: collectClassBindings(raw.workflows), workerEntrypoints: collectSelfBoundEntrypoints(raw), sameWorkerBindings: collectSameWorkerBindings(raw), }, - configDir: dirname(raw.configPath ?? configPath), + configPath: resolvedConfigPath, + configDir, }; } diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index f9654b0d3b5d..9f987f6ea78a 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -2,9 +2,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'acorn'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCloudflareAutoInstrumentPlugin } from '../../src/vite/autoInstrument'; +const { idResolver } = vi.hoisted(() => ({ idResolver: vi.fn() })); +vi.mock('vite', () => ({ createIdResolver: () => idResolver })); + function parseJS(code: string) { return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; } @@ -562,6 +565,84 @@ describe('wranglerConfigPath option', () => { }); }); +describe('package specifier as wrangler `main`', () => { + const MAIN = 'vinext/server/fetch-handler'; + const code = 'export default { fetch() { return new Response("ok"); } };'; + const ssr = { name: 'ssr' }; + + beforeEach(() => { + idResolver.mockReset(); + }); + + function createPlugin(files: Record = {}) { + const dir = writeTempDir({ 'wrangler.jsonc': JSON.stringify({ main: MAIN }), ...files }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const entryPath = join(dir, 'node_modules/vinext/dist/server/fetch-handler.js'); + idResolver.mockImplementation(async (_env: unknown, id: string) => (id === MAIN ? entryPath : undefined)); + const warn = vi.fn(); + const tx = (id: string, environment = ssr) => + plugin.transform.call({ parse: (c: string) => parseJS(c), warn, environment }, code, id); + + return { tx, entryPath, dir, warn }; + } + + it('wraps the module the specifier resolves to', async () => { + const { tx, entryPath, dir } = createPlugin(); + + const result = await tx(entryPath); + expect(idResolver).toHaveBeenCalledWith(ssr, MAIN, join(dir, 'wrangler.jsonc')); + 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__);', + '', + ].join('\n'), + ); + }); + + it('resolves the specifier once per environment', async () => { + const { tx, dir } = createPlugin(); + + await tx(join(dir, 'a.js')); + await tx(join(dir, 'b.js')); + await tx(join(dir, 'a.js'), { name: 'rsc' }); + expect(idResolver).toHaveBeenCalledTimes(2); + expect(idResolver).toHaveBeenCalledWith({ name: 'rsc' }, MAIN, join(dir, 'wrangler.jsonc')); + }); + + it('does not treat the specifier as a path relative to the config', async () => { + const { tx, dir } = createPlugin(); + + expect(await tx(join(dir, MAIN))).toBeUndefined(); + expect(await tx(join(dir, `${MAIN}.js`))).toBeUndefined(); + }); + + it('imports instrument.server.* from beside the wrangler config', async () => { + const { tx, entryPath } = createPlugin({ + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const result = await tx(entryPath); + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from '../../../../instrument.server.ts';"); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS_CALLBACK__,'); + }); + + it('warns once when the specifier does not resolve', async () => { + const { tx, dir, warn } = createPlugin(); + idResolver.mockResolvedValue(undefined); + + expect(await tx(join(dir, 'a.js'))).toBeUndefined(); + expect(await tx(join(dir, 'b.js'))).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[sentry] Could not resolve the wrangler `main` "vinext/server/fetch-handler". Auto-instrumentation disabled.', + ); + }); +}); + // --------------------------------------------------------------------------- // instrument.server.* auto-detection (config from a conventional module) // --------------------------------------------------------------------------- diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 7e26dd77465b..de159305ee3e 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -75,6 +75,49 @@ describe('default export wrapping', () => { expect(result.code).toContain('__SENTRY__.withSentry('); }); + it('wraps a local binding exported as default by a specifier', () => { + const code = ['var server_default = { fetch: handle };', 'export { server_default as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var server_default = { fetch: handle };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', + '', + ].join('\n'), + ); + }); + + it('leaves sibling specifiers of a default specifier export untouched', () => { + const code = [ + 'const helper = 1;', + 'const handler = { fetch() {} };', + 'export { helper, handler as default };', + ].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const helper = 1;', + 'const handler = { fetch() {} };', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'export { helper };', + 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + '', + ].join('\n'), + ); + }); + + it('skips a hand-wrapped local exported as default by a specifier', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'const handler = withSentry((env) => ({}), { fetch() {} });', + 'export { handler as default };', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + it('uses custom options callback', () => { const custom: TransformContext = { classWrappers: doWrappers(), diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 9d8612146b60..87396151e929 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -41,6 +41,14 @@ describe('resolveWranglerConfig', () => { expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); }); + it('keeps a package specifier `main` unchanged', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': JSON.stringify({ main: '@astrojs/cloudflare/entrypoints/server' }), + }); + + expect(resolveWranglerConfig(dir)!.config.main).toBe('@astrojs/cloudflare/entrypoints/server'); + }); + it('parses wrangler.json', () => { const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ From 103b2dd26ee85a8889bd12fd00a69d2a9666a3af Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 13 Oct 2026 21:00:49 -0700 Subject: [PATCH 2/5] fix: bugbot --- .../cloudflare-autoinstrument-package-entry/package.json | 1 + .../tests/package-entry.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json index b0875bc0b32c..2211b7f3455b 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json @@ -18,6 +18,7 @@ "@cloudflare/vite-plugin": "^1.47.0", "@cloudflare/workers-types": "^5.20260727.1", "@playwright/test": "~1.63.0", + "@sentry/conventions": "0.27.0", "@sentry-internal/test-utils": "link:../../../test-utils", "vite": "^8.1.5", "wrangler": "^4.114.0" diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts index 9a1fd7448c8e..7baf4af55892 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts @@ -1,4 +1,6 @@ import { expect, test } from '@playwright/test'; +import { SENTRY_ENVIRONMENT, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; // The worker entry is a package with no Sentry calls. Any event that arrives @@ -7,16 +9,16 @@ import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/t test('wraps a package entry with withSentry (options from instrument.server.ts)', async ({ baseURL }) => { const spanPromise = waitForStreamedSpan( 'cloudflare-autoinstrument-package-entry', - span => getSpanOp(span) === 'http.server' && span.is_segment, + span => getSpanOp(span) === HTTP_SERVER && span.is_segment, ); const res = await fetch(baseURL!); expect(res.status).toBe(200); const span = await spanPromise; - expect(span.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); + expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare'); // Only `instrument.server.ts` sets this environment. - expect(span.attributes['sentry.environment']?.value).toBe('qa'); + expect(span.attributes[SENTRY_ENVIRONMENT]?.value).toBe('qa'); }); test('captures an error thrown by a package entry', async ({ baseURL }) => { From 2d9b2390029ef9de8b3542ac18baac44d17e7e2a Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 14 Oct 2026 09:35:03 -0700 Subject: [PATCH 3/5] fix(cloudflare): set the wrapped default export where its binding is set For `export { handler as default }`, the transform declared `const __SENTRY_DEFAULT_EXPORT__ = withSentry(...)` at the end of the module. Until that line ran, the default export was in the temporal dead zone. A circular import that read it during module evaluation threw a `ReferenceError` and stopped the Worker from starting, where the original `var` binding was already set. The wrapper is now declared right after the statement that declares the binding. It is a `var`, so an earlier read gets `undefined`, as it would from the bundler's own `var`, and never throws. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cloudflare/src/vite/transform.ts | 37 ++++++++- .../cloudflare/test/vite/transform.test.ts | 76 ++++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 517585dc056f..063ef1576c05 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -61,6 +61,10 @@ interface VariableDeclarationNode extends BaseNode { declarations?: VariableDeclaratorNode[]; } +interface ImportDeclarationNode extends BaseNode { + specifiers?: Array<{ local?: { type: string; name?: string } }>; +} + function isCallToMethod(node: BaseNode, methodName: string): boolean { if (node.type !== 'CallExpression') return false; const callee = (node as CallExpressionNode).callee; @@ -169,6 +173,7 @@ export function applyAutoInstrumentTransforms( optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, autoWrapped: new Set(), manuallyWrappedLocals: collectManuallyWrappedLocals(ast), + declaringStatements: collectDeclaringStatements(ast), }; const { wrappedClasses } = state; @@ -245,6 +250,8 @@ interface TransformState { * specifier must report it as wrapped rather than wrap it a second time. */ manuallyWrappedLocals: Set; + /** Top-level binding name → the statement that declares it. */ + declaringStatements: Map; } /** @@ -332,6 +339,24 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { return locals; } +function collectDeclaringStatements(ast: ProgramBody): Map { + const statements = new Map(); + for (const node of ast.body) { + const declaration = node.type === 'ExportNamedDeclaration' ? (node as ExportNamedNode).declaration : node; + if (!declaration) continue; + const ids = + declaration.type === 'VariableDeclaration' + ? ((declaration as VariableDeclarationNode).declarations ?? []).map(declarator => declarator.id) + : declaration.type === 'ImportDeclaration' + ? ((declaration as ImportDeclarationNode).specifiers ?? []).map(specifier => specifier.local) + : [(declaration as ClassDeclarationNode).id]; + for (const id of ids) { + if (id?.type === 'Identifier' && id.name) statements.set(id.name, node); + } + } + return statements; +} + function collectTopLevelClasses(ast: ProgramBody): Map { const classes = new Map(); for (const node of ast.body) { @@ -505,7 +530,9 @@ function wrapSpecifierExports(node: ExportNamedNode, ctx: TransformContext, stat /** * `export { handler as default }`, the form bundlers emit for a default export. The wrapper is - * declared at the end of the module, where `handler` is sure to be initialized. + * declared right after `handler`, so the default export is set as soon as `handler` is. It is a + * `var`: a circular import that reads the default export earlier gets `undefined`, as it would + * from the bundler's own `var`, where a `const` would throw and stop the Worker from starting. */ function wrapDefaultSpecifier(localName: string, state: TransformState): string | undefined { if (state.manuallyWrappedLocals.has(localName)) return undefined; @@ -517,7 +544,13 @@ function wrapDefaultSpecifier(localName: string, state: TransformState): string return `${wrappedName} as default`; } - state.ms.append(`\nconst __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});\n`); + const wrapper = `var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});`; + const declaringStatement = state.declaringStatements.get(localName); + if (declaringStatement) { + state.ms.appendLeft(declaringStatement.end, `\n${wrapper}`); + } else { + state.ms.append(`\n${wrapper}\n`); + } state.needsImport = true; state.autoWrapped.add(DEFAULT_EXPORT); return '__SENTRY_DEFAULT_EXPORT__ as default'; diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index de159305ee3e..c71514bafea0 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { parse } from 'acorn'; import { describe, expect, it } from 'vitest'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type TransformContext } from '../../src/vite/transform'; @@ -82,13 +86,78 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", 'var server_default = { fetch: handle };', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', - '', ].join('\n'), ); }); + it('declares the default wrapper after a local declared below the export', () => { + const code = ['export { handler as default };', 'const handler = { fetch() {} };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('declares the default wrapper after a function declaration', () => { + const code = ['function handler() {}', 'export { handler as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'function handler() {}', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + ].join('\n'), + ); + }); + + it('declares the default wrapper after the import of an imported local', () => { + const code = ["import handler from './handler';", 'export { handler as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import handler from './handler';", + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + ].join('\n'), + ); + }); + + it('keeps the default export readable from a circular import during module evaluation', () => { + // `b` reads the entry's default export while the entry is still evaluating, as bundled code can. + const entry = [ + "import { readDefault } from './b.mjs';", + 'var server_default = { fetch() {} };', + 'console.log(readDefault());', + 'export { server_default as default };', + ].join('\n'); + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => handler };', + ); + + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-cycle-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync( + join(dir, 'b.mjs'), + "import handler from './a.mjs';\nexport function readDefault() { return typeof handler.fetch; }\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('function'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('leaves sibling specifiers of a default specifier export untouched', () => { const code = [ 'const helper = 1;', @@ -101,10 +170,9 @@ describe('default export wrapping', () => { "import * as __SENTRY__ from '@sentry/cloudflare';", 'const helper = 1;', 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', 'export { helper };', - 'const __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', - '', ].join('\n'), ); }); From 1698a1d750c4ec4c27bbf23d5a145a852acc9d5f Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 14 Oct 2026 11:17:32 -0700 Subject: [PATCH 4/5] fix(cloudflare): wrap the default export as it is at the end of the module For `export { handler as default }`, the wrapper ran right after `handler` was declared. A handler assigned later (`let handler; ... handler = { fetch }`) was exported as `undefined`, and a method added later (`handler.fetch = ...`) was not instrumented, because `withSentry` only patches the methods that exist when it runs. The default export is now set in two steps. Right after `handler` is declared, it is bound to `handler` as-is, so a circular import that reads it during module evaluation still gets the handler. At the end of the module, it is rebound to `withSentry(..., handler)`, which sees the handler as it ends up. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cloudflare/src/vite/transform.ts | 19 ++++--- .../cloudflare/test/vite/transform.test.ts | 55 ++++++++++++++++--- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 063ef1576c05..2d5781d611b7 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -529,10 +529,13 @@ function wrapSpecifierExports(node: ExportNamedNode, ctx: TransformContext, stat } /** - * `export { handler as default }`, the form bundlers emit for a default export. The wrapper is - * declared right after `handler`, so the default export is set as soon as `handler` is. It is a - * `var`: a circular import that reads the default export earlier gets `undefined`, as it would - * from the bundler's own `var`, where a `const` would throw and stop the Worker from starting. + * `export { handler as default }`, the form bundlers emit for a default export. The export is set + * in two steps. Right after `handler` is declared, it is bound to `handler` as-is, so a circular + * import that reads it during module evaluation gets the handler, as before the transform. At the + * end of the module, it is rebound to the wrapper, so a handler assigned or given methods later is + * wrapped as it ends up. The binding is a `var`: a read before `handler` is declared gets + * `undefined`, as it would from the bundler's own `var`, where a `const` would throw and stop the + * Worker from starting. */ function wrapDefaultSpecifier(localName: string, state: TransformState): string | undefined { if (state.manuallyWrappedLocals.has(localName)) return undefined; @@ -544,13 +547,13 @@ function wrapDefaultSpecifier(localName: string, state: TransformState): string return `${wrappedName} as default`; } - const wrapper = `var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});`; const declaringStatement = state.declaringStatements.get(localName); if (declaringStatement) { - state.ms.appendLeft(declaringStatement.end, `\n${wrapper}`); - } else { - state.ms.append(`\n${wrapper}\n`); + state.ms.appendLeft(declaringStatement.end, `\nvar __SENTRY_DEFAULT_EXPORT__ = ${localName};`); } + state.ms.append( + `\n${declaringStatement ? '' : 'var '}__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});`, + ); state.needsImport = true; state.autoWrapped.add(DEFAULT_EXPORT); return '__SENTRY_DEFAULT_EXPORT__ as default'; diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index c71514bafea0..49ef0b0cd33e 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -86,13 +86,14 @@ describe('default export wrapping', () => { [ "import * as __SENTRY__ from '@sentry/cloudflare';", 'var server_default = { fetch: handle };', - 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', + 'var __SENTRY_DEFAULT_EXPORT__ = server_default;', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', ].join('\n'), ); }); - it('declares the default wrapper after a local declared below the export', () => { + it('binds the default export after a local declared below the export', () => { const code = ['export { handler as default };', 'const handler = { fetch() {} };'].join('\n'); expect(transform(code, ctx)!.code).toBe( @@ -100,33 +101,36 @@ describe('default export wrapping', () => { "import * as __SENTRY__ from '@sentry/cloudflare';", 'export { __SENTRY_DEFAULT_EXPORT__ as default };', 'const handler = { fetch() {} };', - 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', ].join('\n'), ); }); - it('declares the default wrapper after a function declaration', () => { + it('binds the default export after a function declaration', () => { const code = ['function handler() {}', 'export { handler as default };'].join('\n'); expect(transform(code, ctx)!.code).toBe( [ "import * as __SENTRY__ from '@sentry/cloudflare';", 'function handler() {}', - 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', ].join('\n'), ); }); - it('declares the default wrapper after the import of an imported local', () => { + it('binds the default export after the import of an imported local', () => { const code = ["import handler from './handler';", 'export { handler as default };'].join('\n'); expect(transform(code, ctx)!.code).toBe( [ "import * as __SENTRY__ from '@sentry/cloudflare';", "import handler from './handler';", - 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', ].join('\n'), ); }); @@ -158,6 +162,40 @@ describe('default export wrapping', () => { } }); + /** + * Runs `a.mjs` transformed, under plain Node, with a `withSentry` stub that marks each method it + * finds as instrumented, and returns what `main.mjs` prints. + */ + function runInstrumented(entry: string): string { + const transformed = transform(entry, 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; } };', + ); + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-late-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync( + join(dir, 'main.mjs'), + "import handler from './a.mjs';\nconsole.log(String(handler?.fetch?.instrumented));\n", + ); + return execFileSync(process.execPath, [join(dir, 'main.mjs')], { encoding: 'utf8' }).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it('wraps a default export assigned after its declaration', () => { + const entry = ['let handler;', 'handler = { fetch() {} };', 'export { handler as default };'].join('\n'); + expect(runInstrumented(entry)).toBe('true'); + }); + + it('wraps a method added to the default export after its declaration', () => { + const entry = ['const handler = {};', 'handler.fetch = function () {};', 'export { handler as default };'].join( + '\n', + ); + expect(runInstrumented(entry)).toBe('true'); + }); + it('leaves sibling specifiers of a default specifier export untouched', () => { const code = [ 'const helper = 1;', @@ -170,9 +208,10 @@ describe('default export wrapping', () => { "import * as __SENTRY__ from '@sentry/cloudflare';", 'const helper = 1;', 'const handler = { fetch() {} };', - 'var __SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', 'export { __SENTRY_DEFAULT_EXPORT__ as default };', 'export { helper };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', ].join('\n'), ); }); From 99fc4b2d9eaa3f3a8e64a39a25af74d1e83e1887 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 14 Oct 2026 11:37:53 -0700 Subject: [PATCH 5/5] fix: bugbot finding --- packages/cloudflare/src/vite/transform.ts | 19 +++++++++----- .../cloudflare/test/vite/transform.test.ts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 2d5781d611b7..49b623d6c431 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -219,7 +219,7 @@ interface TransformState { needsImport: boolean; wrappedClasses: Set; /** - * Top-level (non-exported) class declarations, so specifier exports like + * Top-level class declarations, exported or not, so specifier exports like * `export { MyDO }` can locate the class they refer to. */ topLevelClasses: Map; @@ -326,8 +326,9 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { const locals = new Set(); for (const node of ast.body) { - if (node.type !== 'VariableDeclaration') continue; - for (const declarator of (node as VariableDeclarationNode).declarations ?? []) { + const declaration = declarationOf(node); + if (declaration?.type !== 'VariableDeclaration') continue; + for (const declarator of (declaration as VariableDeclarationNode).declarations ?? []) { const name = declarator.id?.type === 'Identifier' ? declarator.id.name : undefined; const init = declarator.init; if (name && init && wrapperMethods.some(method => isCallToMethod(init, method))) { @@ -342,7 +343,7 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { function collectDeclaringStatements(ast: ProgramBody): Map { const statements = new Map(); for (const node of ast.body) { - const declaration = node.type === 'ExportNamedDeclaration' ? (node as ExportNamedNode).declaration : node; + const declaration = declarationOf(node); if (!declaration) continue; const ids = declaration.type === 'VariableDeclaration' @@ -360,13 +361,19 @@ function collectDeclaringStatements(ast: ProgramBody): Map { function collectTopLevelClasses(ast: ProgramBody): Map { const classes = new Map(); for (const node of ast.body) { - if (node.type !== 'ClassDeclaration') continue; - const classNode = node as ClassDeclarationNode; + const declaration = declarationOf(node); + if (declaration?.type !== 'ClassDeclaration') continue; + const classNode = declaration as ClassDeclarationNode; if (classNode.id?.name) classes.set(classNode.id.name, classNode); } return classes; } +/** The declaration a top-level statement makes, looking through `export` (`export const x = ...`). */ +function declarationOf(node: BaseNode): BaseNode | null | undefined { + return node.type === 'ExportNamedDeclaration' ? (node as ExportNamedNode).declaration : node; +} + function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void { const decl = node.declaration; diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 49ef0b0cd33e..92396bf21bae 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -216,6 +216,15 @@ describe('default export wrapping', () => { ); }); + it('skips a hand-wrapped `export const` exported as default by a specifier', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export const handler = withSentry((env) => ({}), { fetch() {} });', + 'export { handler as default };', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + it('skips a hand-wrapped local exported as default by a specifier', () => { const code = [ "import { withSentry } from '@sentry/cloudflare';", @@ -650,6 +659,22 @@ describe('WorkerEntrypoint class wrapping (structural)', () => { expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); }); + it.each([ + ['after', ['export class AdminEntry extends WorkerEntrypoint {}', 'export { AdminEntry as default };']], + ['before', ['export { AdminEntry as default };', 'export class AdminEntry extends WorkerEntrypoint {}']], + ])('shares one wrapper between `export class` and a default specifier %s it', (_order, lines) => { + const code = ["import { WorkerEntrypoint } from 'cloudflare:workers';", ...lines].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code.match(/withSentry\(/g)).toHaveLength(1); + // The wrapper follows the class, so it never reads the class before it is set. + expect(result.code.indexOf('const __SENTRY_WRAPPED_AdminEntry__')).toBeGreaterThan( + result.code.indexOf('class AdminEntry extends WorkerEntrypoint {}'), + ); + expect(result.code).toContain('export { __SENTRY_WRAPPED_AdminEntry__ as default };'); + expect(result.code).toContain('export { __SENTRY_WRAPPED_AdminEntry__ as AdminEntry };'); + }); + it('wraps a class extending an aliased WorkerEntrypoint import', () => { const code = [ "import { WorkerEntrypoint as WE } from 'cloudflare:workers';",