Skip to content

Commit 35dd305

Browse files
jrvb-rlReflexclaude
authored
feat: default to HTTP/2 transport on Node (#815)
Co-authored-by: Reflex <reflex@runloop.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9792541 commit 35dd305

12 files changed

Lines changed: 263 additions & 61 deletions

File tree

README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -357,29 +357,27 @@ await runloop.devboxes.create({...}, {
357357

358358
### HTTP/2 transport
359359

360-
On Node.js, the SDK can send requests over HTTP/2, which multiplexes many concurrent requests over a small number of TLS connections instead of opening a connection per request. Enable it with the `http2` option:
360+
On Node.js, the SDK sends requests over HTTP/2 **by default**, multiplexing many concurrent requests over a small number of TLS connections instead of opening a connection per request. The transport is built on Node's native `node:http2` and manages a bounded pool of persistent H2 sessions per origin, auto-scaling with load. On the web, Deno, and other runtimes the platform `fetch` already speaks HTTP/2, so this option is a no-op there.
361+
362+
To tune the pool — for example to raise the number of connections for a high-concurrency workload — pass options as `http2`:
361363

362364
<!-- prettier-ignore -->
363365
```ts
364366
const runloop = new RunloopSDK({
365-
http2: true,
367+
http2: { maxConnections: 20 },
366368
});
367369
```
368370

369-
Requests are routed through an [undici](https://github.com/nodejs/undici) connection pool with HTTP/2 enabled, falling back to HTTP/1.1 for origins that don't negotiate h2 via ALPN. It is intended for HTTP/2-capable origins such as the Runloop API. This transport uses undici and therefore **requires Node.js >= 20.18.1** (see Requirements).
370-
371-
`http2: true` uses a default bounded pool. To control the pool yourself — for example to raise the number of connections or multiplexed streams for a high-concurrency workload — pass a configured undici `Dispatcher` (such as an `Agent`) instead. The SDK uses it verbatim and does not manage its lifecycle, the same way it treats a custom `httpAgent`:
371+
To opt out and use the HTTP/1.1 `node-fetch` transport, set `http2: false`:
372372

373373
<!-- prettier-ignore -->
374374
```ts
375-
import { Agent } from 'undici';
376-
377375
const runloop = new RunloopSDK({
378-
http2: new Agent({ allowH2: true, connections: 8, pipelining: 100 }),
376+
http2: false,
379377
});
380378
```
381379

382-
The `httpAgent` option does not apply to the HTTP/2 transport (undici has no Node `http.Agent` concept); set `http2` to a `Dispatcher` to tune connections. A one-time warning is emitted if both `http2` and `httpAgent` are provided.
380+
The `httpAgent` option only applies to the HTTP/1.1 transport. A Node `http.Agent` configures HTTP/1.1 socket pooling (keep-alive, max sockets), which has no equivalent under HTTP/2 — the H2 transport multiplexes over its own managed connections — so `httpAgent` has no effect there. To tune HTTP/2 connection behavior, use `http2: { … }` instead. To keep an existing `httpAgent` working, passing one without an explicit `http2` value keeps the client on HTTP/1.1 (with a one-time warning); pass `http2: false` to select HTTP/1.1 explicitly and silence the warning. If both `httpAgent` and `http2` are set, `httpAgent` is ignored (also warned once). A custom `fetch` always takes precedence over `http2`.
383381

384382
## Semantic versioning
385383

@@ -400,7 +398,7 @@ TypeScript >= 4.5 is supported.
400398
The following runtimes are supported:
401399

402400
- Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more)
403-
- Node.js 20.18.1 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions. (Raised from 18 because the SDK now depends on undici 7 on Node; the HTTP/2 transport needs the undici >= 7.23.0 crash fix, and the package pins `^7.26.0`.)
401+
- Node.js 20.18.1 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions.
404402
- Deno v1.28.0 or higher.
405403
- Bun 1.0 or later.
406404
- Cloudflare Workers.

src/_shims/index-deno.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ type _fetch = typeof fetch;
1010
export { _fetch as fetch };
1111

1212
// The platform `fetch` already negotiates HTTP/2 at the transport layer, so
13-
// `{ http2: ... }` is a no-op on Deno — reuse the global fetch and ignore any passed
14-
// dispatcher (undici dispatchers are a Node-only concept).
13+
// `{ http2: ... }` is a no-op on Deno — reuse the global fetch and ignore any
14+
// passed pool options (the `node:http2` transport is Node-only).
1515
export const makeHttp2Fetch = () => _fetch;
1616

1717
const _Request = Request;

src/_shims/index.d.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ export const fetch: SelectType<typeof manual.fetch, typeof auto.fetch>;
1717

1818
/**
1919
* Build an HTTP/2-capable `fetch`, used when the client is constructed with
20-
* `{ http2: ... }`. In Node this is the undici adapter (`Agent({ allowH2: true })`);
21-
* the optional `dispatcher` lets the caller pass a configured undici `Dispatcher`
22-
* (the `http2: <Dispatcher>` passthrough), defaulting to the SDK's bounded pool. On
23-
* the web it returns the platform `fetch`, which already negotiates HTTP/2.
20+
* `{ http2: ... }`. On Node this is the native `node:http2` transport; the
21+
* optional `options` tune its connection pool (the `http2: <H2FetchOptions>`
22+
* passthrough), defaulting to the SDK's shared bounded pool. On the web it
23+
* returns the platform `fetch`, which already negotiates HTTP/2.
2424
*/
25-
export function makeHttp2Fetch(dispatcher?: any): typeof fetch;
25+
export function makeHttp2Fetch(options?: any): typeof fetch;
2626

2727
// @ts-ignore
2828
export type Request = SelectType<manual.Request, auto.Request>;

src/_shims/registry.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ export interface Shims {
88
fetch: any;
99
/**
1010
* Build an HTTP/2-capable `fetch`, used when the client is constructed with
11-
* `{ http2: ... }`. In Node this is the undici adapter (`Agent({ allowH2: true })`);
12-
* the optional `dispatcher` lets the caller pass a configured undici `Dispatcher`
13-
* (the `http2: <Dispatcher>` passthrough), defaulting to the SDK's bounded pool. On
14-
* the web the platform `fetch` already negotiates HTTP/2, so the argument is ignored.
11+
* `{ http2: ... }`. On Node this is the native `node:http2` transport; the
12+
* optional `options` tune its connection pool (the `http2: <H2FetchOptions>`
13+
* passthrough), defaulting to the SDK's shared bounded pool. On the web the
14+
* platform `fetch` already negotiates HTTP/2, so the argument is ignored.
1515
*/
16-
makeHttp2Fetch: (dispatcher?: any) => any;
16+
makeHttp2Fetch: (options?: any) => any;
1717
Request: any;
1818
Response: any;
1919
Headers: any;

src/_shims/web-runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ export function getRuntime({ manuallyImported }: { manuallyImported?: boolean }
3636
kind: 'web',
3737
fetch: _fetch,
3838
// The platform `fetch` already negotiates HTTP/2 at the transport layer, so
39-
// `{ http2: ... }` is a no-op on the web — reuse the global fetch and ignore any
40-
// passed dispatcher (undici dispatchers are a Node-only concept).
39+
// `{ http2: ... }` is a no-op on the web — reuse the global fetch and ignore
40+
// any passed pool options (the `node:http2` transport is Node-only).
4141
makeHttp2Fetch: () => _fetch,
4242
Request: _Request,
4343
Response: _Response,

src/index.ts

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

3-
import { type Agent, makeHttp2Fetch } from './_shims/index';
3+
import { type Agent } from './_shims/index';
4+
import { resolveHttp2Fetch } from './lib/http2-transport';
45
import * as Core from './core';
56
import * as Errors from './error';
67
import * as Pagination from './pagination';
@@ -294,14 +295,20 @@ export interface ClientOptions {
294295
/**
295296
* Send requests over HTTP/2 using native `node:http2` connection pools.
296297
*
297-
* - `true` uses the SDK's default bounded HTTP/2 pool.
298+
* HTTP/2 is the default transport on Node.js: it multiplexes many concurrent
299+
* requests over a small number of TLS connections instead of opening one
300+
* connection per request.
301+
*
302+
* - `true` / omitted uses the SDK's default bounded HTTP/2 pool.
298303
* - Pass `H2FetchOptions` to tune pool size, timeouts, etc.
304+
* - `false` opts out and uses the HTTP/1.1 `node-fetch` transport.
299305
*
300306
* On the HTTP/2 path the `httpAgent` option is not used — the H2 transport
301-
* manages its own persistent connections. A one-time warning is emitted if
302-
* both `http2` and `httpAgent` are set.
307+
* manages its own persistent connections. Passing an `httpAgent` without an
308+
* explicit `http2` value keeps the client on HTTP/1.1 (with a one-time
309+
* warning); pass `http2: false` to opt out silently.
303310
*
304-
* @default false
311+
* @default true
305312
*/
306313
http2?: boolean | import('./lib/h2-transport').H2FetchOptions | undefined;
307314

@@ -341,11 +348,6 @@ export interface ClientOptions {
341348
* console.log(result.exitCode);
342349
* ```
343350
*/
344-
// Emitted at most once per process when `http2` and `httpAgent` are combined (see
345-
// the constructor). Module-scoped flag mirrors the `fileFromPathWarned` pattern in
346-
// _shims/node-runtime.ts.
347-
let http2HttpAgentWarned = false;
348-
349351
export class Runloop extends Core.APIClient {
350352
bearerToken: string;
351353

@@ -359,7 +361,7 @@ export class Runloop extends Core.APIClient {
359361
* @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
360362
* @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections.
361363
* @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation.
362-
* @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.
364+
* @param {boolean | H2FetchOptions} [opts.http2=true] - Send requests over HTTP/2. Enabled by default on Node.js; pass `false` to use HTTP/1.1, or H2FetchOptions to tune the pool. Has no effect on browsers/Deno/Bun (their platform `fetch` already uses HTTP/2) or when a custom `fetch` is provided.
363365
* @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request.
364366
* @param {Core.Headers} opts.defaultHeaders - Default headers to include with every request to the API.
365367
* @param {Core.DefaultQuery} opts.defaultQuery - Default query parameters to include with every request to the API.
@@ -381,28 +383,15 @@ export class Runloop extends Core.APIClient {
381383
baseURL: baseURL || `https://api.runloop.ai`,
382384
};
383385

384-
// `httpAgent` does not apply to the HTTP/2 transport — it manages its own
385-
// persistent connections. Warn once instead of silently ignoring.
386-
if (!options.fetch && options.http2 && options.httpAgent && !http2HttpAgentWarned) {
387-
http2HttpAgentWarned = true;
388-
console.warn(
389-
'[runloop] `httpAgent` is ignored when `http2` is set: the HTTP/2 transport manages ' +
390-
'its own connections. To tune the H2 pool, pass options as `http2` ' +
391-
'(e.g. `http2: { maxConnections: 20 }`).',
392-
);
393-
}
394-
395386
super({
396387
baseURL: options.baseURL!,
397388
baseURLOverridden: baseURL ? baseURL !== 'https://api.runloop.ai' : false,
398389
timeout: options.timeout ?? 30000 /* 30 seconds */,
399390
httpAgent: options.httpAgent,
400391
maxRetries: options.maxRetries,
401-
fetch:
402-
options.fetch ??
403-
(options.http2 ?
404-
makeHttp2Fetch(typeof options.http2 === 'object' ? options.http2 : undefined)
405-
: undefined),
392+
// HTTP/2 is the default transport on Node; a custom `fetch` always wins.
393+
// See `resolveHttp2Fetch` for the `http2` / `httpAgent` resolution.
394+
fetch: options.fetch ?? resolveHttp2Fetch(options),
406395
});
407396

408397
const customHeadersEnv = Core.readEnv('RUNLOOP_CUSTOM_HEADERS');

src/lib/h2-transport/index.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/**
22
* A high-performance HTTP/2 fetch adapter built on Node's native `node:http2`.
33
*
4-
* Replaces undici for HTTP/2 transport. Manages a pool of persistent H2 sessions
5-
* per origin, multiplexing requests as concurrent streams. Each session handles up
6-
* to the server-advertised MAX_CONCURRENT_STREAMS; the pool auto-scales between
7-
* minConnections and maxConnections as load requires.
4+
* Manages a pool of persistent H2 sessions per origin, multiplexing requests as
5+
* concurrent streams. Each session handles up to the server-advertised
6+
* MAX_CONCURRENT_STREAMS; the pool auto-scales between minConnections and
7+
* maxConnections as load requires.
88
*
99
* Usage:
1010
* const fetch = createH2Fetch({ maxConnections: 20 });
@@ -17,9 +17,16 @@
1717

1818
import { Readable } from 'node:stream';
1919
import { H2Pool, type H2PoolOptions } from './pool';
20+
import type { H2Response } from './response';
2021
import { MultipartBody } from '../../_shims/MultipartBody';
22+
import { Response } from 'node-fetch';
2123
import { type Fetch } from '../../core';
2224

25+
// Statuses that must not carry a body per the Fetch spec; node-fetch's Response
26+
// rejects a non-null body for these. (101 is an HTTP/1.1 upgrade status and can't
27+
// occur over HTTP/2, so it's omitted.)
28+
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
29+
2330
const MIN_NODE_MAJOR = 18;
2431

2532
function checkNodeVersion(): void {
@@ -63,6 +70,25 @@ async function normalizeBody(body: unknown): Promise<string | Buffer | null> {
6370
return String(body);
6471
}
6572

73+
/**
74+
* Adapt an internal {@link H2Response} to a standard node-fetch `Response`, so
75+
* callers get the same type the default (HTTP/1.1) transport returns — including
76+
* `instanceof Response`. Body streaming is preserved: the H2 body is a web
77+
* `ReadableStream`, which node-fetch consumes as a Node `Readable`.
78+
*/
79+
function toFetchResponse(h2: H2Response): Response {
80+
const headers: Record<string, string> = {};
81+
for (const [key, value] of h2.headers.entries()) headers[key] = value;
82+
83+
const body = NULL_BODY_STATUSES.has(h2.status) ? null : Readable.fromWeb(h2.body as any);
84+
const response = new Response(body as any, { status: h2.status, headers });
85+
86+
// node-fetch derives `url` from the request internals it never saw; expose the
87+
// real request URL (`Response.url` is a prototype getter, shadowed here).
88+
Object.defineProperty(response, 'url', { value: h2.url, writable: true, configurable: true });
89+
return response;
90+
}
91+
6692
/**
6793
* Build a fetch adapter backed by native HTTP/2 connection pools.
6894
*
@@ -109,7 +135,8 @@ export function createH2Fetch(options?: H2PoolOptions): H2Fetch {
109135
}
110136

111137
const pool = getPool(parsed.origin);
112-
return pool.request(path, method.toUpperCase(), reqHeaders, body, signal) as any;
138+
const h2resp = await pool.request(path, method.toUpperCase(), reqHeaders, body, signal);
139+
return toFetchResponse(h2resp) as any;
113140
}) as H2Fetch;
114141

115142
h2Fetch.close = async () => {

src/lib/h2-transport/session.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export class H2Session {
7979
// Enlarge the session-level flow-control window so the server can push
8080
// large responses without stalling on WINDOW_UPDATE round trips.
8181
session.setLocalWindowSize(DEFAULT_INITIAL_WINDOW_SIZE);
82+
// An idle pooled session must not keep the process alive; each in-flight
83+
// request re-refs the socket (see `request`) and it is unref'd again once
84+
// the stream count returns to zero.
85+
session.unref();
8286
resolve();
8387
});
8488

@@ -140,6 +144,9 @@ export class H2Session {
140144

141145
const stream = this._session!.request(h2Headers);
142146
this._activeStreams++;
147+
// Keep the process alive while this request is outstanding; idle sessions
148+
// are unref'd (see `connect` and `cleanup`).
149+
this._session!.ref();
143150

144151
let settled = false;
145152
let cleaned = false;
@@ -155,6 +162,7 @@ export class H2Session {
155162
stream.on('error', () => {});
156163
stream.destroy();
157164
this._activeStreams--;
165+
if (this._activeStreams === 0) this._session?.unref();
158166
reject(createAbortError());
159167
return;
160168
}
@@ -165,6 +173,7 @@ export class H2Session {
165173
if (cleaned) return;
166174
cleaned = true;
167175
this._activeStreams--;
176+
if (this._activeStreams === 0) this._session?.unref();
168177
if (signal) signal.removeEventListener('abort', onAbort);
169178
if (this._state === SessionState.DRAINING && this._activeStreams === 0) {
170179
this._close();

src/lib/http2-transport.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { makeHttp2Fetch } from '../_shims/index';
2+
import { type Fetch } from '../core';
3+
import type { H2FetchOptions } from './h2-transport';
4+
5+
/**
6+
* Transport-selection logic for the client, kept out of the generated
7+
* `src/index.ts` (which holds only a one-line call-site hook).
8+
*
9+
* This selection matters only on Node.js. There the SDK's default transport is
10+
* `node-fetch`, which speaks HTTP/1.1, so using HTTP/2 means swapping in the
11+
* dedicated `node:http2` transport that {@link makeHttp2Fetch} builds. On
12+
* browsers, Deno, and Bun the platform `fetch` already negotiates HTTP/2, so the
13+
* shim's {@link makeHttp2Fetch} simply returns that platform `fetch` and this
14+
* whole resolution is effectively a no-op.
15+
*/
16+
17+
// A single HTTP/2 transport shared by every client that uses the default pool,
18+
// so short-lived clients don't each open (and leak) their own H2 sessions.
19+
// Clients that pass explicit `H2FetchOptions` get a dedicated pool, since they
20+
// asked to tune it.
21+
let sharedDefaultFetch: Fetch | undefined;
22+
23+
function getSharedDefaultFetch(): Fetch {
24+
return (sharedDefaultFetch ??= makeHttp2Fetch());
25+
}
26+
27+
// Each warning is emitted at most once per process. Module-scoped flags mirror
28+
// the `fileFromPathWarned` pattern in _shims/node-runtime.ts.
29+
let httpAgentIgnoredWarned = false;
30+
let httpAgentFallbackWarned = false;
31+
32+
function warnHttpAgentIgnored(): void {
33+
if (httpAgentIgnoredWarned) return;
34+
httpAgentIgnoredWarned = true;
35+
console.warn(
36+
'[runloop] `httpAgent` is ignored when `http2` is set: the HTTP/2 transport manages ' +
37+
'its own connections. To tune the H2 pool, pass options as `http2` ' +
38+
'(e.g. `http2: { maxConnections: 20 }`).',
39+
);
40+
}
41+
42+
function warnHttpAgentFallback(): void {
43+
if (httpAgentFallbackWarned) return;
44+
httpAgentFallbackWarned = true;
45+
console.warn(
46+
'[runloop] HTTP/2 is the default transport, but `httpAgent` is set, so this client ' +
47+
'uses HTTP/1.1. Remove `httpAgent` to use HTTP/2, or pass `http2: false` to select ' +
48+
'HTTP/1.1 explicitly and silence this warning.',
49+
);
50+
}
51+
52+
export interface Http2TransportOptions {
53+
http2?: boolean | H2FetchOptions | undefined;
54+
httpAgent?: unknown;
55+
}
56+
57+
/**
58+
* Resolve the `fetch` implementation for the transport, or `undefined` to fall
59+
* back to the default HTTP/1.1 (`node-fetch`) transport. Only called when the
60+
* caller did not supply a custom `fetch` (a custom `fetch` always wins).
61+
*
62+
* - `http2: false` → HTTP/1.1.
63+
* - `http2: true` / omitted → the shared default HTTP/2 pool.
64+
* - `http2: H2FetchOptions` → a dedicated HTTP/2 pool tuned with those options.
65+
* - a bare `httpAgent` (no explicit `http2`) → HTTP/1.1, so existing agents keep
66+
* working; warns once since it overrides the HTTP/2 default.
67+
*
68+
* `httpAgent` never applies to the HTTP/2 path: a Node `http.Agent` configures
69+
* HTTP/1.1 socket pooling, which has no equivalent in HTTP/2's multiplexed model
70+
* (its knobs live on `H2FetchOptions`). So the H2 branches ignore it, warning
71+
* once, rather than pretending to honor it.
72+
*/
73+
export function resolveHttp2Fetch(options: Http2TransportOptions): Fetch | undefined {
74+
if (options.http2 === false) return undefined;
75+
76+
if (options.http2) {
77+
if (options.httpAgent) warnHttpAgentIgnored();
78+
return typeof options.http2 === 'object' ? makeHttp2Fetch(options.http2) : getSharedDefaultFetch();
79+
}
80+
81+
if (options.httpAgent) {
82+
warnHttpAgentFallback();
83+
return undefined;
84+
}
85+
86+
return getSharedDefaultFetch();
87+
}

0 commit comments

Comments
 (0)