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
13 changes: 13 additions & 0 deletions .github/workflows/smoke-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,17 @@ concurrency:

jobs:
smoke-tests:
name: smoke-tests (${{ matrix.transport }})
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# Run the full suite over both transports: default node-fetch (HTTP/1.1)
# and the undici adapter (HTTP/2). SMOKE_HTTP2 is read in tests/smoketests/utils.ts.
transport: [http1, http2]
env:
SMOKE_HTTP2: ${{ matrix.transport == 'http2' && '1' || '0' }}
steps:
- name: Checkout
uses: runloopai/checkout@main
Expand Down Expand Up @@ -66,5 +75,9 @@ jobs:
- name: Verify generated example artifacts
run: yarn check:examples-md

- name: Verify HTTP/2 negotiation
if: matrix.transport == 'http2'
run: node tests/smoketests/scripts/verify-http2.mjs

- name: Run smoke tests
run: yarn test:smoke --maxWorkers=800% --color
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,32 @@ await runloop.devboxes.create({...}, {
});
```

### HTTP/2 transport

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).

`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`:

<!-- prettier-ignore -->
```ts
import { Agent } from 'undici';

const runloop = new RunloopSDK({
http2: new Agent({ allowH2: true, connections: 8, pipelining: 100 }),
});
```

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.

## 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 +400,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 needs the undici >= 7.23.0 crash fix, and the package pins `^7.26.0`.)
- Deno v1.28.0 or higher.
- Bun 1.0 or later.
- Cloudflare Workers.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7",
"tar": "^7.5.2",
"undici": "^7.26.0",
"uuidv7": "^1.0.2",
"zod": "^3.24.1"
},
Expand Down
5 changes: 5 additions & 0 deletions src/_shims/index-deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ const _fetch = fetch;
type _fetch = typeof fetch;
export { _fetch as fetch };

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

const _Request = Request;
type _Request = Request;
export { _Request as Request };
Expand Down
9 changes: 9 additions & 0 deletions src/_shims/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export type Agent = SelectType<manual.Agent, auto.Agent>;
// @ts-ignore
export const fetch: SelectType<typeof manual.fetch, typeof auto.fetch>;

/**
* Build an HTTP/2-capable `fetch`, used when the client is constructed with
* `{ http2: ... }`. In Node this is the undici adapter (`Agent({ allowH2: true })`);
* the optional `dispatcher` lets the caller pass a configured undici `Dispatcher`
* (the `http2: <Dispatcher>` passthrough), defaulting to the SDK's bounded pool. On
* the web it returns the platform `fetch`, which already negotiates HTTP/2.
*/
export function makeHttp2Fetch(dispatcher?: any): typeof fetch;

// @ts-ignore
export type Request = SelectType<manual.Request, auto.Request>;
// @ts-ignore
Expand Down
2 changes: 2 additions & 0 deletions src/_shims/node-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +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';

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

Expand Down Expand Up @@ -66,6 +67,7 @@ export function getRuntime(): Shims {
return {
kind: 'node',
fetch: nf.default,
makeHttp2Fetch: createUndiciFetch,
Request: nf.Request,
Response: nf.Response,
Headers: nf.Headers,
Expand Down
10 changes: 10 additions & 0 deletions src/_shims/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { type RequestOptions } from '../core';
export interface Shims {
kind: string;
fetch: any;
/**
* Build an HTTP/2-capable `fetch`, used when the client is constructed with
* `{ http2: ... }`. In Node this is the undici adapter (`Agent({ allowH2: true })`);
* the optional `dispatcher` lets the caller pass a configured undici `Dispatcher`
* (the `http2: <Dispatcher>` passthrough), defaulting to the SDK's bounded pool. On
* the web the platform `fetch` already negotiates HTTP/2, so the argument is ignored.
*/
makeHttp2Fetch: (dispatcher?: any) => any;
Request: any;
Response: any;
Headers: any;
Expand All @@ -27,6 +35,7 @@ export interface Shims {
export let auto = false;
export let kind: Shims['kind'] | undefined = undefined;
export let fetch: Shims['fetch'] | undefined = undefined;
export let makeHttp2Fetch: Shims['makeHttp2Fetch'] | undefined = undefined;
export let Request: Shims['Request'] | undefined = undefined;
export let Response: Shims['Response'] | undefined = undefined;
export let Headers: Shims['Headers'] | undefined = undefined;
Expand All @@ -53,6 +62,7 @@ export function setShims(shims: Shims, options: { auto: boolean } = { auto: fals
auto = options.auto;
kind = shims.kind;
fetch = shims.fetch;
makeHttp2Fetch = shims.makeHttp2Fetch;
Request = shims.Request;
Response = shims.Response;
Headers = shims.Headers;
Expand Down
4 changes: 4 additions & 0 deletions src/_shims/web-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export function getRuntime({ manuallyImported }: { manuallyImported?: boolean }
return {
kind: 'web',
fetch: _fetch,
// The platform `fetch` already negotiates HTTP/2 at the transport layer, so
// `{ http2: ... }` is a no-op on the web — reuse the global fetch and ignore any
// passed dispatcher (undici dispatchers are a Node-only concept).
makeHttp2Fetch: () => _fetch,
Request: _Request,
Response: _Response,
Headers: _Headers,
Expand Down
59 changes: 57 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import { type Agent } from './_shims/index';
import { type Agent, makeHttp2Fetch } from './_shims/index';
import * as Core from './core';
import * as Errors from './error';
import * as Pagination from './pagination';
Expand Down Expand Up @@ -290,6 +290,38 @@ export interface ClientOptions {
*/
fetch?: Core.Fetch | undefined;

/**
* Send requests over HTTP/2 (with automatic fallback to HTTP/1.1).
*
* 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 (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.
*
* @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;

/**
* The maximum number of times that the client will retry a request in case of a
* temporary failure, like a network error or a 5XX error from the server.
Expand Down Expand Up @@ -326,6 +358,11 @@ export interface ClientOptions {
* console.log(result.exitCode);
* ```
*/
// Emitted at most once per process when `http2` and `httpAgent` are combined (see
// the constructor). Module-scoped flag mirrors the `fileFromPathWarned` pattern in
// _shims/node-runtime.ts.
let http2HttpAgentWarned = false;

