Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<!-- prettier-ignore -->
```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:
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
56 changes: 37 additions & 19 deletions src/lib/undici-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,60 @@
/**
* 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 };

// 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 };
Expand Down
39 changes: 39 additions & 0 deletions tests/lib/undici-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
19 changes: 19 additions & 0 deletions tests/smoketests/scripts/verify-http2.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down