Skip to content

Commit 4cafa17

Browse files
dines-rlclaudetode-rlsid-rlReflex
authored
feat: opt-in HTTP/2 multiplexing transport via undici 7 (Node >= 20.18.1) (#791)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tony Deng <tony@runloop.ai> Co-authored-by: sid-rl <siddarth@runloop.ai> Co-authored-by: Reflex <agent@reflex.dev>
1 parent 984d514 commit 4cafa17

16 files changed

Lines changed: 477 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ jobs:
2626
- name: Set up Node
2727
uses: runloopai/setup-node@main
2828
with:
29-
node-version: '18'
29+
# undici 7 (the http2 transport dep) requires Node >= 20.18.1, so the
30+
# lint job can no longer bootstrap on Node 18. See README Requirements.
31+
node-version: '20'
3032

3133
- name: Bootstrap
3234
run: ./scripts/bootstrap

.github/workflows/smoke-tests.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,17 @@ concurrency:
2828

2929
jobs:
3030
smoke-tests:
31+
name: smoke-tests (${{ matrix.transport }})
3132
runs-on: ubuntu-latest
3233
timeout-minutes: 60
34+
strategy:
35+
fail-fast: false
36+
matrix:
37+
# Run the full suite over both transports: default node-fetch (HTTP/1.1)
38+
# and the undici adapter (HTTP/2). SMOKE_HTTP2 is read in tests/smoketests/utils.ts.
39+
transport: [http1, http2]
40+
env:
41+
SMOKE_HTTP2: ${{ matrix.transport == 'http2' && '1' || '0' }}
3342
steps:
3443
- name: Checkout
3544
uses: runloopai/checkout@main
@@ -66,5 +75,9 @@ jobs:
6675
- name: Verify generated example artifacts
6776
run: yarn check:examples-md
6877

78+
- name: Verify HTTP/2 negotiation
79+
if: matrix.transport == 'http2'
80+
run: node tests/smoketests/scripts/verify-http2.mjs
81+
6982
- name: Run smoke tests
7083
run: yarn test:smoke --maxWorkers=800% --color

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,32 @@ await runloop.devboxes.create({...}, {
355355
});
356356
```
357357

358+
### HTTP/2 transport
359+
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:
361+
362+
<!-- prettier-ignore -->
363+
```ts
364+
const runloop = new RunloopSDK({
365+
http2: true,
366+
});
367+
```
368+
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`:
372+
373+
<!-- prettier-ignore -->
374+
```ts
375+
import { Agent } from 'undici';
376+
377+
const runloop = new RunloopSDK({
378+
http2: new Agent({ allowH2: true, connections: 8, pipelining: 100 }),
379+
});
380+
```
381+
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.
383+
358384
## Semantic versioning
359385

360386
This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions:
@@ -374,7 +400,7 @@ TypeScript >= 4.5 is supported.
374400
The following runtimes are supported:
375401

376402
- Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more)
377-
- Node.js 18 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions.
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`.)
378404
- Deno v1.28.0 or higher.
379405
- Bun 1.0 or later.
380406
- Cloudflare Workers.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"formdata-node": "^4.3.2",
4949
"node-fetch": "^2.6.7",
5050
"tar": "^7.5.2",
51+
"undici": "^7.26.0",
5152
"uuidv7": "^1.0.2",
5253
"zod": "^3.24.1"
5354
},

src/_shims/index-deno.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ const _fetch = fetch;
99
type _fetch = typeof fetch;
1010
export { _fetch as fetch };
1111

12+
// 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).
15+
export const makeHttp2Fetch = () => _fetch;
16+
1217
const _Request = Request;
1318
type _Request = Request;
1419
export { _Request as Request };

src/_shims/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ export type Agent = SelectType<manual.Agent, auto.Agent>;
1515
// @ts-ignore
1616
export const fetch: SelectType<typeof manual.fetch, typeof auto.fetch>;
1717

18+
/**
19+
* 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.
24+
*/
25+
export function makeHttp2Fetch(dispatcher?: any): typeof fetch;
26+
1827
// @ts-ignore
1928
export type Request = SelectType<manual.Request, auto.Request>;
2029
// @ts-ignore

src/_shims/node-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { type RequestOptions } from '../core';
1414
import { MultipartBody } from './MultipartBody';
1515
import { type Shims } from './registry';
1616
import { ReadableStream } from 'node:stream/web';
17+
import { createUndiciFetch } from '../lib/undici-fetch';
1718

1819
type FileFromPathOptions = Omit<FilePropertyBag, 'lastModified'>;
1920

@@ -66,6 +67,7 @@ export function getRuntime(): Shims {
6667
return {
6768
kind: 'node',
6869
fetch: nf.default,
70+
makeHttp2Fetch: createUndiciFetch,
6971
Request: nf.Request,
7072
Response: nf.Response,
7173
Headers: nf.Headers,

src/_shims/registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { type RequestOptions } from '../core';
66
export interface Shims {
77
kind: string;
88
fetch: any;
9+
/**
10+
* 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.
15+
*/
16+
makeHttp2Fetch: (dispatcher?: any) => any;
917
Request: any;
1018
Response: any;
1119
Headers: any;
@@ -27,6 +35,7 @@ export interface Shims {
2735
export let auto = false;
2836
export let kind: Shims['kind'] | undefined = undefined;
2937
export let fetch: Shims['fetch'] | undefined = undefined;
38+
export let makeHttp2Fetch: Shims['makeHttp2Fetch'] | undefined = undefined;
3039
export let Request: Shims['Request'] | undefined = undefined;
3140
export let Response: Shims['Response'] | undefined = undefined;
3241
export let Headers: Shims['Headers'] | undefined = undefined;
@@ -53,6 +62,7 @@ export function setShims(shims: Shims, options: { auto: boolean } = { auto: fals
5362
auto = options.auto;
5463
kind = shims.kind;
5564
fetch = shims.fetch;
65+
makeHttp2Fetch = shims.makeHttp2Fetch;
5666
Request = shims.Request;
5767
Response = shims.Response;
5868
Headers = shims.Headers;

src/_shims/web-runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export function getRuntime({ manuallyImported }: { manuallyImported?: boolean }
3535
return {
3636
kind: 'web',
3737
fetch: _fetch,
38+
// 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).
41+
makeHttp2Fetch: () => _fetch,
3842
Request: _Request,
3943
Response: _Response,
4044
Headers: _Headers,

src/index.ts

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

3-
import { type Agent } from './_shims/index';
3+
import { type Agent, makeHttp2Fetch } from './_shims/index';
44
import * as Core from './core';
55
import * as Errors from './error';
66
import * as Pagination from './pagination';
@@ -291,6 +291,38 @@ export interface ClientOptions {
291291
*/
292292
fetch?: Core.Fetch | undefined;
293293

294+
/**
295+
* Send requests over HTTP/2 (with automatic fallback to HTTP/1.1).
296+
*
297+
* In Node.js this swaps the default `node-fetch` transport for an undici-backed
298+
* adapter (`Agent({ allowH2: true })`) that negotiates HTTP/2 via ALPN. On the
299+
* web the platform `fetch` already speaks HTTP/2, so this is a no-op there.
300+
* Ignored when a custom `fetch` is provided.
301+
*
302+
* - `true` uses the SDK's default bounded HTTP/2 pool (a few TLS sessions, many
303+
* multiplexed streams each).
304+
* - Pass a configured undici `Dispatcher` (e.g. `new Agent({ allowH2: true,
305+
* connections, pipelining })`) to control the pool yourself — the SDK uses it
306+
* verbatim and does not manage its lifecycle, exactly like `httpAgent`.
307+
*
308+
* **Intended for HTTP/2-capable origins (such as the Runloop API).** When the
309+
* origin does not negotiate h2, undici falls back to HTTP/1.1 with request
310+
* pipelining enabled on the shared dispatcher; pipelining is unsafe against
311+
* many HTTP/1.1 servers and proxies. Do not enable this flag if your traffic
312+
* may be routed through a non-h2 intermediary.
313+
*
314+
* On the HTTP/2 path the `httpAgent` option is not used, since undici manages
315+
* connections through its own dispatcher rather than a Node `http.Agent` — to
316+
* tune connections here, pass a `Dispatcher` as shown above. A one-time warning
317+
* is emitted if both `http2` and `httpAgent` are set.
318+
*
319+
* @default false
320+
*/
321+
// The `import('undici').Dispatcher` type is inlined (rather than a top-of-file
322+
// import) to keep this manual addition to a generated file regen-friendly and to
323+
// avoid pulling undici types onto the web/deno code paths; it is type-only/erased.
324+
http2?: boolean | import('undici').Dispatcher | undefined;
325+
294326
/**
295327
* The maximum number of times that the client will retry a request in case of a
296328
* temporary failure, like a network error or a 5XX error from the server.
@@ -327,6 +359,11 @@ export interface ClientOptions {
327359
* console.log(result.exitCode);
328360
* ```
329361
*/
362+
// Emitted at most once per process when `http2` and `httpAgent` are combined (see
363+
// the constructor). Module-scoped flag mirrors the `fileFromPathWarned` pattern in
364+
// _shims/node-runtime.ts.
365+
let http2HttpAgentWarned = false;
366+
330367
export class Runloop extends Core.APIClient {
331368
bearerToken: string;
332369

@@ -340,6 +377,7 @@ export class Runloop extends Core.APIClient {
340377
* @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out.
341378
* @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections.
342379
* @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation.
380+
* @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.
343381
* @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request.
344382
* @param {Core.Headers} opts.defaultHeaders - Default headers to include with every request to the API.
345383
* @param {Core.DefaultQuery} opts.defaultQuery - Default query parameters to include with every request to the API.
@@ -361,13 +399,30 @@ export class Runloop extends Core.APIClient {
361399
baseURL: baseURL || `https://api.runloop.ai`,
362400
};
363401

402+
// `httpAgent` (a Node `http.Agent`) does not apply to the HTTP/2 transport —
403+
// undici manages its own dispatcher and has no `http.Agent` concept. Warn once
404+
// instead of silently ignoring it. (Skipped when a custom `fetch` supersedes
405+
// `http2` entirely.)
406+
if (!options.fetch && options.http2 && options.httpAgent && !http2HttpAgentWarned) {
407+
http2HttpAgentWarned = true;
408+
console.warn(
409+
'[runloop] `httpAgent` is ignored when `http2` is set: undici manages its own ' +
410+
'dispatcher and has no Node http.Agent concept. To configure the HTTP/2 transport, ' +
411+
'pass a configured undici Dispatcher as `http2` (e.g. `http2: new Agent({ connections, pipelining })`).',
412+
);
413+
}
414+
364415
super({
365416
baseURL: options.baseURL!,
366417
baseURLOverridden: baseURL ? baseURL !== 'https://api.runloop.ai' : false,
367418
timeout: options.timeout ?? 30000 /* 30 seconds */,
368419
httpAgent: options.httpAgent,
369420
maxRetries: options.maxRetries,
370-
fetch: options.fetch,
421+
fetch:
422+
options.fetch ??
423+
(options.http2 ?
424+
makeHttp2Fetch(typeof options.http2 === 'object' ? options.http2 : undefined)
425+
: undefined),
371426
});
372427

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

0 commit comments

Comments
 (0)