Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/nuxt/src/runtime/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
// fixme: Can this be exported like this?
export { sentryCloudflareNitroPlugin } from './sentry-cloudflare.server';
53 changes: 5 additions & 48 deletions packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,50 +8,7 @@ import type { NuxtRenderHTMLContext } from 'nuxt/app';
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
import { addSentryTracingMetaTags } from '../utils';

interface CfEventType {
protocol: string;
host: string;
method: string;
headers: Record<string, string>;
context: {
cf: {
httpProtocol?: string;
country?: string;
// ...other CF properties
};
cloudflare: {
context: ExecutionContext;
request?: Record<string, unknown>;
env?: Record<string, unknown>;
};
};
}

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-check';

/**
* Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt.
Expand Down Expand Up @@ -107,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<unknown, IncomingRequestCfProperties<unknown>>;

const requestHandlerOptions = {
options: cloudflareOptions,
request,
context: event.context.cloudflare.context,
context: getCloudflareProperties(event).context,
};

return wrapRequestHandler(requestHandlerOptions, () => {
Expand All @@ -124,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);
}

Expand Down
92 changes: 92 additions & 0 deletions packages/nuxt/src/runtime/utils/event-type-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { CfProperties, ExecutionContext } from '@cloudflare/workers-types';

interface EventBase {
protocol: string;
host: string;
method: string;
headers: Record<string, string>;
}

interface MinimalCloudflareProps {
context: ExecutionContext;
request?: Record<string, unknown>;
env?: Record<string, unknown>;
}

// 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.11.7 (PR: https://github.com/nitrojs/nitro/pull/3224)
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;
}
216 changes: 216 additions & 0 deletions packages/nuxt/test/runtime/utils/event-type-check.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});