diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d872548..0853019 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 7454208..6df9ddb 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/scripts/generate-client.ts b/scripts/generate-client.ts index 21613bd..35b2ed4 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 829252f..5af5b27 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/deno/smoke.ts b/tests/deno/smoke.ts new file mode 100644 index 0000000..d7d85a4 --- /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)}` + ); +}); diff --git a/tests/request-init.test.ts b/tests/request-init.test.ts new file mode 100644 index 0000000..05bba75 --- /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']); + }); +});