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/.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/README.md b/README.md index e0bad3390..5de4f3d16 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,32 @@ await runloop.devboxes.create({...}, { }); ``` +### HTTP/2 transport + +On Node.js, the SDK can send requests over HTTP/2, which multiplexes many concurrent requests over a small number of TLS connections instead of opening a connection per request. Enable it with the `http2` option: + + +```ts +const runloop = new RunloopSDK({ + http2: true, +}); +``` + +Requests are routed through an [undici](https://github.com/nodejs/undici) connection pool with HTTP/2 enabled, falling back to HTTP/1.1 for origins that don't negotiate h2 via ALPN. It is intended for HTTP/2-capable origins such as the Runloop API. This transport uses undici and therefore **requires Node.js >= 20.18.1** (see Requirements). + +`http2: true` uses a default bounded pool. To control the pool yourself — for example to raise the number of connections or multiplexed streams for a high-concurrency workload — pass a configured undici `Dispatcher` (such as an `Agent`) instead. The SDK uses it verbatim and does not manage its lifecycle, the same way it treats a custom `httpAgent`: + + +```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: @@ -374,7 +400,7 @@ TypeScript >= 4.5 is supported. The following runtimes are supported: - Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more) -- Node.js 18 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions. +- Node.js 20.18.1 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions. (Raised from 18 because the SDK now depends on undici 7 on Node; the HTTP/2 transport needs the undici >= 7.23.0 crash fix, and the package pins `^7.26.0`.) - Deno v1.28.0 or higher. - Bun 1.0 or later. - Cloudflare Workers. diff --git a/package.json b/package.json index 5b97c0cfc..3bc0aa402 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": "^7.26.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..511392ef6 100644 --- a/src/_shims/index-deno.ts +++ b/src/_shims/index-deno.ts @@ -9,6 +9,11 @@ const _fetch = fetch; type _fetch = typeof fetch; export { _fetch as fetch }; +// The platform `fetch` already negotiates HTTP/2 at the transport layer, so +// `{ http2: ... }` is a no-op on Deno — reuse the global fetch and ignore any passed +// dispatcher (undici dispatchers are a Node-only concept). +export const makeHttp2Fetch = () => _fetch; + const _Request = Request; type _Request = Request; export { _Request as Request }; diff --git a/src/_shims/index.d.ts b/src/_shims/index.d.ts index 97968fed3..165ab27f3 100644 --- a/src/_shims/index.d.ts +++ b/src/_shims/index.d.ts @@ -15,6 +15,15 @@ export type Agent = SelectType; // @ts-ignore export const fetch: SelectType; +/** + * 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 function makeHttp2Fetch(dispatcher?: any): typeof fetch; + // @ts-ignore export type Request = SelectType; // @ts-ignore diff --git a/src/_shims/node-runtime.ts b/src/_shims/node-runtime.ts index 24a6aae13..ad83b2215 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 { createUndiciFetch } from '../lib/undici-fetch'; type FileFromPathOptions = Omit; @@ -66,6 +67,7 @@ export function getRuntime(): Shims { return { kind: 'node', fetch: nf.default, + makeHttp2Fetch: createUndiciFetch, Request: nf.Request, Response: nf.Response, Headers: nf.Headers, diff --git a/src/_shims/registry.ts b/src/_shims/registry.ts index 18e96314b..78459d975 100644 --- a/src/_shims/registry.ts +++ b/src/_shims/registry.ts @@ -6,6 +6,14 @@ import { type RequestOptions } from '../core'; export interface Shims { kind: string; fetch: any; + /** + * Build an HTTP/2-capable `fetch`, used when the client is constructed with + * `{ http2: ... }`. In Node this is the undici adapter (`Agent({ allowH2: true })`); + * the optional `dispatcher` lets the caller pass a configured undici `Dispatcher` + * (the `http2: ` passthrough), defaulting to the SDK's bounded pool. On + * the web the platform `fetch` already negotiates HTTP/2, so the argument is ignored. + */ + makeHttp2Fetch: (dispatcher?: any) => any; Request: any; Response: any; Headers: any; @@ -27,6 +35,7 @@ export interface Shims { export let auto = false; export let kind: Shims['kind'] | undefined = undefined; export let fetch: Shims['fetch'] | undefined = undefined; +export let makeHttp2Fetch: Shims['makeHttp2Fetch'] | undefined = undefined; export let Request: Shims['Request'] | undefined = undefined; export let Response: Shims['Response'] | undefined = undefined; export let Headers: Shims['Headers'] | undefined = undefined; @@ -53,6 +62,7 @@ export function setShims(shims: Shims, options: { auto: boolean } = { auto: fals auto = options.auto; kind = shims.kind; fetch = shims.fetch; + makeHttp2Fetch = shims.makeHttp2Fetch; Request = shims.Request; Response = shims.Response; Headers = shims.Headers; diff --git a/src/_shims/web-runtime.ts b/src/_shims/web-runtime.ts index 7f8a9a5be..c09108731 100644 --- a/src/_shims/web-runtime.ts +++ b/src/_shims/web-runtime.ts @@ -35,6 +35,10 @@ export function getRuntime({ manuallyImported }: { manuallyImported?: boolean } return { kind: 'web', fetch: _fetch, + // The platform `fetch` already negotiates HTTP/2 at the transport layer, so + // `{ http2: ... }` is a no-op on the web — reuse the global fetch and ignore any + // passed dispatcher (undici dispatchers are a Node-only concept). + makeHttp2Fetch: () => _fetch, Request: _Request, Response: _Response, Headers: _Headers, diff --git a/src/index.ts b/src/index.ts index 89bb9eba6..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 } 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'; @@ -290,6 +290,38 @@ export interface ClientOptions { */ fetch?: Core.Fetch | undefined; + /** + * Send requests over HTTP/2 (with automatic fallback to HTTP/1.1). + * + * In Node.js this swaps the default `node-fetch` transport for an undici-backed + * adapter (`Agent({ allowH2: true })`) that negotiates HTTP/2 via ALPN. On the + * web the platform `fetch` already speaks HTTP/2, so this is a no-op there. + * Ignored when a custom `fetch` is provided. + * + * - `true` uses the SDK's default bounded HTTP/2 pool (a few TLS sessions, many + * multiplexed streams each). + * - Pass a configured undici `Dispatcher` (e.g. `new Agent({ allowH2: true, + * connections, pipelining })`) to control the pool yourself — the SDK uses it + * verbatim and does not manage its lifecycle, exactly like `httpAgent`. + * + * **Intended for HTTP/2-capable origins (such as the Runloop API).** When the + * origin does not negotiate h2, undici falls back to HTTP/1.1 with request + * pipelining enabled on the shared dispatcher; pipelining is unsafe against + * many HTTP/1.1 servers and proxies. Do not enable this flag if your traffic + * may be routed through a non-h2 intermediary. + * + * On the HTTP/2 path the `httpAgent` option is not used, since undici manages + * connections through its own dispatcher rather than a Node `http.Agent` — to + * tune connections here, pass a `Dispatcher` as shown above. A one-time warning + * is emitted if both `http2` and `httpAgent` are set. + * + * @default false + */ + // The `import('undici').Dispatcher` type is inlined (rather than a top-of-file + // import) to keep this manual addition to a generated file regen-friendly and to + // avoid pulling undici types onto the web/deno code paths; it is type-only/erased. + http2?: boolean | import('undici').Dispatcher | undefined; + /** * The maximum number of times that the client will retry a request in case of a * temporary failure, like a network error or a 5XX error from the server. @@ -326,6 +358,11 @@ export interface ClientOptions { * console.log(result.exitCode); * ``` */ +// Emitted at most once per process when `http2` and `httpAgent` are combined (see +// the constructor). Module-scoped flag mirrors the `fileFromPathWarned` pattern in +// _shims/node-runtime.ts. +let http2HttpAgentWarned = false; + export class Runloop extends Core.APIClient { bearerToken: string; @@ -339,6 +376,7 @@ export class Runloop extends Core.APIClient { * @param {number} [opts.timeout=30 seconds] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. * @param {number} [opts.httpAgent] - An HTTP agent used to manage HTTP(s) connections. * @param {Core.Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. + * @param {boolean | import('undici').Dispatcher} [opts.http2=false] - Send requests over HTTP/2 (Node only; ignored when `fetch` is provided). `true` uses the default bounded pool; pass an undici `Dispatcher` to control the pool yourself. * @param {number} [opts.maxRetries=5] - The maximum number of times the client will retry a request. * @param {Core.Headers} opts.defaultHeaders - Default headers to include with every request to the API. * @param {Core.DefaultQuery} opts.defaultQuery - Default query parameters to include with every request to the API. @@ -360,13 +398,30 @@ export class Runloop extends Core.APIClient { baseURL: baseURL || `https://api.runloop.ai`, }; + // `httpAgent` (a Node `http.Agent`) does not apply to the HTTP/2 transport — + // undici manages its own dispatcher and has no `http.Agent` concept. Warn once + // instead of silently ignoring it. (Skipped when a custom `fetch` supersedes + // `http2` entirely.) + if (!options.fetch && options.http2 && options.httpAgent && !http2HttpAgentWarned) { + http2HttpAgentWarned = true; + console.warn( + '[runloop] `httpAgent` is ignored when `http2` is set: undici manages its own ' + + 'dispatcher and has no Node http.Agent concept. To configure the HTTP/2 transport, ' + + 'pass a configured undici Dispatcher as `http2` (e.g. `http2: new Agent({ connections, pipelining })`).', + ); + } + super({ baseURL: options.baseURL!, baseURLOverridden: baseURL ? baseURL !== 'https://api.runloop.ai' : false, timeout: options.timeout ?? 30000 /* 30 seconds */, httpAgent: options.httpAgent, maxRetries: options.maxRetries, - fetch: options.fetch, + fetch: + options.fetch ?? + (options.http2 ? + makeHttp2Fetch(typeof options.http2 === 'object' ? options.http2 : undefined) + : undefined), }); const customHeadersEnv = Core.readEnv('RUNLOOP_CUSTOM_HEADERS'); diff --git a/src/lib/undici-fetch.ts b/src/lib/undici-fetch.ts new file mode 100644 index 000000000..376f38b75 --- /dev/null +++ b/src/lib/undici-fetch.ts @@ -0,0 +1,124 @@ +/** + * A fetch-compatible adapter backed by undici's HTTP/2 support, using a bounded + * connection pool that multiplexes many concurrent requests over a few TLS sessions. + * + * undici is the same engine that powers Node's built-in global `fetch`. An `Agent` + * with `allowH2: true` negotiates HTTP/2 via ALPN and transparently falls back to + * HTTP/1.1 when the origin doesn't advertise h2. Two options make it actually + * multiplex rather than open one connection per request: + * - `connections` bounds the pool to a few TLS sessions per origin. Without it + * undici opens a fresh connection for every concurrent request (a connection + * storm) instead of reusing sessions. + * - `pipelining` (undici default: 1) is the max concurrent streams undici runs + * per session; it must be > 1 for H2 stream multiplexing to happen at all. + * + * undici returns a standard WHATWG `Response`, so the rest of core.ts — which only + * touches standard Response members (`.status`, `.ok`, `.headers`, `.text()`, + * `.json()`, `.body`, `.arrayBuffer()`, `.blob()`) — is unchanged. undici is dual + * CJS/ESM and `require`-able from this `"type": "commonjs"` package, so there is no + * dynamic-import hack and no second HTTP stack. + * + * Note: `pipelining > 1` also enables HTTP/1.1 request pipelining on the fallback + * path, so `http2: true` (opt-in) is intended for h2-capable origins. Requires + * undici >= 7.23.0 — multiplexed H2 assert-crashes on 6.x (undici PR #4845) — and + * therefore Node >= 20.18.1. + * + * `createUndiciFetch(dispatcher?)` builds the adapter around a dispatcher: with no + * argument it uses the shared default pool below (what `http2: true` selects); a + * caller can instead pass their own configured undici `Dispatcher` (what + * `http2: ` selects) for full control over the pool, exactly like the + * SDK's `httpAgent` escape hatch — the SDK does not manage its lifecycle. + * + * Lives in src/lib/ (the Stainless custom-code dir) so it survives regeneration. + */ +import { Agent, fetch as undiciFetchImpl, type Dispatcher } from 'undici'; +import { Readable } from 'node:stream'; +import { MultipartBody } from '../_shims/MultipartBody'; +import { type Fetch } from '../core'; + +const KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; +// Bound the pool to a few TLS sessions per origin and multiplex many H2 streams +// over each. 4 x 64 = 256 concurrent requests in flight before undici queues the rest. +const H2_MAX_CONNECTIONS = 4; +const H2_MAX_CONCURRENT_STREAMS = 64; + +// One module-scoped default dispatcher, reused across requests: a bounded HTTP/2 pool +// with keep-alive. `allowH2` negotiates h2 over TLS via ALPN and transparently falls +// back to HTTP/1.1 when the origin doesn't offer h2; `connections`/`pipelining` make it +// multiplex (see the file header). Used when the caller passes `http2: true` (no custom +// dispatcher). +const h2Dispatcher = new Agent({ + allowH2: true, + connections: H2_MAX_CONNECTIONS, + pipelining: H2_MAX_CONCURRENT_STREAMS, + keepAliveTimeout: KEEP_ALIVE_TIMEOUT_MS, + keepAliveMaxTimeout: KEEP_ALIVE_TIMEOUT_MS, + // Disable undici's body/headers timeouts (both default to 300s) so this matches the + // node-fetch transport, which has no client-side body timeout. The SDK's own + // AbortController (core.ts `fetchWithTimeout`, governed by the `timeout` option) is + // the single source of truth. Without this, a long-lived stream idle for >300s — e.g. + // an SSE/exec stream behind `withStreamAutoReconnect` — would get an undici + // BodyTimeoutError that the reconnect predicate doesn't recognize, so it would throw + // instead of reconnect (as it does on node-fetch). A caller passing their own + // dispatcher owns this policy. + bodyTimeout: 0, + headersTimeout: 0, +}); + +type NormalizedBody = { body: any; isStream: boolean }; + +// Map the body shapes core.ts produces (string | Buffer/ArrayBufferView | +// Node Readable for multipart | null) onto a valid undici BodyInit. A Node +// Readable must become a Web ReadableStream and requires `duplex: 'half'`. +// Exported for unit tests. @internal +export function normalizeBody(body: unknown): NormalizedBody { + if (body == null) return { body: undefined, isStream: false }; + if (typeof body === 'string') return { body, isStream: false }; + if (Buffer.isBuffer(body)) return { body, isStream: false }; + // Unwrap MultipartBody (wraps a Readable in `.body`). core.ts already unwraps + // it, but handle it defensively. + if (body instanceof MultipartBody) return normalizeBody((body as MultipartBody).body); + if (body instanceof Readable) { + return { body: Readable.toWeb(body) as any, isStream: true }; + } + // ArrayBufferView (Uint8Array, DataView, typed arrays) and ArrayBuffer are + // valid BodyInit as-is / after a Buffer wrap. + if (ArrayBuffer.isView(body)) return { body, isStream: false }; + if (body instanceof ArrayBuffer) return { body: Buffer.from(body), isStream: false }; + return { body: String(body), isStream: false }; +} + +/** + * Build a fetch adapter bound to a dispatcher. `dispatcher` defaults to the shared + * bounded h2 pool above (the `http2: true` case); pass a configured undici + * `Dispatcher` to use it verbatim (the `http2: ` passthrough case). The + * dispatcher is resolved once here, at client-construction time, then reused for + * every request — matching the "one module-scoped dispatcher" model. + */ +export function createUndiciFetch(dispatcher?: Dispatcher): Fetch { + const chosen = dispatcher ?? h2Dispatcher; + return async (url, init) => { + // core.ts injects a node-fetch-style `agent` in RequestInit; undici uses a + // `dispatcher` instead, so drop `agent`. Pull `signal` and `body` out to + // normalize them; pass everything else (method, headers, redirect, …) through. + const { agent: _ignoredAgent, body: rawBody, signal, ...rest } = (init ?? {}) as any; + + const { body, isStream } = normalizeBody(rawBody); + + const undiciInit: any = { + ...rest, + body, + // core.ts passes a standard web AbortSignal (from `new AbortController()`), + // which undici accepts directly. + signal: signal ?? undefined, + dispatcher: chosen, + }; + // A streamed request body requires the half-duplex hint or undici throws. + if (isStream) undiciInit.duplex = 'half'; + + // undici returns a genuine WHATWG Response. The SDK is typed against the + // node-fetch Response, so cast through `any`; at runtime core.ts only touches + // standard Response members that both implementations support. + return (await undiciFetchImpl(url as any, undiciInit)) as any; + }; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index d49bd8264..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; @@ -96,6 +97,75 @@ 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 ? makeHttp2Fetch(...) : 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('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 new file mode 100644 index 000000000..7ca1d2df7 --- /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('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'); + 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 new file mode 100644 index 000000000..cfb2b480f --- /dev/null +++ b/tests/smoketests/scripts/verify-http2.mjs @@ -0,0 +1,99 @@ +/** + * 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 = []; +let connectCount = 0; +diagnostics_channel.subscribe('undici:client:connected', (msg) => { + connectCount++; + 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})`); +} + +// ── 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/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