From b56fc56085f0f20ebb8380cb2e91650a95eda34c Mon Sep 17 00:00:00 2001 From: Oscar Perez Date: Mon, 10 Aug 2026 17:37:40 +0200 Subject: [PATCH 1/2] fix: strip client selector from request options (breaks Deno / edge runtimes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _bind injects the per-instance client into operation options under the `client` key, and @hey-api/client-fetch spreads the whole options object into the fetch RequestInit. Deno defines its own `client` RequestInit option (a Deno.HttpClient) and validates it, so new Request(...) threw and every SDK call failed on Deno, Deno Deploy, and Supabase Edge Functions. Node's undici silently ignores the stray key, which is why the suite never caught it. Operations now dispatch through a wrapper client whose HTTP methods strip the selector before hey-api builds the request; the real client (config, interceptors) is untouched. Applied in both src/client.ts and the generate-client.ts template so regeneration preserves the fix. Workaround for https://github.com/hey-api/hey-api/issues/4177 — remove once the upstream fix ships and the dependency can be upgraded. Co-Authored-By: Claude Fable 5 --- scripts/generate-client.ts | 58 +++++++++++++++++++++++++++- src/client.ts | 58 +++++++++++++++++++++++++++- tests/request-init.test.ts | 79 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/request-init.test.ts diff --git a/scripts/generate-client.ts b/scripts/generate-client.ts index 21613bd4..35b2ed4c 100644 --- a/scripts/generate-client.ts +++ b/scripts/generate-client.ts @@ -338,6 +338,13 @@ export class Zernio { */ private _client!: Client; + /** + * Wrapper around \`_client\` that \`_bind\` injects into operations. Identical + * to \`_client\` except that its HTTP methods strip the \`client\` selector from + * request options before dispatch (see \`_stripClientSelector\`). + */ + private _dispatchClient!: Client; + /** * API key used for authentication. */ @@ -358,12 +365,56 @@ export class Zernio { return ((options?: Record) => { const withClient = { ...options }; if (withClient['client'] === undefined) { - withClient['client'] = this._client; + withClient['client'] = this._dispatchClient; } return (operation as (opts: unknown) => unknown)(withClient); }) as F; } + /** + * TODO: remove once hey-api ships a fix for + * https://github.com/hey-api/hey-api/issues/4177 and we can upgrade. + * + * \`_bind\` injects the per-instance client into each operation's options + * under the \`client\` key, and the generated operations forward that whole + * options object to @hey-api/client-fetch, which spreads it into the fetch + * \`RequestInit\`. Node's undici ignores unknown init fields, but Deno defines + * its own \`client\` init option (a \`Deno.HttpClient\`) and validates it, so + * \`new Request(...)\` throws and every SDK call fails on Deno, Deno Deploy, + * and Supabase Edge Functions. The selector has already done its job by the + * time an HTTP method runs, so this wrapper strips it before hey-api builds + * the request. The real client is left untouched — config and interceptors + * still live on it, and the wrapper delegates everything else to it. + */ + private static _stripClientSelector(client: Client): Client { + const wrapped = Object.create(client) as Client; + const methods = [ + 'connect', + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', + 'trace', + ] as const; + for (const method of methods) { + const dispatch = client[method] as (requestOptions?: unknown) => unknown; + (wrapped as unknown as Record)[method] = ( + requestOptions?: Record + ) => { + if (requestOptions && 'client' in requestOptions) { + const sanitized = { ...requestOptions }; + delete sanitized['client']; + return dispatch(sanitized); + } + return dispatch(requestOptions); + }; + } + return wrapped; + } + ${namespaceProps.join('\n\n')} /** @@ -405,6 +456,11 @@ ${namespaceProps.join('\n\n')} }) ); + // Deno / WinterCG compatibility: operations dispatch through a wrapper + // that strips the \`client\` selector from request options (see + // \`_stripClientSelector\`). + this._dispatchClient = Zernio._stripClientSelector(this._client); + // Add auth interceptor this._client.interceptors.request.use((request) => { request.headers.set('Authorization', \`Bearer \${this.apiKey}\`); diff --git a/src/client.ts b/src/client.ts index 829252f3..5af5b278 100644 --- a/src/client.ts +++ b/src/client.ts @@ -613,6 +613,13 @@ export class Zernio { */ private _client!: Client; + /** + * Wrapper around `_client` that `_bind` injects into operations. Identical + * to `_client` except that its HTTP methods strip the `client` selector from + * request options before dispatch (see `_stripClientSelector`). + */ + private _dispatchClient!: Client; + /** * API key used for authentication. */ @@ -633,12 +640,56 @@ export class Zernio { return ((options?: Record) => { const withClient = { ...options }; if (withClient['client'] === undefined) { - withClient['client'] = this._client; + withClient['client'] = this._dispatchClient; } return (operation as (opts: unknown) => unknown)(withClient); }) as F; } + /** + * TODO: remove once hey-api ships a fix for + * https://github.com/hey-api/hey-api/issues/4177 and we can upgrade. + * + * `_bind` injects the per-instance client into each operation's options + * under the `client` key, and the generated operations forward that whole + * options object to @hey-api/client-fetch, which spreads it into the fetch + * `RequestInit`. Node's undici ignores unknown init fields, but Deno defines + * its own `client` init option (a `Deno.HttpClient`) and validates it, so + * `new Request(...)` throws and every SDK call fails on Deno, Deno Deploy, + * and Supabase Edge Functions. The selector has already done its job by the + * time an HTTP method runs, so this wrapper strips it before hey-api builds + * the request. The real client is left untouched — config and interceptors + * still live on it, and the wrapper delegates everything else to it. + */ + private static _stripClientSelector(client: Client): Client { + const wrapped = Object.create(client) as Client; + const methods = [ + 'connect', + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'post', + 'put', + 'trace', + ] as const; + for (const method of methods) { + const dispatch = client[method] as (requestOptions?: unknown) => unknown; + (wrapped as unknown as Record)[method] = ( + requestOptions?: Record + ) => { + if (requestOptions && 'client' in requestOptions) { + const sanitized = { ...requestOptions }; + delete sanitized['client']; + return dispatch(sanitized); + } + return dispatch(requestOptions); + }; + } + return wrapped; + } + /** * validate API */ @@ -1839,6 +1890,11 @@ export class Zernio { }) ); + // Deno / WinterCG compatibility: operations dispatch through a wrapper + // that strips the `client` selector from request options (see + // `_stripClientSelector`). + this._dispatchClient = Zernio._stripClientSelector(this._client); + // 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..05bba75c --- /dev/null +++ b/tests/request-init.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Zernio from '../src'; + +/** + * `_bind` injects the per-instance client into each operation's options under + * the `client` key, and @hey-api/client-fetch spreads the whole per-request + * options object into the fetch `RequestInit`. Node's undici ignores unknown + * init fields, but Deno defines its own `client` init option (a + * `Deno.HttpClient`) and validates it, so `new Request(...)` throws and every + * SDK call fails on Deno, Deno Deploy, and Supabase Edge Functions + * (hey-api upstream: https://github.com/hey-api/hey-api/issues/4177). + * + * These tests run under Node, so they capture 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 client selector 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 routes through 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']); + }); + + it('keeps per-instance isolation between two clients', 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 first = new Zernio({ apiKey: 'sk_FIRST' }); + const second = new Zernio({ apiKey: 'sk_SECOND' }); + await first.accounts.listAccounts({}); + await second.accounts.listAccounts({}); + await first.accounts.listAccounts({}); + + expect(authHeaders).toEqual(['Bearer sk_FIRST', 'Bearer sk_SECOND', 'Bearer sk_FIRST']); + }); +}); From dd3e6a3bc1a5ad782b3705c2421b9750bcd2963e Mon Sep 17 00:00:00 2001 From: Oscar Perez Date: Mon, 10 Aug 2026 18:08:57 +0200 Subject: [PATCH 2/2] ci: run a Deno smoke test of the built bundle Deno and other WinterCG runtimes validate RequestInit strictly, so bugs that leak internal SDK fields into new Request(...) are invisible to the Node-only suite but break every call on Deno Deploy / Supabase Edge Functions. The new CI job builds the bundle and runs a real SDK call under Deno against a stubbed fetch (no network, no credentials); the tests fail on the pre-fix build and pass with the fix. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 33 ++++++++++++++++++ package.json | 1 + tests/deno/smoke.ts | 72 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 tests/deno/smoke.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d872548d..0853019f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,36 @@ jobs: - name: Run tests run: npm test + + deno: + # Deno (and other WinterCG runtimes) validate `RequestInit` strictly, so + # bugs that leak internal SDK fields into `new Request(...)` are invisible + # to the Node test suite but break every call on Deno Deploy / Supabase + # Edge Functions. This job runs a real SDK call (stubbed fetch, no network) + # against the built bundle under Deno. + name: Deno smoke test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build SDK + run: npm run build + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run Deno smoke test + run: npm run test:deno diff --git a/package.json b/package.json index 7454208a..6df9ddb2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:deno": "deno test --allow-read --allow-env tests/deno/smoke.ts", "prepublishOnly": "npm run build", "fetch-spec": "curl -o openapi.yaml https://zernio.com/openapi.yaml" }, diff --git a/tests/deno/smoke.ts b/tests/deno/smoke.ts new file mode 100644 index 00000000..d7d85a4e --- /dev/null +++ b/tests/deno/smoke.ts @@ -0,0 +1,72 @@ +/** + * Deno smoke test for the built SDK bundle. Run with: + * + * npm run build && npm run test:deno + * + * Deno (and other WinterCG runtimes) validate `RequestInit` strictly, so bugs + * that leak internal SDK fields into `new Request(...)` are invisible on Node + * but fatal here — see https://github.com/hey-api/hey-api/issues/4177 and the + * `_stripClientSelector` workaround in src/client.ts. Every request in this + * file goes through a stubbed `fetch`; no network I/O and no real credentials. + * + * This file deliberately avoids a `.test.ts` suffix so vitest's default glob + * ignores it; `deno test` runs it by explicit path. + */ +import Zernio from '../../dist/index.mjs'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function stubFetch(onRequest?: (request: Request) => void): void { + globalThis.fetch = ((input: Request | URL | string) => { + onRequest?.(input as Request); + return Promise.resolve( + new Response(JSON.stringify({ accounts: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }) as typeof fetch; +} + +Deno.test('SDK call constructs a valid Request under Deno', async () => { + stubFetch(); + const client = new Zernio({ apiKey: 'sk_deno_ci' }); + + // Before the _stripClientSelector fix this threw at `new Request(...)` with + // "Argument 2 `client` must be a Deno.HttpClient", before fetch was reached. + const result = await client.accounts.listAccounts({}); + + assert(result.response?.status === 200, 'expected a 200 from the stubbed fetch'); +}); + +Deno.test('auth header is applied through the per-instance client', async () => { + const authHeaders: (string | null)[] = []; + stubFetch((request) => authHeaders.push(request.headers.get('Authorization'))); + + const client = new Zernio({ apiKey: 'sk_deno_ci' }); + await client.accounts.listAccounts({}); + + assert(authHeaders.length === 1, 'expected exactly one request'); + assert(authHeaders[0] === 'Bearer sk_deno_ci', `unexpected auth header: ${authHeaders[0]}`); +}); + +Deno.test('two instances keep their own credentials', async () => { + const authHeaders: (string | null)[] = []; + stubFetch((request) => authHeaders.push(request.headers.get('Authorization'))); + + const first = new Zernio({ apiKey: 'sk_FIRST' }); + const second = new Zernio({ apiKey: 'sk_SECOND' }); + await first.accounts.listAccounts({}); + await second.accounts.listAccounts({}); + await first.accounts.listAccounts({}); + + assert( + JSON.stringify(authHeaders) === + JSON.stringify(['Bearer sk_FIRST', 'Bearer sk_SECOND', 'Bearer sk_FIRST']), + `instances leaked credentials: ${JSON.stringify(authHeaders)}` + ); +});