From 7fa8ef5c09773c7b772d8ddf508c2f7a5cd7cf7f Mon Sep 17 00:00:00 2001 From: Alexander Dines Date: Fri, 29 May 2026 11:55:17 -0700 Subject: [PATCH 1/7] feat: add opt-in HTTP/2 transport via undici Adds a `http2: true` client option that routes Node requests through an undici adapter (`Agent({ allowH2: true })`), negotiating HTTP/2 over ALPN with automatic fallback to HTTP/1.1. undici powers Node's global fetch, is require-able from this CommonJS package, and returns a standard WHATWG Response, so the rest of core.ts is unchanged -- no dynamic-import hack and no second HTTP stack. - src/lib/undici-fetch.ts: the adapter. Strips the node-fetch-style `agent` that core.ts injects, reuses a module-scoped h2 dispatcher, converts multipart Node Readable bodies via Readable.toWeb + duplex:'half', and returns the undici Response directly. - src/index.ts: the `http2` ClientOptions flag selects the adapter (opt-in, default false; ignored when a custom `fetch` is supplied). - src/_shims/*: expose an `http2Fetch` hook on the Node/web/deno runtimes (web and deno reuse the platform fetch, which already speaks HTTP/2). - tests/smoketests: run the suite over both transports (http1/http2 matrix) and add a plain-node harness (verify-http2.mjs) that proves ALPN h2 negotiation, success-body parsing, and clean rejection on a 401. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/smoke-tests.yml | 13 ++++ package.json | 1 + src/_shims/index-deno.ts | 4 ++ src/_shims/index.d.ts | 8 +++ src/_shims/node-runtime.ts | 2 + src/_shims/registry.ts | 9 +++ src/_shims/web-runtime.ts | 3 + src/index.ts | 19 +++++- src/lib/undici-fetch.ts | 81 +++++++++++++++++++++++ tests/smoketests/scripts/verify-http2.mjs | 80 ++++++++++++++++++++++ tests/smoketests/utils.ts | 9 +++ yarn.lock | 5 ++ 12 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/lib/undici-fetch.ts create mode 100644 tests/smoketests/scripts/verify-http2.mjs diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 3ffc19b8e..bc9c5c6e0 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -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 @@ -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 diff --git a/package.json b/package.json index 5b97c0cfc..284165f81 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "formdata-node": "^4.3.2", "node-fetch": "^2.6.7", "tar": "^7.5.2", + "undici": "^6.21.0", "uuidv7": "^1.0.2", "zod": "^3.24.1" }, diff --git a/src/_shims/index-deno.ts b/src/_shims/index-deno.ts index 9955a8dea..fd866ac5c 100644 --- a/src/_shims/index-deno.ts +++ b/src/_shims/index-deno.ts @@ -9,6 +9,10 @@ const _fetch = fetch; type _fetch = typeof fetch; export { _fetch as fetch }; +// The platform `fetch` already negotiates HTTP/2 at the transport layer, so +// `{ http2: true }` is a no-op on Deno — reuse the global fetch. +export { _fetch as http2Fetch }; + const _Request = Request; type _Request = Request; export { _Request as Request }; diff --git a/src/_shims/index.d.ts b/src/_shims/index.d.ts index 97968fed3..1904461e0 100644 --- a/src/_shims/index.d.ts +++ b/src/_shims/index.d.ts @@ -15,6 +15,14 @@ export type Agent = SelectType; // @ts-ignore export const fetch: SelectType; +/** + * An HTTP/2-capable `fetch`, used when the client is constructed with + * `{ http2: true }`. In Node this is the undici adapter (`Agent({ allowH2: true })`); + * on the web it is the platform `fetch`, which already negotiates HTTP/2 + * transparently. + */ +export const http2Fetch: any; + // @ts-ignore export type Request = SelectType; // @ts-ignore diff --git a/src/_shims/node-runtime.ts b/src/_shims/node-runtime.ts index 24a6aae13..0c3748434 100644 --- a/src/_shims/node-runtime.ts +++ b/src/_shims/node-runtime.ts @@ -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 { undiciFetch } from '../lib/undici-fetch'; type FileFromPathOptions = Omit; @@ -66,6 +67,7 @@ export function getRuntime(): Shims { return { kind: 'node', fetch: nf.default, + http2Fetch: undiciFetch, Request: nf.Request, Response: nf.Response, Headers: nf.Headers, diff --git a/src/_shims/registry.ts b/src/_shims/registry.ts index 18e96314b..554e5e63f 100644 --- a/src/_shims/registry.ts +++ b/src/_shims/registry.ts @@ -6,6 +6,13 @@ import { type RequestOptions } from '../core'; export interface Shims { kind: string; fetch: any; + /** + * An HTTP/2-capable `fetch` implementation, used when the client is + * constructed with `{ http2: true }`. In Node this is the undici adapter + * (`Agent({ allowH2: true })`); on the web the platform `fetch` already + * negotiates HTTP/2 transparently. + */ + http2Fetch: any; Request: any; Response: any; Headers: any; @@ -27,6 +34,7 @@ export interface Shims { export let auto = false; export let kind: Shims['kind'] | undefined = undefined; export let fetch: Shims['fetch'] | undefined = undefined; +export let http2Fetch: Shims['http2Fetch'] | undefined = undefined; export let Request: Shims['Request'] | undefined = undefined; export let Response: Shims['Response'] | undefined = undefined; export let Headers: Shims['Headers'] | undefined = undefined; @@ -53,6 +61,7 @@ export function setShims(shims: Shims, options: { auto: boolean } = { auto: fals auto = options.auto; kind = shims.kind; fetch = shims.fetch; + http2Fetch = shims.http2Fetch; Request = shims.Request; Response = shims.Response; Headers = shims.Headers; diff --git a/src/_shims/web-runtime.ts b/src/_shims/web-runtime.ts index 7f8a9a5be..4af9ce0a6 100644 --- a/src/_shims/web-runtime.ts +++ b/src/_shims/web-runtime.ts @@ -35,6 +35,9 @@ export function getRuntime({ manuallyImported }: { manuallyImported?: boolean } return { kind: 'web', fetch: _fetch, + // The platform `fetch` already negotiates HTTP/2 at the transport layer, + // so `{ http2: true }` is a no-op on the web — reuse the global fetch. + http2Fetch: _fetch, Request: _Request, Response: _Response, Headers: _Headers, diff --git a/src/index.ts b/src/index.ts index 89bb9eba6..a6f90e992 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, http2Fetch } from './_shims/index'; import * as Core from './core'; import * as Errors from './error'; import * as Pagination from './pagination'; @@ -290,6 +290,21 @@ 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. + * + * Note: 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`. + * + * @default false + */ + http2?: boolean | 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. @@ -366,7 +381,7 @@ export class Runloop extends Core.APIClient { timeout: options.timeout ?? 30000 /* 30 seconds */, httpAgent: options.httpAgent, maxRetries: options.maxRetries, - fetch: options.fetch, + fetch: options.fetch ?? (options.http2 ? http2Fetch : undefined), }); const customHeadersEnv = Core.readEnv('RUNLOOP_CUSTOM_HEADERS'); diff --git a/src/lib/undici-fetch.ts b/src/lib/undici-fetch.ts new file mode 100644 index 000000000..db112b416 --- /dev/null +++ b/src/lib/undici-fetch.ts @@ -0,0 +1,81 @@ +/** + * A fetch-compatible adapter backed by undici's HTTP/2 support. + * + * 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 + * touches standard Response members (`.status`, `.ok`, `.headers`, `.text()`, + * `.json()`, `.body`, `.arrayBuffer()`, `.blob()`) — is unchanged. + * + * 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. + * + * 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. + */ +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 h2Dispatcher = new Agent({ + allowH2: true, + keepAliveTimeout: 10 * 60 * 1000, + keepAliveMaxTimeout: 10 * 60 * 1000, +}); + +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 { + 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 }; +} + +export const undiciFetch: Fetch = 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: h2Dispatcher, + }; + // 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` (the prior got adapter did the + // same); at runtime core.ts only uses standard Response members. + return (await undiciFetchImpl(url as any, undiciInit)) as any; +}; + +export default undiciFetch; diff --git a/tests/smoketests/scripts/verify-http2.mjs b/tests/smoketests/scripts/verify-http2.mjs new file mode 100644 index 000000000..8120f7f35 --- /dev/null +++ b/tests/smoketests/scripts/verify-http2.mjs @@ -0,0 +1,80 @@ +/** + * Plain-node verification harness for the HTTP/2 (undici) transport. + * + * Runs OUTSIDE jest against the BUILT package — which is exactly how real + * clients consume the SDK, and the only place a `"type": "commonjs"` interop + * regression would surface. It proves three things a green smoke run cannot: + * + * 1. h2 is actually NEGOTIATED (not a silent HTTP/1.1 fallback) — asserted by + * reading the TLS socket's ALPN protocol via undici's diagnostics channel. + * 2. A success response body parses (exercises Response.json()). + * 3. A non-2xx response REJECTS with a readable error and does NOT crash the + * process — the exact failure mode of the old got adapter on a 401. + * + * Usage: RUNLOOP_API_KEY=... [RUNLOOP_BASE_URL=...] node tests/smoketests/scripts/verify-http2.mjs + * Exit code 0 = all checks passed, 1 = a check failed, 2 = misconfigured. + */ +import diagnostics_channel from 'node:diagnostics_channel'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const distPath = new URL('../../../dist/index.js', import.meta.url).pathname; +const { Runloop } = require(distPath); + +const apiKey = process.env.RUNLOOP_API_KEY; +const baseURL = process.env.RUNLOOP_BASE_URL; // falls back to SDK default (prod) if unset +if (!apiKey) { + console.error('RUNLOOP_API_KEY is required'); + process.exit(2); +} + +// Capture the negotiated ALPN protocol for every undici connection. Channels are +// keyed by name globally, so this catches the SDK's own undici regardless of +// which module instance created the connection. +const alpnSeen = []; +diagnostics_channel.subscribe('undici:client:connected', (msg) => { + const proto = msg?.socket?.alpnProtocol; + if (proto) alpnSeen.push(proto); +}); + +let failures = 0; +const check = (cond, label) => { + console.log(`${cond ? 'PASS' : 'FAIL'}: ${label}`); + if (!cond) failures++; +}; + +const newClient = (overrides) => + new Runloop({ bearerToken: apiKey, baseURL, maxRetries: 0, timeout: 30_000, ...overrides }); + +// ── Pass A: HTTP/2 success path ─────────────────────────────────────────── +alpnSeen.length = 0; +try { + const res = await newClient({ http2: true }).devboxes.list({ limit: 1 }); + check(res != null, 'h2: GET devboxes.list resolved with a parsed body'); +} catch (e) { + check(false, `h2: GET devboxes.list resolved (threw ${e?.constructor?.name}: ${e?.message})`); +} +check(alpnSeen.includes('h2'), `h2: TLS ALPN negotiated 'h2' (saw: ${alpnSeen.join(', ') || 'none'})`); + +// ── Pass B: HTTP/2 error path must reject cleanly, not crash ────────────── +try { + await newClient({ http2: true, bearerToken: 'ak_invalid_token_for_verify' }).devboxes.list({ limit: 1 }); + check(false, 'h2: bad token rejected (it unexpectedly succeeded)'); +} catch (e) { + check(true, 'h2: bad token rejected without crashing the process'); + check( + /401|unauthor|invalid|authentication/i.test(`${e?.status ?? ''} ${e?.message ?? ''}`), + `h2: error carries a readable body (${e?.constructor?.name}: ${(e?.message || '').slice(0, 70)})`, + ); +} + +// ── Pass C: HTTP/1.1 control path still works ───────────────────────────── +try { + const res = await newClient({ http2: false }).devboxes.list({ limit: 1 }); + check(res != null, 'h1: GET devboxes.list resolved (node-fetch control)'); +} catch (e) { + check(false, `h1: GET devboxes.list resolved (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/tests/smoketests/utils.ts b/tests/smoketests/utils.ts index af69f4bfd..d58b7cf5e 100644 --- a/tests/smoketests/utils.ts +++ b/tests/smoketests/utils.ts @@ -1,6 +1,13 @@ import { Runloop, RunloopSDK } from '@runloop/api-client'; import { NetworkPolicy, GatewayConfig, McpConfig } from '@runloop/api-client/sdk'; +/** + * Run the smoke tests over HTTP/2 (the undici adapter) instead of the default + * node-fetch (HTTP/1.1) transport. Toggled by the SMOKE_HTTP2 env var so CI can + * run the same suite over both transports. + */ +export const useHttp2 = ['1', 'true'].includes((process.env['SMOKE_HTTP2'] ?? '').toLowerCase()); + export function makeClient(overrides: Partial[0]> = {}) { const baseURL = process.env['RUNLOOP_BASE_URL']; const bearerToken = process.env['RUNLOOP_API_KEY']; @@ -10,6 +17,7 @@ export function makeClient(overrides: Partial Date: Fri, 29 May 2026 12:50:09 -0700 Subject: [PATCH 2/7] add new test for 100 startup comparison between 1.1/2 --- package.json | 1 + .../scripts/devbox-startup-benchmark.mjs | 334 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 tests/smoketests/scripts/devbox-startup-benchmark.mjs diff --git a/package.json b/package.json index 284165f81..4024abed4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "scripts": { "test": "./scripts/test", "test:smoke": "RUN_SMOKETESTS=1 jest --verbose tests/smoketests", + "test:e2e:devbox-startup": "yarn build && node tests/smoketests/scripts/devbox-startup-benchmark.mjs", "test:examples": "RUN_SMOKETESTS=1 jest --verbose tests/smoketests/examples/examples.test.ts", "test:objects": "RUN_SMOKETESTS=1 jest --config jest.config.objects.js --verbose", "test:objects-coverage": "RUN_SMOKETESTS=1 jest --verbose --config jest.config.objects.js --coverage --coverageReporters=text --coverageReporters=json-summary", diff --git a/tests/smoketests/scripts/devbox-startup-benchmark.mjs b/tests/smoketests/scripts/devbox-startup-benchmark.mjs new file mode 100644 index 000000000..6364fe94d --- /dev/null +++ b/tests/smoketests/scripts/devbox-startup-benchmark.mjs @@ -0,0 +1,334 @@ +/** + * Devbox startup benchmark for comparing the SDK HTTP/1.1 and HTTP/2 transports. + * + * Runs OUTSIDE the normal smoke suite because the default configuration creates + * 100 devboxes per transport. Build first, then run explicitly: + * + * RUNLOOP_API_KEY=... [RUNLOOP_BASE_URL=...] [RUNLOOP_E2E_DEVBOX_COUNT=100] yarn test:e2e:devbox-startup + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const distPath = new URL('../../../dist/index.js', import.meta.url).pathname; + +let Runloop; +try { + ({ Runloop } = require(distPath)); +} catch (error) { + console.error(`Failed to load built SDK from ${distPath}. Run \`yarn build\` first.`); + console.error(`${error?.constructor?.name ?? 'Error'}: ${error?.message ?? error}`); + process.exit(2); +} + +const DEFAULT_DEVBOX_COUNT = 100; +const AWAIT_RUNNING_TIMEOUT_MS = 20 * 60 * 1000; +const COMMAND_TIMEOUT_MS = 2 * 60 * 1000; +const SHUTDOWN_TIMEOUT_MS = 2 * 60 * 1000; + +const apiKey = process.env.RUNLOOP_API_KEY; +const baseURL = process.env.RUNLOOP_BASE_URL; +const devboxCount = parsePositiveInteger(process.env.RUNLOOP_E2E_DEVBOX_COUNT, DEFAULT_DEVBOX_COUNT); +const resultsPath = process.env.RUNLOOP_E2E_RESULTS_PATH ?? defaultResultsPath(); + +if (!apiKey) { + console.error('RUNLOOP_API_KEY is required'); + process.exit(2); +} + +if (devboxCount === undefined) { + console.error('RUNLOOP_E2E_DEVBOX_COUNT must be a positive integer'); + process.exit(2); +} + +const transports = [ + { name: 'http1', http2: false }, + { name: 'http2', http2: true }, +]; + +const startedAt = new Date().toISOString(); +const passResults = []; + +for (const transport of transports) { + passResults.push(await runTransportPass(transport)); +} + +const endedAt = new Date().toISOString(); +const artifact = { + benchmark: 'devbox-startup-http1-vs-http2', + startedAt, + endedAt, + config: { + devboxCount, + baseURL: baseURL ?? null, + awaitRunningTimeoutMs: AWAIT_RUNNING_TIMEOUT_MS, + commandTimeoutMs: COMMAND_TIMEOUT_MS, + shutdownTimeoutMs: SHUTDOWN_TIMEOUT_MS, + }, + summary: passResults.map(({ records, summary, ...pass }) => pass), + passes: passResults, +}; + +await fs.mkdir(path.dirname(resultsPath), { recursive: true }); +await fs.writeFile(resultsPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + +printComparison(passResults); +console.log(`\nWrote results artifact: ${resultsPath}`); + +const hasWorkflowFailures = passResults.some((pass) => pass.workflowFailureCount > 0); +process.exit(hasWorkflowFailures ? 1 : 0); + +async function runTransportPass(transport) { + const client = new Runloop({ + bearerToken: apiKey, + baseURL, + timeout: 120_000, + maxRetries: 0, + http2: transport.http2, + }); + + console.log(`\nStarting ${transport.name} pass with ${devboxCount} concurrent devboxes`); + const wallStart = performance.now(); + const settled = await Promise.allSettled( + Array.from({ length: devboxCount }, (_, index) => runDevboxWorkflow(client, transport.name, index)), + ); + const wallTimeMs = performance.now() - wallStart; + + const records = settled.map((result, index) => { + if (result.status === 'fulfilled') return result.value; + return { + transport: transport.name, + index, + devboxId: null, + startupDurationMs: null, + commandDurationMs: null, + lifecycleDurationMs: null, + command: null, + shutdownStatus: 'skipped', + shutdownError: null, + error: serializeError(result.reason), + }; + }); + + const workflowFailureCount = records.filter((record) => record.error).length; + const shutdownFailureCount = records.filter((record) => record.shutdownStatus === 'failed').length; + const startupStats = summarizeDurations(records.map((record) => record.startupDurationMs)); + const summary = { + transport: transport.name, + requested: devboxCount, + successCount: records.length - workflowFailureCount, + failureCount: workflowFailureCount, + shutdownFailureCount, + startup: startupStats, + wallTimeMs, + }; + + printPassSummary(summary); + + return { + transport: transport.name, + http2: transport.http2, + requested: devboxCount, + successCount: summary.successCount, + workflowFailureCount, + shutdownFailureCount, + startupStats, + wallTimeMs, + summary, + records, + }; +} + +async function runDevboxWorkflow(client, transport, index) { + const lifecycleStart = performance.now(); + const record = { + transport, + index, + devboxId: null, + startupDurationMs: null, + commandDurationMs: null, + lifecycleDurationMs: null, + command: null, + shutdownStatus: 'skipped', + shutdownError: null, + error: null, + }; + + let commandStart; + + try { + const startupStart = performance.now(); + const devbox = await client.devboxes.create({ + name: uniqueName(`e2e-startup-${transport}-${index}`), + launch_parameters: { + resource_size_request: 'X_SMALL', + keep_alive_time_seconds: 60 * 5, + }, + }); + + record.devboxId = devbox.id; + + await client.devboxes.awaitRunning(devbox.id, { + longPoll: { timeoutMs: AWAIT_RUNNING_TIMEOUT_MS }, + }); + record.startupDurationMs = performance.now() - startupStart; + + commandStart = performance.now(); + const execution = await client.devboxes.executeAndAwaitCompletion( + devbox.id, + { command: 'node -v' }, + { longPoll: { timeoutMs: COMMAND_TIMEOUT_MS } }, + ); + record.commandDurationMs = performance.now() - commandStart; + record.command = { + executionId: execution.execution_id, + status: execution.status, + exitStatus: execution.exit_status ?? null, + stdout: execution.stdout ?? null, + stderr: execution.stderr ?? null, + stdoutTruncated: execution.stdout_truncated ?? null, + stderrTruncated: execution.stderr_truncated ?? null, + }; + + if (execution.status !== 'completed') { + throw new Error(`node -v did not complete; status=${execution.status}`); + } + if (execution.exit_status !== 0) { + throw new Error(`node -v exited with status ${execution.exit_status}`); + } + } catch (error) { + if (commandStart !== undefined && record.commandDurationMs === null) { + record.commandDurationMs = performance.now() - commandStart; + } + record.error = serializeError(error); + } finally { + if (record.devboxId) { + const shutdownStart = performance.now(); + try { + const shutdown = await client.devboxes.shutdown( + record.devboxId, + { force: true }, + { timeout: SHUTDOWN_TIMEOUT_MS }, + ); + record.shutdownStatus = shutdown.status ?? 'success'; + record.shutdownDurationMs = performance.now() - shutdownStart; + } catch (shutdownError) { + record.shutdownStatus = 'failed'; + record.shutdownDurationMs = performance.now() - shutdownStart; + record.shutdownError = serializeError(shutdownError); + } + } + + record.lifecycleDurationMs = performance.now() - lifecycleStart; + } + + return record; +} + +function printPassSummary(summary) { + console.log(`\n${summary.transport} summary`); + console.table([ + { + transport: summary.transport, + requested: summary.requested, + successes: summary.successCount, + failures: summary.failureCount, + shutdownFailures: summary.shutdownFailureCount, + minMs: round(summary.startup.min), + p50Ms: round(summary.startup.p50), + p90Ms: round(summary.startup.p90), + p95Ms: round(summary.startup.p95), + p99Ms: round(summary.startup.p99), + maxMs: round(summary.startup.max), + avgMs: round(summary.startup.avg), + wallTimeMs: round(summary.wallTimeMs), + }, + ]); +} + +function printComparison(results) { + const [http1, http2] = results; + const metrics = [ + ['startup p50 ms', http1.startupStats.p50, http2.startupStats.p50], + ['startup p90 ms', http1.startupStats.p90, http2.startupStats.p90], + ['startup p95 ms', http1.startupStats.p95, http2.startupStats.p95], + ['startup p99 ms', http1.startupStats.p99, http2.startupStats.p99], + ['wall time ms', http1.wallTimeMs, http2.wallTimeMs], + ]; + + console.log('\nHTTP/1.1 vs HTTP/2 comparison'); + console.table( + metrics.map(([metric, http1Value, http2Value]) => ({ + metric, + http1: round(http1Value), + http2: round(http2Value), + deltaHttp2MinusHttp1: http1Value == null || http2Value == null ? null : round(http2Value - http1Value), + })), + ); +} + +function summarizeDurations(values) { + const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); + if (sorted.length === 0) { + return { + count: 0, + min: null, + p50: null, + p90: null, + p95: null, + p99: null, + max: null, + avg: null, + }; + } + + const sum = sorted.reduce((total, value) => total + value, 0); + return { + count: sorted.length, + min: sorted[0], + p50: percentile(sorted, 50), + p90: percentile(sorted, 90), + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + max: sorted[sorted.length - 1], + avg: sum / sorted.length, + }; +} + +function percentile(sortedValues, percentileValue) { + if (sortedValues.length === 0) return null; + const index = Math.ceil((percentileValue / 100) * sortedValues.length) - 1; + return sortedValues[Math.min(sortedValues.length - 1, Math.max(0, index))]; +} + +function parsePositiveInteger(value, fallback) { + if (value === undefined || value === '') return fallback; + if (!/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return parsed > 0 && Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function uniqueName(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function defaultResultsPath() { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return path.join('tmp', `devbox-startup-benchmark-${timestamp}.json`); +} + +function serializeError(error) { + if (!error) return null; + return { + name: error.name ?? error.constructor?.name ?? 'Error', + message: error.message ?? String(error), + status: error.status ?? null, + stack: error.stack ?? null, + }; +} + +function round(value) { + return value == null ? null : Math.round(value); +} From 3aedda0c74e724d8f6d71cc8d17cd30eacb64f89 Mon Sep 17 00:00:00 2001 From: Tony Deng Date: Fri, 29 May 2026 13:29:22 -0700 Subject: [PATCH 3/7] Push health tes --- package.json | 1 + .../scripts/health-endpoint-benchmark.mjs | 343 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 tests/smoketests/scripts/health-endpoint-benchmark.mjs diff --git a/package.json b/package.json index 4024abed4..cf151ab2a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "./scripts/test", "test:smoke": "RUN_SMOKETESTS=1 jest --verbose tests/smoketests", "test:e2e:devbox-startup": "yarn build && node tests/smoketests/scripts/devbox-startup-benchmark.mjs", + "test:e2e:health": "yarn build && node tests/smoketests/scripts/health-endpoint-benchmark.mjs", "test:examples": "RUN_SMOKETESTS=1 jest --verbose tests/smoketests/examples/examples.test.ts", "test:objects": "RUN_SMOKETESTS=1 jest --config jest.config.objects.js --verbose", "test:objects-coverage": "RUN_SMOKETESTS=1 jest --verbose --config jest.config.objects.js --coverage --coverageReporters=text --coverageReporters=json-summary", diff --git a/tests/smoketests/scripts/health-endpoint-benchmark.mjs b/tests/smoketests/scripts/health-endpoint-benchmark.mjs new file mode 100644 index 000000000..951ec46a0 --- /dev/null +++ b/tests/smoketests/scripts/health-endpoint-benchmark.mjs @@ -0,0 +1,343 @@ +/** + * Health endpoint benchmark for comparing the SDK HTTP/1.1 and HTTP/2 transports. + * + * Runs OUTSIDE the normal smoke suite. Build first, then run explicitly: + * + * RUNLOOP_API_KEY=... [RUNLOOP_BASE_URL=...] [RUNLOOP_E2E_HEALTH_REQUEST_COUNT=10000] yarn test:e2e:health + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import diagnostics_channel from 'node:diagnostics_channel'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const distPath = new URL('../../../dist/index.js', import.meta.url).pathname; + +let Runloop; +try { + ({ Runloop } = require(distPath)); +} catch (error) { + console.error(`Failed to load built SDK from ${distPath}. Run \`yarn build\` first.`); + console.error(`${error?.constructor?.name ?? 'Error'}: ${error?.message ?? error}`); + process.exit(2); +} + +const DEFAULT_REQUEST_COUNT = 10_000; + +const apiKey = process.env.RUNLOOP_API_KEY_DEV; +const baseURL = process.env.RUNLOOP_BASE_URL ?? 'https://api.runloop.pro'; +const requestCount = parsePositiveInteger( + process.env.RUNLOOP_E2E_HEALTH_REQUEST_COUNT ?? process.env.RUNLOOP_E2E_DEVBOX_COUNT, + DEFAULT_REQUEST_COUNT, +); +const resultsPath = process.env.RUNLOOP_E2E_RESULTS_PATH ?? defaultResultsPath(); + +if (!apiKey) { + console.error('RUNLOOP_API_KEY is required'); + process.exit(2); +} + +if (requestCount === undefined) { + console.error('RUNLOOP_E2E_HEALTH_REQUEST_COUNT must be a positive integer'); + process.exit(2); +} + +const transports = [ + { name: 'http1', http2: false }, + { name: 'http2', http2: true }, +]; + +const startedAt = new Date().toISOString(); +const passResults = []; + +for (const transport of transports) { + passResults.push(await runTransportPass(transport)); +} + +const endedAt = new Date().toISOString(); +const artifact = { + benchmark: 'health-endpoint-http1-vs-http2', + startedAt, + endedAt, + config: { + requestCount, + baseURL, + endpoint: '/health', + }, + summary: passResults.map(({ records, summary, ...pass }) => pass), + passes: passResults, +}; + +await fs.mkdir(path.dirname(resultsPath), { recursive: true }); +await fs.writeFile(resultsPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + +printComparison(passResults); +console.log(`\nWrote results artifact: ${resultsPath}`); + +const hasFailures = passResults.some((pass) => pass.failureCount > 0); +process.exit(hasFailures ? 1 : 0); + +async function runTransportPass(transport) { + const diagnostics = createUndiciConnectionDiagnostics(); + const client = new Runloop({ + bearerToken: apiKey, + baseURL, + timeout: 30_000, + maxRetries: 0, + http2: transport.http2, + }); + + console.log(`\nStarting ${transport.name} pass with ${requestCount} concurrent health checks`); + const wallStart = performance.now(); + diagnostics.start(); + let settled; + let wallTimeMs; + try { + settled = await Promise.allSettled( + Array.from({ length: requestCount }, (_, index) => pingHealthEndpoint(client, transport.name, index)), + ); + wallTimeMs = performance.now() - wallStart; + } finally { + diagnostics.stop(); + } + + const records = settled.map((result, index) => { + if (result.status === 'fulfilled') return result.value; + return { + transport: transport.name, + index, + healthDurationMs: null, + status: null, + contentType: null, + bodySample: null, + error: serializeError(result.reason), + }; + }); + + const failureCount = records.filter((record) => record.error).length; + const healthStats = summarizeDurations(records.map((record) => record.healthDurationMs)); + const connectionDiagnostics = diagnostics.summary(); + const summary = { + transport: transport.name, + requested: requestCount, + successCount: records.length - failureCount, + failureCount, + health: healthStats, + wallTimeMs, + connectionDiagnostics, + }; + + printPassSummary(summary); + + return { + transport: transport.name, + http2: transport.http2, + requested: requestCount, + successCount: summary.successCount, + failureCount, + healthStats, + wallTimeMs, + connectionDiagnostics, + summary, + records, + }; +} + +async function pingHealthEndpoint(client, transport, index) { + const healthStart = performance.now(); + const record = { + transport, + index, + healthDurationMs: null, + status: null, + contentType: null, + bodySample: null, + error: null, + }; + + try { + const response = await client.get('/health').asResponse(); + record.healthDurationMs = performance.now() - healthStart; + record.status = response.status; + record.contentType = response.headers.get('content-type'); + + const body = await response.text(); + record.bodySample = body.length > 200 ? `${body.slice(0, 200)}...` : body; + } catch (error) { + record.healthDurationMs = performance.now() - healthStart; + record.error = serializeError(error); + } + + return record; +} + +function printPassSummary(summary) { + console.log(`\n${summary.transport} summary`); + console.table([ + { + transport: summary.transport, + requested: summary.requested, + successes: summary.successCount, + failures: summary.failureCount, + minMs: round(summary.health.min), + p50Ms: round(summary.health.p50), + p90Ms: round(summary.health.p90), + p95Ms: round(summary.health.p95), + p99Ms: round(summary.health.p99), + maxMs: round(summary.health.max), + avgMs: round(summary.health.avg), + wallTimeMs: round(summary.wallTimeMs), + undiciConnections: summary.connectionDiagnostics.connectionCount, + alpnH2: summary.connectionDiagnostics.alpnCounts.h2 ?? 0, + alpnHttp1: summary.connectionDiagnostics.alpnCounts['http/1.1'] ?? 0, + h2Fallbacks: summary.connectionDiagnostics.h2FallbackCount, + uniqueLocalPorts: summary.connectionDiagnostics.uniqueLocalPorts.length, + }, + ]); + + if (summary.connectionDiagnostics.connectionCount > 0) { + console.log(`${summary.transport} undici connection diagnostics`); + console.table([ + { + connections: summary.connectionDiagnostics.connectionCount, + alpn: JSON.stringify(summary.connectionDiagnostics.alpnCounts), + h2: summary.connectionDiagnostics.h2ConnectionCount, + h1Fallbacks: summary.connectionDiagnostics.h2FallbackCount, + uniqueLocalPorts: summary.connectionDiagnostics.uniqueLocalPorts.length, + localPorts: summary.connectionDiagnostics.uniqueLocalPorts.join(', '), + }, + ]); + } +} + +function printComparison(results) { + const [http1, http2] = results; + const metrics = [ + ['health p50 ms', http1.healthStats.p50, http2.healthStats.p50], + ['health p90 ms', http1.healthStats.p90, http2.healthStats.p90], + ['health p95 ms', http1.healthStats.p95, http2.healthStats.p95], + ['health p99 ms', http1.healthStats.p99, http2.healthStats.p99], + ['wall time ms', http1.wallTimeMs, http2.wallTimeMs], + ]; + + console.log('\nHTTP/1.1 vs HTTP/2 comparison'); + console.table( + metrics.map(([metric, http1Value, http2Value]) => ({ + metric, + http1: round(http1Value), + http2: round(http2Value), + deltaHttp2MinusHttp1: http1Value == null || http2Value == null ? null : round(http2Value - http1Value), + })), + ); +} + +function summarizeDurations(values) { + const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); + if (sorted.length === 0) { + return { + count: 0, + min: null, + p50: null, + p90: null, + p95: null, + p99: null, + max: null, + avg: null, + }; + } + + const sum = sorted.reduce((total, value) => total + value, 0); + return { + count: sorted.length, + min: sorted[0], + p50: percentile(sorted, 50), + p90: percentile(sorted, 90), + p95: percentile(sorted, 95), + p99: percentile(sorted, 99), + max: sorted[sorted.length - 1], + avg: sum / sorted.length, + }; +} + +function percentile(sortedValues, percentileValue) { + if (sortedValues.length === 0) return null; + const index = Math.ceil((percentileValue / 100) * sortedValues.length) - 1; + return sortedValues[Math.min(sortedValues.length - 1, Math.max(0, index))]; +} + +function parsePositiveInteger(value, fallback) { + if (value === undefined || value === '') return fallback; + if (!/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return parsed > 0 && Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function defaultResultsPath() { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return path.join('tmp', `health-endpoint-benchmark-${timestamp}.json`); +} + +function createUndiciConnectionDiagnostics() { + const events = []; + const onConnected = (message) => { + const socket = message?.socket; + const rawAlpn = socket?.alpnProtocol; + const alpnProtocol = + typeof rawAlpn === 'string' && rawAlpn.length > 0 ? rawAlpn + : rawAlpn === false ? 'http/1.1' + : 'unknown'; + + events.push({ + alpnProtocol, + localPort: socket?.localPort ?? null, + remoteAddress: socket?.remoteAddress ?? null, + remotePort: socket?.remotePort ?? null, + encrypted: Boolean(socket?.encrypted), + }); + }; + + return { + start() { + diagnostics_channel.subscribe('undici:client:connected', onConnected); + }, + stop() { + diagnostics_channel.unsubscribe('undici:client:connected', onConnected); + }, + summary() { + const alpnCounts = {}; + const localPorts = new Set(); + for (const event of events) { + alpnCounts[event.alpnProtocol] = (alpnCounts[event.alpnProtocol] ?? 0) + 1; + if (event.localPort != null) localPorts.add(event.localPort); + } + + const h2ConnectionCount = alpnCounts.h2 ?? 0; + const http1ConnectionCount = alpnCounts['http/1.1'] ?? 0; + + return { + connectionCount: events.length, + alpnCounts, + h2ConnectionCount, + http1ConnectionCount, + h2FallbackCount: events.length - h2ConnectionCount, + uniqueLocalPorts: [...localPorts].sort((a, b) => a - b), + events, + }; + }, + }; +} + +function serializeError(error) { + if (!error) return null; + return { + name: error.name ?? error.constructor?.name ?? 'Error', + message: error.message ?? String(error), + status: error.status ?? null, + stack: error.stack ?? null, + }; +} + +function round(value) { + return value == null ? null : Math.round(value); +} From 67ed6a6f95d703be758a3535afc5805583d1c2cd Mon Sep 17 00:00:00 2001 From: sid-rl Date: Fri, 29 May 2026 16:00:39 -0700 Subject: [PATCH 4/7] feat(http2): multiplex over a bounded undici pool (undici 7.26) (#793) Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 +- README.md | 15 +++++- package.json | 2 +- src/lib/undici-fetch.ts | 56 +++++++++++++++-------- tests/lib/undici-fetch.test.ts | 39 ++++++++++++++++ tests/smoketests/scripts/verify-http2.mjs | 19 ++++++++ yarn.lock | 8 ++-- 7 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 tests/lib/undici-fetch.test.ts 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" From 46a3694d8a88ac1f4e62e83f672ce72488cde52f Mon Sep 17 00:00:00 2001 From: Reflex Date: Fri, 29 May 2026 23:55:00 +0000 Subject: [PATCH 5/7] review feedback: docs, test, drop benchmarks - src/index.ts: add `http2` to constructor JSDoc; expand ClientOptions JSDoc to flag that H1 fallback enables request pipelining and is unsafe against non-h2 intermediaries. - README.md: drop "(experimental)" framing now that the JSDoc and README agree on stability/caveats. - tests/index.test.ts: add `custom fetch wins over http2` test to lock in the precedence in `options.fetch ?? (options.http2 ? http2Fetch : undefined)`. - tests/smoketests/scripts/: drop devbox-startup and health-endpoint benchmark harnesses (verify-http2.mjs is kept as the regression guard). package.json: drop the matching test:e2e:* scripts. --- README.md | 2 +- package.json | 2 - src/index.ts | 11 +- tests/index.test.ts | 23 ++ .../scripts/devbox-startup-benchmark.mjs | 334 ----------------- .../scripts/health-endpoint-benchmark.mjs | 343 ------------------ 6 files changed, 33 insertions(+), 682 deletions(-) delete mode 100644 tests/smoketests/scripts/devbox-startup-benchmark.mjs delete mode 100644 tests/smoketests/scripts/health-endpoint-benchmark.mjs diff --git a/README.md b/README.md index 101e978bb..3881a4b93 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ await runloop.devboxes.create({...}, { }); ``` -### HTTP/2 transport (experimental) +### 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: diff --git a/package.json b/package.json index 6512ad39b..3bc0aa402 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "scripts": { "test": "./scripts/test", "test:smoke": "RUN_SMOKETESTS=1 jest --verbose tests/smoketests", - "test:e2e:devbox-startup": "yarn build && node tests/smoketests/scripts/devbox-startup-benchmark.mjs", - "test:e2e:health": "yarn build && node tests/smoketests/scripts/health-endpoint-benchmark.mjs", "test:examples": "RUN_SMOKETESTS=1 jest --verbose tests/smoketests/examples/examples.test.ts", "test:objects": "RUN_SMOKETESTS=1 jest --config jest.config.objects.js --verbose", "test:objects-coverage": "RUN_SMOKETESTS=1 jest --verbose --config jest.config.objects.js --coverage --coverageReporters=text --coverageReporters=json-summary", diff --git a/src/index.ts b/src/index.ts index a6f90e992..e747e8e2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -298,8 +298,14 @@ export interface ClientOptions { * web the platform `fetch` already speaks HTTP/2, so this is a no-op there. * Ignored when a custom `fetch` is provided. * - * Note: 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`. + * **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`. * * @default false */ @@ -354,6 +360,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} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). * @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. diff --git a/tests/index.test.ts b/tests/index.test.ts index d49bd8264..64d97e557 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -96,6 +96,29 @@ describe('instantiate client', () => { expect(response).toEqual({ url: 'http://localhost:5000/foo', custom: true }); }); + test('custom fetch wins over http2', async () => { + // When both `fetch` and `http2` are provided, the custom fetch must be used — + // the undici (h2) adapter should not run. Locks in src/index.ts: + // fetch: options.fetch ?? (options.http2 ? http2Fetch : undefined) + const customFetch = jest.fn((url: RequestInfo) => + Promise.resolve( + new Response(JSON.stringify({ url, custom: true }), { + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + const client = new Runloop({ + baseURL: 'http://localhost:5000/', + bearerToken: 'My Bearer Token', + http2: true, + fetch: customFetch as any, + }); + + const response = await client.get('/foo'); + expect(response).toEqual({ url: 'http://localhost:5000/foo', custom: true }); + expect(customFetch).toHaveBeenCalledTimes(1); + }); + test('explicit global fetch', async () => { // make sure the global fetch type is assignable to our Fetch type const client = new Runloop({ diff --git a/tests/smoketests/scripts/devbox-startup-benchmark.mjs b/tests/smoketests/scripts/devbox-startup-benchmark.mjs deleted file mode 100644 index 6364fe94d..000000000 --- a/tests/smoketests/scripts/devbox-startup-benchmark.mjs +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Devbox startup benchmark for comparing the SDK HTTP/1.1 and HTTP/2 transports. - * - * Runs OUTSIDE the normal smoke suite because the default configuration creates - * 100 devboxes per transport. Build first, then run explicitly: - * - * RUNLOOP_API_KEY=... [RUNLOOP_BASE_URL=...] [RUNLOOP_E2E_DEVBOX_COUNT=100] yarn test:e2e:devbox-startup - */ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { performance } from 'node:perf_hooks'; - -const require = createRequire(import.meta.url); -const distPath = new URL('../../../dist/index.js', import.meta.url).pathname; - -let Runloop; -try { - ({ Runloop } = require(distPath)); -} catch (error) { - console.error(`Failed to load built SDK from ${distPath}. Run \`yarn build\` first.`); - console.error(`${error?.constructor?.name ?? 'Error'}: ${error?.message ?? error}`); - process.exit(2); -} - -const DEFAULT_DEVBOX_COUNT = 100; -const AWAIT_RUNNING_TIMEOUT_MS = 20 * 60 * 1000; -const COMMAND_TIMEOUT_MS = 2 * 60 * 1000; -const SHUTDOWN_TIMEOUT_MS = 2 * 60 * 1000; - -const apiKey = process.env.RUNLOOP_API_KEY; -const baseURL = process.env.RUNLOOP_BASE_URL; -const devboxCount = parsePositiveInteger(process.env.RUNLOOP_E2E_DEVBOX_COUNT, DEFAULT_DEVBOX_COUNT); -const resultsPath = process.env.RUNLOOP_E2E_RESULTS_PATH ?? defaultResultsPath(); - -if (!apiKey) { - console.error('RUNLOOP_API_KEY is required'); - process.exit(2); -} - -if (devboxCount === undefined) { - console.error('RUNLOOP_E2E_DEVBOX_COUNT must be a positive integer'); - process.exit(2); -} - -const transports = [ - { name: 'http1', http2: false }, - { name: 'http2', http2: true }, -]; - -const startedAt = new Date().toISOString(); -const passResults = []; - -for (const transport of transports) { - passResults.push(await runTransportPass(transport)); -} - -const endedAt = new Date().toISOString(); -const artifact = { - benchmark: 'devbox-startup-http1-vs-http2', - startedAt, - endedAt, - config: { - devboxCount, - baseURL: baseURL ?? null, - awaitRunningTimeoutMs: AWAIT_RUNNING_TIMEOUT_MS, - commandTimeoutMs: COMMAND_TIMEOUT_MS, - shutdownTimeoutMs: SHUTDOWN_TIMEOUT_MS, - }, - summary: passResults.map(({ records, summary, ...pass }) => pass), - passes: passResults, -}; - -await fs.mkdir(path.dirname(resultsPath), { recursive: true }); -await fs.writeFile(resultsPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); - -printComparison(passResults); -console.log(`\nWrote results artifact: ${resultsPath}`); - -const hasWorkflowFailures = passResults.some((pass) => pass.workflowFailureCount > 0); -process.exit(hasWorkflowFailures ? 1 : 0); - -async function runTransportPass(transport) { - const client = new Runloop({ - bearerToken: apiKey, - baseURL, - timeout: 120_000, - maxRetries: 0, - http2: transport.http2, - }); - - console.log(`\nStarting ${transport.name} pass with ${devboxCount} concurrent devboxes`); - const wallStart = performance.now(); - const settled = await Promise.allSettled( - Array.from({ length: devboxCount }, (_, index) => runDevboxWorkflow(client, transport.name, index)), - ); - const wallTimeMs = performance.now() - wallStart; - - const records = settled.map((result, index) => { - if (result.status === 'fulfilled') return result.value; - return { - transport: transport.name, - index, - devboxId: null, - startupDurationMs: null, - commandDurationMs: null, - lifecycleDurationMs: null, - command: null, - shutdownStatus: 'skipped', - shutdownError: null, - error: serializeError(result.reason), - }; - }); - - const workflowFailureCount = records.filter((record) => record.error).length; - const shutdownFailureCount = records.filter((record) => record.shutdownStatus === 'failed').length; - const startupStats = summarizeDurations(records.map((record) => record.startupDurationMs)); - const summary = { - transport: transport.name, - requested: devboxCount, - successCount: records.length - workflowFailureCount, - failureCount: workflowFailureCount, - shutdownFailureCount, - startup: startupStats, - wallTimeMs, - }; - - printPassSummary(summary); - - return { - transport: transport.name, - http2: transport.http2, - requested: devboxCount, - successCount: summary.successCount, - workflowFailureCount, - shutdownFailureCount, - startupStats, - wallTimeMs, - summary, - records, - }; -} - -async function runDevboxWorkflow(client, transport, index) { - const lifecycleStart = performance.now(); - const record = { - transport, - index, - devboxId: null, - startupDurationMs: null, - commandDurationMs: null, - lifecycleDurationMs: null, - command: null, - shutdownStatus: 'skipped', - shutdownError: null, - error: null, - }; - - let commandStart; - - try { - const startupStart = performance.now(); - const devbox = await client.devboxes.create({ - name: uniqueName(`e2e-startup-${transport}-${index}`), - launch_parameters: { - resource_size_request: 'X_SMALL', - keep_alive_time_seconds: 60 * 5, - }, - }); - - record.devboxId = devbox.id; - - await client.devboxes.awaitRunning(devbox.id, { - longPoll: { timeoutMs: AWAIT_RUNNING_TIMEOUT_MS }, - }); - record.startupDurationMs = performance.now() - startupStart; - - commandStart = performance.now(); - const execution = await client.devboxes.executeAndAwaitCompletion( - devbox.id, - { command: 'node -v' }, - { longPoll: { timeoutMs: COMMAND_TIMEOUT_MS } }, - ); - record.commandDurationMs = performance.now() - commandStart; - record.command = { - executionId: execution.execution_id, - status: execution.status, - exitStatus: execution.exit_status ?? null, - stdout: execution.stdout ?? null, - stderr: execution.stderr ?? null, - stdoutTruncated: execution.stdout_truncated ?? null, - stderrTruncated: execution.stderr_truncated ?? null, - }; - - if (execution.status !== 'completed') { - throw new Error(`node -v did not complete; status=${execution.status}`); - } - if (execution.exit_status !== 0) { - throw new Error(`node -v exited with status ${execution.exit_status}`); - } - } catch (error) { - if (commandStart !== undefined && record.commandDurationMs === null) { - record.commandDurationMs = performance.now() - commandStart; - } - record.error = serializeError(error); - } finally { - if (record.devboxId) { - const shutdownStart = performance.now(); - try { - const shutdown = await client.devboxes.shutdown( - record.devboxId, - { force: true }, - { timeout: SHUTDOWN_TIMEOUT_MS }, - ); - record.shutdownStatus = shutdown.status ?? 'success'; - record.shutdownDurationMs = performance.now() - shutdownStart; - } catch (shutdownError) { - record.shutdownStatus = 'failed'; - record.shutdownDurationMs = performance.now() - shutdownStart; - record.shutdownError = serializeError(shutdownError); - } - } - - record.lifecycleDurationMs = performance.now() - lifecycleStart; - } - - return record; -} - -function printPassSummary(summary) { - console.log(`\n${summary.transport} summary`); - console.table([ - { - transport: summary.transport, - requested: summary.requested, - successes: summary.successCount, - failures: summary.failureCount, - shutdownFailures: summary.shutdownFailureCount, - minMs: round(summary.startup.min), - p50Ms: round(summary.startup.p50), - p90Ms: round(summary.startup.p90), - p95Ms: round(summary.startup.p95), - p99Ms: round(summary.startup.p99), - maxMs: round(summary.startup.max), - avgMs: round(summary.startup.avg), - wallTimeMs: round(summary.wallTimeMs), - }, - ]); -} - -function printComparison(results) { - const [http1, http2] = results; - const metrics = [ - ['startup p50 ms', http1.startupStats.p50, http2.startupStats.p50], - ['startup p90 ms', http1.startupStats.p90, http2.startupStats.p90], - ['startup p95 ms', http1.startupStats.p95, http2.startupStats.p95], - ['startup p99 ms', http1.startupStats.p99, http2.startupStats.p99], - ['wall time ms', http1.wallTimeMs, http2.wallTimeMs], - ]; - - console.log('\nHTTP/1.1 vs HTTP/2 comparison'); - console.table( - metrics.map(([metric, http1Value, http2Value]) => ({ - metric, - http1: round(http1Value), - http2: round(http2Value), - deltaHttp2MinusHttp1: http1Value == null || http2Value == null ? null : round(http2Value - http1Value), - })), - ); -} - -function summarizeDurations(values) { - const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); - if (sorted.length === 0) { - return { - count: 0, - min: null, - p50: null, - p90: null, - p95: null, - p99: null, - max: null, - avg: null, - }; - } - - const sum = sorted.reduce((total, value) => total + value, 0); - return { - count: sorted.length, - min: sorted[0], - p50: percentile(sorted, 50), - p90: percentile(sorted, 90), - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - max: sorted[sorted.length - 1], - avg: sum / sorted.length, - }; -} - -function percentile(sortedValues, percentileValue) { - if (sortedValues.length === 0) return null; - const index = Math.ceil((percentileValue / 100) * sortedValues.length) - 1; - return sortedValues[Math.min(sortedValues.length - 1, Math.max(0, index))]; -} - -function parsePositiveInteger(value, fallback) { - if (value === undefined || value === '') return fallback; - if (!/^\d+$/.test(value)) return undefined; - const parsed = Number(value); - return parsed > 0 && Number.isSafeInteger(parsed) ? parsed : undefined; -} - -function uniqueName(prefix) { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function defaultResultsPath() { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - return path.join('tmp', `devbox-startup-benchmark-${timestamp}.json`); -} - -function serializeError(error) { - if (!error) return null; - return { - name: error.name ?? error.constructor?.name ?? 'Error', - message: error.message ?? String(error), - status: error.status ?? null, - stack: error.stack ?? null, - }; -} - -function round(value) { - return value == null ? null : Math.round(value); -} diff --git a/tests/smoketests/scripts/health-endpoint-benchmark.mjs b/tests/smoketests/scripts/health-endpoint-benchmark.mjs deleted file mode 100644 index 951ec46a0..000000000 --- a/tests/smoketests/scripts/health-endpoint-benchmark.mjs +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Health endpoint benchmark for comparing the SDK HTTP/1.1 and HTTP/2 transports. - * - * Runs OUTSIDE the normal smoke suite. Build first, then run explicitly: - * - * RUNLOOP_API_KEY=... [RUNLOOP_BASE_URL=...] [RUNLOOP_E2E_HEALTH_REQUEST_COUNT=10000] yarn test:e2e:health - */ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import diagnostics_channel from 'node:diagnostics_channel'; -import { createRequire } from 'node:module'; -import { performance } from 'node:perf_hooks'; - -const require = createRequire(import.meta.url); -const distPath = new URL('../../../dist/index.js', import.meta.url).pathname; - -let Runloop; -try { - ({ Runloop } = require(distPath)); -} catch (error) { - console.error(`Failed to load built SDK from ${distPath}. Run \`yarn build\` first.`); - console.error(`${error?.constructor?.name ?? 'Error'}: ${error?.message ?? error}`); - process.exit(2); -} - -const DEFAULT_REQUEST_COUNT = 10_000; - -const apiKey = process.env.RUNLOOP_API_KEY_DEV; -const baseURL = process.env.RUNLOOP_BASE_URL ?? 'https://api.runloop.pro'; -const requestCount = parsePositiveInteger( - process.env.RUNLOOP_E2E_HEALTH_REQUEST_COUNT ?? process.env.RUNLOOP_E2E_DEVBOX_COUNT, - DEFAULT_REQUEST_COUNT, -); -const resultsPath = process.env.RUNLOOP_E2E_RESULTS_PATH ?? defaultResultsPath(); - -if (!apiKey) { - console.error('RUNLOOP_API_KEY is required'); - process.exit(2); -} - -if (requestCount === undefined) { - console.error('RUNLOOP_E2E_HEALTH_REQUEST_COUNT must be a positive integer'); - process.exit(2); -} - -const transports = [ - { name: 'http1', http2: false }, - { name: 'http2', http2: true }, -]; - -const startedAt = new Date().toISOString(); -const passResults = []; - -for (const transport of transports) { - passResults.push(await runTransportPass(transport)); -} - -const endedAt = new Date().toISOString(); -const artifact = { - benchmark: 'health-endpoint-http1-vs-http2', - startedAt, - endedAt, - config: { - requestCount, - baseURL, - endpoint: '/health', - }, - summary: passResults.map(({ records, summary, ...pass }) => pass), - passes: passResults, -}; - -await fs.mkdir(path.dirname(resultsPath), { recursive: true }); -await fs.writeFile(resultsPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); - -printComparison(passResults); -console.log(`\nWrote results artifact: ${resultsPath}`); - -const hasFailures = passResults.some((pass) => pass.failureCount > 0); -process.exit(hasFailures ? 1 : 0); - -async function runTransportPass(transport) { - const diagnostics = createUndiciConnectionDiagnostics(); - const client = new Runloop({ - bearerToken: apiKey, - baseURL, - timeout: 30_000, - maxRetries: 0, - http2: transport.http2, - }); - - console.log(`\nStarting ${transport.name} pass with ${requestCount} concurrent health checks`); - const wallStart = performance.now(); - diagnostics.start(); - let settled; - let wallTimeMs; - try { - settled = await Promise.allSettled( - Array.from({ length: requestCount }, (_, index) => pingHealthEndpoint(client, transport.name, index)), - ); - wallTimeMs = performance.now() - wallStart; - } finally { - diagnostics.stop(); - } - - const records = settled.map((result, index) => { - if (result.status === 'fulfilled') return result.value; - return { - transport: transport.name, - index, - healthDurationMs: null, - status: null, - contentType: null, - bodySample: null, - error: serializeError(result.reason), - }; - }); - - const failureCount = records.filter((record) => record.error).length; - const healthStats = summarizeDurations(records.map((record) => record.healthDurationMs)); - const connectionDiagnostics = diagnostics.summary(); - const summary = { - transport: transport.name, - requested: requestCount, - successCount: records.length - failureCount, - failureCount, - health: healthStats, - wallTimeMs, - connectionDiagnostics, - }; - - printPassSummary(summary); - - return { - transport: transport.name, - http2: transport.http2, - requested: requestCount, - successCount: summary.successCount, - failureCount, - healthStats, - wallTimeMs, - connectionDiagnostics, - summary, - records, - }; -} - -async function pingHealthEndpoint(client, transport, index) { - const healthStart = performance.now(); - const record = { - transport, - index, - healthDurationMs: null, - status: null, - contentType: null, - bodySample: null, - error: null, - }; - - try { - const response = await client.get('/health').asResponse(); - record.healthDurationMs = performance.now() - healthStart; - record.status = response.status; - record.contentType = response.headers.get('content-type'); - - const body = await response.text(); - record.bodySample = body.length > 200 ? `${body.slice(0, 200)}...` : body; - } catch (error) { - record.healthDurationMs = performance.now() - healthStart; - record.error = serializeError(error); - } - - return record; -} - -function printPassSummary(summary) { - console.log(`\n${summary.transport} summary`); - console.table([ - { - transport: summary.transport, - requested: summary.requested, - successes: summary.successCount, - failures: summary.failureCount, - minMs: round(summary.health.min), - p50Ms: round(summary.health.p50), - p90Ms: round(summary.health.p90), - p95Ms: round(summary.health.p95), - p99Ms: round(summary.health.p99), - maxMs: round(summary.health.max), - avgMs: round(summary.health.avg), - wallTimeMs: round(summary.wallTimeMs), - undiciConnections: summary.connectionDiagnostics.connectionCount, - alpnH2: summary.connectionDiagnostics.alpnCounts.h2 ?? 0, - alpnHttp1: summary.connectionDiagnostics.alpnCounts['http/1.1'] ?? 0, - h2Fallbacks: summary.connectionDiagnostics.h2FallbackCount, - uniqueLocalPorts: summary.connectionDiagnostics.uniqueLocalPorts.length, - }, - ]); - - if (summary.connectionDiagnostics.connectionCount > 0) { - console.log(`${summary.transport} undici connection diagnostics`); - console.table([ - { - connections: summary.connectionDiagnostics.connectionCount, - alpn: JSON.stringify(summary.connectionDiagnostics.alpnCounts), - h2: summary.connectionDiagnostics.h2ConnectionCount, - h1Fallbacks: summary.connectionDiagnostics.h2FallbackCount, - uniqueLocalPorts: summary.connectionDiagnostics.uniqueLocalPorts.length, - localPorts: summary.connectionDiagnostics.uniqueLocalPorts.join(', '), - }, - ]); - } -} - -function printComparison(results) { - const [http1, http2] = results; - const metrics = [ - ['health p50 ms', http1.healthStats.p50, http2.healthStats.p50], - ['health p90 ms', http1.healthStats.p90, http2.healthStats.p90], - ['health p95 ms', http1.healthStats.p95, http2.healthStats.p95], - ['health p99 ms', http1.healthStats.p99, http2.healthStats.p99], - ['wall time ms', http1.wallTimeMs, http2.wallTimeMs], - ]; - - console.log('\nHTTP/1.1 vs HTTP/2 comparison'); - console.table( - metrics.map(([metric, http1Value, http2Value]) => ({ - metric, - http1: round(http1Value), - http2: round(http2Value), - deltaHttp2MinusHttp1: http1Value == null || http2Value == null ? null : round(http2Value - http1Value), - })), - ); -} - -function summarizeDurations(values) { - const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); - if (sorted.length === 0) { - return { - count: 0, - min: null, - p50: null, - p90: null, - p95: null, - p99: null, - max: null, - avg: null, - }; - } - - const sum = sorted.reduce((total, value) => total + value, 0); - return { - count: sorted.length, - min: sorted[0], - p50: percentile(sorted, 50), - p90: percentile(sorted, 90), - p95: percentile(sorted, 95), - p99: percentile(sorted, 99), - max: sorted[sorted.length - 1], - avg: sum / sorted.length, - }; -} - -function percentile(sortedValues, percentileValue) { - if (sortedValues.length === 0) return null; - const index = Math.ceil((percentileValue / 100) * sortedValues.length) - 1; - return sortedValues[Math.min(sortedValues.length - 1, Math.max(0, index))]; -} - -function parsePositiveInteger(value, fallback) { - if (value === undefined || value === '') return fallback; - if (!/^\d+$/.test(value)) return undefined; - const parsed = Number(value); - return parsed > 0 && Number.isSafeInteger(parsed) ? parsed : undefined; -} - -function defaultResultsPath() { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - return path.join('tmp', `health-endpoint-benchmark-${timestamp}.json`); -} - -function createUndiciConnectionDiagnostics() { - const events = []; - const onConnected = (message) => { - const socket = message?.socket; - const rawAlpn = socket?.alpnProtocol; - const alpnProtocol = - typeof rawAlpn === 'string' && rawAlpn.length > 0 ? rawAlpn - : rawAlpn === false ? 'http/1.1' - : 'unknown'; - - events.push({ - alpnProtocol, - localPort: socket?.localPort ?? null, - remoteAddress: socket?.remoteAddress ?? null, - remotePort: socket?.remotePort ?? null, - encrypted: Boolean(socket?.encrypted), - }); - }; - - return { - start() { - diagnostics_channel.subscribe('undici:client:connected', onConnected); - }, - stop() { - diagnostics_channel.unsubscribe('undici:client:connected', onConnected); - }, - summary() { - const alpnCounts = {}; - const localPorts = new Set(); - for (const event of events) { - alpnCounts[event.alpnProtocol] = (alpnCounts[event.alpnProtocol] ?? 0) + 1; - if (event.localPort != null) localPorts.add(event.localPort); - } - - const h2ConnectionCount = alpnCounts.h2 ?? 0; - const http1ConnectionCount = alpnCounts['http/1.1'] ?? 0; - - return { - connectionCount: events.length, - alpnCounts, - h2ConnectionCount, - http1ConnectionCount, - h2FallbackCount: events.length - h2ConnectionCount, - uniqueLocalPorts: [...localPorts].sort((a, b) => a - b), - events, - }; - }, - }; -} - -function serializeError(error) { - if (!error) return null; - return { - name: error.name ?? error.constructor?.name ?? 'Error', - message: error.message ?? String(error), - status: error.status ?? null, - stack: error.stack ?? null, - }; -} - -function round(value) { - return value == null ? null : Math.round(value); -} From 541c387ef0f8f04d395d78ed7785800c45b607cc Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 1 Jun 2026 10:43:46 -0700 Subject: [PATCH 6/7] feat(http2): accept a user-supplied undici Dispatcher (passthrough) Make `http2` accept `boolean | undici.Dispatcher`: `true` keeps the default bounded pool, and passing a configured undici Dispatcher uses it verbatim -- the SDK manages nothing, mirroring the `httpAgent` escape hatch. This lets callers tune the connection pool (connections x pipelining) for large fan-outs without the SDK owning new knobs. Also warn once when `httpAgent` and `http2` are combined: undici has no Node `http.Agent` concept, so `httpAgent` is inapplicable on the h2 path -- make the override loud instead of silent (skipped when a custom `fetch` supersedes http2). - src/lib/undici-fetch.ts: add createUndiciFetch(dispatcher?) factory over the shared default pool; drop the standalone undiciFetch export. - src/_shims/*: replace the internal http2Fetch export with a makeHttp2Fetch(dispatcher?) factory across node/web/deno/registry/index.d.ts. - src/index.ts: http2?: boolean | import('undici').Dispatcher; resolve the dispatcher in the constructor; one-time httpAgent+http2 warning. - README + JSDoc: document the passthrough; httpAgent does not apply to h2. - tests/index.test.ts: MockAgent passthrough test (proves the dispatcher is honored end-to-end) + a warn-once test. Co-Authored-By: Claude Opus 4.8 --- README.md | 13 +++++++ src/_shims/index-deno.ts | 5 ++- src/_shims/index.d.ts | 11 +++--- src/_shims/node-runtime.ts | 4 +- src/_shims/registry.ts | 15 +++---- src/_shims/web-runtime.ts | 7 ++-- src/index.ts | 43 +++++++++++++++++--- src/lib/undici-fetch.ts | 71 ++++++++++++++++++++-------------- tests/index.test.ts | 49 ++++++++++++++++++++++- tests/lib/undici-fetch.test.ts | 2 +- 10 files changed, 166 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 3881a4b93..f4d884c76 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,19 @@ const runloop = new RunloopSDK({ 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`: + + +```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: diff --git a/src/_shims/index-deno.ts b/src/_shims/index-deno.ts index fd866ac5c..511392ef6 100644 --- a/src/_shims/index-deno.ts +++ b/src/_shims/index-deno.ts @@ -10,8 +10,9 @@ type _fetch = typeof fetch; export { _fetch as fetch }; // The platform `fetch` already negotiates HTTP/2 at the transport layer, so -// `{ http2: true }` is a no-op on Deno — reuse the global fetch. -export { _fetch as http2Fetch }; +// `{ 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; diff --git a/src/_shims/index.d.ts b/src/_shims/index.d.ts index 1904461e0..165ab27f3 100644 --- a/src/_shims/index.d.ts +++ b/src/_shims/index.d.ts @@ -16,12 +16,13 @@ export type Agent = SelectType; export const fetch: SelectType; /** - * An HTTP/2-capable `fetch`, used when the client is constructed with - * `{ http2: true }`. In Node this is the undici adapter (`Agent({ allowH2: true })`); - * on the web it is the platform `fetch`, which already negotiates HTTP/2 - * transparently. + * 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: ` passthrough), defaulting to the SDK's bounded pool. On + * the web it returns the platform `fetch`, which already negotiates HTTP/2. */ -export const http2Fetch: any; +export function makeHttp2Fetch(dispatcher?: any): typeof fetch; // @ts-ignore export type Request = SelectType; diff --git a/src/_shims/node-runtime.ts b/src/_shims/node-runtime.ts index 0c3748434..ad83b2215 100644 --- a/src/_shims/node-runtime.ts +++ b/src/_shims/node-runtime.ts @@ -14,7 +14,7 @@ import { type RequestOptions } from '../core'; import { MultipartBody } from './MultipartBody'; import { type Shims } from './registry'; import { ReadableStream } from 'node:stream/web'; -import { undiciFetch } from '../lib/undici-fetch'; +import { createUndiciFetch } from '../lib/undici-fetch'; type FileFromPathOptions = Omit; @@ -67,7 +67,7 @@ export function getRuntime(): Shims { return { kind: 'node', fetch: nf.default, - http2Fetch: undiciFetch, + makeHttp2Fetch: createUndiciFetch, Request: nf.Request, Response: nf.Response, Headers: nf.Headers, diff --git a/src/_shims/registry.ts b/src/_shims/registry.ts index 554e5e63f..78459d975 100644 --- a/src/_shims/registry.ts +++ b/src/_shims/registry.ts @@ -7,12 +7,13 @@ export interface Shims { kind: string; fetch: any; /** - * An HTTP/2-capable `fetch` implementation, used when the client is - * constructed with `{ http2: true }`. In Node this is the undici adapter - * (`Agent({ allowH2: true })`); on the web the platform `fetch` already - * negotiates HTTP/2 transparently. + * 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: ` passthrough), defaulting to the SDK's bounded pool. On + * the web the platform `fetch` already negotiates HTTP/2, so the argument is ignored. */ - http2Fetch: any; + makeHttp2Fetch: (dispatcher?: any) => any; Request: any; Response: any; Headers: any; @@ -34,7 +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 http2Fetch: Shims['http2Fetch'] | 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; @@ -61,7 +62,7 @@ export function setShims(shims: Shims, options: { auto: boolean } = { auto: fals auto = options.auto; kind = shims.kind; fetch = shims.fetch; - http2Fetch = shims.http2Fetch; + makeHttp2Fetch = shims.makeHttp2Fetch; Request = shims.Request; Response = shims.Response; Headers = shims.Headers; diff --git a/src/_shims/web-runtime.ts b/src/_shims/web-runtime.ts index 4af9ce0a6..c09108731 100644 --- a/src/_shims/web-runtime.ts +++ b/src/_shims/web-runtime.ts @@ -35,9 +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: true }` is a no-op on the web — reuse the global fetch. - http2Fetch: _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, diff --git a/src/index.ts b/src/index.ts index e747e8e2c..8d20be5fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { type Agent, http2Fetch } 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'; @@ -298,6 +298,12 @@ export interface ClientOptions { * 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 @@ -305,11 +311,16 @@ export interface ClientOptions { * 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`. + * 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 */ - http2?: boolean | undefined; + // 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 @@ -347,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; @@ -360,7 +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} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). + * @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. @@ -382,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 ?? (options.http2 ? http2Fetch : undefined), + fetch: + options.fetch ?? + (options.http2 ? + makeHttp2Fetch(typeof options.http2 === 'object' ? options.http2 : undefined) + : undefined), }); const customHeadersEnv = Core.readEnv('RUNLOOP_CUSTOM_HEADERS'); diff --git a/src/lib/undici-fetch.ts b/src/lib/undici-fetch.ts index 61fad7aa4..84f9c8164 100644 --- a/src/lib/undici-fetch.ts +++ b/src/lib/undici-fetch.ts @@ -23,9 +23,15 @@ * 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: ` 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 } from 'undici'; +import { Agent, fetch as undiciFetchImpl, type Dispatcher } from 'undici'; import { Readable } from 'node:stream'; import { MultipartBody } from '../_shims/MultipartBody'; import { type Fetch } from '../core'; @@ -36,10 +42,11 @@ const KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; 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). +// 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, @@ -71,29 +78,37 @@ export function normalizeBody(body: unknown): NormalizedBody { return { body: String(body), isStream: false }; } -export const undiciFetch: Fetch = 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; +/** + * 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: ` 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 { 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: h2Dispatcher, - }; - // A streamed request body requires the half-duplex hint or undici throws. - if (isStream) undiciInit.duplex = 'half'; + 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` (the prior got adapter did the - // same); at runtime core.ts only uses standard Response members. - return (await undiciFetchImpl(url as any, undiciInit)) as any; -}; - -export default undiciFetch; + // undici returns a genuine WHATWG Response. The SDK is typed against the + // node-fetch Response, so cast through `any` (the prior got adapter did the + // same); at runtime core.ts only uses standard Response members. + return (await undiciFetchImpl(url as any, undiciInit)) as any; + }; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 64d97e557..4753104fa 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -4,6 +4,7 @@ import { Runloop } from '@runloop/api-client'; import { APIUserAbortError } from '@runloop/api-client'; import { Headers } from '@runloop/api-client/core'; import defaultFetch, { Response, type RequestInit, type RequestInfo } from 'node-fetch'; +import { MockAgent } from 'undici'; describe('instantiate client', () => { const env = process.env; @@ -99,7 +100,7 @@ describe('instantiate client', () => { test('custom fetch wins over http2', async () => { // When both `fetch` and `http2` are provided, the custom fetch must be used — // the undici (h2) adapter should not run. Locks in src/index.ts: - // fetch: options.fetch ?? (options.http2 ? http2Fetch : undefined) + // fetch: options.fetch ?? (options.http2 ? makeHttp2Fetch(...) : undefined) const customFetch = jest.fn((url: RequestInfo) => Promise.resolve( new Response(JSON.stringify({ url, custom: true }), { @@ -119,6 +120,52 @@ describe('instantiate client', () => { expect(customFetch).toHaveBeenCalledTimes(1); }); + test('http2 passthrough routes requests through a user-supplied undici Dispatcher', async () => { + // Passing an undici Dispatcher as `http2` must thread it all the way to + // undici.fetch's `dispatcher` (client -> _shims/makeHttp2Fetch -> createUndiciFetch). + // A MockAgent is a real Dispatcher, so if the request is served by our intercept, + // the SDK provably used the dispatcher we passed (net connect is disabled). + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + mockAgent + .get('http://localhost:5000') + .intercept({ path: /^\/foo/, method: 'GET' }) + .reply(200, { mocked: true }, { headers: { 'content-type': 'application/json' } }); + + const client = new Runloop({ + baseURL: 'http://localhost:5000/', + bearerToken: 'My Bearer Token', + maxRetries: 0, + http2: mockAgent, + }); + + try { + const response = await client.get('/foo'); + expect(response).toEqual({ mocked: true }); + } finally { + await mockAgent.close(); + } + }); + + test('warns once when http2 and httpAgent are combined', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + // http2 alone (or httpAgent alone) does not warn. + new Runloop({ baseURL: 'http://localhost:5000/', bearerToken: 'My Bearer Token', http2: true }); + expect(warn).not.toHaveBeenCalled(); + + // Combining them warns — exactly once per process (module-scoped flag), so the + // second construction is silent. + const opts = { baseURL: 'http://localhost:5000/', bearerToken: 'My Bearer Token', httpAgent: {} as any }; + new Runloop({ ...opts, http2: true }); + new Runloop({ ...opts, http2: true }); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('httpAgent'); + } finally { + warn.mockRestore(); + } + }); + test('explicit global fetch', async () => { // make sure the global fetch type is assignable to our Fetch type const client = new Runloop({ diff --git a/tests/lib/undici-fetch.test.ts b/tests/lib/undici-fetch.test.ts index 754c0528c..7ca1d2df7 100644 --- a/tests/lib/undici-fetch.test.ts +++ b/tests/lib/undici-fetch.test.ts @@ -5,7 +5,7 @@ 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', () => { +describe('undici-fetch / normalizeBody', () => { test('passes string / Buffer / typed array through unchanged (non-stream)', () => { expect(normalizeBody('hi')).toEqual({ body: 'hi', isStream: false }); const buf = Buffer.from('b'); From 600b684a8710375799cb6fab52e174637a226264 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 1 Jun 2026 12:46:36 -0700 Subject: [PATCH 7/7] fix(http2): match node-fetch stream timeout semantics; doc cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #791. - undici's bodyTimeout/headersTimeout both default to 300s; the default h2Dispatcher set neither, so on the HTTP/2 path a long-lived stream idle for >300s (e.g. an SSE/exec stream behind withStreamAutoReconnect) would hit an undici BodyTimeoutError. The reconnect predicate (isIdleTimeoutReconnectError) only recognizes 408 / TimeoutError, so it would throw instead of reconnect — a divergence from node-fetch, which has no client-side body timeout. Set bodyTimeout: 0, headersTimeout: 0 so the SDK's own AbortController (the `timeout` option) is the single source of truth on both transports. A caller passing their own dispatcher owns this policy. - Reword a stale comment that referenced a "got adapter" (a dropped iteration of this branch; never existed in the repo). - README: clarify undici >= 7.23.0 is the crash-fix floor and the package pins ^7.26.0. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- src/lib/undici-fetch.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4d884c76..5de4f3d16 100644 --- a/README.md +++ b/README.md @@ -400,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 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.) +- 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. diff --git a/src/lib/undici-fetch.ts b/src/lib/undici-fetch.ts index 84f9c8164..376f38b75 100644 --- a/src/lib/undici-fetch.ts +++ b/src/lib/undici-fetch.ts @@ -53,6 +53,16 @@ const h2Dispatcher = new Agent({ 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 }; @@ -107,8 +117,8 @@ export function createUndiciFetch(dispatcher?: Dispatcher): Fetch { if (isStream) undiciInit.duplex = 'half'; // undici returns a genuine WHATWG Response. The SDK is typed against the - // node-fetch Response, so cast through `any` (the prior got adapter did the - // same); at runtime core.ts only uses standard Response members. + // 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; }; }