Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => 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}`);
Expand Down
63 changes: 63 additions & 0 deletions tests/request-init.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});