diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index c5d199e0a1f9..7d5b615fbde4 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -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" diff --git a/packages/sveltekit/rollup.npm.config.mjs b/packages/sveltekit/rollup.npm.config.mjs index e4d71a469e1e..331f396b7075 100644 --- a/packages/sveltekit/rollup.npm.config.mjs +++ b/packages/sveltekit/rollup.npm.config.mjs @@ -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. @@ -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, }, diff --git a/packages/sveltekit/src/index.workerd.ts b/packages/sveltekit/src/index.workerd.ts new file mode 100644 index 000000000000..57ece8b573ea --- /dev/null +++ b/packages/sveltekit/src/index.workerd.ts @@ -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'; diff --git a/packages/sveltekit/src/server-common/utils.ts b/packages/sveltekit/src/server-common/utils.ts index d3237d5a87d3..732e2d3d489d 100644 --- a/packages/sveltekit/src/server-common/utils.ts +++ b/packages/sveltekit/src/server-common/utils.ts @@ -7,16 +7,36 @@ export type MinimalCloudflareExecutionContext = { waitUntil(promise: Promise): 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 */ @@ -26,7 +46,7 @@ export function getCloudflareExecutionContext(platform: unknown): MinimalCloudfl context?: MinimalCloudflareExecutionContext; }; - return ctx ?? context; + return ctx ?? context ?? fallbackExecutionContextProvider?.(); } /** diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 4d3de90f750f..651cc6af6f37 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -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 @@ -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, diff --git a/packages/sveltekit/src/worker/cloudflareWorkers.d.ts b/packages/sveltekit/src/worker/cloudflareWorkers.d.ts new file mode 100644 index 000000000000..06aa8f35c564 --- /dev/null +++ b/packages/sveltekit/src/worker/cloudflareWorkers.d.ts @@ -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): void; +} diff --git a/packages/sveltekit/test/index.workerd.test.ts b/packages/sveltekit/test/index.workerd.test.ts new file mode 100644 index 000000000000..1326f3ef4064 --- /dev/null +++ b/packages/sveltekit/test/index.workerd.test.ts @@ -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(); + }); +}); diff --git a/packages/sveltekit/test/server-common/handleError.test.ts b/packages/sveltekit/test/server-common/handleError.test.ts index 6b3ee06c17c1..ff59415cb2c9 100644 --- a/packages/sveltekit/test/server-common/handleError.test.ts +++ b/packages/sveltekit/test/server-common/handleError.test.ts @@ -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'); @@ -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)); + }); }); }); diff --git a/packages/sveltekit/test/server-common/utils.test.ts b/packages/sveltekit/test/server-common/utils.test.ts index df0858ed4654..0360743deca2 100644 --- a/packages/sveltekit/test/server-common/utils.test.ts +++ b/packages/sveltekit/test/server-common/utils.test.ts @@ -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: { @@ -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); + }); +}); diff --git a/packages/sveltekit/test/worker/cloudflare.test.ts b/packages/sveltekit/test/worker/cloudflare.test.ts index dc7a43c61e41..dea694b292ec 100644 --- a/packages/sveltekit/test/worker/cloudflare.test.ts +++ b/packages/sveltekit/test/worker/cloudflare.test.ts @@ -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 => { @@ -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'); @@ -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