From a43bdbbccda5fe2507e11a8645598b627d88a09e Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 11 Sep 2025 10:45:04 +0200 Subject: [PATCH 1/5] Update types --- .../plugins/sentry-cloudflare.server.ts | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 4ddbe8749586..0488b44095d3 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -9,25 +9,46 @@ import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; -interface CfEventType { +interface EventBase { protocol: string; host: string; method: string; headers: Record; +} + +interface MinimalCfProps { + httpProtocol?: string; + country?: string; + // ...other CF properties +} + +interface MinimalCloudflareProps { + context: ExecutionContext; + request?: Record; + env?: Record; +} + +// Direct shape: cf and cloudflare are directly on context +interface CfEventDirect extends EventBase { context: { - cf: { - httpProtocol?: string; - country?: string; - // ...other CF properties - }; - cloudflare: { - context: ExecutionContext; - request?: Record; - env?: Record; + cf: MinimalCfProps; + cloudflare: MinimalCloudflareProps; + }; +} + +// Nested shape: cf and cloudflare are under _platform +// Since Nitro v2.12.0 (PR: https://github.com/nitrojs/nitro/commit/911a63bc478183acb472d05e977584dcdce61abf) +interface CfEventPlatform extends EventBase { + context: { + _platform: { + cf: MinimalCfProps; + cloudflare: MinimalCloudflareProps; }; }; } +type CfEventType = CfEventDirect | CfEventPlatform; + function isEventType(event: unknown): event is CfEventType { if (event === null || typeof event !== 'object') return false; From 9f0018685d4bca504529dabf36fe895338312261 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 11 Sep 2025 11:11:51 +0200 Subject: [PATCH 2/5] add type checks --- packages/nuxt/src/runtime/plugins/index.ts | 1 - .../plugins/sentry-cloudflare.server.ts | 74 +-------------- packages/nuxt/src/runtime/utils/event-type.ts | 92 +++++++++++++++++++ 3 files changed, 97 insertions(+), 70 deletions(-) create mode 100644 packages/nuxt/src/runtime/utils/event-type.ts diff --git a/packages/nuxt/src/runtime/plugins/index.ts b/packages/nuxt/src/runtime/plugins/index.ts index dbe41b848a0c..b7a5c217edf2 100644 --- a/packages/nuxt/src/runtime/plugins/index.ts +++ b/packages/nuxt/src/runtime/plugins/index.ts @@ -1,2 +1 @@ -// fixme: Can this be exported like this? export { sentryCloudflareNitroPlugin } from './sentry-cloudflare.server'; diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 0488b44095d3..03755b50cb92 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -1,4 +1,4 @@ -import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; +import type { IncomingRequestCfProperties } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from '@sentry/core'; @@ -8,71 +8,7 @@ import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; - -interface EventBase { - protocol: string; - host: string; - method: string; - headers: Record; -} - -interface MinimalCfProps { - httpProtocol?: string; - country?: string; - // ...other CF properties -} - -interface MinimalCloudflareProps { - context: ExecutionContext; - request?: Record; - env?: Record; -} - -// Direct shape: cf and cloudflare are directly on context -interface CfEventDirect extends EventBase { - context: { - cf: MinimalCfProps; - cloudflare: MinimalCloudflareProps; - }; -} - -// Nested shape: cf and cloudflare are under _platform -// Since Nitro v2.12.0 (PR: https://github.com/nitrojs/nitro/commit/911a63bc478183acb472d05e977584dcdce61abf) -interface CfEventPlatform extends EventBase { - context: { - _platform: { - cf: MinimalCfProps; - cloudflare: MinimalCloudflareProps; - }; - }; -} - -type CfEventType = CfEventDirect | CfEventPlatform; - -function isEventType(event: unknown): event is CfEventType { - if (event === null || typeof event !== 'object') return false; - - return ( - // basic properties - 'protocol' in event && - 'host' in event && - typeof event.protocol === 'string' && - typeof event.host === 'string' && - // context property - 'context' in event && - typeof event.context === 'object' && - event.context !== null && - // context.cf properties - 'cf' in event.context && - typeof event.context.cf === 'object' && - event.context.cf !== null && - // context.cloudflare properties - 'cloudflare' in event.context && - typeof event.context.cloudflare === 'object' && - event.context.cloudflare !== null && - 'context' in event.context.cloudflare - ); -} +import { getCfProperties, getCloudflareProperties, isEventType } from '../utils/event-type'; /** * Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt. @@ -128,13 +64,13 @@ export const sentryCloudflareNitroPlugin = const request = new Request(url, { method: event.method, headers: event.headers, - cf: event.context.cf, + cf: getCfProperties(event), }) as Request>; const requestHandlerOptions = { options: cloudflareOptions, request, - context: event.context.cloudflare.context, + context: getCloudflareProperties(event).context, }; return wrapRequestHandler(requestHandlerOptions, () => { @@ -145,7 +81,7 @@ export const sentryCloudflareNitroPlugin = const traceData = getTraceData(); if (traceData && Object.keys(traceData).length > 0) { // Storing trace data in the WeakMap using event.context.cf as key for later use in HTML meta-tags - traceDataMap.set(event.context.cf, traceData); + traceDataMap.set(getCfProperties(event), traceData); debug.log('Stored trace data for later use in HTML meta-tags: ', traceData); } diff --git a/packages/nuxt/src/runtime/utils/event-type.ts b/packages/nuxt/src/runtime/utils/event-type.ts new file mode 100644 index 000000000000..8f585faa9068 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/event-type.ts @@ -0,0 +1,92 @@ +import type { CfProperties, ExecutionContext } from '@cloudflare/workers-types'; + +interface EventBase { + protocol: string; + host: string; + method: string; + headers: Record; +} + +interface MinimalCloudflareProps { + context: ExecutionContext; + request?: Record; + env?: Record; +} + +// Direct shape: cf and cloudflare are directly on context +interface CfEventDirect extends EventBase { + context: { + cf: CfProperties; + cloudflare: MinimalCloudflareProps; + }; +} + +// Nested shape: cf and cloudflare are under _platform +// Since Nitro v2.12.0 (PR: https://github.com/nitrojs/nitro/commit/911a63bc478183acb472d05e977584dcdce61abf) +interface CfEventPlatform extends EventBase { + context: { + _platform: { + cf: CfProperties; + cloudflare: MinimalCloudflareProps; + }; + }; +} + +export type CfEventType = CfEventDirect | CfEventPlatform; + +function hasCfAndCloudflare(context: unknown): boolean { + return ( + context !== null && + typeof context === 'object' && + // context.cf properties + 'cf' in context && + typeof context.cf === 'object' && + context.cf !== null && + // context.cloudflare properties + 'cloudflare' in context && + typeof context.cloudflare === 'object' && + context.cloudflare !== null && + 'context' in context.cloudflare + ); +} + +/** + * Type guard to check if an event is a Cloudflare event (nested in _platform or direct) + */ +export function isEventType(event: unknown): event is CfEventType { + if (event === null || typeof event !== 'object') return false; + + return ( + // basic properties + 'protocol' in event && + 'host' in event && + typeof event.protocol === 'string' && + typeof event.host === 'string' && + // context property + 'context' in event && + typeof event.context === 'object' && + event.context !== null && + // context.cf properties + (hasCfAndCloudflare(event.context) || ('_platform' in event.context && hasCfAndCloudflare(event.context._platform))) + ); +} + +/** + * Extracts cf properties from a Cloudflare event + */ +export function getCfProperties(event: CfEventType): CfProperties { + if ('cf' in event.context) { + return event.context.cf; + } + return event.context._platform.cf; +} + +/** + * Extracts cloudflare properties from a Cloudflare event + */ +export function getCloudflareProperties(event: CfEventType): MinimalCloudflareProps { + if ('cloudflare' in event.context) { + return event.context.cloudflare; + } + return event.context._platform.cloudflare; +} From c0982ff0038ed6a4e254aa3aa3abd724ef33830b Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 11 Sep 2025 11:22:21 +0200 Subject: [PATCH 3/5] add tests --- .../plugins/sentry-cloudflare.server.ts | 2 +- .../{event-type.ts => event-type-check.ts} | 0 .../runtime/utils/event-type-check.test.ts | 216 ++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) rename packages/nuxt/src/runtime/utils/{event-type.ts => event-type-check.ts} (100%) create mode 100644 packages/nuxt/test/runtime/utils/event-type-check.test.ts diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 03755b50cb92..e22d8c9f1639 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -8,7 +8,7 @@ import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; -import { getCfProperties, getCloudflareProperties, isEventType } from '../utils/event-type'; +import { getCfProperties, getCloudflareProperties, isEventType } from '../utils/event-type-check'; /** * Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt. diff --git a/packages/nuxt/src/runtime/utils/event-type.ts b/packages/nuxt/src/runtime/utils/event-type-check.ts similarity index 100% rename from packages/nuxt/src/runtime/utils/event-type.ts rename to packages/nuxt/src/runtime/utils/event-type-check.ts diff --git a/packages/nuxt/test/runtime/utils/event-type-check.test.ts b/packages/nuxt/test/runtime/utils/event-type-check.test.ts new file mode 100644 index 000000000000..a63de96b193d --- /dev/null +++ b/packages/nuxt/test/runtime/utils/event-type-check.test.ts @@ -0,0 +1,216 @@ +import type { CfProperties } from '@cloudflare/workers-types'; +import { describe, expect, it, vi } from 'vitest'; +import { + type CfEventType, + getCfProperties, + getCloudflareProperties, + isEventType, +} from '../../../src/runtime/utils/event-type-check'; + +describe('event-type-check', () => { + const mockCfProperties: CfProperties = { + colo: 'IMLND', + country: 'IL', + region: 'CoreRegion', + timezone: 'ImagineLand/Core', + city: 'Core', + } as CfProperties; + + const mockCloudflareProperties = { + context: { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + props: { key: 'value' }, + }, + request: { url: 'https://example.com' }, + env: { API_KEY: 'test' }, + }; + + const createUnnestedCfEvent = (): CfEventType => ({ + protocol: 'https', + host: 'example.com', + method: 'GET', + headers: { 'user-agent': 'test' }, + context: { + cf: mockCfProperties, + cloudflare: mockCloudflareProperties, + }, + }); + + const createPlatformCfEvent = (): CfEventType => ({ + protocol: 'https', + host: 'example.com', + method: 'POST', + headers: { 'content-type': 'application/json' }, + context: { + _platform: { + cf: mockCfProperties, + cloudflare: mockCloudflareProperties, + }, + }, + }); + + describe('isEventType', () => { + describe('should return true for valid Cloudflare events', () => { + it.each([ + ['direct cf event', createUnnestedCfEvent()], + ['platform cf event', createPlatformCfEvent()], + ])('%s', (_, event) => { + expect(isEventType(event)).toBe(true); + }); + }); + + describe('should return false for invalid inputs', () => { + it.each([ + ['null', null], + ['undefined', undefined], + ['string', 'invalid'], + ['number', 123], + ['boolean', true], + ['array', []], + ['empty object', {}], + ])('%s', (_, input) => { + expect(isEventType(input)).toBe(false); + }); + }); + + describe('should return false for objects missing required properties', () => { + const baseEvent = createUnnestedCfEvent(); + + it.each([ + ['missing protocol', { ...baseEvent, protocol: undefined }], + ['missing host', { ...baseEvent, host: undefined }], + ['missing context', { ...baseEvent, context: undefined }], + ['null context', { ...baseEvent, context: null }], + ['context without cf', { ...baseEvent, context: { cloudflare: mockCloudflareProperties } }], + ['context without cloudflare', { ...baseEvent, context: { cf: mockCfProperties } }], + ['context with null cf', { ...baseEvent, context: { cf: null, cloudflare: mockCloudflareProperties } }], + ['context with null cloudflare', { ...baseEvent, context: { cf: mockCfProperties, cloudflare: null } }], + [ + 'cloudflare without context property', + { + ...baseEvent, + context: { + cf: mockCfProperties, + cloudflare: { request: {}, env: {} }, + }, + }, + ], + ])('%s', (_, invalidEvent) => { + expect(isEventType(invalidEvent)).toBe(false); + }); + }); + + describe('should return false for platform events missing required properties', () => { + const basePlatformEvent = createPlatformCfEvent(); + + it.each([ + [ + 'platform without cf', + { + ...basePlatformEvent, + context: { + _platform: { + cloudflare: mockCloudflareProperties, + }, + }, + }, + ], + [ + 'platform without cloudflare', + { + ...basePlatformEvent, + context: { + _platform: { + cf: mockCfProperties, + }, + }, + }, + ], + [ + 'platform with null cf', + { + ...basePlatformEvent, + context: { + _platform: { + cf: null, + cloudflare: mockCloudflareProperties, + }, + }, + }, + ], + ])('%s', (_, invalidEvent) => { + expect(isEventType(invalidEvent)).toBe(false); + }); + }); + }); + + describe('getCfProperties', () => { + it.each([ + ['direct cf event', createUnnestedCfEvent()], + ['platform cf event', createPlatformCfEvent()], + ])('should extract cf properties from %s', (_, event) => { + const result = getCfProperties(event); + expect(result).toEqual(mockCfProperties); + expect(result.colo).toBe('IMLND'); + expect(result.country).toBe('IL'); + }); + + it('should return the same cf properties for both event types', () => { + const directEvent = createUnnestedCfEvent(); + const platformEvent = createPlatformCfEvent(); + + const directCf = getCfProperties(directEvent); + const platformCf = getCfProperties(platformEvent); + + expect(directCf).toEqual(platformCf); + }); + }); + + describe('getCloudflareProperties', () => { + it.each([ + ['direct cf event', createUnnestedCfEvent()], + ['platform cf event', createPlatformCfEvent()], + ])('should extract cloudflare properties from %s', (_, event) => { + const result = getCloudflareProperties(event); + expect(result).toEqual(mockCloudflareProperties); + expect(result.context).toBeDefined(); + expect(result.request).toEqual({ url: 'https://example.com' }); + expect(result.env).toEqual({ API_KEY: 'test' }); + }); + + it('should return the same cloudflare properties for both event types', () => { + const directEvent = createUnnestedCfEvent(); + const platformEvent = createPlatformCfEvent(); + + const directCloudflare = getCloudflareProperties(directEvent); + const platformCloudflare = getCloudflareProperties(platformEvent); + + expect(directCloudflare).toEqual(platformCloudflare); + }); + }); + + describe('integration tests', () => { + it('should work together for a complete workflow', () => { + const event = createUnnestedCfEvent(); + + expect(isEventType(event)).toBe(true); + + const cf = getCfProperties(event); + const cloudflare = getCloudflareProperties(event); + + expect(cf.country).toBe('IL'); + expect(cloudflare.request?.url).toBe('https://example.com'); + }); + + it('should handle both event structures consistently', () => { + const events = [createUnnestedCfEvent(), createPlatformCfEvent()]; + + events.forEach(event => { + expect(isEventType(event)).toBe(true); + expect(getCfProperties(event)).toEqual(mockCfProperties); + expect(getCloudflareProperties(event)).toEqual(mockCloudflareProperties); + }); + }); + }); +}); From 89f14cedbd4453053ac9c0fcc233bb30cdb4f029 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 11 Sep 2025 11:27:45 +0200 Subject: [PATCH 4/5] fix comment --- packages/nuxt/src/runtime/utils/event-type-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt/src/runtime/utils/event-type-check.ts b/packages/nuxt/src/runtime/utils/event-type-check.ts index 8f585faa9068..8ddff37d809c 100644 --- a/packages/nuxt/src/runtime/utils/event-type-check.ts +++ b/packages/nuxt/src/runtime/utils/event-type-check.ts @@ -22,7 +22,7 @@ interface CfEventDirect extends EventBase { } // Nested shape: cf and cloudflare are under _platform -// Since Nitro v2.12.0 (PR: https://github.com/nitrojs/nitro/commit/911a63bc478183acb472d05e977584dcdce61abf) +// Since Nitro v2.11.7 (PR: https://github.com/nitrojs/nitro/pull/3224) interface CfEventPlatform extends EventBase { context: { _platform: { From 4cc3eb92e6c2c6dc7f676105c1f9f914534fdd63 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 11 Sep 2025 12:14:19 +0200 Subject: [PATCH 5/5] fix html hook --- .../plugins/sentry-cloudflare.server.ts | 16 +++++++- .../src/runtime/utils/event-type-check.ts | 39 ++++++++++++------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index e22d8c9f1639..5438ac829d8a 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -8,7 +8,7 @@ import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; -import { getCfProperties, getCloudflareProperties, isEventType } from '../utils/event-type-check'; +import { getCfProperties, getCloudflareProperties, hasCfProperty, isEventType } from '../utils/event-type-check'; /** * Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt. @@ -101,7 +101,19 @@ export const sentryCloudflareNitroPlugin = // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { - const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined; + let storedTraceData: ReturnType | undefined = undefined; + + if ( + event?.context && + '_platform' in event.context && + event.context._platform && + hasCfProperty(event.context._platform) + ) { + storedTraceData = traceDataMap.get(event.context._platform.cf); + } else if (event?.context && hasCfProperty(event.context)) { + // legacy support (before Nitro v2.11.7 (PR: https://github.com/nitrojs/nitro/pull/3224)) + storedTraceData = traceDataMap.get(event.context.cf); + } if (storedTraceData && Object.keys(storedTraceData).length > 0) { debug.log('Using stored trace data for HTML meta-tags: ', storedTraceData); diff --git a/packages/nuxt/src/runtime/utils/event-type-check.ts b/packages/nuxt/src/runtime/utils/event-type-check.ts index 8ddff37d809c..1da0d8b2677a 100644 --- a/packages/nuxt/src/runtime/utils/event-type-check.ts +++ b/packages/nuxt/src/runtime/utils/event-type-check.ts @@ -13,35 +13,30 @@ interface MinimalCloudflareProps { env?: Record; } +interface CloudflareContext { + cf: CfProperties; + cloudflare: MinimalCloudflareProps; +} + // Direct shape: cf and cloudflare are directly on context interface CfEventDirect extends EventBase { - context: { - cf: CfProperties; - cloudflare: MinimalCloudflareProps; - }; + context: CloudflareContext; } // Nested shape: cf and cloudflare are under _platform // Since Nitro v2.11.7 (PR: https://github.com/nitrojs/nitro/pull/3224) interface CfEventPlatform extends EventBase { context: { - _platform: { - cf: CfProperties; - cloudflare: MinimalCloudflareProps; - }; + _platform: CloudflareContext; }; } export type CfEventType = CfEventDirect | CfEventPlatform; -function hasCfAndCloudflare(context: unknown): boolean { +function hasCloudflareProperty(context: unknown): boolean { return ( context !== null && typeof context === 'object' && - // context.cf properties - 'cf' in context && - typeof context.cf === 'object' && - context.cf !== null && // context.cloudflare properties 'cloudflare' in context && typeof context.cloudflare === 'object' && @@ -50,6 +45,24 @@ function hasCfAndCloudflare(context: unknown): boolean { ); } +/** + * Type guard to check if an event context object has cf properties + */ +export function hasCfProperty(context: unknown): context is { cf: CfProperties } { + return ( + context !== null && + typeof context === 'object' && + // context.cf properties + 'cf' in context && + typeof context.cf === 'object' && + context.cf !== null + ); +} + +function hasCfAndCloudflare(context: unknown): context is CloudflareContext { + return hasCfProperty(context) && hasCloudflareProperty(context); +} + /** * Type guard to check if an event is a Cloudflare event (nested in _platform or direct) */