diff --git a/packages/js-client-rest/README.md b/packages/js-client-rest/README.md index bdd3a15..caa9fdf 100644 --- a/packages/js-client-rest/README.md +++ b/packages/js-client-rest/README.md @@ -49,6 +49,20 @@ Or directly using an endpoint from the API: await client.api('collections').getCollections(); ``` +### Custom `fetch` + +By default the client uses [`undici`](https://github.com/nodejs/undici)'s `fetch` on Node.js +(with a connection pool you can size via `maxConnections`) and the platform's global `fetch` +elsewhere. You can override the transport entirely by passing your own `fetch` — useful for +proxies, custom dispatchers/agents, or to guarantee that the `fetch` and its dispatcher come +from the same `undici` version: + +```ts +import {fetch} from 'undici'; + +const client = new QdrantClient({url: 'http://127.0.0.1:6333', fetch}); +``` + ### Typed Error Handling A non-ok fetch response throws a generic `ApiError` diff --git a/packages/js-client-rest/package.json b/packages/js-client-rest/package.json index 823663c..2cd9bc1 100644 --- a/packages/js-client-rest/package.json +++ b/packages/js-client-rest/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", - "undici": "^6.24.0" + "undici": "^6.27.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.1.0", diff --git a/packages/js-client-rest/src/api-client.ts b/packages/js-client-rest/src/api-client.ts index 6fcd5fd..43ae59c 100644 --- a/packages/js-client-rest/src/api-client.ts +++ b/packages/js-client-rest/src/api-client.ts @@ -1,6 +1,7 @@ import {ApiError, Fetcher, Middleware} from '@qdrant/openapi-typescript-fetch'; import {paths} from './openapi/generated_schema.js'; -import {createDispatcher} from './dispatcher.js'; +import {createTransport} from './transport.js'; +import {createNodeFetch} from './node-fetch.js'; import { QdrantClientResourceExhaustedError, QdrantClientTimeoutError, @@ -20,7 +21,7 @@ export function createApis(baseUrl: string, args: RestArgs): ClientApi { export type OpenApiClient = ReturnType; -export function createClient(baseUrl: string, {headers, timeout, connections}: RestArgs): Client { +export function createClient(baseUrl: string, {headers, timeout, connections, fetch}: RestArgs): Client { const use: Middleware[] = []; use.push((url, init, next) => { const ctx = getContextHeaders(); @@ -68,19 +69,25 @@ export function createClient(baseUrl: string, {headers, timeout, connections}: R throw QdrantClientUnexpectedResponseError.forResponse(response); }); + // Terminal middleware: performs the actual request. Must be last so its + // `next` sits closest to the transport and the middlewares above can wrap it. + // + // Fetch selection (kept as a ternary at the call site so the `undici` branch + // is tree-shaken from browser bundles, where `process` becomes `undefined`): + // A. a caller-supplied `fetch`; + // B. on Node, undici's fetch + Agent from the same package (fixes #134); + // C. otherwise, the global `fetch` (handled inside `createTransport`). + const fetchImpl = + fetch ?? + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (typeof process !== 'undefined' && process.versions?.node ? createNodeFetch(connections) : undefined); + use.push(createTransport(fetchImpl)); + const client = Fetcher.for(); - // Configure client with 'undici' agent which is used in Node 18+ client.configure({ baseUrl, init: { headers, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - dispatcher: - typeof process !== 'undefined' && - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - process.versions?.node - ? createDispatcher(connections) - : undefined, }, use, }); diff --git a/packages/js-client-rest/src/dispatcher.ts b/packages/js-client-rest/src/dispatcher.ts deleted file mode 100644 index 12edfd1..0000000 --- a/packages/js-client-rest/src/dispatcher.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return */ - -import {Agent} from 'undici'; - -declare global { - interface RequestInit { - dispatcher?: Agent | undefined; - } -} - -export const createDispatcher = (connections = 25) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - new Agent({ - // timeouts are handled by AbortSignal in our middleware - bodyTimeout: 0, - headersTimeout: 0, - // https://stackoverflow.com/a/36437932/558180 - // pipelining: 1, - // a sensible max connections value - connections, - // will be overriden by header Keep-Alive, just as sensible default - keepAliveTimeout: 10_000, - }); diff --git a/packages/js-client-rest/src/node-fetch.ts b/packages/js-client-rest/src/node-fetch.ts new file mode 100644 index 0000000..50512a3 --- /dev/null +++ b/packages/js-client-rest/src/node-fetch.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */ + +import {Agent, fetch as undiciFetch} from 'undici'; +import {FetchFn} from './types.js'; + +/** + * Build a `fetch` backed by `undici`'s own `fetch` and an `undici` `Agent` from + * the *same* package, so the dispatcher contract always matches the fetch that + * consumes it — regardless of the undici version Node ships (fixes #134). + * + * This module is the *only* place that imports `undici`, and it must only ever + * be referenced behind a `process` guard at the call site (see `api-client.ts`). + * Bundlers targeting the browser replace `process` with `undefined`, which makes + * that branch dead and lets them drop this whole module — and the `undici` + * dependency along with it — from the browser build. + */ +export function createNodeFetch(connections?: number): FetchFn { + const agent = new Agent({ + // timeouts are handled by AbortSignal in our middleware + bodyTimeout: 0, + headersTimeout: 0, + // a sensible max connections value + connections, + // will be overridden by header Keep-Alive, just a sensible default + keepAliveTimeout: 10_000, + }); + // undici's fetch/Response/RequestInit are structurally compatible with the + // DOM lib types we expose via FetchFn, but not assignable, so cast across. + type UndiciRequestInit = Parameters[1]; + return ((url: string, init?: RequestInit) => + undiciFetch(url, {...init, dispatcher: agent} as unknown as UndiciRequestInit)) as unknown as FetchFn; +} diff --git a/packages/js-client-rest/src/qdrant-client.ts b/packages/js-client-rest/src/qdrant-client.ts index b3b8b48..0a5e0f4 100644 --- a/packages/js-client-rest/src/qdrant-client.ts +++ b/packages/js-client-rest/src/qdrant-client.ts @@ -1,6 +1,6 @@ import {OpenApiClient, createApis} from './api-client.js'; import {QdrantClientConfigError} from './errors.js'; -import {RestArgs, Schemas} from './types.js'; +import {FetchFn, RestArgs, Schemas} from './types.js'; import {PACKAGE_VERSION, ClientVersion} from './client-version.js'; import {ClientApi} from './openapi/generated_client_type.js'; @@ -33,6 +33,12 @@ export type QdrantClientParams = { * Check compatibility with the server version. Default: `true` */ checkCompatibility?: boolean; + /** + * Custom `fetch` implementation. When provided it is used for every request + * instead of the built-in transport. Useful to supply `undici`'s own `fetch`, + * a proxy-aware fetch, or a fetch with a custom dispatcher/Agent. + */ + fetch?: FetchFn; }; export class QdrantClient { @@ -114,7 +120,7 @@ export class QdrantClient { const address = this._port ? `${this._host}:${this._port}` : this._host; this._restUri = `${this._scheme}://${address}${this._prefix}`; const connections = args.maxConnections; - const restArgs: RestArgs = {headers, timeout, connections}; + const restArgs: RestArgs = {headers, timeout, connections, fetch: args.fetch}; this._openApiClient = createApis(this._restUri, restArgs); diff --git a/packages/js-client-rest/src/transport.ts b/packages/js-client-rest/src/transport.ts new file mode 100644 index 0000000..343a805 --- /dev/null +++ b/packages/js-client-rest/src/transport.ts @@ -0,0 +1,76 @@ +import {ApiError, Middleware} from '@qdrant/openapi-typescript-fetch'; +import {FetchFn} from './types.js'; + +/** + * bigint-aware JSON reviver, mirrored from `@qdrant/openapi-typescript-fetch`'s + * internal `fetcher.ts`. We re-implement the response parsing in our own + * terminal middleware (see `createTransport`), so we must keep the same + * large-integer handling: integers that don't fit into a safe JS number are + * parsed from their raw source text into a `bigint`. Only active on runtimes + * that support the JSON source-text access proposal (`JSON.rawJSON`). + */ +type JsonReviver = (this: unknown, key: string, value: unknown) => unknown; + +const bigintReviver: JsonReviver | undefined = + 'rawJSON' in JSON + ? function (_key, val, context?: {source: string}) { + if (typeof val === 'number' && Number.isInteger(val) && !Number.isSafeInteger(val) && context) { + try { + return BigInt(context.source); + } catch { + return val; + } + } + return val; + } + : undefined; + +/** Parse a `fetch` Response body the same way `@qdrant/openapi-typescript-fetch` does. */ +async function getResponseData(response: Response): Promise { + if (response.status === 204) { + return undefined; + } + const contentType = response.headers.get('content-type'); + const responseText = await response.text(); + if (contentType?.includes('application/json')) { + return JSON.parse(responseText, bigintReviver); + } + try { + return JSON.parse(responseText, bigintReviver); + } catch { + return responseText; + } +} + +/** + * A terminal middleware that performs the actual request with `fetchImpl` and + * builds the `ApiResponse` expected by `@qdrant/openapi-typescript-fetch`. + * + * It intentionally ignores `next`: by handling the request here we bypass the + * library's own call to the *global* `fetch`. That's what lets us drive the + * request through a fetch implementation that matches our dispatcher (see + * `createNodeFetch`) and avoids the "two undici in one process" mismatch (#134). + * + * `fetchImpl` defaults to the platform's global `fetch` (browser / edge / a Node + * runtime without undici), so the only thing the caller must decide is whether + * to override it. + */ +export function createTransport(fetchImpl?: FetchFn): Middleware { + const doFetch: FetchFn = fetchImpl ?? ((url, init) => globalThis.fetch(url, init)); + return async (url, init) => { + const response = await doFetch(url, init); + const data = await getResponseData(response); + const result = { + headers: response.headers, + url: response.url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + data, + }; + if (result.ok) { + return result; + } + throw new ApiError(result); + }; +} diff --git a/packages/js-client-rest/src/types.ts b/packages/js-client-rest/src/types.ts index 3af7a79..06a868d 100644 --- a/packages/js-client-rest/src/types.ts +++ b/packages/js-client-rest/src/types.ts @@ -1,9 +1,20 @@ import {components} from './openapi/generated_schema.js'; +/** + * A WHATWG-compatible `fetch` implementation. Used to let callers inject their + * own transport (e.g. `undici`'s `fetch`, a proxy-aware fetch, or a test double). + */ +export type FetchFn = (input: string, init?: RequestInit) => Promise; + export interface RestArgs { headers: Headers; timeout: number; connections?: number; + /** + * Custom `fetch` implementation. When provided it is used for every request + * instead of the built-in undici / global-fetch transport. + */ + fetch?: FetchFn; } // Definitions (in OpenAPI 2.0) or Schemas (in OpenAPI 3.0) – Data models that describe your API inputs and outputs. diff --git a/packages/js-client-rest/tests/unit/api-client.test.ts b/packages/js-client-rest/tests/unit/api-client.test.ts index 3699a58..a1da36b 100644 --- a/packages/js-client-rest/tests/unit/api-client.test.ts +++ b/packages/js-client-rest/tests/unit/api-client.test.ts @@ -1,40 +1,34 @@ import {createApis, createClient} from '../../src/api-client.js'; import {QdrantClientTimeoutError, QdrantClientUnexpectedResponseError} from '../../src/errors.js'; -import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest'; +import {vi, describe, test, expect} from 'vitest'; describe('apiClient', () => { const headers = new Headers(); headers.set('content-type', 'application/json'); const createFetchResponse = (status: number) => ({ headers, + url: '', ok: true, status, + statusText: '', json: () => new Promise((resolve) => resolve({error_message: 'response error'})), text: () => new Promise((resolve) => resolve(JSON.stringify({error_message: 'response error'}))), }); - let originalFetch: typeof global.fetch; - - beforeEach(() => { - originalFetch = global.fetch; - }); - - afterEach(() => { - global.fetch = originalFetch; - }); test('status 200', async () => { - global.fetch = vi.fn().mockResolvedValue(createFetchResponse(200)); + const fetch = vi.fn().mockResolvedValue(createFetchResponse(200)); const apis = createApis('http://my-domain.com', { timeout: Infinity, headers, + fetch, }); await expect(apis.collectionExists({collection_name: 'my-collection'})).resolves.toMatchObject({ data: {error_message: 'response error'}, }); - expect(global.fetch).toBeCalledWith( + expect(fetch).toBeCalledWith( expect.stringMatching('http://my-domain.com/collections/my-collection/exists'), expect.objectContaining({ method: 'GET', @@ -43,11 +37,12 @@ describe('apiClient', () => { }); test('status 400', async () => { - global.fetch = vi.fn().mockResolvedValue(createFetchResponse(400)); + const fetch = vi.fn().mockResolvedValue(createFetchResponse(400)); const client = createClient('http://my-domain.com', { timeout: Infinity, headers, + fetch, }); const telemetry = client.path('/telemetry').method('get').create(); @@ -57,14 +52,31 @@ describe('apiClient', () => { test('signal abort: timeout', async () => { const err = new Error(); err.name = 'AbortError'; - global.fetch = vi.fn().mockRejectedValue(err); + const fetch = vi.fn().mockRejectedValue(err); const client = createClient('http://my-domain.com', { timeout: 0, headers, + fetch, }); const telemetry = client.path('/telemetry').method('get').create(); await expect(telemetry({})).rejects.toThrowError(QdrantClientTimeoutError); }); + + test('injected fetch is used and no dispatcher is set on init', async () => { + const fetch = vi.fn().mockResolvedValue(createFetchResponse(200)); + + const apis = createApis('http://my-domain.com', { + timeout: Infinity, + headers, + fetch, + }); + + await apis.collectionExists({collection_name: 'my-collection'}); + + expect(fetch).toHaveBeenCalledTimes(1); + const calls = fetch.mock.calls as unknown as [string, Record][]; + expect(calls[0][1]).not.toHaveProperty('dispatcher'); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ba1caa..f21ccbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,8 +88,8 @@ importers: specifier: '>=4.7' version: 5.0.4 undici: - specifier: ^6.24.0 - version: 6.25.0 + specifier: ^6.27.0 + version: 6.27.0 devDependencies: '@rollup/plugin-commonjs': specifier: ^24.1.0 @@ -751,6 +751,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitest/expect@0.31.4': resolution: {integrity: sha512-tibyx8o7GUyGHZGyPgzwiaPaLDQ9MMuCOrc03BYT0nryUuhLbL7NV2r/q98iv5STlwMgaKuFJkgBW/8iPKwlSg==} @@ -1803,8 +1804,8 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@6.25.0: - resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} uri-js@4.4.1: @@ -3455,7 +3456,7 @@ snapshots: dependencies: '@fastify/busboy': 2.1.0 - undici@6.25.0: {} + undici@6.27.0: {} uri-js@4.4.1: dependencies: