diff --git a/src/client.ts b/src/client.ts index aa661b2d..5ebf4fd9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1835,6 +1835,32 @@ export class Zernio { }) ); + // `_bind` injects this instance's client under the `client` key so each + // operation runs on the right per-instance client. @hey-api/client-fetch + // then spreads the whole per-request options object — `client` included — + // into the fetch `RequestInit`. Node's undici ignores unknown init fields, + // but Deno (and other WinterCG runtimes) reject a `client` that is not a + // `Deno.HttpClient`, so `new Request(...)` throws + // "Failed to construct 'Request': Argument 2 `client` must be a + // Deno.HttpClient" and every call fails under Deno Deploy, Supabase Edge + // Functions, Cloudflare Workers, etc. The selector has already done its job + // before the HTTP method runs, so strip it before the request is built. + const dispatchers = this._client as unknown as Record< + string, + (requestOptions?: Record) => unknown + >; + for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect']) { + const dispatch = dispatchers[method].bind(this._client); + dispatchers[method] = (requestOptions) => { + if (requestOptions && 'client' in requestOptions) { + const withoutClientSelector = { ...requestOptions }; + delete withoutClientSelector['client']; + return dispatch(withoutClientSelector); + } + return dispatch(requestOptions); + }; + } + // Add auth interceptor this._client.interceptors.request.use((request) => { request.headers.set('Authorization', `Bearer ${this.apiKey}`); diff --git a/tests/request-init.test.ts b/tests/request-init.test.ts new file mode 100644 index 00000000..40532545 --- /dev/null +++ b/tests/request-init.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Zernio from '../src'; + +/** + * `_bind` injects this instance's client under the `client` key so each call + * runs on the right per-instance client. @hey-api/client-fetch then spreads the + * whole per-request options object into the fetch `RequestInit`. Node's undici + * ignores unknown init fields, but Deno (and other WinterCG runtimes) reject a + * `client` that is not a `Deno.HttpClient` — `new Request(...)` throws and every + * call fails under Deno Deploy, Supabase Edge Functions, Cloudflare Workers. + * + * This asserts the selector never reaches the RequestInit. It runs under Node, + * so it captures the init at `new Request` time (undici would otherwise silently + * swallow the stray field and hide the bug). + */ +describe('request construction (Deno / WinterCG compatibility)', () => { + const OriginalRequest = globalThis.Request; + const originalFetch = globalThis.fetch; + let capturedInits: (RequestInit | undefined)[]; + + beforeEach(() => { + capturedInits = []; + globalThis.Request = class extends OriginalRequest { + constructor(input: RequestInfo | URL, init?: RequestInit) { + capturedInits.push(init); + super(input, init); + } + } as unknown as typeof Request; + globalThis.fetch = vi.fn( + async () => + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }) + ) as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.Request = OriginalRequest; + globalThis.fetch = originalFetch; + }); + + it('never leaks the internal client into the fetch RequestInit', async () => { + const client = new Zernio({ apiKey: 'sk_test' }); + + await client.accounts.listAccounts({}); + + expect(capturedInits.length).toBeGreaterThan(0); + for (const init of capturedInits) { + expect(init && 'client' in init).toBe(false); + } + }); + + it('still selects the per-instance client (auth header is applied)', async () => { + const authHeaders: (string | null)[] = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + authHeaders.push((input as Request).headers.get('Authorization')); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + + const client = new Zernio({ apiKey: 'sk_ABCD' }); + await client.accounts.listAccounts({}); + + expect(authHeaders).toEqual(['Bearer sk_ABCD']); + }); +});