Skip to content

Commit c00a694

Browse files
chargomeclaude
andauthored
fix(sveltekit): Resolve the Cloudflare execution context without event.platform (#24241)
`@sveltejs/adapter-cloudflare@8.0.0-next.7` no longer passes `platform` to SvelteKit (sveltejs/kit#16754), so `initCloudflareSentryHandle` never initialized the SDK and no events were sent, which broke the `sveltekit-3-cloudflare-workers` E2E test. Adds a `workerd` export condition whose entry registers `waitUntil` from `cloudflare:workers` as the fallback execution context, keeping that import out of the generic `worker` entry so other bundlers resolving `worker` stay unaffected. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 4e23004 commit c00a694

10 files changed

Lines changed: 196 additions & 14 deletions

File tree

‎packages/sveltekit/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
},
2626
".": {
2727
"types": "./build/types/index.types.d.ts",
28+
"workerd": {
29+
"import": "./build/esm/index.workerd.js",
30+
"require": "./build/cjs/index.workerd.js"
31+
},
2832
"worker": {
2933
"import": "./build/esm/index.worker.js",
3034
"require": "./build/cjs/index.worker.js"

‎packages/sveltekit/rollup.npm.config.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default makeNPMConfigVariants(
66
'src/index.server.ts',
77
'src/index.client.ts',
88
'src/index.worker.ts',
9+
'src/index.workerd.ts',
910
'src/client/index.ts',
1011
// Browser-tracing variants, kept as standalone entrypoints so the `sentrySvelteKit()` plugin
1112
// (or the `exports` fallback) can select one per SvelteKit version.
@@ -18,7 +19,7 @@ export default makeNPMConfigVariants(
1819
packageSpecificConfig: {
1920
// Keep the variant subpath external so the transpiled output preserves the import for the
2021
// consumer to resolve (via `exports` or the `sentrySvelteKit()` plugin).
21-
external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant'],
22+
external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant', 'cloudflare:workers'],
2223
output: {
2324
dynamicImportInCjs: true,
2425
},
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as cloudflareWorkers from 'cloudflare:workers';
2+
import { setCloudflareExecutionContextFallback } from './server-common/utils';
3+
4+
// `cloudflare:workers` only resolves in Cloudflare's own bundlers (wrangler, `@cloudflare/vite-plugin`),
5+
// which all select the `workerd` export condition. Consumers of the generic `worker` condition keep
6+
// getting `index.worker`, which has no such import and would otherwise fail to bundle.
7+
//
8+
// A namespace import keeps a missing `waitUntil` export (runtimes older than August 2025) a missing
9+
// property instead of a module linking error that would take the whole Worker down.
10+
setCloudflareExecutionContextFallback(() =>
11+
typeof cloudflareWorkers.waitUntil === 'function' ? { waitUntil: cloudflareWorkers.waitUntil } : undefined,
12+
);
13+
14+
export * from './worker';

‎packages/sveltekit/src/server-common/utils.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,36 @@ export type MinimalCloudflareExecutionContext = {
77
waitUntil(promise: Promise<unknown>): void;
88
};
99

10+
type CloudflareExecutionContextProvider = () => MinimalCloudflareExecutionContext | undefined;
11+
12+
let fallbackExecutionContextProvider: CloudflareExecutionContextProvider | undefined;
13+
14+
/**
15+
* Registers where to get the execution context from when `platform` doesn't carry one.
16+
*
17+
* Since `8.0.0-next.7`, `@sveltejs/adapter-cloudflare` no longer passes a `platform` object to
18+
* SvelteKit at all; `waitUntil` is imported from `cloudflare:workers` instead. That module only
19+
* resolves under the `workerd` export condition, so the entry point for that condition registers it
20+
* here instead of the shared worker code importing it directly.
21+
*
22+
* @see https://github.com/sveltejs/kit/pull/16754
23+
*/
24+
export function setCloudflareExecutionContextFallback(provider: CloudflareExecutionContextProvider | undefined): void {
25+
fallbackExecutionContextProvider = provider;
26+
}
27+
1028
/**
11-
* Reads the Cloudflare execution context off a SvelteKit `platform` object.
29+
* Reads the Cloudflare execution context off a SvelteKit `platform` object, falling back to the
30+
* provider registered via `setCloudflareExecutionContextFallback`.
1231
*
1332
* The property name differs by adapter version:
1433
* - `@sveltejs/adapter-cloudflare` <= 7 exposes it as `platform.context`
15-
* - `@sveltejs/adapter-cloudflare` 8 renamed it to `platform.ctx`
34+
* - `@sveltejs/adapter-cloudflare` 8 renamed it to `platform.ctx`, and later prereleases dropped
35+
* `platform` altogether (see the fallback)
1636
*
17-
* We read both so that request isolation and `waitUntil`-based flushing keep working across the
18-
* adapter versions our peer range allows. Both accesses fail silently when the shape changes, so
19-
* dropping either one costs us events without surfacing an error.
37+
* We read all of them so that request isolation and `waitUntil`-based flushing keep working across
38+
* the adapter versions our peer range allows. Every access fails silently when the shape changes,
39+
* so dropping any of them costs us events without surfacing an error.
2040
*
2141
* @see https://github.com/sveltejs/kit/pull/16668
2242
*/
@@ -26,7 +46,7 @@ export function getCloudflareExecutionContext(platform: unknown): MinimalCloudfl
2646
context?: MinimalCloudflareExecutionContext;
2747
};
2848

29-
return ctx ?? context;
49+
return ctx ?? context ?? fallbackExecutionContextProvider?.();
3050
}
3151

3252
/**

‎packages/sveltekit/src/worker/cloudflare.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
3434
setAsyncLocalStorageAsyncContextStrategy();
3535

3636
const handleInitSentry: Handle = ({ event, resolve }) => {
37-
// if event.platform exists (should be there in a cloudflare worker), then do the cloudflare sentry init
38-
if (event.platform) {
37+
const context = getCloudflareExecutionContext(event.platform);
38+
39+
// Either signals a Cloudflare Worker: `event.platform` up to `adapter-cloudflare` 8.0.0-next.6, the
40+
// execution context resolved through `cloudflare:workers` (see `index.workerd.ts`) after that.
41+
if (event.platform || context) {
3942
// This is an optional local that the `sentryHandle` handler checks for to avoid double isolation
4043
// In Cloudflare the `wrapRequestHandler` function already takes care of
4144
// - request isolation
@@ -46,8 +49,8 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
4649
{
4750
options: opts,
4851
request: event.request,
49-
// @ts-expect-error This will exist in Cloudflare
50-
context: getCloudflareExecutionContext(event.platform),
52+
// @ts-expect-error The SDK only ever calls `waitUntil`, the wrapper's type asks for the full context
53+
context,
5154
// We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler
5255
// where we can distinguish between redirects and actual errors.
5356
captureErrors: false,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// `@cloudflare/workers-types` declares this module, but pulling it in would also add Workers globals
2+
// that clash with the DOM lib the client half of this package is checked against. Only the export
3+
// the SDK reads is declared here; the declaration is build-time only and never emitted.
4+
declare module 'cloudflare:workers' {
5+
export function waitUntil(promise: Promise<unknown>): void;
6+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { getCloudflareExecutionContext, setCloudflareExecutionContextFallback } from '../src/server-common/utils';
3+
4+
describe('workerd entry point', () => {
5+
afterEach(() => {
6+
setCloudflareExecutionContextFallback(undefined);
7+
vi.resetModules();
8+
vi.doUnmock('cloudflare:workers');
9+
});
10+
11+
it('registers `waitUntil` from `cloudflare:workers` as the fallback execution context', async () => {
12+
const waitUntil = vi.fn();
13+
vi.doMock('cloudflare:workers', () => ({ waitUntil }));
14+
15+
const workerdSdk = await import('../src/index.workerd');
16+
17+
const context = getCloudflareExecutionContext(undefined);
18+
const task = Promise.resolve();
19+
context?.waitUntil(task);
20+
21+
expect(waitUntil).toHaveBeenCalledWith(task);
22+
expect(workerdSdk.initCloudflareSentryHandle).toBeTypeOf('function');
23+
});
24+
25+
it('still prefers the execution context on `platform`', async () => {
26+
vi.doMock('cloudflare:workers', () => ({ waitUntil: vi.fn() }));
27+
await import('../src/index.workerd');
28+
29+
const ctx = { waitUntil: vi.fn() };
30+
31+
expect(getCloudflareExecutionContext({ ctx })).toBe(ctx);
32+
});
33+
34+
it('resolves no execution context on runtimes where `waitUntil` is not importable', async () => {
35+
vi.doMock('cloudflare:workers', () => ({}));
36+
await import('../src/index.workerd');
37+
38+
expect(getCloudflareExecutionContext(undefined)).toBeUndefined();
39+
});
40+
});

‎packages/sveltekit/test/server-common/handleError.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as SentryCore from '@sentry/core';
22
import type { HandleServerError, RequestEvent } from '@sveltejs/kit';
33
import { beforeEach, describe, expect, it, vi } from 'vitest';
44
import { handleErrorWithSentry } from '../../src/server-common/handleError';
5+
import { setCloudflareExecutionContextFallback } from '../../src/server-common/utils';
56

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

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

159160
expect(mockCaptureException).toHaveBeenCalledTimes(1);
160161
});
162+
163+
// `@sveltejs/adapter-cloudflare` >= 8.0.0-next.7 passes no `platform` at all; the `workerd` entry point
164+
// registers `waitUntil` from `cloudflare:workers` as the fallback instead
165+
it('calls the fallback waitUntil if the event carries no platform', async () => {
166+
const wrappedHandleError = handleErrorWithSentry();
167+
const mockError = new Error('test');
168+
const waitUntilSpy = vi.fn();
169+
setCloudflareExecutionContextFallback(() => ({ waitUntil: waitUntilSpy }));
170+
171+
try {
172+
await wrappedHandleError({
173+
error: mockError,
174+
event: { ...requestEvent, platform: undefined },
175+
status: 500,
176+
message: 'Internal Error',
177+
});
178+
} finally {
179+
setCloudflareExecutionContextFallback(undefined);
180+
}
181+
182+
expect(waitUntilSpy).toHaveBeenCalledTimes(1);
183+
expect(waitUntilSpy).toHaveBeenCalledWith(expect.any(Promise));
184+
});
161185
});
162186
});
163187

‎packages/sveltekit/test/server-common/utils.test.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { describe, expect, it } from 'vitest';
2-
import { getTracePropagationData } from '../../src/server-common/utils';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import {
3+
getCloudflareExecutionContext,
4+
getTracePropagationData,
5+
setCloudflareExecutionContextFallback,
6+
} from '../../src/server-common/utils';
37

48
const MOCK_REQUEST_EVENT: any = {
59
request: {
@@ -48,3 +52,39 @@ describe('getTracePropagationData', () => {
4852
expect(baggage).toBeUndefined();
4953
});
5054
});
55+
56+
describe('getCloudflareExecutionContext', () => {
57+
afterEach(() => {
58+
setCloudflareExecutionContextFallback(undefined);
59+
});
60+
61+
it.each([
62+
['context', 'adapter-cloudflare <= 7'],
63+
['ctx', 'adapter-cloudflare 8'],
64+
])('reads platform.%s (%s)', platformKey => {
65+
const ctx = { waitUntil: vi.fn() };
66+
67+
expect(getCloudflareExecutionContext({ [platformKey]: ctx })).toBe(ctx);
68+
});
69+
70+
it('returns undefined without a platform and without a registered fallback', () => {
71+
expect(getCloudflareExecutionContext(undefined)).toBeUndefined();
72+
expect(getCloudflareExecutionContext({})).toBeUndefined();
73+
});
74+
75+
// `adapter-cloudflare` >= 8.0.0-next.7 passes no `platform` object at all
76+
it('falls back to the registered provider when the platform carries no execution context', () => {
77+
const fallbackCtx = { waitUntil: vi.fn() };
78+
setCloudflareExecutionContextFallback(() => fallbackCtx);
79+
80+
expect(getCloudflareExecutionContext(undefined)).toBe(fallbackCtx);
81+
expect(getCloudflareExecutionContext({})).toBe(fallbackCtx);
82+
});
83+
84+
it('prefers the execution context on the platform over the fallback', () => {
85+
const ctx = { waitUntil: vi.fn() };
86+
setCloudflareExecutionContextFallback(() => ({ waitUntil: vi.fn() }));
87+
88+
expect(getCloudflareExecutionContext({ ctx })).toBe(ctx);
89+
});
90+
});

‎packages/sveltekit/test/worker/cloudflare.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as SentryCloudflare from '@sentry/cloudflare';
22
import { _INTERNAL_wrapRequestHandler as wrapRequestHandler } from '@sentry/cloudflare';
33
import type { Carrier, GLOBAL_OBJ } from '@sentry/core';
4-
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { setCloudflareExecutionContextFallback } from '../../src/server-common/utils';
56
import { initCloudflareSentryHandle } from '../../src/worker';
67

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

31+
afterEach(() => {
32+
setCloudflareExecutionContextFallback(undefined);
33+
});
34+
3035
it('sets the async context strategy when called', () => {
3136
vi.spyOn(SentryCloudflare, 'setAsyncLocalStorageAsyncContextStrategy');
3237

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

93+
// `@sveltejs/adapter-cloudflare` >= 8.0.0-next.7 passes no `platform` at all; the `workerd` entry point
94+
// registers `waitUntil` from `cloudflare:workers` as the fallback instead
95+
it('calls wrapRequestHandler with the fallback execution context, if no platform data is set', async () => {
96+
const { options, event, resolve, request } = getHandlerInput();
97+
// @ts-expect-error - removing platform data
98+
delete event.platform;
99+
const fallbackContext = { waitUntil: vi.fn() };
100+
setCloudflareExecutionContextFallback(() => fallbackContext);
101+
102+
// @ts-expect-error - resolving an empty object is enough for this test
103+
vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb());
104+
105+
const handle = initCloudflareSentryHandle(options);
106+
107+
// @ts-expect-error - only passing a partial event object
108+
await handle({ event, resolve });
109+
110+
expect(wrapRequestHandler).toHaveBeenCalledTimes(1);
111+
expect(wrapRequestHandler).toHaveBeenCalledWith(
112+
expect.objectContaining({ request, context: fallbackContext, captureErrors: false }),
113+
expect.any(Function),
114+
);
115+
expect(resolve).toHaveBeenCalledTimes(1);
116+
});
117+
88118
it('falls back to resolving the event, if no platform data is set', async () => {
89119
const { options, event, resolve } = getHandlerInput();
90120
// @ts-expect-error - removing platform data

0 commit comments

Comments
 (0)