Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions packages/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
},
".": {
"types": "./build/types/index.types.d.ts",
"workerd": {
"import": "./build/esm/index.workerd.js",
"require": "./build/cjs/index.workerd.js"
},
"worker": {
"import": "./build/esm/index.worker.js",
"require": "./build/cjs/index.worker.js"
Expand Down
3 changes: 2 additions & 1 deletion packages/sveltekit/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default makeNPMConfigVariants(
'src/index.server.ts',
'src/index.client.ts',
'src/index.worker.ts',
'src/index.workerd.ts',
'src/client/index.ts',
// Browser-tracing variants, kept as standalone entrypoints so the `sentrySvelteKit()` plugin
// (or the `exports` fallback) can select one per SvelteKit version.
Expand All @@ -18,7 +19,7 @@ export default makeNPMConfigVariants(
packageSpecificConfig: {
// Keep the variant subpath external so the transpiled output preserves the import for the
// consumer to resolve (via `exports` or the `sentrySvelteKit()` plugin).
external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant'],
external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant', 'cloudflare:workers'],
output: {
dynamicImportInCjs: true,
},
Expand Down
14 changes: 14 additions & 0 deletions packages/sveltekit/src/index.workerd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as cloudflareWorkers from 'cloudflare:workers';
import { setCloudflareExecutionContextFallback } from './server-common/utils';

// `cloudflare:workers` only resolves in Cloudflare's own bundlers (wrangler, `@cloudflare/vite-plugin`),
// which all select the `workerd` export condition. Consumers of the generic `worker` condition keep
// getting `index.worker`, which has no such import and would otherwise fail to bundle.
//
// A namespace import keeps a missing `waitUntil` export (runtimes older than August 2025) a missing
// property instead of a module linking error that would take the whole Worker down.
setCloudflareExecutionContextFallback(() =>
typeof cloudflareWorkers.waitUntil === 'function' ? { waitUntil: cloudflareWorkers.waitUntil } : undefined,
);

export * from './worker';
32 changes: 26 additions & 6 deletions packages/sveltekit/src/server-common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,36 @@ export type MinimalCloudflareExecutionContext = {
waitUntil(promise: Promise<unknown>): void;
};

type CloudflareExecutionContextProvider = () => MinimalCloudflareExecutionContext | undefined;

let fallbackExecutionContextProvider: CloudflareExecutionContextProvider | undefined;

/**
* Registers where to get the execution context from when `platform` doesn't carry one.
*
* Since `8.0.0-next.7`, `@sveltejs/adapter-cloudflare` no longer passes a `platform` object to
* SvelteKit at all; `waitUntil` is imported from `cloudflare:workers` instead. That module only
* resolves under the `workerd` export condition, so the entry point for that condition registers it
* here instead of the shared worker code importing it directly.
*
* @see https://github.com/sveltejs/kit/pull/16754
*/
export function setCloudflareExecutionContextFallback(provider: CloudflareExecutionContextProvider | undefined): void {
fallbackExecutionContextProvider = provider;
}

/**
* Reads the Cloudflare execution context off a SvelteKit `platform` object.
* Reads the Cloudflare execution context off a SvelteKit `platform` object, falling back to the
* provider registered via `setCloudflareExecutionContextFallback`.
*
* The property name differs by adapter version:
* - `@sveltejs/adapter-cloudflare` <= 7 exposes it as `platform.context`
* - `@sveltejs/adapter-cloudflare` 8 renamed it to `platform.ctx`
* - `@sveltejs/adapter-cloudflare` 8 renamed it to `platform.ctx`, and later prereleases dropped
* `platform` altogether (see the fallback)
*
* We read both so that request isolation and `waitUntil`-based flushing keep working across the
* adapter versions our peer range allows. Both accesses fail silently when the shape changes, so
* dropping either one costs us events without surfacing an error.
* We read all of them so that request isolation and `waitUntil`-based flushing keep working across
* the adapter versions our peer range allows. Every access fails silently when the shape changes,
* so dropping any of them costs us events without surfacing an error.
*
* @see https://github.com/sveltejs/kit/pull/16668
*/
Expand All @@ -26,7 +46,7 @@ export function getCloudflareExecutionContext(platform: unknown): MinimalCloudfl
context?: MinimalCloudflareExecutionContext;
};

return ctx ?? context;
return ctx ?? context ?? fallbackExecutionContextProvider?.();
}

/**
Expand Down
11 changes: 7 additions & 4 deletions packages/sveltekit/src/worker/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
setAsyncLocalStorageAsyncContextStrategy();

const handleInitSentry: Handle = ({ event, resolve }) => {
// if event.platform exists (should be there in a cloudflare worker), then do the cloudflare sentry init
if (event.platform) {
const context = getCloudflareExecutionContext(event.platform);

// Either signals a Cloudflare Worker: `event.platform` up to `adapter-cloudflare` 8.0.0-next.6, the
// execution context resolved through `cloudflare:workers` (see `index.workerd.ts`) after that.
if (event.platform || context) {
// This is an optional local that the `sentryHandle` handler checks for to avoid double isolation
// In Cloudflare the `wrapRequestHandler` function already takes care of
// - request isolation
Expand All @@ -46,8 +49,8 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
{
options: opts,
request: event.request,
// @ts-expect-error This will exist in Cloudflare
context: getCloudflareExecutionContext(event.platform),
// @ts-expect-error The SDK only ever calls `waitUntil`, the wrapper's type asks for the full context
context,
// We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler
// where we can distinguish between redirects and actual errors.
captureErrors: false,
Expand Down
6 changes: 6 additions & 0 deletions packages/sveltekit/src/worker/cloudflareWorkers.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// `@cloudflare/workers-types` declares this module, but pulling it in would also add Workers globals
// that clash with the DOM lib the client half of this package is checked against. Only the export
// the SDK reads is declared here; the declaration is build-time only and never emitted.
declare module 'cloudflare:workers' {
export function waitUntil(promise: Promise<unknown>): void;
}
40 changes: 40 additions & 0 deletions packages/sveltekit/test/index.workerd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCloudflareExecutionContext, setCloudflareExecutionContextFallback } from '../src/server-common/utils';

describe('workerd entry point', () => {
afterEach(() => {
setCloudflareExecutionContextFallback(undefined);
vi.resetModules();
vi.doUnmock('cloudflare:workers');
});

it('registers `waitUntil` from `cloudflare:workers` as the fallback execution context', async () => {
const waitUntil = vi.fn();
vi.doMock('cloudflare:workers', () => ({ waitUntil }));

const workerdSdk = await import('../src/index.workerd');

const context = getCloudflareExecutionContext(undefined);
const task = Promise.resolve();
context?.waitUntil(task);

expect(waitUntil).toHaveBeenCalledWith(task);
expect(workerdSdk.initCloudflareSentryHandle).toBeTypeOf('function');
});

it('still prefers the execution context on `platform`', async () => {
vi.doMock('cloudflare:workers', () => ({ waitUntil: vi.fn() }));
await import('../src/index.workerd');

const ctx = { waitUntil: vi.fn() };

expect(getCloudflareExecutionContext({ ctx })).toBe(ctx);
});

it('resolves no execution context on runtimes where `waitUntil` is not importable', async () => {
vi.doMock('cloudflare:workers', () => ({}));
await import('../src/index.workerd');

expect(getCloudflareExecutionContext(undefined)).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions packages/sveltekit/test/server-common/handleError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as SentryCore from '@sentry/core';
import type { HandleServerError, RequestEvent } from '@sveltejs/kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleErrorWithSentry } from '../../src/server-common/handleError';
import { setCloudflareExecutionContextFallback } from '../../src/server-common/utils';

const mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'xx');

Expand Down Expand Up @@ -158,6 +159,29 @@ describe('handleError (server)', () => {

expect(mockCaptureException).toHaveBeenCalledTimes(1);
});

// `@sveltejs/adapter-cloudflare` >= 8.0.0-next.7 passes no `platform` at all; the `workerd` entry point
// registers `waitUntil` from `cloudflare:workers` as the fallback instead
it('calls the fallback waitUntil if the event carries no platform', async () => {
const wrappedHandleError = handleErrorWithSentry();
const mockError = new Error('test');
const waitUntilSpy = vi.fn();
setCloudflareExecutionContextFallback(() => ({ waitUntil: waitUntilSpy }));

try {
await wrappedHandleError({
error: mockError,
event: { ...requestEvent, platform: undefined },
status: 500,
message: 'Internal Error',
});
} finally {
setCloudflareExecutionContextFallback(undefined);
}

expect(waitUntilSpy).toHaveBeenCalledTimes(1);
expect(waitUntilSpy).toHaveBeenCalledWith(expect.any(Promise));
});
});
});

Expand Down
44 changes: 42 additions & 2 deletions packages/sveltekit/test/server-common/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { getTracePropagationData } from '../../src/server-common/utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getCloudflareExecutionContext,
getTracePropagationData,
setCloudflareExecutionContextFallback,
} from '../../src/server-common/utils';

const MOCK_REQUEST_EVENT: any = {
request: {
Expand Down Expand Up @@ -48,3 +52,39 @@ describe('getTracePropagationData', () => {
expect(baggage).toBeUndefined();
});
});

describe('getCloudflareExecutionContext', () => {
afterEach(() => {
setCloudflareExecutionContextFallback(undefined);
});

it.each([
['context', 'adapter-cloudflare <= 7'],
['ctx', 'adapter-cloudflare 8'],
])('reads platform.%s (%s)', platformKey => {
const ctx = { waitUntil: vi.fn() };

expect(getCloudflareExecutionContext({ [platformKey]: ctx })).toBe(ctx);
});

it('returns undefined without a platform and without a registered fallback', () => {
expect(getCloudflareExecutionContext(undefined)).toBeUndefined();
expect(getCloudflareExecutionContext({})).toBeUndefined();
});

// `adapter-cloudflare` >= 8.0.0-next.7 passes no `platform` object at all
it('falls back to the registered provider when the platform carries no execution context', () => {
const fallbackCtx = { waitUntil: vi.fn() };
setCloudflareExecutionContextFallback(() => fallbackCtx);

expect(getCloudflareExecutionContext(undefined)).toBe(fallbackCtx);
expect(getCloudflareExecutionContext({})).toBe(fallbackCtx);
});

it('prefers the execution context on the platform over the fallback', () => {
const ctx = { waitUntil: vi.fn() };
setCloudflareExecutionContextFallback(() => ({ waitUntil: vi.fn() }));

expect(getCloudflareExecutionContext({ ctx })).toBe(ctx);
});
});
32 changes: 31 additions & 1 deletion packages/sveltekit/test/worker/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as SentryCloudflare from '@sentry/cloudflare';
import { _INTERNAL_wrapRequestHandler as wrapRequestHandler } from '@sentry/cloudflare';
import type { Carrier, GLOBAL_OBJ } from '@sentry/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setCloudflareExecutionContextFallback } from '../../src/server-common/utils';
import { initCloudflareSentryHandle } from '../../src/worker';

vi.mock('@sentry/cloudflare', async importOriginal => {
Expand All @@ -27,6 +28,10 @@ describe('initCloudflareSentryHandle', () => {
vi.mocked(wrapRequestHandler).mockClear();
});

afterEach(() => {
setCloudflareExecutionContextFallback(undefined);
});

it('sets the async context strategy when called', () => {
vi.spyOn(SentryCloudflare, 'setAsyncLocalStorageAsyncContextStrategy');

Expand Down Expand Up @@ -85,6 +90,31 @@ describe('initCloudflareSentryHandle', () => {
expect(locals._sentrySkipRequestIsolation).toBe(true);
});

// `@sveltejs/adapter-cloudflare` >= 8.0.0-next.7 passes no `platform` at all; the `workerd` entry point
// registers `waitUntil` from `cloudflare:workers` as the fallback instead
it('calls wrapRequestHandler with the fallback execution context, if no platform data is set', async () => {
const { options, event, resolve, request } = getHandlerInput();
// @ts-expect-error - removing platform data
delete event.platform;
const fallbackContext = { waitUntil: vi.fn() };
setCloudflareExecutionContextFallback(() => fallbackContext);

// @ts-expect-error - resolving an empty object is enough for this test
vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb());

const handle = initCloudflareSentryHandle(options);

// @ts-expect-error - only passing a partial event object
await handle({ event, resolve });

expect(wrapRequestHandler).toHaveBeenCalledTimes(1);
expect(wrapRequestHandler).toHaveBeenCalledWith(
expect.objectContaining({ request, context: fallbackContext, captureErrors: false }),
expect.any(Function),
);
expect(resolve).toHaveBeenCalledTimes(1);
});

it('falls back to resolving the event, if no platform data is set', async () => {
const { options, event, resolve } = getHandlerInput();
// @ts-expect-error - removing platform data
Expand Down
Loading