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:
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: teamleaderleo/stensibly#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.
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:
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: teamleaderleo/stensibly#474 (comment)
Runtime enforcement trace
ServiceWorkerGlobalScope declares fetch as a C++ member and registers it with JSG_METHOD(fetch):
|
jsg::Optional<Request::Initializer> requestInitr); |
|
|
|
jsg::Ref<ServiceWorkerGlobalScope> getSelf() { |
|
return JSG_THIS; |
|
} |
|
|
|
// Implemented in global-scope.c++ to avoid including crypto.h |
|
JSG_NESTED_TYPE(WorkerGlobalScope); |
|
if (flags.getSpecCompliantPropertyAttributes()) { |
|
// EventTarget is also declared on WorkerGlobalScope, but V8's |
|
// FunctionTemplate::Inherit() does not propagate instance-template |
|
// properties. Redeclare here so it becomes an own property of globalThis. |
|
JSG_NESTED_TYPE(EventTarget); |
|
} |
|
|
|
JSG_METHOD(btoa); |
|
JSG_METHOD(atob); |
|
|
|
JSG_METHOD(setTimeout); |
|
JSG_METHOD(clearTimeout); |
|
JSG_METHOD(setInterval); |
JSG creates a v8::Signature specifically to protect methods from the wrong this, and registerMethod() attaches it to the method template:
|
auto prototype = constructor->PrototypeTemplate(); |
|
|
|
// Signatures protect our methods from being invoked with the wrong `this`. |
|
auto signature = v8::Signature::New(isolate, constructor); |
|
|
|
auto instance = constructor->InstanceTemplate(); |
|
|
|
instance->SetInternalFieldCount(Wrappable::INTERNAL_FIELD_COUNT); |
|
|
|
auto functionTemplate = v8::FunctionTemplate::NewWithCFunctionOverloads(isolate, |
|
&MethodCallback<TypeWrapper, name, isContext, Self, decltype(method), method, |
|
ArgumentIndexes<decltype(method)>>::callback, |
|
v8::Local<v8::Value>(), signature, length, v8::ConstructorBehavior::kThrow, |
|
v8::SideEffectType::kHasSideEffect, {&cFunction, 1}); |
|
|
|
prototype->Set(isolate, name, functionTemplate); |
|
return; |
|
} |
|
} |
|
|
|
prototype->Set(isolate, name, |
|
v8::FunctionTemplate::New(isolate, |
|
&MethodCallback<TypeWrapper, name, isContext, Self, decltype(method), method, |
|
ArgumentIndexes<decltype(method)>>::callback, |
|
v8::Local<v8::Value>(), signature, length, v8::ConstructorBehavior::kThrow)); |
|
} |
|
|
|
template <const char* name, typename Method, Method method> |
|
inline void registerStaticMethod() { |
|
// Per Web IDL, function .length = number of required arguments. |
|
constexpr int specLength = requiredArgumentCount<TypeWrapper, Method>; |
|
const int length = getSpecCompliantPropertyAttributes(isolate) ? specLength : 0; |
|
|
|
if constexpr (isFastApiCompatible<Method>) { |
|
if (typeWrapper.isFastApiEnabled()) { |
|
// Must outlive the FunctionTemplate; see registerMethod for details. |
|
static const auto cFunction = v8::CFunction::Make(StaticMethodCallback<TypeWrapper, name, |
|
Self, Method, method, ArgumentIndexes<Method>>::template fastCallback<>); |
|
|
|
// Create a function template with both slow and fast paths |
V8 converts undefined/null to the global proxy, then validates the resulting receiver against the signature. Unrelated objects remain unrelated and produce Illegal invocation:
This matches Chromium and Web IDL operation binding behavior.
Where generated declarations lose the receiver
Current declarations are receiver-free:
interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
fetch(
input: RequestInfo | URL,
init?: RequestInit<RequestInitCfProperties>,
): Promise<Response>;
}
declare function fetch(
input: RequestInfo | URL,
init?: RequestInit<RequestInitCfProperties>,
): Promise<Response>;
The relevant generation seams are:
FunctionTraits<R (This::*)(Args...)> retains return and arguments but not the C++ owner used by RTTI:
|
|
|
template <typename R, typename... Args> |
|
struct FunctionTraits<R(Args...)> { |
|
using ReturnType = R; |
|
using ArgsTuple = std::tuple<Args...>; |
|
}; |
|
|
|
template <typename R, typename... Args> |
|
struct FunctionTraits<R (*)(Args...)> { |
|
using ReturnType = R; |
|
using ArgsTuple = std::tuple<Args...>; |
|
}; |
|
|
|
template <typename This, typename R, typename... Args> |
|
struct FunctionTraits<R (This::*)(Args...)> { |
|
using ReturnType = R; |
|
using ArgsTuple = std::tuple<Args...>; |
|
}; |
|
|
|
template <typename T> |
|
struct FunctionTraits<T, std::void_t<decltype(&T::operator())>> |
|
: public FunctionTraits<decltype(&T::operator())> {}; |
|
|
|
template <typename This, typename R, typename... Args> |
|
struct FunctionTraits<R (This::*)(Args...) const> { |
|
using ReturnType = R; |
|
using ArgsTuple = std::tuple<Args...>; |
|
}; |
|
|
|
template <typename Configuration, typename Tuple> |
createMethodPartial(fullyQualifiedParentName, method) already receives the parent type name, but emits only method.args and the result:
|
method: Method |
|
): [ts.Modifier[], string, ts.ParameterDeclaration[], ts.TypeNode] { |
|
const modifiers: ts.Modifier[] = []; |
|
if (method.static) { |
|
modifiers.push(f.createToken(ts.SyntaxKind.StaticKeyword)); |
|
} |
|
const name = method.name; |
|
const params = createParamDeclarationNodes( |
|
fullyQualifiedParentName, |
|
name, |
|
method.args.toArray(), |
|
/* forMethod */ true |
|
); |
|
const result = createTypeNode(method.returnType); |
|
return [modifiers, name, params, result]; |
|
} |
|
|
maybeExtractGlobalNode() converts ServiceWorkerGlobalScope members into top-level functions while copying the parameter list unchanged:
|
export function maybeExtractGlobalNode( |
|
ctx: ts.TransformationContext, |
|
node: ts.Node, |
|
modifiers?: readonly ts.ModifierLike[] |
|
): ts.Statement | undefined { |
|
if ( |
|
(ts.isMethodSignature(node) || ts.isMethodDeclaration(node)) && |
|
ts.isIdentifier(node.name) |
|
) { |
|
return ctx.factory.createFunctionDeclaration( |
|
modifiers, |
|
/* asteriskToken */ undefined, |
|
node.name, |
|
node.typeParameters, |
|
node.parameters, |
|
node.type, |
|
/* body */ undefined |
|
); |
|
} |
|
if ( |
|
(ts.isPropertySignature(node) || |
|
ts.isPropertyDeclaration(node) || |
|
ts.isGetAccessorDeclaration(node)) && |
|
ts.isIdentifier(node.name) |
|
) { |
|
assert(node.type !== undefined); |
|
// Don't create global nodes for nested types, they'll already be there |
|
if (!ts.isTypeQueryNode(node.type)) { |
|
const varDeclaration = ctx.factory.createVariableDeclaration( |
|
node.name, |
|
/* exclamationToken */ undefined, |
|
node.type |
|
); |
|
const varDeclarationList = ctx.factory.createVariableDeclarationList( |
|
[varDeclaration], |
|
ts.NodeFlags.Const // Use `const` instead of `var` |
|
); |
|
return ctx.factory.createVariableStatement(modifiers, varDeclarationList); |
|
} |
|
} |
|
} |
|
|
|
function createGlobalScopeVisitor( |
Bounded direction
For ordinary non-static JSG methods, TypeScript can model the runtime requirement directly:
interface Crypto {
getRandomValues<T extends ArrayBufferView>(
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:
declare function fetch(
this: ServiceWorkerGlobalScope | null | void,
input: RequestInfo | URL,
init?: RequestInit<RequestInitCfProperties>,
): Promise<Response>;
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
- Is a generator-only prototype using the existing parent type context an acceptable first step?
- For inherited methods, should the explicit receiver use the declaring type or generated leaf type?
- Which non-static JSG methods intentionally permit receiver-independent invocation?
- What generated type should represent the legal Worker-global receiver without recursive
globalThis output?
- 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:
Summary
JSG registers ordinary resource methods with an owning V8
Signature, so currentworkerdrejects 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 currentworkerdrejects before the native callback executes:this.fetchImpl(...)supplies theClientinstance asthis, producingTypeError: Illegal invocation. This proposal requests no runtime behavior change. It asks whether generated Worker declarations should expose receiver requirements using explicit TypeScriptthisparameters.User impact
This gap is difficult to catch locally:
fetchimplementations tolerate unrelated receivers;workerdreject the same call;A production OAuth adapter encountered this path after storing ambient Worker
fetchon a client instance. Detailed downstream investigation: teamleaderleo/stensibly#474Runtime reproduction
Tested with:
workerdpackage1.20260728.1/workerd 2026-07-28144.0.7559.961.3.1426.5.0Harness target:
data:text/plain,receiver-ok.fetch(url)globalThis.fetch(url)self.fetch(url)detached(url)detached.call(undefined, url)detached.call(globalThis, url)detached.call({}, url)holder.fetch(url)Exact
workerderror:Commands and raw output: teamleaderleo/stensibly#474 (comment)
Runtime enforcement trace
ServiceWorkerGlobalScopedeclaresfetchas a C++ member and registers it withJSG_METHOD(fetch):workerd/src/workerd/api/global-scope.h
Lines 792 to 798 in 6aa890b
workerd/src/workerd/api/global-scope.h
Lines 852 to 865 in 6aa890b
JSG creates a
v8::Signaturespecifically to protect methods from the wrongthis, andregisterMethod()attaches it to the method template:workerd/src/workerd/jsg/resource.h
Lines 2018 to 2026 in 6aa890b
workerd/src/workerd/jsg/resource.h
Lines 1339 to 1369 in 6aa890b
V8 converts
undefined/nullto the global proxy, then validates the resulting receiver against the signature. Unrelated objects remain unrelated and produceIllegal invocation:This matches Chromium and Web IDL operation binding behavior.
Where generated declarations lose the receiver
Current declarations are receiver-free:
The relevant generation seams are:
FunctionTraits<R (This::*)(Args...)>retains return and arguments but not the C++ owner used by RTTI:workerd/src/workerd/jsg/rtti.h
Lines 80 to 109 in 6aa890b
createMethodPartial(fullyQualifiedParentName, method)already receives the parent type name, but emits onlymethod.argsand the result:workerd/types/src/generator/structure.ts
Lines 32 to 48 in 6aa890b
maybeExtractGlobalNode()convertsServiceWorkerGlobalScopemembers into top-level functions while copying the parameter list unchanged:workerd/types/src/transforms/globals.ts
Lines 74 to 116 in 6aa890b
Bounded direction
For ordinary non-static JSG methods, TypeScript can model the runtime requirement directly:
For extracted Worker globals, one union receiver can preserve bare/null/global calls while rejecting an unrelated holder in TypeScript 5.8.3:
A Cloudflare-specific caveat remains: because the global transform emits free declarations rather than making
typeof globalThisexactlyServiceWorkerGlobalScope, a complete generated-output fixture may need a designated-global alias or another receiver member to preserveglobalThis.fetch(...)without creating recursive output.Suggested first prototype:
this: OwningTypeto generated ordinary non-static JSG methods;tscfixtures covering bare calls, actual global calls, detached calls,.call(null),.call(undefined),.call({}), and unrelated holders;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
globalThisoutput?Out of scope
Related receiver precedent:
getRandomBytes()#2716