diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index f6d2fc8cddde..9daaf32f6d20 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -9,7 +9,7 @@ "scripts": { "lint": "oxlint . --type-aware", "lint:fix": "oxlint . --fix --type-aware", - "lint:types": "tsc --noEmit", + "lint:types": "tsc --noEmit && tsc --noEmit -p suites/types/tsconfig.v4.json && tsc --noEmit -p suites/types/tsconfig.v5.json && tsc --noEmit -p suites/types/typegen/tsconfig.v4.json && tsc --noEmit -p suites/types/typegen/tsconfig.v5.json", "test": "vitest run", "test:watch": "yarn test --watch" }, @@ -31,6 +31,7 @@ "devDependencies": { "@cloudflare/vite-plugin": "1.34.0", "@cloudflare/workers-types": "^4.20260426.0", + "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@sentry-internal/test-utils": "10.67.0", "@sentry/conventions": "0.16.0", "eslint-plugin-regexp": "^3.1.0", diff --git a/dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts b/dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts new file mode 100644 index 000000000000..19aa2ae70f8f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/durableobject.ts @@ -0,0 +1,68 @@ +/** + * Type tests for `instrumentDurableObjectWithSentry`. + * + * The env of the options callback must be inferred from the Durable Object class — + * via its `DurableObject` base or an explicit constructor — and never collapse + * to `unknown`. + */ +import { DurableObject } from 'cloudflare:workers'; +import { instrumentDurableObjectWithSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface DoEnv { + SENTRY_DSN: string; + MY_DO: DurableObjectNamespace; +} + +// --------------------------------------------------------------------------- +// 1. Env inferred from the `DurableObject` base class +// --------------------------------------------------------------------------- +class MyDurableObject extends DurableObject { + async fetch(request: Request): Promise { + return new Response(request.url); + } +} + +export const instrumentedDo = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObject); + +// The instrumented class keeps its type, including RPC methods and the namespace typing. +const _doClass: typeof MyDurableObject = instrumentedDo; + +// --------------------------------------------------------------------------- +// 2. Explicit constructor with env annotation, bare base class +// --------------------------------------------------------------------------- +class MyDurableObjectCustomCtor extends DurableObject { + constructor(ctx: DurableObjectState, env: DoEnv) { + super(ctx, env); + } +} + +export const instrumentedDoCustomCtor = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObjectCustomCtor); + +// --------------------------------------------------------------------------- +// 3. Explicit generic +// --------------------------------------------------------------------------- +export const instrumentedDoExplicit = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObject); + +// --------------------------------------------------------------------------- +// 4. DurableObject +// --------------------------------------------------------------------------- +interface DoProps { + shard: string; +} + +class MyDurableObjectWithProps extends DurableObject {} + +export const instrumentedDoWithProps = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObjectWithProps); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/misc.ts b/dev-packages/cloudflare-integration-tests/suites/types/misc.ts new file mode 100644 index 000000000000..d67a6ec729e3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/misc.ts @@ -0,0 +1,42 @@ +/** + * Type tests for `sentryPagesPlugin` and `defineCloudflareOptions`. + */ +import { defineCloudflareOptions, sentryPagesPlugin } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface PagesEnv { + SENTRY_DSN: string; +} + +// --------------------------------------------------------------------------- +// sentryPagesPlugin: explicit Env is typed, the default does not error +// --------------------------------------------------------------------------- +export const pagesPluginExplicit = sentryPagesPlugin(context => { + // `env` is `PagesEnv & { ASSETS: ... }` — the workers-types Pages intersection. + expectTypeOf(context.env.SENTRY_DSN).toEqualTypeOf(); + return { dsn: context.env.SENTRY_DSN }; +}); + +export const pagesPluginDefault = sentryPagesPlugin(context => { + // No explicit generic: env access must not fail compilation. + return { dsn: context.env.SENTRY_DSN }; +}); + +// --------------------------------------------------------------------------- +// defineCloudflareOptions: explicit Env is typed, the default does not error +// --------------------------------------------------------------------------- +interface OptionsEnv { + SENTRY_DSN: string; +} + +export const optionsExplicit = defineCloudflareOptions(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}); + +export const optionsDefault = defineCloudflareOptions(env => { + expectTypeOf(env).toBeAny(); + return { dsn: env.SENTRY_DSN }; +}); + +export const optionsStatic = defineCloudflareOptions({ tracesSampleRate: 1.0 }); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json new file mode 100644 index 000000000000..9533ab134c6e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.json @@ -0,0 +1,5 @@ +{ + // this tsconfig.json is only here so the IDE can find the types + // the actual lint:types goes against tsconfig.v4.json and tsconfig.v5.json directly + "extends": "./tsconfig.v5.json" +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json new file mode 100644 index 000000000000..f17d7f6ec09e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v4.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + + // The shared type tests in this folder run once per supported `@cloudflare/workers-types` + // major (see `./tsconfig.v5.json` for v5). The env inference machinery reads generic + // defaults off `ExportedHandler`, `WorkerEntrypoint`, `DurableObject` and + // `WorkflowEntrypoint`, so a change to any of those between majors has to fail here + // rather than in a user's project. + "include": ["./*.ts"], + + // The extended config hands this whole folder to these per-version programs by excluding + // it; the inherited exclude must be cleared here or no inputs remain. + "exclude": [], + + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json new file mode 100644 index 000000000000..6c6e59f522b1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/tsconfig.v5.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.v4.json", + + // Same files as `./tsconfig.v4.json`, against `@cloudflare/workers-types` v5 (installed + // under the `@cloudflare/workers-types-v5` alias). + "compilerOptions": { + "types": ["@cloudflare/workers-types-v5"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json new file mode 100644 index 000000000000..588eef1f2f74 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v4.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.json", + + // Separate program: the global `Cloudflare.Env` augmentation in this folder simulates + // a project that ran `wrangler types`. It must not leak into the main program, where + // the no-typegen fallback (`env` resolves to `any`) is under test. Runs once per + // supported `@cloudflare/workers-types` major (see `./tsconfig.v5.json` for v5). + "include": ["./**/*.ts"], + + // The extended config excludes this folder (to keep the augmentation out of the main + // program); since this program is exactly that folder, the inherited exclude must be + // cleared or no inputs remain. + "exclude": [], + + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json new file mode 100644 index 000000000000..6c6e59f522b1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/typegen/tsconfig.v5.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.v4.json", + + // Same files as `./tsconfig.v4.json`, against `@cloudflare/workers-types` v5 (installed + // under the `@cloudflare/workers-types-v5` alias). + "compilerOptions": { + "types": ["@cloudflare/workers-types-v5"] + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts b/dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts new file mode 100644 index 000000000000..814d37748d86 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/typegen/withsentry.ts @@ -0,0 +1,81 @@ +/** + * Type tests for the wrangler-generated (`wrangler types`) setup. + * + * This folder is a separate tsc program (see `./tsconfig.json`) so that the global + * `Cloudflare.Env` augmentation below simulates a project that ran `wrangler types`, + * without affecting the other suites: in the main program `Cloudflare.Env` stays the + * empty interface. + */ +import { DurableObject, WorkerEntrypoint, WorkflowEntrypoint } from 'cloudflare:workers'; +import { instrumentDurableObjectWithSentry, instrumentWorkflowWithSentry, withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +// Simulates `wrangler types` output. +declare global { + namespace Cloudflare { + interface Env { + SENTRY_DSN: string; + MY_KV: KVNamespace; + } + } +} + +// --------------------------------------------------------------------------- +// Bare handler, no annotations: env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +export const typegen = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + async fetch(request, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return new Response(request.url); + }, + }, +); + +// --------------------------------------------------------------------------- +// Bare Durable Object (no generic): env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +class MyDurableObject extends DurableObject {} + +export const instrumentedDo = instrumentDurableObjectWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyDurableObject); + +// --------------------------------------------------------------------------- +// Bare WorkerEntrypoint / WorkflowEntrypoint (no generic), the setup Cloudflare +// recommends: env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +class MyEntrypoint extends WorkerEntrypoint {} + +export const entrypoint = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypoint); + +class MyWorkflow extends WorkflowEntrypoint {} + +export const workflow = instrumentWorkflowWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyWorkflow); + +// --------------------------------------------------------------------------- +// Framework-wrapped handlers without their own env type (TanStack's +// `ServerEntry`): env picks up the generated `Cloudflare.Env` +// --------------------------------------------------------------------------- +type ServerEntry = { + fetch: (request: Request, opts?: unknown) => Promise | Response; +}; + +declare const serverEntry: ServerEntry; + +export const tanstack = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, serverEntry); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts b/dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts new file mode 100644 index 000000000000..5153939cefd4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/withsentry-props.ts @@ -0,0 +1,50 @@ +/** + * Type tests for `ExportedHandler`'s 4th generic — `Props` for `ExecutionContext`. + * + * A `Props`-typed handler must still be accepted by `withSentry`, keep its exact type, + * and have its env inferred. Runs in every program, since both `@cloudflare/workers-types` + * v4 and v5 carry the `Props` generic. + */ +import { withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface PropsEnv { + SENTRY_DSN: string; +} + +interface MyProps { + jobId: string; +} + +const propsHandler: ExportedHandler = { + async fetch(_, env, ctx) { + expectTypeOf(ctx.props.jobId).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, +}; + +// A pre-typed handler with `ExecutionContext` is accepted and its env inferred. +export const props = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, propsHandler); + +// The wrapped handler keeps its exact type, including the Props. +expectTypeOf(props).toEqualTypeOf>(); + +// A `satisfies` handler literal with `ExecutionContext` works too. +export const propsSatisfies = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + async fetch(_, env, ctx) { + expectTypeOf(env).toEqualTypeOf(); + expectTypeOf(ctx.props.jobId).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts b/dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts new file mode 100644 index 000000000000..ca5505fdd514 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/withsentry.ts @@ -0,0 +1,311 @@ +/** + * Type tests for `withSentry` with `ExportedHandler` (object) handlers. + * + * Regression coverage for https://github.com/getsentry/sentry-javascript/issues/18294: + * the `env` of the options callback must be inferred from the passed handler wherever + * possible, fall back to the explicit `Env` generic / wrangler-generated `Cloudflare.Env`, + * and never collapse to `unknown` (which breaks compilation). + * + * NOTE: This tsc program intentionally has no `Cloudflare.Env` augmentation — the + * wrangler-generated scenario lives in `suites/types/typegen/` (separate tsconfig). + */ +import { Hono } from 'hono'; +import { withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +// --------------------------------------------------------------------------- +// 1. The issue reproduction: manual (exported) Env interface + `satisfies` +// --------------------------------------------------------------------------- +interface ManualEnv { + SENTRY_DATA_SOURCE_NAME: string; +} + +export const reproduction = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME, sendDefaultPii: true }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + return Response.json({ env }, { status: 200 }); + }, + } satisfies ExportedHandler, +); + +// The wrapped handler stays assignable to the user's handler type. +const _reproductionHandler: ExportedHandler = reproduction; + +// --------------------------------------------------------------------------- +// 2. Pre-typed handler constant — env inferred from the declared handler type +// --------------------------------------------------------------------------- +interface PretypedEnv { + MY_KV: KVNamespace; +} + +const pretypedHandler: ExportedHandler = { + async fetch(_, env) { + await env.MY_KV.get('key'); + return new Response('ok'); + }, +}; + +export const pretyped = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; +}, pretypedHandler); + +// An annotated callback pins the env independent of the handler. +export const annotatedCallback = withSentry((env: PretypedEnv) => { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return { dsn: 'https://public@dsn.ingest.sentry.io/1337' }; +}, pretypedHandler); + +// ...including with a bare handler literal (the most common setup in the suites). +export const annotatedCallbackBareHandler = withSentry((_: PretypedEnv) => ({}), { + async fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.MY_KV; + return new Response('ok'); + }, +}); + +// --------------------------------------------------------------------------- +// 3. Bare handler, no annotations, no `wrangler types` — env must not error +// (falls back to `any`; with typegen it would be `Cloudflare.Env`, see typegen/) +// --------------------------------------------------------------------------- +export const bare = withSentry( + env => { + expectTypeOf(env).toBeAny(); + // Unknown bindings must not fail compilation when no types were generated. + return { dsn: env.SENTRY_DSN }; + }, + { + async fetch(request, env, ctx) { + ctx.waitUntil(Promise.resolve()); + return new Response(request.url); + }, + }, +); + +// --------------------------------------------------------------------------- +// 4. Explicit `withSentry(...)` — with satisfies-typed and bare handlers +// --------------------------------------------------------------------------- +interface ExplicitEnv { + SENTRY_DSN: string; +} + +export const explicitGeneric = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + } satisfies ExportedHandler, +); + +export const explicitGenericBareHandler = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + }, +); + +// --------------------------------------------------------------------------- +// 5. Fully explicit `withSentry` +// --------------------------------------------------------------------------- +interface QueueMessage { + userId: string; +} +interface CfMetadata { + country: string; +} + +export const fullyExplicit = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + async queue(batch) { + expectTypeOf(batch).toEqualTypeOf>(); + void batch.messages[0]?.body.userId; + }, + }, +); + +// --------------------------------------------------------------------------- +// 6. env is inferred from non-fetch handler methods too (scheduled, queue, ...) +// --------------------------------------------------------------------------- +interface CronEnv { + SENTRY_DSN: string; +} + +export const scheduledOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + scheduled(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +export const queueOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + queue(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +export const tailOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + tail(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +export const emailOnly = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + email(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +// A handler with several methods still infers one consistent env. +export const mixedMethods = withSentry( + env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; + }, + { + fetch(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + return new Response('ok'); + }, + async queue(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + async scheduled(_, env) { + expectTypeOf(env).toEqualTypeOf(); + void env.SENTRY_DSN; + }, + } satisfies ExportedHandler, +); + +// The options callback may also return `undefined`. +export const undefinedOptions = withSentry(() => undefined, { + async fetch() { + return new Response('ok'); + }, +}); + +// --------------------------------------------------------------------------- +// 7. Hono apps: `Bindings` is inferred as the callback env and members beyond +// `fetch` survive the wrapping +// --------------------------------------------------------------------------- +interface HonoEnv { + SENTRY_DSN: string; +} + +const realHonoApp = new Hono<{ Bindings: HonoEnv }>(); + +export const realHono = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoApp); + +// Members beyond `fetch` survive the wrapping. +realHono.onError(err => new Response(`Error: ${err.message}`, { status: 500 })); + +// Hono apps using `Variables` (context state) still infer `Bindings` as the env. +interface HonoVariables { + user: string; +} + +const realHonoAppWithVariables = new Hono<{ Bindings: HonoEnv; Variables: HonoVariables }>(); + +export const realHonoWithVariables = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoAppWithVariables); + +// Hono with an annotated callback and with an explicit generic. +export const realHonoAnnotated = withSentry((env: HonoEnv) => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoApp); + +export const realHonoExplicit = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, realHonoApp); + +// --------------------------------------------------------------------------- +// 8. TanStack-style wrapped handlers (`ServerEntry` carries no env type): +// an annotated callback or an explicit generic pins the env; unannotated +// falls back to `any` here / `Cloudflare.Env` with typegen (see typegen/) +// --------------------------------------------------------------------------- +type ServerEntry = { + fetch: (request: Request, opts?: unknown) => Promise | Response; +}; + +declare const tanstackHandler: ServerEntry; + +export const tanstack = withSentry((env: ManualEnv) => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME }; +}, tanstackHandler); + +export const tanstackExplicit = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME }; +}, tanstackHandler); + +export const tanstackBare = withSentry(env => { + expectTypeOf(env).toBeAny(); + return { dsn: env.SENTRY_DATA_SOURCE_NAME }; +}, tanstackHandler); + +// --------------------------------------------------------------------------- +// 9. Inferred envs are exact: the `expectTypeOf(env).toEqualTypeOf()` +// assertions above only hold for the precise interface — never for `any`, +// `unknown`, or a wider type — so a silent fallback cannot sneak back in. +// --------------------------------------------------------------------------- diff --git a/dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts b/dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts new file mode 100644 index 000000000000..bf45979478cc --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/workerentrypoint.ts @@ -0,0 +1,77 @@ +/** + * Type tests for `withSentry` with `WorkerEntrypoint` class handlers. + * + * The env of the options callback must be inferred from the class — via its + * `WorkerEntrypoint` base or an explicit constructor — and never collapse + * to `unknown`. + */ +import { WorkerEntrypoint } from 'cloudflare:workers'; +import { withSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface EntrypointEnv { + SENTRY_DSN: string; +} + +// --------------------------------------------------------------------------- +// 1. Env inferred from the `WorkerEntrypoint` base class +// --------------------------------------------------------------------------- +class MyEntrypoint extends WorkerEntrypoint { + override async fetch(request: Request): Promise { + return new Response(request.url); + } +} + +export const entrypoint = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypoint); + +// The instrumented class keeps its type (constructor + instance shape). +const _entrypointClass: typeof MyEntrypoint = entrypoint; + +// --------------------------------------------------------------------------- +// 2. WorkerEntrypoint +// --------------------------------------------------------------------------- +interface EntrypointProps { + name: string; +} + +class MyEntrypointWithProps extends WorkerEntrypoint {} + +export const entrypointWithProps = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypointWithProps); + +// --------------------------------------------------------------------------- +// 3. Explicit constructor with env annotation, bare base class +// --------------------------------------------------------------------------- +class MyEntrypointCustomCtor extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: EntrypointEnv) { + super(ctx, env); + } +} + +export const entrypointCustomCtor = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypointCustomCtor); + +// --------------------------------------------------------------------------- +// 4. Bare `WorkerEntrypoint` without typegen — env must not error (any fallback) +// --------------------------------------------------------------------------- +class MyBareEntrypoint extends WorkerEntrypoint {} + +export const bareEntrypoint = withSentry(env => { + expectTypeOf(env).toBeAny(); + return { dsn: env.SENTRY_DSN }; +}, MyBareEntrypoint); + +// --------------------------------------------------------------------------- +// 5. Explicit generic with a class handler +// --------------------------------------------------------------------------- +export const entrypointExplicit = withSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyEntrypoint); diff --git a/dev-packages/cloudflare-integration-tests/suites/types/workflow.ts b/dev-packages/cloudflare-integration-tests/suites/types/workflow.ts new file mode 100644 index 000000000000..4fd7e57f28ad --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/types/workflow.ts @@ -0,0 +1,51 @@ +/** + * Type tests for `instrumentWorkflowWithSentry`. + * + * The env of the options callback must be inferred from the Workflow class, and the + * payload type must keep flowing into `run`. + */ +import { WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; +import { instrumentWorkflowWithSentry } from '@sentry/cloudflare'; +import { expectTypeOf } from 'vitest'; + +interface WorkflowEnv { + SENTRY_DSN: string; +} + +interface WorkflowPayload { + orderId: string; +} + +// --------------------------------------------------------------------------- +// 1. Env and payload inferred from the `WorkflowEntrypoint` base class +// --------------------------------------------------------------------------- +class MyWorkflow extends WorkflowEntrypoint { + override async run(event: WorkflowEvent, step: WorkflowStep): Promise { + expectTypeOf(event.payload.orderId).toEqualTypeOf(); + await step.do('step', async () => { + void event.payload.orderId; + }); + } +} + +export const instrumentedWorkflow = instrumentWorkflowWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyWorkflow); + +const _workflowClass: typeof MyWorkflow = instrumentedWorkflow; + +// --------------------------------------------------------------------------- +// 2. Explicit constructor with env annotation, bare base class +// --------------------------------------------------------------------------- +class MyWorkflowCustomCtor extends WorkflowEntrypoint { + constructor(ctx: ExecutionContext, env: WorkflowEnv) { + super(ctx, env); + } +} + +export const instrumentedWorkflowCustomCtor = instrumentWorkflowWithSentry(env => { + expectTypeOf(env).toEqualTypeOf(); + return { dsn: env.SENTRY_DSN }; +}, MyWorkflowCustomCtor); diff --git a/dev-packages/cloudflare-integration-tests/tsconfig.json b/dev-packages/cloudflare-integration-tests/tsconfig.json index f90c59831b76..a074c80918ef 100644 --- a/dev-packages/cloudflare-integration-tests/tsconfig.json +++ b/dev-packages/cloudflare-integration-tests/tsconfig.json @@ -5,7 +5,10 @@ // `suites/prisma` imports a Prisma-generated client (`./generated`) that only exists after codegen, // so it can't be type-checked as part of the static suite check. - "exclude": ["suites/prisma/**"], + // `suites/types` has its own per-version programs (`tsconfig.v4.json` / `tsconfig.v5.json`, + // and the same pair under `typegen/`), since each set of files is checked once per + // supported `@cloudflare/workers-types` major. See `lint:types`. + "exclude": ["suites/prisma/**", "suites/types/**"], "compilerOptions": { // Although this seems wrong to include `DOM` here, it's necessary to make diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts index 46ac9c5d909a..7c45eddf4c16 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts @@ -34,7 +34,7 @@ function streamWorkersAi(): Response { return result.toTextStreamResponse(); } -class MyBaseAgent extends Agent { +class MyBaseAgent extends Agent { @callable() async greet(name: string): Promise { // User keys — instrumented, spans expected @@ -57,7 +57,7 @@ class MyBaseAgent extends Agent { } } -class MyChatAgentBase extends AIChatAgent { +class MyChatAgentBase extends AIChatAgent { @callable() async greet(name: string): Promise { return `Hello, ${name}!`; @@ -79,7 +79,7 @@ export const MyAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyBaseAge export const MyChatAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyChatAgentBase); export default Sentry.withSentry(sentryOptions, { - async fetch(request: Request, env: Env): Promise { + async fetch(request, env): Promise { const agentResponse = await routeAgentRequest(request, env); if (agentResponse) { @@ -88,4 +88,4 @@ export default Sentry.withSentry(sentryOptions, { return new Response(null, { status: 404 }); }, -} satisfies ExportedHandler); +} satisfies ExportedHandler); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts index 1fcaac159709..154f291d0462 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/server.ts @@ -10,6 +10,5 @@ export default Sentry.withSentry( tracesSampleRate: 1.0, environment: 'qa', }), - // @ts-expect-error - handler is not typed as a Cloudflare handler wrapFetchWithSentry(handler), ); diff --git a/packages/cloudflare/src/defineCloudflareOptions.ts b/packages/cloudflare/src/defineCloudflareOptions.ts index b751221f0f61..a05686a2a9bd 100644 --- a/packages/cloudflare/src/defineCloudflareOptions.ts +++ b/packages/cloudflare/src/defineCloudflareOptions.ts @@ -1,5 +1,5 @@ -import type { env as cloudflareEnv } from 'cloudflare:workers'; import type { CloudflareOptions } from './client'; +import type { DefaultEnv } from './types'; /** * Define the Sentry options for a Cloudflare Worker in a dedicated module. @@ -35,7 +35,7 @@ import type { CloudflareOptions } from './client'; * export default defineCloudflareOptions({ tracesSampleRate: 1.0 }); * ``` */ -export function defineCloudflareOptions( +export function defineCloudflareOptions( optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined), ): (env: Env) => CloudflareOptions | undefined { if (typeof optionsOrCallback === 'function') { diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e087ef8eb3bc..fe72ccb9b0a9 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -12,6 +12,7 @@ import { instrumentContext } from './utils/instrumentContext'; import { extractRpcMeta } from './utils/rpcMeta'; import { getEffectiveRpcPropagation } from './utils/rpcOptions'; import { instrumentCloudflareAgent } from './instrumentations/agents'; +import type { DefaultEnv, ResolveEnv } from './types'; import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; /** @@ -280,10 +281,12 @@ export function finalizeWithRpcInstrumentation( * ``` */ export function instrumentDurableObjectWithSentry< - E, - T extends DurableObject, - C extends new (state: DurableObjectState, env: E) => T, ->(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { + Env = DefaultEnv, + // oxlint-disable-next-line typescript/no-explicit-any + T extends DurableObject = DurableObject, + // oxlint-disable-next-line typescript/no-explicit-any + C extends new (state: DurableObjectState, env: any) => T = new (state: DurableObjectState, env: any) => T, +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { construct(target, [ctx, env], newTarget) { const { obj, options, context } = constructInstrumentedDurableObject( @@ -340,10 +343,12 @@ export function instrumentDurableObjectWithSentry< * ``` */ export function instrumentAgentWithSentry< - E, - T extends DurableObject, - C extends new (state: DurableObjectState, env: E) => T, ->(optionsCallback: (env: E) => CloudflareOptions, AgentClass: C): C { + Env = DefaultEnv, + // oxlint-disable-next-line typescript/no-explicit-any + T extends DurableObject = DurableObject, + // oxlint-disable-next-line typescript/no-explicit-any + C extends new (state: DurableObjectState, env: any) => T = new (state: DurableObjectState, env: any) => T, +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions, AgentClass: C): C { return new Proxy(AgentClass, { construct(target, [ctx, env], newTarget) { const { obj, options, context } = constructInstrumentedDurableObject( diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index c0b46f027be8..c2646ad32e5a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -2,6 +2,7 @@ import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from '../client'; import { getFinalOptions } from '../options'; +import type { DefaultEnv, ResolveEnv } from '../types'; import { instrumentContext } from '../utils/instrumentContext'; import { extractRpcMeta } from '../utils/rpcMeta'; import { type UncheckedMethod, wrapMethodWithSentry } from '../wrapMethodWithSentry'; @@ -148,11 +149,13 @@ function instrumentMethod( * ``` */ export function instrumentWorkerEntrypoint< - Env, - Props, - T extends InstanceType>, - C extends new (ctx: ExecutionContext, env: Env) => T, ->(optionsCallback: (env: Env) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C { + Env = DefaultEnv, + Props = {}, + // oxlint-disable-next-line typescript/no-explicit-any + T extends WorkerEntrypoint = WorkerEntrypoint, + // oxlint-disable-next-line typescript/no-explicit-any + C extends new (ctx: ExecutionContext, env: any) => T = new (ctx: ExecutionContext, env: any) => T, +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C { // Set up AsyncLocalStorage strategy ONCE at instrumentation time, not per-request // This is critical - calling this per-request would create a new AsyncLocalStorage // each time, breaking scope isolation for concurrent requests diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 323ae2712835..ed2d9a05f7f5 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -1,4 +1,5 @@ -import type { EmailMessage, ExportedHandler } from '@cloudflare/workers-types'; +import type { EmailMessage } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv } from 'cloudflare:workers'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { GENERAL_FUNCTION_SPAN_OP } from '@sentry/conventions/op'; @@ -62,8 +63,7 @@ function wrapEmailHandler( /** * Instruments an email handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerEmail>( +export function instrumentExportedHandlerEmail( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts index 4f96447e9e34..6c6bf443036c 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -1,4 +1,4 @@ -import type { ExportedHandler } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import type { CloudflareOptions } from '../../client'; import { ensureInstrumented } from '../../instrument'; @@ -11,8 +11,7 @@ import { instrumentEnv } from './instrumentEnv'; /** * Instruments a fetch handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerFetch>( +export function instrumentExportedHandlerFetch( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { @@ -25,7 +24,10 @@ export function instrumentExportedHandlerFetch new Proxy(original, { apply(target, thisArg, args: Parameters>) { - const [request, env, ctx] = args; + const [rawRequest, env, ctx] = args; + // `T['fetch']` resolves to the structural `AnyHandlerMethod`, whose parameters are + // `any` — but the fetch event is always a `Request` at runtime. + const request = rawRequest as Request; if (request.method === 'OPTIONS' || request.method === 'HEAD') { return target.apply(thisArg, args); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index 00e532d6bf8c..ac609e241a55 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -1,4 +1,5 @@ -import type { ExportedHandler, MessageBatch } from '@cloudflare/workers-types'; +import type { MessageBatch } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { MESSAGING_QUEUE_PROCESS_SPAN_OP } from '@sentry/conventions/op'; @@ -68,8 +69,7 @@ function wrapQueueHandler( /** * Instruments a queue handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerQueue>( +export function instrumentExportedHandlerQueue( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 5c8eb3a3f90c..018dd8b56ee1 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -1,4 +1,5 @@ -import type { ExportedHandler, ScheduledController } from '@cloudflare/workers-types'; +import type { ScheduledController } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { GENERAL_FUNCTION_SPAN_OP } from '@sentry/conventions/op'; @@ -61,8 +62,7 @@ function wrapScheduledHandler( /** * Instruments a scheduled handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerScheduled>( +export function instrumentExportedHandlerScheduled( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts index 9b1dcc2d22a0..925f2b504605 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts @@ -1,4 +1,5 @@ -import type { ExportedHandler, TraceItem } from '@cloudflare/workers-types'; +import type { TraceItem } from '@cloudflare/workers-types'; +import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import { captureException, withIsolationScope } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; @@ -37,8 +38,7 @@ function wrapTailHandler(options: CloudflareOptions, context: ExecutionContext, /** * Instruments a tail handler for ExportedHandler (env/ctx come from args). */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentExportedHandlerTail>( +export function instrumentExportedHandlerTail( handler: T, optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, ): void { diff --git a/packages/cloudflare/src/pages-plugin.ts b/packages/cloudflare/src/pages-plugin.ts index 518b1c448017..888b352300ce 100644 --- a/packages/cloudflare/src/pages-plugin.ts +++ b/packages/cloudflare/src/pages-plugin.ts @@ -36,7 +36,8 @@ import { init } from './sdk'; * @returns A plugin function that can be used in Cloudflare Pages. */ export function sentryPagesPlugin< - Env = unknown, + // oxlint-disable-next-line typescript/no-explicit-any + Env = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any Params extends string = any, Data extends Record = Record, diff --git a/packages/cloudflare/src/types.ts b/packages/cloudflare/src/types.ts new file mode 100644 index 000000000000..97b4403f872a --- /dev/null +++ b/packages/cloudflare/src/types.ts @@ -0,0 +1,118 @@ +import type { env as cloudflareEnv } from 'cloudflare:workers'; + +type IsAny = 0 extends 1 & T ? true : false; + +/** + * A handler method of an `ExportedHandler` (`fetch`, `scheduled`, `queue`, ...). + */ +// oxlint-disable-next-line typescript/no-explicit-any +type AnyHandlerMethod = (event: any, env: any, ctx: any) => any; + +/** + * Structural version of `ExportedHandler` that accepts every env/message/metadata/props + * combination. Used as the handler constraint of `withSentry` instead of + * `ExportedHandler`: on `@cloudflare/workers-types` v5, `ExportedHandler` + * has a 4th generic (`ExecutionContext`) which defaults to `unknown`, so a direct + * `ExportedHandler` constraint rejects handlers typed with a concrete + * `Props`. Referencing the 4th generic is not an option either — it does not exist on v4, + * which the SDK also supports. + */ +export interface AnyExportedHandler { + fetch?: AnyHandlerMethod; + scheduled?: AnyHandlerMethod; + queue?: AnyHandlerMethod; + email?: AnyHandlerMethod; + tail?: AnyHandlerMethod; + trace?: AnyHandlerMethod; + tailStream?: AnyHandlerMethod; + connect?: AnyHandlerMethod; + test?: AnyHandlerMethod; +} + +type HandlerMethodName = keyof AnyExportedHandler; + +/** + * Extracts the `Env` type from a handler method signature (its second parameter). + * `any` methods are skipped: framework app types like Hono's carry a string index + * signature, which would otherwise make every missing handler method read as `any` + * and poison the inferred env. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type EnvFromMethod = + IsAny extends true + ? never + : // oxlint-disable-next-line typescript/no-explicit-any + Method extends (event: any, env: infer Env, ctx: any) => any + ? Env + : never; + +/** + * Extracts the `Env` type from an `ExportedHandler`-shaped type. + * + * This infers the env from any of the handler methods (`fetch`, `scheduled`, `queue`, ...), + * so a handler literal with `satisfies ExportedHandler` or a pre-typed + * `ExportedHandler` both yield their `Env`. The structural read (instead of + * `T extends ExportedHandler`) keeps inference working for + * `ExecutionContext`-typed handlers on workers-types v5. + */ +type InferEnvFromHandler = T extends AnyExportedHandler + ? { + [K in HandlerMethodName]: K extends keyof T ? EnvFromMethod : never; + }[HandlerMethodName] + : never; + +/** + * Extracts the `Env` type from a class constructor type (`WorkerEntrypoint`, `DurableObject` + * or `WorkflowEntrypoint` subclasses). The env is inferred from the second constructor parameter, + * which carries the user's env type both for explicit constructors and for constructors inherited + * from a `WorkerEntrypoint` / `DurableObject` / `WorkflowEntrypoint` base class. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type InferEnvFromConstructor = T extends new (ctx: any, env: infer Env) => any ? Env : never; + +/** + * Infers the `Env` type from a handler passed to one of the instrumentation functions. + * Returns `never` when nothing can be inferred. + */ +export type InferEnv = [InferEnvFromHandler] extends [never] + ? InferEnvFromConstructor + : InferEnvFromHandler; + +/** + * Removes empty object types (`{}`) from a union. Framework handler types like Hono's + * `fetch(request, env?: E['Bindings'] | {}, ...)` union the env with `{}`, which would + * otherwise collapse the inferred env to "empty" — filtering keeps the meaningful member. + */ +// oxlint-disable-next-line typescript/no-explicit-any +type FilterEmptyObjects = T extends any ? ([keyof T] extends [never] ? never : T) : never; + +/** + * Resolves the env type exposed on the options callback of the instrumentation functions. + * + * Resolution order: + * 1. The env inferred from the passed handler (e.g. via `satisfies ExportedHandler`, + * a pre-typed handler, or a `WorkerEntrypoint` subclass). + * 2. The explicitly provided `Env` generic (e.g. `withSentry(...)`), which defaults + * to the wrangler-generated `Cloudflare.Env` (via `typeof cloudflareEnv`). + * 3. `any` as the last resort, so untyped setups keep compiling instead of failing on + * an empty `Cloudflare.Env` interface. + */ +export type ResolveEnv = + IsAny>> extends true + ? ExplicitEnv + : unknown extends FilterEmptyObjects> + ? ExplicitEnv + : [FilterEmptyObjects>] extends [never] + ? ExplicitEnv + : FilterEmptyObjects>; + +/** + * The default env type when nothing can be inferred and no explicit generic is provided: + * the wrangler-generated `Cloudflare.Env` (via `cloudflare:workers`), falling back to `any` + * when the project never ran `wrangler types` (the interface is empty) so property access + * on `env` does not fail. + */ +export type DefaultEnv = [keyof typeof cloudflareEnv] extends [never] + ? // oxlint-disable-next-line typescript/no-explicit-any + any + : typeof cloudflareEnv; diff --git a/packages/cloudflare/src/utils/isCloudflareClass.ts b/packages/cloudflare/src/utils/isCloudflareClass.ts index 40ee8a3fd1a5..3e728ce9040d 100644 --- a/packages/cloudflare/src/utils/isCloudflareClass.ts +++ b/packages/cloudflare/src/utils/isCloudflareClass.ts @@ -26,7 +26,11 @@ type CloudflareClassName = 'WorkerEntrypoint' | 'DurableObject' | 'WorkflowEntry * isCloudflareClass(MyDO, 'DurableObject') // true if MyDO extends DurableObject * ``` */ -export function isCloudflareClass(value: unknown, className: 'WorkerEntrypoint'): value is WorkerEntrypointConstructor; +export function isCloudflareClass( + value: unknown, + className: 'WorkerEntrypoint', + // oxlint-disable-next-line typescript/no-explicit-any +): value is WorkerEntrypointConstructor; export function isCloudflareClass( value: unknown, className: 'DurableObject', diff --git a/packages/cloudflare/src/withSentry.ts b/packages/cloudflare/src/withSentry.ts index 50acb67541dc..460a6eed4015 100644 --- a/packages/cloudflare/src/withSentry.ts +++ b/packages/cloudflare/src/withSentry.ts @@ -1,4 +1,3 @@ -import type { env as cloudflareEnv } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail'; @@ -7,6 +6,7 @@ import { instrumentExportedHandlerQueue } from './instrumentations/worker/instru import { instrumentExportedHandlerScheduled } from './instrumentations/worker/instrumentScheduled'; import { instrumentExportedHandlerTail } from './instrumentations/worker/instrumentTail'; import { isCloudflareClass } from './utils/isCloudflareClass'; +import type { AnyExportedHandler, DefaultEnv, ResolveEnv } from './types'; import { instrumentWorkerEntrypoint, type WorkerEntrypointConstructor, @@ -23,18 +23,15 @@ import { * @param handler {ExportedHandler} The handler to wrap. * @returns The wrapped handler. */ -// TODO(v11): The generic types need to be rewritten to following to improve type safety: -// T extends ExportedHandler | WorkerEntrypointConstructor export function withSentry< - Env = typeof cloudflareEnv, + Env = DefaultEnv, QueueHandlerMessage = unknown, CfHostMetadata = unknown, - T extends ExportedHandler | WorkerEntrypointConstructor = ExportedHandler< - Env, - QueueHandlerMessage, - CfHostMetadata - >, ->(optionsCallback: (env: Env) => CloudflareOptions | undefined, handler: T): T { + // oxlint-disable-next-line typescript/no-explicit-any + T extends AnyExportedHandler | WorkerEntrypointConstructor = + | ExportedHandler + | WorkerEntrypointConstructor, +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions | undefined, handler: T): T { if (isCloudflareClass(handler, 'WorkerEntrypoint')) { // oxlint-disable-next-line typescript/no-explicit-any return instrumentWorkerEntrypoint(optionsCallback as any, handler); diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 721cb752d1ba..fb74b5e8b5a6 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -30,6 +30,7 @@ import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; import { instrumentContext } from './utils/instrumentContext'; +import type { DefaultEnv, ResolveEnv } from './types'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; @@ -198,13 +199,24 @@ class WrappedWorkflowStep implements WorkflowStep { * @returns Instrumented workflow class with the same interface */ export function instrumentWorkflowWithSentry< - E, // Environment type - P, // Payload type - T extends WorkflowEntrypoint, // WorkflowEntrypoint type - C extends new (ctx: ExecutionContext, env: E) => T, // Constructor type of the WorkflowEntrypoint class ->(optionsCallback: (env: E) => CloudflareOptions, WorkFlowClass: C): C { + E = DefaultEnv, // Environment type + P = unknown, // Payload type + // oxlint-disable-next-line typescript/no-explicit-any + T extends WorkflowEntrypoint = WorkflowEntrypoint, // WorkflowEntrypoint type + // The constraint must not route through `T`: workers-types defaults `WorkflowEntrypoint`'s + // `Env` to `unknown` (unlike `WorkerEntrypoint`/`DurableObject`, which default to + // `Cloudflare.Env`), so a bare subclass would be rejected in a `wrangler types` project. + // The callback env is resolved from the inferred constructor via `ResolveEnv` instead. + // oxlint-disable-next-line typescript/no-explicit-any + C extends new (ctx: ExecutionContext, env: any) => WorkflowEntrypoint = new ( + ctx: ExecutionContext, + // oxlint-disable-next-line typescript/no-explicit-any + env: any, + ) => T, // Constructor type of the WorkflowEntrypoint class +>(optionsCallback: (env: ResolveEnv) => CloudflareOptions, WorkFlowClass: C): C { return new Proxy(WorkFlowClass, { - construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { + // oxlint-disable-next-line typescript/no-explicit-any + construct(target: C, args: [ctx: ExecutionContext, env: any], newTarget) { const [ctx, env] = args; const context = instrumentContext(ctx); const options = optionsCallback(env); diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index fab2788cd234..1039686381be 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -10,7 +10,11 @@ import { updateSpanWithRouteParametrization } from './routeParametrization'; declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined; export type ServerEntry = { - fetch: (request: Request, opts?: unknown) => Promise | Response; + // `opts` is forwarded verbatim to the wrapped handler, so this must accept whatever shape + // the real framework entry uses (e.g. TanStack's `RequestOptions`). Under + // parameter contravariance `unknown` would reject such an entry; `any` keeps it assignable. + // oxlint-disable-next-line typescript/no-explicit-any + fetch: (request: Request, opts?: any) => Promise | Response; }; /** diff --git a/yarn.lock b/yarn.lock index c5c4cf35fb14..332f6fcedf51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3113,6 +3113,11 @@ resolved "https://registry.yarnpkg.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260426.1.tgz#b21d2a24afe0b274982d7ccbe7163a5689da1507" integrity sha512-d3Xj/IjINRgNVwH+eKhpUn4xkkcEewbWXbOvBlapiirKWh5zl9m0Epi3qOqmjyRYK6MICqIGXg4qZBEt0lxudw== +"@cloudflare/workers-types-v5@npm:@cloudflare/workers-types@5.20260710.1": + version "5.20260710.1" + resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-5.20260710.1.tgz#215c0cf84c3917552b53a1f5129150abf0b6009f" + integrity sha512-4ooaY2Pb5XGwDn8Fzm6jnTAJkIX0R5LBvL9euQpp2T58sQItlAQd9yivAlkwGhpY5cM1u81/9HaXwKAjXwtyzA== + "@cloudflare/workers-types@4.20250214.0": version "4.20250214.0" resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20250214.0.tgz#f9a8109cdba9425e2a934d6a0870eca2769a5b6f"