diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd296ba19..4e0dd560b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,9 @@ jobs: - name: Set up Node uses: runloopai/setup-node@main with: - node-version: '18' + # undici 7 (the http2 transport dep) requires Node >= 20.18.1, so the + # lint job can no longer bootstrap on Node 18. See README Requirements. + node-version: '20' - name: Bootstrap run: ./scripts/bootstrap diff --git a/README.md b/README.md index e0bad3390..101e978bb 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,19 @@ await runloop.devboxes.create({...}, { }); ``` +### HTTP/2 transport (experimental) + +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: + + +```ts +const runloop = new RunloopSDK({ + http2: true, +}); +``` + +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). + ## Semantic versioning 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 +387,7 @@ TypeScript >= 4.5 is supported. The following runtimes are supported: - Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more) -- Node.js 18 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions. +- 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 requires undici >= 7.23.0.) - Deno v1.28.0 or higher. - Bun 1.0 or later. - Cloudflare Workers. diff --git a/package.json b/package.json index cf151ab2a..6512ad39b 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "formdata-node": "^4.3.2", "node-fetch": "^2.6.7", "tar": "^7.5.2", - "undici": "^6.21.0", + "undici": "^7.26.0", "uuidv7": "^1.0.2", "zod": "^3.24.1" }, diff --git a/src/lib/undici-fetch.ts b/src/lib/undici-fetch.ts index db112b416..61fad7aa4 100644 --- a/src/lib/undici-fetch.ts +++ b/src/lib/undici-fetch.ts @@ -1,34 +1,51 @@ /** - * A fetch-compatible adapter backed by undici's HTTP/2 support. + * A fetch-compatible adapter backed by undici's HTTP/2 support, using a bounded + * connection pool that multiplexes many concurrent requests over a few TLS sessions. * - * undici is the same engine that powers Node's built-in global `fetch`. - * Constructing an `Agent` with `allowH2: true` and passing it as the - * per-request `dispatcher` makes requests negotiate HTTP/2 via ALPN, with - * automatic fallback to HTTP/1.1 when the origin doesn't advertise h2. undici - * returns a standard WHATWG `Response`, so the rest of core.ts — which only + * undici is the same engine that powers Node's built-in global `fetch`. An `Agent` + * with `allowH2: true` negotiates HTTP/2 via ALPN and transparently falls back to + * HTTP/1.1 when the origin doesn't advertise h2. Two options make it actually + * multiplex rather than open one connection per request: + * - `connections` bounds the pool to a few TLS sessions per origin. Without it + * undici opens a fresh connection for every concurrent request (a connection + * storm) instead of reusing sessions. + * - `pipelining` (undici default: 1) is the max concurrent streams undici runs + * per session; it must be > 1 for H2 stream multiplexing to happen at all. + * + * undici returns a standard WHATWG `Response`, so the rest of core.ts — which only * touches standard Response members (`.status`, `.ok`, `.headers`, `.text()`, - * `.json()`, `.body`, `.arrayBuffer()`, `.blob()`) — is unchanged. + * `.json()`, `.body`, `.arrayBuffer()`, `.blob()`) — is unchanged. undici is dual + * CJS/ESM and `require`-able from this `"type": "commonjs"` package, so there is no + * dynamic-import hack and no second HTTP stack. * - * Unlike the previous got@14 approach, undici is dual CJS/ESM and `require`-able - * from this `"type": "commonjs"` package, so there is no dynamic-import hack and - * no second HTTP stack to keep in sync. + * Note: `pipelining > 1` also enables HTTP/1.1 request pipelining on the fallback + * path, so `http2: true` (opt-in) is intended for h2-capable origins. Requires + * undici >= 7.23.0 — multiplexed H2 assert-crashes on 6.x (undici PR #4845) — and + * therefore Node >= 20.18.1. * - * This lives in src/lib/ (the Stainless custom-code dir) so it survives - * regeneration; the only generated file touched is the one-line wiring change - * in src/_shims/node-runtime.ts. + * Lives in src/lib/ (the Stainless custom-code dir) so it survives regeneration. */ import { Agent, fetch as undiciFetchImpl } from 'undici'; import { Readable } from 'node:stream'; import { MultipartBody } from '../_shims/MultipartBody'; import { type Fetch } from '../core'; -// One module-scoped dispatcher, reused across requests: this is the HTTP/2 -// transport, with keep-alive. `allowH2` negotiates h2 over TLS via ALPN and -// transparently falls back to HTTP/1.1 when the origin doesn't offer h2. +const KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; +// Bound the pool to a few TLS sessions per origin and multiplex many H2 streams +// over each. 4 x 64 = 256 concurrent requests in flight before undici queues the rest. +const H2_MAX_CONNECTIONS = 4; +const H2_MAX_CONCURRENT_STREAMS = 64; + +// One module-scoped dispatcher, reused across requests: a bounded HTTP/2 pool with +// keep-alive. `allowH2` negotiates h2 over TLS via ALPN and transparently falls back +// to HTTP/1.1 when the origin doesn't offer h2; `connections`/`pipelining` make it +// multiplex (see the file header). const h2Dispatcher = new Agent({ allowH2: true, - keepAliveTimeout: 10 * 60 * 1000, - keepAliveMaxTimeout: 10 * 60 * 1000, + connections: H2_MAX_CONNECTIONS, + pipelining: H2_MAX_CONCURRENT_STREAMS, + keepAliveTimeout: KEEP_ALIVE_TIMEOUT_MS, + keepAliveMaxTimeout: KEEP_ALIVE_TIMEOUT_MS, }); type NormalizedBody = { body: any; isStream: boolean }; @@ -36,7 +53,8 @@ type NormalizedBody = { body: any; isStream: boolean }; // Map the body shapes core.ts produces (string | Buffer/ArrayBufferView | // Node Readable for multipart | null) onto a valid undici BodyInit. A Node // Readable must become a Web ReadableStream and requires `duplex: 'half'`. -function normalizeBody(body: unknown): NormalizedBody { +// Exported for unit tests. @internal +export function normalizeBody(body: unknown): NormalizedBody { if (body == null) return { body: undefined, isStream: false }; if (typeof body === 'string') return { body, isStream: false }; if (Buffer.isBuffer(body)) return { body, isStream: false }; diff --git a/tests/lib/undici-fetch.test.ts b/tests/lib/undici-fetch.test.ts new file mode 100644 index 000000000..754c0528c --- /dev/null +++ b/tests/lib/undici-fetch.test.ts @@ -0,0 +1,39 @@ +import { Readable } from 'node:stream'; +import { normalizeBody } from '../../src/lib/undici-fetch'; +import { MultipartBody } from '../../src/_shims/MultipartBody'; + +// The adapter's only non-trivial logic: mapping the body shapes core.ts produces onto a valid +// undici BodyInit. End-to-end behavior over both transports is covered by the smoke matrix +// (http1/http2) and verify-http2.mjs; this just pins the shape-conversion rules. +describe('undiciFetch / normalizeBody', () => { + test('passes string / Buffer / typed array through unchanged (non-stream)', () => { + expect(normalizeBody('hi')).toEqual({ body: 'hi', isStream: false }); + const buf = Buffer.from('b'); + expect(normalizeBody(buf)).toEqual({ body: buf, isStream: false }); + const u8 = new Uint8Array([1, 2]); + expect(normalizeBody(u8)).toEqual({ body: u8, isStream: false }); + }); + + test('wraps an ArrayBuffer in a Buffer', () => { + const out = normalizeBody(new Uint8Array([1, 2, 3]).buffer); + expect(out.isStream).toBe(false); + expect(Buffer.isBuffer(out.body)).toBe(true); + }); + + test('returns an undefined body for null / undefined', () => { + expect(normalizeBody(null)).toEqual({ body: undefined, isStream: false }); + expect(normalizeBody(undefined)).toEqual({ body: undefined, isStream: false }); + }); + + test('converts a Node Readable to a web ReadableStream and flags isStream', () => { + const out = normalizeBody(Readable.from(['x'])); + expect(out.isStream).toBe(true); + expect(typeof out.body.getReader).toBe('function'); // WHATWG ReadableStream + }); + + test('unwraps MultipartBody to its inner stream', () => { + const out = normalizeBody(new MultipartBody(Readable.from(['x']))); + expect(out.isStream).toBe(true); + expect(typeof out.body.getReader).toBe('function'); + }); +}); diff --git a/tests/smoketests/scripts/verify-http2.mjs b/tests/smoketests/scripts/verify-http2.mjs index 8120f7f35..cfb2b480f 100644 --- a/tests/smoketests/scripts/verify-http2.mjs +++ b/tests/smoketests/scripts/verify-http2.mjs @@ -32,7 +32,9 @@ if (!apiKey) { // keyed by name globally, so this catches the SDK's own undici regardless of // which module instance created the connection. const alpnSeen = []; +let connectCount = 0; diagnostics_channel.subscribe('undici:client:connected', (msg) => { + connectCount++; const proto = msg?.socket?.alpnProtocol; if (proto) alpnSeen.push(proto); }); @@ -76,5 +78,22 @@ try { check(false, `h1: GET devboxes.list resolved (threw ${e?.constructor?.name}: ${e?.message})`); } +// ── Pass D: HTTP/2 multiplexing — many concurrent requests reuse few connections ── +// The whole point of the bounded H2 pool: N concurrent requests share a small number of +// TLS sessions instead of one connection per request. Default config (pipelining=1) or the +// pre-fix undici Agent would open ~N connections here. +try { + const N = 25; + const before = connectCount; + const client = newClient({ http2: true }); + const results = await Promise.allSettled(Array.from({ length: N }, () => client.devboxes.list({ limit: 1 }))); + const ok = results.filter((r) => r.status === 'fulfilled').length; + const opened = connectCount - before; + check(ok === N, `h2: ${N} concurrent requests all resolved (${ok}/${N})`); + check(opened <= 4, `h2: ${N} concurrent requests multiplexed over <= 4 connections (opened ${opened})`); +} catch (e) { + check(false, `h2: concurrent multiplexing pass threw ${e?.constructor?.name}: ${e?.message}`); +} + console.log(failures === 0 ? '\n✓ ALL HTTP/2 CHECKS PASSED' : `\n✗ ${failures} CHECK(S) FAILED`); process.exit(failures === 0 ? 0 : 1); diff --git a/yarn.lock b/yarn.lock index 93a5f5d29..2b029521b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3844,10 +3844,10 @@ undici-types@~5.26.4: resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici@^6.21.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-6.26.0.tgz#333a35b7f519c48d2dc6aeb38e4e91d9274e0652" - integrity sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A== +undici@^7.26.0: + version "7.26.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.26.0.tgz#d413a2b5752e3e71e003bb268dec32b9a0ad0ce7" + integrity sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg== universalify@^2.0.0: version "2.0.1"