From a85e9fb0a0900d2c2b45d44e0a0ae0c345e2fe64 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 21 Aug 2026 18:50:24 +0300 Subject: [PATCH] feat(cloudflare): Derive rpcTracePropagationTargets from the wrangler config The Vite plugin already knows which bindings resolve to receivers it instruments itself: Durable Object bindings without a `script_name`, and service bindings naming this worker. Those are exactly the bindings whose trailing trace argument is guaranteed to be stripped again, so the plugin adds them to `rpcTracePropagationTargets` and same-deployment RPC traces connect without any configuration. Bindings to other workers stay opt-in. Only bindings whose receiver class the transform wrapped itself are added. A class the user wrapped by hand, or re-exported from another module, runs on its own options and cannot be assumed to strip the argument. The options object only exists once the callback runs with `env`, so the plugin cannot merge at build time. The transform emits an inline callback that wraps the user's one and merges the binding names, so no runtime helper export is needed. Note this default only applies to Vite builds. At runtime a DurableObjectNamespace exposes no origin and a Fetcher does not say which service it points at, so a plain wrangler build still has to list its bindings. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5 --- .../durableobject-rpc/index.ts | 31 +++++ .../durableobject-rpc/instrument.server.ts | 8 ++ .../durableobject-rpc/test.ts | 31 +++++ .../durableobject-rpc/vite.config.mts | 8 ++ .../durableobject-rpc/wrangler.jsonc | 12 ++ .../cloudflare/src/vite/autoInstrument.ts | 1 + packages/cloudflare/src/vite/index.ts | 4 + packages/cloudflare/src/vite/transform.ts | 55 ++++++++- .../cloudflare/src/vite/wranglerConfig.ts | 38 ++++++ .../test/vite/autoInstrument.test.ts | 5 +- .../cloudflare/test/vite/transform.test.ts | 93 +++++++++++++++ .../test/vite/wranglerConfig.test.ts | 111 ++++++++++++++++++ 12 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts new file mode 100644 index 000000000000..c5b7776111ee --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts @@ -0,0 +1,31 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// Nothing is wrapped manually, the Vite plugin wraps both exports and enables RPC trace +// propagation for `COUNTER` on its own. +export class Counter extends DurableObject { + // An uninstrumented receiver would see Sentry's RPC metadata in the trailing optional parameter, + // see https://github.com/getsentry/sentry-javascript/issues/23233. + async increment(by: number, _unused?: number): Promise<{ count: number; argumentCount: number }> { + const count = ((await this.ctx.storage.get('count')) ?? 0) + by; + await this.ctx.storage.put('count', count); + return { count, argumentCount: arguments.length }; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/increment') { + const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e')); + return Response.json(await stub.increment(1)); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts new file mode 100644 index 000000000000..c45c1fcd3b02 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts @@ -0,0 +1,8 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// `rpcTracePropagationTargets` is deliberately absent, the Vite plugin derives `COUNTER` on its own. +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts new file mode 100644 index 000000000000..20483653eeec --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts @@ -0,0 +1,31 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('propagates the trace over a Durable Object RPC call without configuring the binding', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: string | undefined; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); + doTraceId = transactionEvent.contexts?.trace?.trace_id; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + workerTraceId = transactionEvent.contexts?.trace?.trace_id; + }) + .start(signal); + + // `argumentCount` proves the receiver stripped the metadata argument again. + const response = await runner.makeRequest<{ count: number; argumentCount: number }>('get', '/increment'); + expect(response).toEqual({ count: 1, argumentCount: 1 }); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBe(workerTraceId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts new file mode 100644 index 000000000000..4c3b3cd054f6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts @@ -0,0 +1,8 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry transform wraps the entry before the Cloudflare plugin bundles it. + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc new file mode 100644 index 000000000000..8f38ad9c6172 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-rpc", + // `main` points at the source entry so the auto-instrument transform runs during the build. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index aa410ef98fa4..707eed54375e 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -128,6 +128,7 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa agentClasses, optionsFn, optionsImport, + sameWorkerBindings: wranglerConfig.sameWorkerBindings, }); const wrappedClasses = result?.wrappedClasses ?? new Set(); diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index f1fcb6b739b3..0a40077b552a 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -45,6 +45,10 @@ export interface SentryCloudflareVitePluginOptions { * left alone, so this is safe alongside manual instrumentation. Set to * `false` to opt out. * + * The plugin also adds the bindings that resolve to the wrapped classes (this worker's own + * Durable Objects and self service bindings) to `rpcTracePropagationTargets`. Bindings to + * other workers stay opt-in, their receivers may not run Sentry. + * * @default true */ autoInstrumentation?: boolean; diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 65183f8aea98..79329b76d760 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -1,6 +1,9 @@ import MagicString from 'magic-string'; +import type { SameWorkerBinding } from './wranglerConfig'; import { detectWorkerEntrypointClasses } from './workerEntrypoint'; +const MERGED_OPTIONS_IDENTIFIER = '__SENTRY_OPTIONS__'; + // --------------------------------------------------------------------------- // Minimal ESTree node types for the AST nodes we inspect. // --------------------------------------------------------------------------- @@ -108,6 +111,8 @@ export interface TransformContext { optionsFn: string; /** Import statement prepended when `optionsFn` references a separate module. */ optionsImport?: string; + /** @see {@link import('./wranglerConfig').WranglerConfig.sameWorkerBindings} */ + sameWorkerBindings?: readonly SameWorkerBinding[]; } export interface TransformResult { @@ -144,6 +149,7 @@ export function applyAutoInstrumentTransforms( ): TransformResult | undefined { const ms = new MagicString(code); const topLevelClasses = collectTopLevelClasses(ast); + const sameWorkerBindings = ctx.sameWorkerBindings ?? []; const state: TransformState = { ms, needsImport: false, @@ -153,6 +159,9 @@ export function applyAutoInstrumentTransforms( classWrappers: ctx.classWrappers, agentClasses: ctx.agentClasses ?? new Set(), workerEntrypointClasses: detectWorkerEntrypointClasses(ast), + // The identifier must be chosen before wrapping, which bindings survive is only known after. + optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, + autoWrapped: new Set(), }; const { wrappedClasses } = state; @@ -179,6 +188,10 @@ export function applyAutoInstrumentTransforms( return { code, map: ms.generateMap({ hires: true }), wrappedClasses }; } + // `prepend` inserts before earlier prepends, yielding: Sentry import, options import, declaration. + if (sameWorkerBindings.length > 0) { + ms.prepend(buildMergedOptionsDeclaration(sameWorkerBindings, ctx.optionsFn, state)); + } if (ctx.optionsImport) ms.prepend(ctx.optionsImport); ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n"); @@ -212,6 +225,39 @@ interface TransformState { * so they can be wrapped without a config entry. */ workerEntrypointClasses: Set; + optionsFn: string; + /** + * Exported names this transform wrapped itself, unlike `wrappedClasses` which also counts + * hand-wrapped classes. `undefined` marks the default export, mirroring + * {@link SameWorkerBinding.className}. + */ + autoWrapped: Set; +} + +/** + * Builds the callback that merges same-worker binding names into `rpcTracePropagationTargets` at + * runtime, the options object only exists once the callback runs with `env`. Only bindings whose + * class this transform wrapped survive, a hand-wrapped class runs on its own options. + */ +function buildMergedOptionsDeclaration( + sameWorkerBindings: readonly SameWorkerBinding[], + optionsFn: string, + state: TransformState, +): string { + const bindingNames = sameWorkerBindings + .filter(({ className }) => state.autoWrapped.has(className)) + .map(({ bindingName }) => bindingName); + + if (!bindingNames.length) { + return `const ${MERGED_OPTIONS_IDENTIFIER} = ${optionsFn};\n`; + } + + const names = bindingNames.map(name => JSON.stringify(name)).join(', '); + return ( + `const ${MERGED_OPTIONS_IDENTIFIER} = (env) => { ` + + `const opts = (${optionsFn})(env); ` + + `return { ...opts, rpcTracePropagationTargets: [${names}, ...(opts?.rpcTracePropagationTargets ?? [])] }; };\n` + ); } /** @@ -263,8 +309,9 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // `export default ` → `const __SENTRY_DEFAULT_EXPORT__ = ` // MagicString positions are always relative to the original source. state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = '); - state.ms.append(`\nexport default __SENTRY__.withSentry(${ctx.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); + state.ms.append(`\nexport default __SENTRY__.withSentry(${state.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); state.needsImport = true; + state.autoWrapped.add(undefined); } function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void { @@ -336,10 +383,11 @@ function wrapInlineClassExport( // Insert the wrapped re-export after the class body state.ms.appendLeft( exportNode.end, - `\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`, + `\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`, ); state.wrappedClasses.add(className); + state.autoWrapped.add(className); state.renamedLocals.add(className); state.needsImport = true; } @@ -357,6 +405,7 @@ function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformConte if (!localName || !localClass?.id) return; state.wrappedClasses.add(exportedName); + state.autoWrapped.add(exportedName); state.needsImport = true; if (state.renamedLocals.has(localName)) return; state.renamedLocals.add(localName); @@ -367,6 +416,6 @@ function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformConte // wrapped) `localName` binding, so the wrapper is NOT exported here. state.ms.appendLeft( localClass.end, - `\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`, + `\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`, ); } diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index f95ca7264302..c24ee69658ee 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -18,6 +18,17 @@ export interface WranglerConfig { * an export on a *different* worker, which this build can't wrap. */ workerEntrypoints: string[]; + /** + * Bindings whose RPC receiver lives in this worker, so this build instruments it. An `undefined` + * `className` means the default export. Not deduped by class, two bindings may point at the same + * class and both names have to be listed. + */ + sameWorkerBindings: SameWorkerBinding[]; +} + +export interface SameWorkerBinding { + bindingName: string; + className?: string; } /** @@ -62,6 +73,7 @@ export function resolveWranglerConfig( durableObjects: collectClassBindings(raw.durable_objects?.bindings), workflows: collectClassBindings(raw.workflows), workerEntrypoints: collectSelfBoundEntrypoints(raw), + sameWorkerBindings: collectSameWorkerBindings(raw), }, configDir: dirname(raw.configPath ?? configPath), }; @@ -86,6 +98,32 @@ function collectSelfBoundEntrypoints(raw: Unstable_Config): string[] { return [...entrypoints]; } +/** + * Bindings with a `script_name` or naming another worker target a class this build does not wrap, + * so they are excluded and stay opt-in. + */ +function collectSameWorkerBindings(raw: Unstable_Config): SameWorkerBinding[] { + const bindings: SameWorkerBinding[] = []; + + for (const binding of raw.durable_objects?.bindings ?? []) { + if (typeof binding?.name === 'string' && typeof binding.class_name === 'string' && !binding.script_name) { + bindings.push({ bindingName: binding.name, className: binding.class_name }); + } + } + + for (const binding of raw.services ?? []) { + // A service binding is only ours when `service` names this worker; without a `name` none can be. + if (raw.name && binding?.service === raw.name && typeof binding.binding === 'string') { + bindings.push({ + bindingName: binding.binding, + className: typeof binding.entrypoint === 'string' ? binding.entrypoint : undefined, + }); + } + } + + return bindings; +} + /** * Map wrangler class bindings (Durable Objects, Workflows — same shape) to the * `{ name, className }` the transform needs, skipping duplicates and bindings diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index 703120daaf7e..5877ef480883 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -491,6 +491,9 @@ describe('instrument file auto-detection', () => { const code = ['class DurableObject {}', 'export class MyDO extends DurableObject {}'].join('\n'); const result = await tx(code, entryPath)!; - expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS_CALLBACK__,'); + expect(result.code).toContain( + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };', + ); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,'); }); }); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index e12754aaf3ea..810da030e9b8 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -669,3 +669,96 @@ describe('combined transforms', () => { expect(result.code).not.toContain('__SENTRY_DEFAULT_EXPORT__'); }); }); + +describe('same-worker RPC binding floor', () => { + it('declares the merged options callback after both imports and uses it at every wrapper site', () => { + const code = [ + 'export class MyDO {}', + 'export class MyWorkflow {}', + 'export default { fetch() { return new Response("ok"); } };', + ].join('\n'); + + const result = transform(code, { + classWrappers: new Map([ + ['MyDO', 'durableObject'], + ['MyWorkflow', 'workflow'], + ]), + optionsFn: '__SENTRY_OPTIONS_CALLBACK__', + optionsImport: "import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';\n", + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';", + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };', + ].join('\n'), + ); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,'); + expect(result.code).toContain('__SENTRY__.instrumentWorkflowWithSentry(__SENTRY_OPTIONS__,'); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS__, __SENTRY_DEFAULT_EXPORT__)'); + }); + + it('passes the env fallback callback through when there is no instrument file', () => { + const code = 'export class MyDO {}'; + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain( + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (() => undefined)(env); return { ...opts, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };', + ); + }); + + it('enables a self service binding without an entrypoint only when it wrapped the default export', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + + const result = transform(code, { + classWrappers: doWrappers(), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'SELF' }], + })!; + + expect(result.code).toContain('rpcTracePropagationTargets: ["SELF",'); + }); + + it('drops a binding whose class was wrapped by hand', () => { + // A hand-wrapped receiver runs on its own options and would see the trailing argument. + const code = [ + 'export const MyDO = Sentry.instrumentDurableObjectWithSentry(options, class {});', + 'export default { fetch() { return new Response("ok"); } };', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).not.toContain('rpcTracePropagationTargets'); + }); + + it('drops a binding whose class is re-exported from another module', () => { + const code = ['export { MyDO } from "./myDo";', 'export default { fetch() {} };'].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain('const __SENTRY_OPTIONS__ = () => undefined;'); + expect(result.code).not.toContain('rpcTracePropagationTargets'); + }); + + it('leaves the output untouched when there are no same-worker bindings', () => { + const code = 'export class MyDO {}'; + const ctx: TransformContext = { classWrappers: doWrappers('MyDO'), optionsFn: '() => undefined' }; + + expect(transform(code, { ...ctx, sameWorkerBindings: [] })!.code).toBe(transform(code, ctx)!.code); + }); +}); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 7589d69da1c5..274e8bebd160 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -460,3 +460,114 @@ describe('unstable_readConfig: service-binding entrypoint semantics', () => { expect(raw.topLevelName).toBeUndefined(); }); }); + +describe('sameWorkerBindings', () => { + function sameWorkerBindings(files: Record) { + return resolveWranglerConfig(writeTempDir(files))!.config.sameWorkerBindings; + } + + it('includes Durable Object bindings declared by this worker', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'COUNTER', class_name: 'Counter' }] }, + }), + }), + ).toEqual([{ bindingName: 'COUNTER', className: 'Counter' }]); + }); + + it('keeps every binding name pointing at the same Durable Object class', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'COUNTER', class_name: 'Counter' }, + { name: 'COUNTER_ALIAS', class_name: 'Counter' }, + ], + }, + }), + }), + ).toEqual([ + { bindingName: 'COUNTER', className: 'Counter' }, + { bindingName: 'COUNTER_ALIAS', className: 'Counter' }, + ]); + }); + + it('excludes Durable Object bindings owned by another worker', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [{ name: 'REMOTE_DO', class_name: 'Other', script_name: 'other-worker' }], + }, + }), + }), + ).toEqual([]); + }); + + it('excludes a Durable Object binding carrying this worker as `script_name`', () => { + // A `script_name` binding is never wrapped by this build, propagation would target an uninstrumented receiver. + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [{ name: 'SELF_DO', class_name: 'Counter', script_name: 'my-worker' }], + }, + }), + }), + ).toEqual([]); + }); + + it('includes self service bindings and excludes bindings to other workers', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + services: [ + { binding: 'SELF', service: 'my-worker', entrypoint: 'AdminEntry' }, + { binding: 'DEFAULT_SELF', service: 'my-worker' }, + { binding: 'EXTERNAL', service: 'other-worker' }, + ], + }), + }), + ).toEqual([ + { bindingName: 'SELF', className: 'AdminEntry' }, + { bindingName: 'DEFAULT_SELF', className: undefined }, + ]); + }); + + it('derives nothing from service bindings when the config omits `name`', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + services: [{ binding: 'SELF', service: 'my-worker', entrypoint: 'AdminEntry' }], + }), + }), + ).toEqual([]); + }); + + it('never includes workflow bindings or tail consumers', () => { + // Workflow bindings never reach the RPC instrumentation, tail consumers are not `env` bindings. + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + workflows: [{ name: 'MY_WF', binding: 'MY_WF', class_name: 'MyWorkflow' }], + tail_consumers: [{ service: 'my-worker' }], + }), + }), + ).toEqual([]); + }); +});