export class Runloop extends Core.APIClient {
bearerToken: string;

Expand All @@ -339,6 +376,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 {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.
Expand All @@ -360,13 +398,30 @@ 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.)
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 })`).',
);
}

super({
baseURL: options.baseURL!,
baseURLOverridden: baseURL ? baseURL !== 'https://api.runloop.ai' : false,
timeout: options.timeout ?? 30000 /* 30 seconds */,
httpAgent: options.httpAgent,
maxRetries: options.maxRetries,
fetch: options.fetch,
fetch:
options.fetch ??
(options.http2 ?
makeHttp2Fetch(typeof options.http2 === 'object' ? options.http2 : undefined)
: undefined),
});

const customHeadersEnv = Core.readEnv('RUNLOOP_CUSTOM_HEADERS');
Expand Down
124 changes: 124 additions & 0 deletions src/lib/undici-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* 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`. 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. 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.
*
* 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.
*
* `createUndiciFetch(dispatcher?)` builds the adapter around a dispatcher: with no
* argument it uses the shared default pool below (what `http2: true` selects); a
* caller can instead pass their own configured undici `Dispatcher` (what
* `http2: <Dispatcher>` selects) for full control over the pool, exactly like the
* SDK's `httpAgent` escape hatch — the SDK does not manage its lifecycle.
*
* Lives in src/lib/ (the Stainless custom-code dir) so it survives regeneration.
*/
import { Agent, fetch as undiciFetchImpl, type Dispatcher } from 'undici';
import { Readable } from 'node:stream';
import { MultipartBody } from '../_shims/MultipartBody';
import { type Fetch } from '../core';

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 default 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). Used when the caller passes `http2: true` (no custom
// dispatcher).
const h2Dispatcher = new Agent({
allowH2: true,
connections: H2_MAX_CONNECTIONS,
pipelining: H2_MAX_CONCURRENT_STREAMS,
keepAliveTimeout: KEEP_ALIVE_TIMEOUT_MS,
keepAliveMaxTimeout: KEEP_ALIVE_TIMEOUT_MS,
// Disable undici's body/headers timeouts (both default to 300s) so this matches the
// node-fetch transport, which has no client-side body timeout. The SDK's own
// AbortController (core.ts `fetchWithTimeout`, governed by the `timeout` option) is
// the single source of truth. Without this, a long-lived stream idle for >300s — e.g.
// an SSE/exec stream behind `withStreamAutoReconnect` — would get an undici
// BodyTimeoutError that the reconnect predicate doesn't recognize, so it would throw
// instead of reconnect (as it does on node-fetch). A caller passing their own
// dispatcher owns this policy.
bodyTimeout: 0,
headersTimeout: 0,
});

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'`.
// 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 };
// Unwrap MultipartBody (wraps a Readable in `.body`). core.ts already unwraps
// it, but handle it defensively.
if (body instanceof MultipartBody) return normalizeBody((body as MultipartBody).body);
if (body instanceof Readable) {
return { body: Readable.toWeb(body) as any, isStream: true };
}
// ArrayBufferView (Uint8Array, DataView, typed arrays) and ArrayBuffer are
// valid BodyInit as-is / after a Buffer wrap.
if (ArrayBuffer.isView(body)) return { body, isStream: false };
if (body instanceof ArrayBuffer) return { body: Buffer.from(body), isStream: false };
return { body: String(body), isStream: false };
}

/**
* Build a fetch adapter bound to a dispatcher. `dispatcher` defaults to the shared
* bounded h2 pool above (the `http2: true` case); pass a configured undici
* `Dispatcher` to use it verbatim (the `http2: <Dispatcher>` passthrough case). The
* dispatcher is resolved once here, at client-construction time, then reused for
* every request — matching the "one module-scoped dispatcher" model.
*/
export function createUndiciFetch(dispatcher?: Dispatcher): Fetch {
const chosen = dispatcher ?? h2Dispatcher;
return async (url, init) => {
// core.ts injects a node-fetch-style `agent` in RequestInit; undici uses a
// `dispatcher` instead, so drop `agent`. Pull `signal` and `body` out to
// normalize them; pass everything else (method, headers, redirect, …) through.
const { agent: _ignoredAgent, body: rawBody, signal, ...rest } = (init ?? {}) as any;

const { body, isStream } = normalizeBody(rawBody);

const undiciInit: any = {
...rest,
body,
// core.ts passes a standard web AbortSignal (from `new AbortController()`),
// which undici accepts directly.
signal: signal ?? undefined,
dispatcher: chosen,
};
// A streamed request body requires the half-duplex hint or undici throws.
if (isStream) undiciInit.duplex = 'half';

// undici returns a genuine WHATWG Response. The SDK is typed against the
// node-fetch Response, so cast through `any`; at runtime core.ts only touches
// standard Response members that both implementations support.
return (await undiciFetchImpl(url as any, undiciInit)) as any;
};
}
Loading