cloudflare / cloudflare/workerd
Generated TypeScript declarations omit receiver requirements enforced by JSG/V8
- Dominant language
- C++
- Stars
- 8.7k
- Forks
- 739
- Avg merge
- 2d 20h
- Merged PRs (30d)
- 174
Description
## Summary
JSG registers ordinary resource methods with an owning V8 `Signature`, so current `workerd` rejects calls made with an unrelated JavaScript receiver. The generated TypeScript declarations omit that receiver requirement and present those methods as freely rebindable functions.
For Worker global operations such as `fetch`, TypeScript therefore accepts code that current `workerd` rejects before the native callback executes:
```ts
class Client {
fetchImpl = fetch;
run() {
return this.fetchImpl("data:text/plain,ok");
}
}
```
`this.fetchImpl(...)` supplies the `Client` instance as `this`, producing `TypeError: Illegal invocation`. This proposal requests no runtime behavior change. It asks whether generated Worker declarations should expose receiver requirements using explicit TypeScript `this` parameters.
## User impact
This gap is difficult to catch locally:
- generated Worker declarations accept the code;
- Bun and Node tests can also pass because their global `fetch` implementations tolerate unrelated receivers;
- Chromium and `workerd` reject the same call;
- the exception occurs before outbound I/O and can be mistaken for a network failure;
- an arrow wrapper or explicit binding repairs the application boundary, but the generated declarations could provide an earlier diagnostic.
A production OAuth adapter encountered this path after storing ambient Worker `fetch` on a client instance. Detailed downstream investigation: https://github.com/teamleaderleo/stensibly/issues/474
## Runtime reproduction
Tested with:
- `workerd` package `1.20260728.1` / `workerd 2026-07-28`
- Chromium `144.0.7559.96`
- Bun `1.3.14`
- Node `26.5.0`
Harness target: `data:text/plain,receiver-ok`.
```js
const url = "data:text/plain,receiver-ok";
const detached = globalThis.fetch;
const holder = { fetch: detached };
await fetch(url);
await globalThis.fetch(url);
await self.fetch(url);
await detached(url);
await detached.call(undefined, url);
await detached.call(globalThis, url);
await detached.call({}, url);
await holder.fetch(url);
```
| Call form | workerd | Chromium | Bun | Node |
| --- | --- | --- | --- | --- |
| `fetch(url)` | response | response | response | response |
| `globalThis.fetch(url)` | response | response | response | response |
| `self.fetch(url)` | response | response | response | unavailable |
| `detached(url)` | response | response | response | response |
| `detached.call(undefined, url)` | response | response | response | response |
| `detached.call(globalThis, url)` | response | response | response | response |
| `detached.call({}, url)` | illegal invocation | illegal invocation | response | response |
| `holder.fetch(url)` | illegal invocation | illegal invocation | response | response |
Exact `workerd` error:
```text
TypeError: Illegal invocation: function called with incorrect `this` reference. See https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors for details.
```
Commands and raw output: https://github.com/teamleaderleo/stensibly/issues/474#issuecomment-5110331378
## Runtime enforcement trace
`ServiceWorkerGlobalScope` declares `fetch` as a C++ member and registers it with `JSG_METHOD(fetch)`:
- https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/src/workerd/api/global-scope.h#L792-L798
- https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/src/workerd/api/global-scope.h#L852-L865
JSG creates a `v8::Signature` specifically to protect methods from the wrong `this`, and `registerMethod()` attaches it to the method template:
- https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/src/workerd/jsg/resource.h#L2018-L2026
- https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/src/workerd/jsg/resource.h#L1339-L1369
V8 converts `undefined`/`null` to the global proxy, then validates the resulting receiver against the signature. Unrelated objects remain unrelated and produce `Illegal invocation`:
- https://github.com/v8/v8/blob/66b7bf5e0ddc117ecfd04b8d79066fef3d9eaf2b/src/builtins/builtins-api.cc#L25-L54
- https://github.com/v8/v8/blob/66b7bf5e0ddc117ecfd04b8d79066fef3d9eaf2b/src/builtins/builtins-api.cc#L92-L112
- https://github.com/v8/v8/blob/66b7bf5e0ddc117ecfd04b8d79066fef3d9eaf2b/src/builtins/builtins-api.cc#L155-L177
This matches Chromium and Web IDL operation binding behavior.
## Where generated declarations lose the receiver
Current declarations are receiver-free:
```ts
interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
fetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise;
}
declare function fetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise;
```
The relevant generation seams are:
1. `FunctionTraits` retains return and arguments but not the C++ owner used by RTTI:
https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/src/workerd/jsg/rtti.h#L80-L109
2. `createMethodPartial(fullyQualifiedParentName, method)` already receives the parent type name, but emits only `method.args` and the result:
https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/types/src/generator/structure.ts#L32-L48
3. `maybeExtractGlobalNode()` converts `ServiceWorkerGlobalScope` members into top-level functions while copying the parameter list unchanged:
https://github.com/cloudflare/workerd/blob/6aa890be9fa547e3907c805b312e39917a274221/types/src/transforms/globals.ts#L74-L116
## Bounded direction
For ordinary non-static JSG methods, TypeScript can model the runtime requirement directly:
```ts
interface Crypto {
getRandomValues(
this: Crypto,
array: T,
): T;
}
```
For extracted Worker globals, one union receiver can preserve bare/null/global calls while rejecting an unrelated holder in TypeScript 5.8.3:
```ts
declare function fetch(
this: ServiceWorkerGlobalScope | null | void,
input: RequestInfo | URL,
init?: RequestInit,
): Promise;
```
A Cloudflare-specific caveat remains: because the global transform emits free declarations rather than making `typeof globalThis` exactly `ServiceWorkerGlobalScope`, a complete generated-output fixture may need a designated-global alias or another receiver member to preserve `globalThis.fetch(...)` without creating recursive output.
Suggested first prototype:
- add `this: OwningType` to generated ordinary non-static JSG methods;
- leave static methods receiver-free;
- widen only the extracted global copy;
- add snapshot and `tsc` fixtures covering bare calls, actual global calls, detached calls, `.call(null)`, `.call(undefined)`, `.call({})`, and unrelated holders;
- measure compatibility across representative APIs before applying the change broadly.
A generator-only prototype may be possible because the parent type name is already available. RTTI receiver metadata could follow later if another consumer needs it.
## Questions
1. Is a generator-only prototype using the existing parent type context an acceptable first step?
2. For inherited methods, should the explicit receiver use the declaring type or generated leaf type?
3. Which non-static JSG methods intentionally permit receiver-independent invocation?
4. What generated type should represent the legal Worker-global receiver without recursive `globalThis` output?
5. Should this source-diagnostic change follow the normal generated-types release path or an opt-in transition?
## Out of scope
- relaxing JSG/V8 receiver enforcement;
- changing Fetch or Web IDL semantics;
- solving every receiver-erasure path in TypeScript;
- requiring nominal brands across all generated Worker interfaces.
Related receiver precedent:
- https://github.com/cloudflare/workerd/issues/2716
- https://github.com/cloudflare/workerd/pull/2730
Contributor guide
Research direction
Start with types/src/generator/structure.ts and types/src/transforms/globals.ts, then inspect the JSG method context in src/workerd/jsg/rtti.h. Run the existing generated-declaration and TypeScript fixtures before adding coverage for ordinary methods and extracted globals, including valid and unrelated receivers; done means the snapshots and tsc checks reflect the agreed receiver behavior without changing runtime enforcement.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, typescript
- Domain
- developer-experience, testing, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100