From 2020690d62022b2d9a8fd2e0c084b94617c9b3de Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 27 Feb 2026 11:18:05 +0100 Subject: [PATCH 1/2] 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(); + }); + }); +}); From c707de2b4558fcd0f4b71c11dc2b49ae7c16c13b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 2 Mar 2026 08:21:22 +0100 Subject: [PATCH 2/2] fixup! fix(astro): Do not inject withSentry into Cloudflare Pages --- packages/astro/src/integration/index.ts | 15 ++- .../astro/test/integration/cloudflare.test.ts | 101 +++++++++++++++++- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 26991cfede39..5c5ca2710af6 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -278,16 +278,15 @@ function isCloudflarePages(): boolean { const content = fs.readFileSync(configPath, 'utf-8'); if (configFile.endsWith('.toml')) { - return content.includes('pages_build_output_dir'); + // https://regex101.com/r/Uxe4p0/1 + // Match pages_build_output_dir as a TOML key (at start of line, ignoring whitespace) + // This avoids false positives from comments (lines starting with #) + return /^\s*pages_build_output_dir\s*=/m.test(content); } - 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; - } + // Match "pages_build_output_dir" as a JSON key (followed by :) + // This works for both .json and .jsonc without needing to strip comments + return /"pages_build_output_dir"\s*:/.test(content); } return false; diff --git a/packages/astro/test/integration/cloudflare.test.ts b/packages/astro/test/integration/cloudflare.test.ts index f383fe263aae..e928e556ca4b 100644 --- a/packages/astro/test/integration/cloudflare.test.ts +++ b/packages/astro/test/integration/cloudflare.test.ts @@ -150,6 +150,67 @@ describe('Cloudflare Pages vs Workers detection', () => { expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); }); + it('correctly parses wrangler.json with URLs containing double slashes', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + vars: { + API_URL: 'https://api.example.com/v1', + ANOTHER_URL: 'http://localhost:3000', + }, + }), + }); + + 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({ + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + }); + }); + + it('correctly parses wrangler.jsonc with URLs and comments', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.jsonc', + content: `{ + // API configuration + "pages_build_output_dir": "./dist", + "vars": { + "API_URL": "https://api.example.com/v1", // Production API + "WEBHOOK_URL": "https://hooks.example.com/callback" + } + /* Multi-line + comment */ + }`, + }); + + 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({ + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + }); + }); + it('does not show warning for Pages project with wrangler.toml', async () => { getWranglerConfig.mockReturnValue({ filename: 'wrangler.toml', @@ -174,6 +235,40 @@ pages_build_output_dir = "./dist" expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); }); + it('correctly identifies Workers when pages_build_output_dir appears only in comments', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.toml', + content: ` +name = "my-astro-worker" +# pages_build_output_dir is not used for Workers +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', + }); + + // Workers should get both Cloudflare Vite plugins (including sentryCloudflareVitePlugin) + // This distinguishes it from Pages which only gets sentryCloudflareNodeWarningPlugin + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ + vite: expect.objectContaining({ + plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'], + }), + }); + }); + it('does not add Cloudflare Vite plugins for Pages production build', async () => { getWranglerConfig.mockReturnValue({ filename: 'wrangler.json', @@ -195,9 +290,9 @@ pages_build_output_dir = "./dist" }); // Check that sentryCloudflareVitePlugin is NOT in any of the calls - expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( - { vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }) }, - ); + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + }); }); it('still adds SSR noExternal config for Pages in dev mode', async () => {