From 5dcedca0c06db1b731aa5e8a98b94f3b58a116dd Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 27 Feb 2026 11:18:05 +0100 Subject: [PATCH] fix(astro): Do not inject withSentry into Cloudflare Pages --- packages/astro/src/integration/index.ts | 42 ++- .../astro/test/integration/cloudflare.test.ts | 252 ++++++++++++++++++ 2 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 packages/astro/test/integration/cloudflare.test.ts diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 796d6f84a12b..26991cfede39 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -163,6 +163,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } const isCloudflare = config?.adapter?.name?.startsWith('@astrojs/cloudflare'); + const isCloudflareWorkers = isCloudflare && !isCloudflarePages(); if (isCloudflare) { try { @@ -191,8 +192,8 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { injectScript('page-ssr', buildServerSnippet(options || {})); } - if (isCloudflare && command !== 'dev') { - // For Cloudflare production builds, additionally use a Vite plugin to: + if (isCloudflareWorkers && command !== 'dev') { + // For Cloudflare Workers production builds, additionally use a Vite plugin to: // 1. Import the server config at the Worker entry level (so Sentry.init() runs // for ALL requests, not just SSR pages — covers actions and API routes) // 2. Wrap the default export with `withSentry` from @sentry/cloudflare for @@ -215,6 +216,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/ updateConfig({ vite: { + plugins: [sentryCloudflareNodeWarningPlugin()], ssr: { // @sentry/node is required in case we have 2 different @sentry/node // packages installed in the same project. @@ -255,6 +257,42 @@ function findDefaultSdkInitFile(type: 'server' | 'client'): string | undefined { .find(filename => fs.existsSync(filename)); } +/** + * Detects if the project is a Cloudflare Pages project by checking for + * `pages_build_output_dir` in the wrangler configuration file. + * + * Cloudflare Pages projects use `pages_build_output_dir` while Workers projects + * use `assets.directory` or `main` fields instead. + */ +function isCloudflarePages(): boolean { + const cwd = process.cwd(); + const configFiles = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']; + + for (const configFile of configFiles) { + const configPath = path.join(cwd, configFile); + + if (!fs.existsSync(configPath)) { + continue; + } + + const content = fs.readFileSync(configPath, 'utf-8'); + + if (configFile.endsWith('.toml')) { + return content.includes('pages_build_output_dir'); + } + + try { + // Strip comments from JSONC before parsing + const parsed = JSON.parse(content.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')); + return 'pages_build_output_dir' in parsed; + } catch { + return false; + } + } + + return false; +} + function getSourcemapsAssetsGlob(config: AstroConfig): string { // The vercel adapter puts the output into its .vercel directory // However, the way this adapter is written, the config.outDir value is update too late for diff --git a/packages/astro/test/integration/cloudflare.test.ts b/packages/astro/test/integration/cloudflare.test.ts new file mode 100644 index 000000000000..f383fe263aae --- /dev/null +++ b/packages/astro/test/integration/cloudflare.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryAstro } from '../../src/integration'; + +const getWranglerConfig = vi.hoisted(() => vi.fn()); + +vi.mock('fs', async requireActual => { + return { + ...(await requireActual()), + existsSync: vi.fn((p: string) => { + const wranglerConfig = getWranglerConfig(); + + if (wranglerConfig && p.includes(wranglerConfig.filename)) { + return true; + } + return false; + }), + readFileSync: vi.fn(() => { + const wranglerConfig = getWranglerConfig(); + + if (wranglerConfig) { + return wranglerConfig.content; + } + return ''; + }), + }; +}); + +vi.mock('@sentry/vite-plugin', () => ({ + sentryVitePlugin: vi.fn(() => 'sentryVitePlugin'), +})); + +vi.mock('../../src/integration/cloudflare', () => ({ + sentryCloudflareNodeWarningPlugin: vi.fn(() => 'sentryCloudflareNodeWarningPlugin'), + sentryCloudflareVitePlugin: vi.fn(() => 'sentryCloudflareVitePlugin'), +})); + +const baseConfigHookObject = vi.hoisted(() => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + injectScript: vi.fn(), + updateConfig: vi.fn(), +})); + +describe('Cloudflare Pages vs Workers detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + getWranglerConfig.mockReturnValue(null); + }); + + describe('Cloudflare Workers (no pages_build_output_dir)', () => { + it('adds Cloudflare Vite plugins for Workers production build', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + main: 'dist/_worker.js/index.js', + assets: { directory: './dist' }, + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + vite: expect.objectContaining({ + plugins: expect.arrayContaining(['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin']), + }), + }), + ); + }); + + it('adds Cloudflare Vite plugins when no wrangler config exists', async () => { + getWranglerConfig.mockReturnValue(null); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + vite: expect.objectContaining({ + plugins: expect.arrayContaining(['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin']), + }), + }), + ); + }); + }); + + describe('Cloudflare Pages (with pages_build_output_dir)', () => { + it('does not show warning for Pages project with wrangler.json', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + + it('does not show warning for Pages project with wrangler.jsonc', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.jsonc', + content: `{ + // This is a comment + "pages_build_output_dir": "./dist" + }`, + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + + it('does not show warning for Pages project with wrangler.toml', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.toml', + content: ` +name = "my-astro-app" +pages_build_output_dir = "./dist" + `, + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + + it('does not add Cloudflare Vite plugins for Pages production build', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + // Check that sentryCloudflareVitePlugin is NOT in any of the calls + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + { vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }) }, + ); + }); + + it('still adds SSR noExternal config for Pages in dev mode', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'dev', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + vite: expect.objectContaining({ + ssr: expect.objectContaining({ + noExternal: ['@sentry/astro', '@sentry/node'], + }), + }), + }), + ); + }); + }); + + describe('Non-Cloudflare adapters', () => { + it('does not show Cloudflare warning for other adapters', async () => { + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/vercel' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + }); +});