Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
58 changes: 57 additions & 1 deletion scripts/generate-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -358,12 +365,56 @@ export class Zernio {
return ((options?: Record<string, unknown>) => {
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<string, unknown>)[method] = (
requestOptions?: Record<string, unknown>
) => {
if (requestOptions && 'client' in requestOptions) {
const sanitized = { ...requestOptions };
delete sanitized['client'];
return dispatch(sanitized);
}
return dispatch(requestOptions);
};
}
return wrapped;
}

${namespaceProps.join('\n\n')}

/**
Expand Down Expand Up @@ -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}\`);
Expand Down
58 changes: 57 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -633,12 +640,56 @@ export class Zernio {
return ((options?: Record<string, unknown>) => {
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<string, unknown>)[method] = (
requestOptions?: Record<string, unknown>
) => {
if (requestOptions && 'client' in requestOptions) {
const sanitized = { ...requestOptions };
delete sanitized['client'];
return dispatch(sanitized);
}
return dispatch(requestOptions);
};
}
return wrapped;
}

/**
* validate API
*/
Expand Down Expand Up @@ -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}`);
Expand Down
72 changes: 72 additions & 0 deletions tests/deno/smoke.ts
Original file line number Diff line number Diff line change
@@ -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)}`
);
});
79 changes: 79 additions & 0 deletions tests/request-init.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading