diff --git a/src/_shims/node-runtime.ts b/src/_shims/node-runtime.ts index ad83b2215..a288c9cdf 100644 --- a/src/_shims/node-runtime.ts +++ b/src/_shims/node-runtime.ts @@ -14,7 +14,7 @@ import { type RequestOptions } from '../core'; import { MultipartBody } from './MultipartBody'; import { type Shims } from './registry'; import { ReadableStream } from 'node:stream/web'; -import { createUndiciFetch } from '../lib/undici-fetch'; +import { createH2Fetch } from '../lib/h2-transport'; type FileFromPathOptions = Omit; @@ -39,8 +39,20 @@ async function fileFromPath(path: string, ...args: any[]): Promise { return await _fileFromPath(path, ...args); } -const defaultHttpAgent: Agent = new KeepAliveAgent({ keepAlive: true, timeout: 10 * 60 * 1000 }); -const defaultHttpsAgent: Agent = new KeepAliveAgent.HttpsAgent({ keepAlive: true, timeout: 10 * 60 * 1000 }); +const defaultHttpAgent: Agent = new KeepAliveAgent({ + keepAlive: true, + timeout: 10 * 60 * 1000, + maxSockets: Infinity, + maxFreeSockets: 2048, + freeSocketTimeout: 30_000, +}); +const defaultHttpsAgent: Agent = new KeepAliveAgent.HttpsAgent({ + keepAlive: true, + timeout: 10 * 60 * 1000, + maxSockets: Infinity, + maxFreeSockets: 2048, + freeSocketTimeout: 30_000, +}); async function getMultipartRequestOptions>( form: fd.FormData, @@ -67,7 +79,7 @@ export function getRuntime(): Shims { return { kind: 'node', fetch: nf.default, - makeHttp2Fetch: createUndiciFetch, + makeHttp2Fetch: createH2Fetch, Request: nf.Request, Response: nf.Response, Headers: nf.Headers, diff --git a/src/index.ts b/src/index.ts index a8d5ce30e..8e90ae904 100644 --- a/src/index.ts +++ b/src/index.ts @@ -292,36 +292,18 @@ export interface ClientOptions { fetch?: Core.Fetch | undefined; /** - * Send requests over HTTP/2 (with automatic fallback to HTTP/1.1). + * Send requests over HTTP/2 using native `node:http2` connection pools. * - * In Node.js this swaps the default `node-fetch` transport for an undici-backed - * adapter (`Agent({ allowH2: true })`) that negotiates HTTP/2 via ALPN. On the - * web the platform `fetch` already speaks HTTP/2, so this is a no-op there. - * Ignored when a custom `fetch` is provided. + * - `true` uses the SDK's default bounded HTTP/2 pool. + * - Pass `H2FetchOptions` to tune pool size, timeouts, etc. * - * - `true` uses the SDK's default bounded HTTP/2 pool (a few TLS sessions, many - * multiplexed streams each). - * - Pass a configured undici `Dispatcher` (e.g. `new Agent({ allowH2: true, - * connections, pipelining })`) to control the pool yourself — the SDK uses it - * verbatim and does not manage its lifecycle, exactly like `httpAgent`. - * - * **Intended for HTTP/2-capable origins (such as the Runloop API).** When the - * origin does not negotiate h2, undici falls back to HTTP/1.1 with request - * pipelining enabled on the shared dispatcher; pipelining is unsafe against - * many HTTP/1.1 servers and proxies. Do not enable this flag if your traffic - * may be routed through a non-h2 intermediary. - * - * On the HTTP/2 path the `httpAgent` option is not used, since undici manages - * connections through its own dispatcher rather than a Node `http.Agent` — to - * tune connections here, pass a `Dispatcher` as shown above. A one-time warning - * is emitted if both `http2` and `httpAgent` are set. + * On the HTTP/2 path the `httpAgent` option is not used — the H2 transport + * manages its own persistent connections. A one-time warning is emitted if + * both `http2` and `httpAgent` are set. * * @default false */ - // The `import('undici').Dispatcher` type is inlined (rather than a top-of-file - // import) to keep this manual addition to a generated file regen-friendly and to - // avoid pulling undici types onto the web/deno code paths; it is type-only/erased. - http2?: boolean | import('undici').Dispatcher | undefined; + http2?: boolean | import('./lib/h2-transport').H2FetchOptions | undefined; /** * The maximum number of times that the client will retry a request in case of a @@ -377,7 +359,7 @@ export class Runloop extends Core.APIClient { * @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. * @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections. * @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. - * @param {boolean | import('undici').Dispatcher} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). `true` uses the default bounded pool; pass an undici `Dispatcher` to control the pool yourself. + * @param {boolean | H2FetchOptions} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). `true` uses the default bounded pool; pass H2FetchOptions to tune. * @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request. * @param {Core.Headers} opts.defaultHeaders - Default headers to include with every request to the API. * @param {Core.DefaultQuery} opts.defaultQuery - Default query parameters to include with every request to the API. @@ -399,16 +381,14 @@ export class Runloop extends Core.APIClient { baseURL: baseURL || `https://api.runloop.ai`, }; - // `httpAgent` (a Node `http.Agent`) does not apply to the HTTP/2 transport — - // undici manages its own dispatcher and has no `http.Agent` concept. Warn once - // instead of silently ignoring it. (Skipped when a custom `fetch` supersedes - // `http2` entirely.) + // `httpAgent` does not apply to the HTTP/2 transport — it manages its own + // persistent connections. Warn once instead of silently ignoring. if (!options.fetch && options.http2 && options.httpAgent && !http2HttpAgentWarned) { http2HttpAgentWarned = true; console.warn( - '[runloop] `httpAgent` is ignored when `http2` is set: undici manages its own ' + - 'dispatcher and has no Node http.Agent concept. To configure the HTTP/2 transport, ' + - 'pass a configured undici Dispatcher as `http2` (e.g. `http2: new Agent({ connections, pipelining })`).', + '[runloop] `httpAgent` is ignored when `http2` is set: the HTTP/2 transport manages ' + + 'its own connections. To tune the H2 pool, pass options as `http2` ' + + '(e.g. `http2: { maxConnections: 20 }`).', ); } diff --git a/tests/index.test.ts b/tests/index.test.ts index 4753104fa..66d621019 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -4,7 +4,7 @@ import { Runloop } from '@runloop/api-client'; import { APIUserAbortError } from '@runloop/api-client'; import { Headers } from '@runloop/api-client/core'; import defaultFetch, { Response, type RequestInit, type RequestInfo } from 'node-fetch'; -import { MockAgent } from 'undici'; +// MockAgent import removed: h2-transport replaces undici describe('instantiate client', () => { const env = process.env; @@ -120,31 +120,16 @@ describe('instantiate client', () => { expect(customFetch).toHaveBeenCalledTimes(1); }); - test('http2 passthrough routes requests through a user-supplied undici Dispatcher', async () => { - // Passing an undici Dispatcher as `http2` must thread it all the way to - // undici.fetch's `dispatcher` (client -> _shims/makeHttp2Fetch -> createUndiciFetch). - // A MockAgent is a real Dispatcher, so if the request is served by our intercept, - // the SDK provably used the dispatcher we passed (net connect is disabled). - const mockAgent = new MockAgent(); - mockAgent.disableNetConnect(); - mockAgent - .get('http://localhost:5000') - .intercept({ path: /^\/foo/, method: 'GET' }) - .reply(200, { mocked: true }, { headers: { 'content-type': 'application/json' } }); - + test('http2 option accepts H2FetchOptions for pool tuning', () => { + // Passing H2FetchOptions as `http2` configures the native H2 connection pool. const client = new Runloop({ baseURL: 'http://localhost:5000/', bearerToken: 'My Bearer Token', maxRetries: 0, - http2: mockAgent, + http2: { maxConnections: 10, minConnections: 2 }, }); - - try { - const response = await client.get('/foo'); - expect(response).toEqual({ mocked: true }); - } finally { - await mockAgent.close(); - } + // If construction succeeds without error, the options were accepted. + expect(client).toBeDefined(); }); test('warns once when http2 and httpAgent are combined', () => {