release(v4.6.1): bind globalThis.fetch in browser-receiver-sensitive call sites
Browsers' Window.fetch is a WebIDL bound operation; storing it as this.fetchImpl / this.fetchFn and calling via the instance receiver threw "Illegal invocation" on the first request. Bind once at construction in InboxClient, LongPollBridge, and SseBridge. Reported by Prism (multi-device E2EE terminal), blocking every browser consumer of the v4.6 transport stack on inbox.start() / bridge.connect(). WsBridge unaffected (uses WebSocket). Node/Bun fetch tolerates a free receiver, so the bug never surfaced server-side — added regression tests that install a strict-receiver globalThis.fetch to catch the issue without an actual browser harness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -510,3 +510,104 @@ describe('Bridge cursor resume', () => {
|
||||
await sseB.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridges — default fetch is bound to globalThis', () => {
|
||||
// Regression: browsers' `fetch` is a WebIDL bound operation that throws
|
||||
// "Illegal invocation" when called via `this.fetchFn(...)`. Constructors
|
||||
// for LongPollBridge / SseBridge must `bind(globalThis)`.
|
||||
function installStrictFetch(): { restore: () => void; getReceiver: () => unknown } {
|
||||
const realFetch = globalThis.fetch;
|
||||
let observedReceiver: unknown = 'unset';
|
||||
function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise<Response> {
|
||||
observedReceiver = this;
|
||||
if (this !== globalThis) {
|
||||
throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response('{"blobs":[]}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: strictFetch,
|
||||
});
|
||||
return {
|
||||
restore: () =>
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: realFetch,
|
||||
}),
|
||||
getReceiver: () => observedReceiver,
|
||||
};
|
||||
}
|
||||
|
||||
test('LongPollBridge default path passes globalThis as `this`', async () => {
|
||||
const { restore, getReceiver } = installStrictFetch();
|
||||
try {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const bridge = new LongPollBridge({
|
||||
baseUrl: 'http://example.invalid',
|
||||
auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' },
|
||||
pollTimeoutMs: 100,
|
||||
requestTimeoutMs: 200,
|
||||
errorBackoffMs: 50,
|
||||
disableLoop: true,
|
||||
});
|
||||
await bridge.connect({ onMessage: () => {} });
|
||||
expect(getReceiver()).toBe(globalThis);
|
||||
await bridge.disconnect();
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('SseBridge default path passes globalThis as `this`', async () => {
|
||||
// For SSE we only need to know the very first fetch was bound; the
|
||||
// 200-with-empty-stream response will let openOnce return cleanly,
|
||||
// and disableAutoReconnect prevents an infinite reconnect loop.
|
||||
const realFetch = globalThis.fetch;
|
||||
let observedReceiver: unknown = 'unset';
|
||||
function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise<Response> {
|
||||
observedReceiver = this;
|
||||
if (this !== globalThis) {
|
||||
throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
|
||||
}
|
||||
// Empty SSE-shaped body: stream closes immediately.
|
||||
return Promise.resolve(
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: strictFetch,
|
||||
});
|
||||
try {
|
||||
const id = await generateIdentityKeyPair(crypto);
|
||||
const bridge = new SseBridge({
|
||||
baseUrl: 'http://example.invalid',
|
||||
auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' },
|
||||
initialBackoffMs: 50,
|
||||
maxBackoffMs: 100,
|
||||
disableAutoReconnect: true,
|
||||
});
|
||||
await bridge.connect({ onMessage: () => {} });
|
||||
expect(observedReceiver).toBe(globalThis);
|
||||
await bridge.disconnect();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: realFetch,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user