From d722a063220661751d0c72cd2bcf6811ea567f39 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:25:09 +0000 Subject: [PATCH 1/3] feat(js-sdk)!: drop undici 7 and the in-flight cap, depend on undici@^8 only undici 8 honors the server's SETTINGS_MAX_CONCURRENT_STREAMS per h2 connection and spills to additional connections when saturated, so the dual undici/undici8 selection is no longer needed. Requires Node.js >= 22.19.0 (undici 8's engine floor); older Node falls back to the global fetch instead of loading undici. Concurrency is governed by undici 8's dispatcher: per-connection HTTP/2 stream limits advertised by the server plus the configured connection pools. Removes limitConcurrency and the E2B_API_INFLIGHT_REQUESTS, E2B_ENVD_INFLIGHT_REQUESTS, and E2B_ENVD_RPC_INFLIGHT_REQUESTS env vars. Co-Authored-By: mish@e2b.dev --- .changeset/drop-undici7.md | 11 ++ packages/cli/package.json | 2 +- packages/code-interpreter-js/package.json | 2 +- packages/desktop-js/package.json | 2 +- packages/js-sdk/package.json | 7 +- packages/js-sdk/src/api/http2.ts | 21 +--- packages/js-sdk/src/api/inflight.ts | 82 -------------- packages/js-sdk/src/api/metadata.ts | 21 ---- packages/js-sdk/src/envd/http2.ts | 40 +------ packages/js-sdk/src/undici.ts | 18 ++-- packages/js-sdk/tests/api/http2.test.ts | 25 ----- packages/js-sdk/tests/api/inflight.test.ts | 101 ------------------ packages/js-sdk/tests/envd/http2.test.ts | 49 --------- .../tests/runtimes/browser/vitest.config.mts | 2 +- packages/js-sdk/tests/undici.test.ts | 11 +- pnpm-lock.yaml | 11 +- 16 files changed, 32 insertions(+), 373 deletions(-) create mode 100644 .changeset/drop-undici7.md delete mode 100644 packages/js-sdk/src/api/inflight.ts delete mode 100644 packages/js-sdk/tests/api/inflight.test.ts diff --git a/.changeset/drop-undici7.md b/.changeset/drop-undici7.md new file mode 100644 index 0000000000..69747b9646 --- /dev/null +++ b/.changeset/drop-undici7.md @@ -0,0 +1,11 @@ +--- +'e2b': minor +'@e2b/cli': minor +'@e2b/code-interpreter': minor +'@e2b/desktop': minor +--- + +Require Node.js 22.19.0 or newer and depend on `undici@^8` only (undici 7 is dropped). + +- undici 8 respects the server's HTTP/2 `SETTINGS_MAX_CONCURRENT_STREAMS` and opens more connections when one is saturated. +- Breaking: the SDK-level in-flight request cap is removed, along with the `E2B_API_INFLIGHT_REQUESTS`, `E2B_ENVD_INFLIGHT_REQUESTS` and `E2B_ENVD_RPC_INFLIGHT_REQUESTS` env vars. Concurrency is governed by undici's connection pools (`E2B_API_CONNECTIONS`, `E2B_ENVD_RPC_CONNECTIONS`). diff --git a/packages/cli/package.json b/packages/cli/package.json index 9ac83b1751..28884b19d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -89,6 +89,6 @@ "yup": "^1.7.1" }, "engines": { - "node": ">=22.9.0" + "node": ">=22.19.0" } } diff --git a/packages/code-interpreter-js/package.json b/packages/code-interpreter-js/package.json index f6bfe16c2e..0c1978f027 100644 --- a/packages/code-interpreter-js/package.json +++ b/packages/code-interpreter-js/package.json @@ -64,7 +64,7 @@ "vm" ], "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "browserslist": [ "defaults" diff --git a/packages/desktop-js/package.json b/packages/desktop-js/package.json index 22ea0ed6f2..12a2ca9a28 100644 --- a/packages/desktop-js/package.json +++ b/packages/desktop-js/package.json @@ -36,7 +36,7 @@ "package.json" ], "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "browserslist": [ "defaults" diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 677b4ce5da..2f0f908653 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -97,13 +97,10 @@ "openapi-fetch": "^0.17.0", "platform": "^1.3.6", "tar": "^7.5.22", - "undici": "^7.30.0" - }, - "optionalDependencies": { - "undici8": "npm:undici@8.11.2" + "undici": "^8.11.2" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "browserslist": [ "defaults" diff --git a/packages/js-sdk/src/api/http2.ts b/packages/js-sdk/src/api/http2.ts index 41aaba410e..38f16f8ca2 100644 --- a/packages/js-sdk/src/api/http2.ts +++ b/packages/js-sdk/src/api/http2.ts @@ -1,6 +1,6 @@ import { runtime } from '../utils' import { DEFAULT_HTTP_VERSION, type HttpVersion } from '../connectionConfig' -import { parseInflightLimitEnv, parsePositiveIntEnv } from './metadata' +import { parsePositiveIntEnv } from './metadata' import { buildDispatchedFetch, createRuntimeFetch, @@ -8,9 +8,6 @@ import { } from '../undici' const DEFAULT_API_CONNECTION_LIMIT = 100 -// 1000 = ~10 streams per connection (with the 100-conn default). -// Override via env if your workload needs different. -const DEFAULT_API_INFLIGHT_LIMIT = 1000 // Fetchers are cached per proxy and HTTP version so requests without a proxy // keep sharing a single dispatcher while each distinct proxy URL gets its own. @@ -42,7 +39,6 @@ export function createApiFetchForRuntime( currentRuntime = runtime, options: { connectionLimit?: number - inflightLimit?: number proxy?: string httpVersion?: HttpVersion loadUndici?: () => Promise @@ -53,7 +49,6 @@ export function createApiFetchForRuntime( return createRuntimeFetch(currentRuntime, () => buildDispatchedFetch({ connections: options.connectionLimit ?? getApiConnectionLimit(), - inflightLimit: options.inflightLimit ?? getApiInflightLimit(), proxy: options.proxy, httpVersion: options.httpVersion, loadUndici: options.loadUndici, @@ -67,17 +62,3 @@ export function getApiConnectionLimit(): number { DEFAULT_API_CONNECTION_LIMIT ) } - -/** - * Returns the configured max number of API requests that can be in flight at - * once, or `0` to disable the cap. - * - * Defaults to `1000` ({@link DEFAULT_API_INFLIGHT_LIMIT}). Override via - * `E2B_API_INFLIGHT_REQUESTS` env var; set to `0` to disable the cap entirely. - */ -export function getApiInflightLimit(): number { - return parseInflightLimitEnv( - 'E2B_API_INFLIGHT_REQUESTS', - DEFAULT_API_INFLIGHT_LIMIT - ) -} diff --git a/packages/js-sdk/src/api/inflight.ts b/packages/js-sdk/src/api/inflight.ts deleted file mode 100644 index 16401467af..0000000000 --- a/packages/js-sdk/src/api/inflight.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { isRequestLike } from '../is' - -/** - * Simple FIFO semaphore used to cap the number of in-flight requests sent - * through a fetch dispatcher. - */ -class Semaphore { - private active = 0 - private readonly queue: Array<() => void> = [] - - constructor(private readonly max: number) {} - - async acquire(signal?: AbortSignal): Promise<() => void> { - if (signal?.aborted) throw abortReason(signal) - if (this.active < this.max) { - this.active++ - return () => this.release() - } - - return new Promise<() => void>((resolve, reject) => { - const onAcquire = () => { - signal?.removeEventListener('abort', onAbort) - this.active++ - resolve(() => this.release()) - } - const onAbort = () => { - const i = this.queue.indexOf(onAcquire) - if (i >= 0) this.queue.splice(i, 1) - reject(abortReason(signal)) - } - this.queue.push(onAcquire) - signal?.addEventListener('abort', onAbort, { once: true }) - }) - } - - private release() { - this.active-- - const next = this.queue.shift() - if (next) next() - } -} - -function abortReason(signal: AbortSignal | undefined): unknown { - return signal?.reason ?? new DOMException('Aborted', 'AbortError') -} - -/** - * Wrap `fetcher` so at most `max` requests are in-flight at any time. - * Subsequent requests are FIFO-queued inside the SDK process and dispatched - * as earlier requests settle. - * - * NOTE: the slot is released as soon as `fetcher` resolves with the response - * headers, not when the response body is fully consumed. This means the - * effective concurrency can be higher than `max` while bodies are - * still streaming. - * - * TODO: release on body end (consume/cancel/error) so the - * SDK-level cap aligns with the dispatcher's connection accounting - */ -export function limitConcurrency( - fetcher: typeof fetch, - max: number -): typeof fetch { - if (!Number.isFinite(max) || max <= 0) { - return fetcher - } - - const sem = new Semaphore(max) - - return (async (input, init) => { - // A Request the current global class disowns still carries the signal we - // have to honor while it waits for a slot. - const signal = - init?.signal ?? (isRequestLike(input) ? input.signal : undefined) - const release = await sem.acquire(signal) - try { - return await fetcher(input, init) - } finally { - release() - } - }) as typeof fetch -} diff --git a/packages/js-sdk/src/api/metadata.ts b/packages/js-sdk/src/api/metadata.ts index 4eba2e0986..9f82916740 100644 --- a/packages/js-sdk/src/api/metadata.ts +++ b/packages/js-sdk/src/api/metadata.ts @@ -62,24 +62,3 @@ export function parsePositiveIntEnv( return parsed } - -/** - * Parse an inflight-limit env var. Returns `0` to disable the cap (documented - * opt-out) or a positive integer to cap concurrency. Throws on non-integer or - * negative values so misconfiguration is surfaced loudly rather than silently - * removing the cap. A return value of `0` is recognized by - * {@link limitConcurrency} as "no cap". - */ -export function parseInflightLimitEnv( - name: string, - defaultValue: number -): number { - const parsed = parseIntEnv(name, defaultValue) - if (parsed < 0) { - throw new Error( - `Invalid ${name}=${parsed}: expected a non-negative integer ` + - '(use 0 to disable the cap).' - ) - } - return parsed -} diff --git a/packages/js-sdk/src/envd/http2.ts b/packages/js-sdk/src/envd/http2.ts index ccf31d24ad..312da43dab 100644 --- a/packages/js-sdk/src/envd/http2.ts +++ b/packages/js-sdk/src/envd/http2.ts @@ -1,7 +1,7 @@ import { runtime } from '../utils' import { DEFAULT_HTTP_VERSION, type HttpVersion } from '../connectionConfig' import type { FetchOpts } from '../api/http2' -import { parseInflightLimitEnv, parsePositiveIntEnv } from '../api/metadata' +import { parsePositiveIntEnv } from '../api/metadata' import { buildDispatchedFetch, createRuntimeFetch, @@ -10,7 +10,6 @@ import { type EnvdFetchOptions = { connectionLimit?: number - inflightLimit?: number proxy?: string httpVersion?: HttpVersion loadUndici?: () => Promise @@ -22,8 +21,6 @@ const envdFetchers = new Map() const envdRpcFetchers = new Map() const DEFAULT_ENVD_CONNECTION_LIMIT = 10 const DEFAULT_ENVD_RPC_CONNECTION_LIMIT = 200 -const DEFAULT_ENVD_INFLIGHT_LIMIT = 2000 -const DEFAULT_ENVD_RPC_INFLIGHT_LIMIT = 2000 export function createEnvdFetchForRuntime( currentRuntime = runtime, @@ -32,7 +29,6 @@ export function createEnvdFetchForRuntime( return createRuntimeFetch(currentRuntime, () => buildDispatchedFetch({ connections: options.connectionLimit ?? DEFAULT_ENVD_CONNECTION_LIMIT, - inflightLimit: options.inflightLimit ?? 0, proxy: options.proxy, httpVersion: options.httpVersion, loadUndici: options.loadUndici, @@ -61,7 +57,6 @@ export function createEnvdFetch({ // Keep one origin connection for short envd REST calls. If ALPN falls back // to h1, this favors connection pressure over per-sandbox throughput. const envdFetch = createEnvdFetchForRuntime(runtime, { - inflightLimit: getEnvdInflightLimit(), proxy, httpVersion, }) @@ -83,7 +78,6 @@ export function createEnvdRpcFetch({ const envdRpcFetch = createEnvdFetchForRuntime(runtime, { connectionLimit: getEnvdRpcConnectionLimit(), - inflightLimit: getEnvdRpcInflightLimit(), proxy, httpVersion, }) @@ -98,35 +92,3 @@ export function getEnvdRpcConnectionLimit(): number { DEFAULT_ENVD_RPC_CONNECTION_LIMIT ) } - -/** - * Returns the configured max number of envd REST requests (e.g. - * `files.read`/`files.write`) that can be in flight at once across all - * sandboxes in this SDK process, or `0` to disable the cap. - * - * Defaults to `2000` ({@link DEFAULT_ENVD_INFLIGHT_LIMIT}). Override - * via `E2B_ENVD_INFLIGHT_REQUESTS` env var; set to `0` to disable the cap - * entirely. - */ -export function getEnvdInflightLimit(): number { - return parseInflightLimitEnv( - 'E2B_ENVD_INFLIGHT_REQUESTS', - DEFAULT_ENVD_INFLIGHT_LIMIT - ) -} - -/** - * Returns the configured max number of envd RPC requests that - * can be in flight at once across all sandboxes in this SDK process, - * or `0` to disable the cap. - * - * Defaults to `2000` ({@link DEFAULT_ENVD_RPC_INFLIGHT_LIMIT}). Override - * via `E2B_ENVD_RPC_INFLIGHT_REQUESTS` env var; set to `0` to disable the cap - * entirely. - */ -export function getEnvdRpcInflightLimit(): number { - return parseInflightLimitEnv( - 'E2B_ENVD_RPC_INFLIGHT_REQUESTS', - DEFAULT_ENVD_RPC_INFLIGHT_LIMIT - ) -} diff --git a/packages/js-sdk/src/undici.ts b/packages/js-sdk/src/undici.ts index 86f4b9f34f..752dfd65a9 100644 --- a/packages/js-sdk/src/undici.ts +++ b/packages/js-sdk/src/undici.ts @@ -1,6 +1,5 @@ import { compareVersions } from 'compare-versions' -import { limitConcurrency } from './api/inflight' import { isReadableStreamLike, isRequestLike } from './is' import { dynamicImport, toDispatchableStream } from './utils' import { DEFAULT_HTTP_VERSION, type HttpVersion } from './connectionConfig' @@ -25,10 +24,10 @@ const UNDICI_8_MIN_NODE = '22.19.0' export function getUndiciPackageCandidates(nodeVersion: string): string[] { if (compareVersions(nodeVersion, UNDICI_8_MIN_NODE) >= 0) { - return ['undici8', 'undici'] + return ['undici'] } - return ['undici'] + return [] } export async function loadUndici(): Promise { @@ -36,7 +35,7 @@ export async function loadUndici(): Promise { try { return await dynamicImport(packageName) } catch { - // Try the next package supported by this Node version. + // Fall back to the global fetch when undici cannot load. } } @@ -90,13 +89,10 @@ export function createRuntimeFetch( /** * Build a fetch bound to a bounded undici dispatcher (HTTP/2 enabled unless * `httpVersion` is `'1.1'`, `connections` origin connections, optional proxy - * tunnel), capped at `inflightLimit` in-flight requests (`0` disables the - * cap). Falls back to the global fetch — still capped — when undici cannot be - * loaded. + * tunnel). Falls back to the global fetch when undici cannot be loaded. */ export async function buildDispatchedFetch(options: { connections: number - inflightLimit: number proxy?: string httpVersion?: HttpVersion loadUndici?: () => Promise @@ -104,7 +100,7 @@ export async function buildDispatchedFetch(options: { const undici = await (options.loadUndici ?? loadUndici)() if (!undici) { - return limitConcurrency(lateBoundGlobalFetch(), options.inflightLimit) + return lateBoundGlobalFetch() } const { Agent, ProxyAgent, fetch: undiciFetch } = undici @@ -125,7 +121,7 @@ export async function buildDispatchedFetch(options: { init?: UndiciRequestInit ) => Promise - const wrapped: typeof fetch = ((input, init) => { + return ((input, init) => { const request = toUndiciRequestInput(input, init) return fetchWithDispatcher(request.input, { @@ -133,8 +129,6 @@ export async function buildDispatchedFetch(options: { dispatcher, }) }) as typeof fetch - - return limitConcurrency(wrapped, options.inflightLimit) } function toUndiciRequestInput( diff --git a/packages/js-sdk/tests/api/http2.test.ts b/packages/js-sdk/tests/api/http2.test.ts index a1cc6fe18c..08a645f826 100644 --- a/packages/js-sdk/tests/api/http2.test.ts +++ b/packages/js-sdk/tests/api/http2.test.ts @@ -4,7 +4,6 @@ afterEach(() => { vi.restoreAllMocks() vi.resetModules() delete process.env.E2B_API_CONNECTIONS - delete process.env.E2B_API_INFLIGHT_REQUESTS }) test('uses undici with a bounded HTTP/2 dispatcher for API requests', async () => { @@ -191,27 +190,3 @@ test('getApiConnectionLimit throws on a malformed env value', async () => { expect(() => getApiConnectionLimit()).toThrow(/E2B_API_CONNECTIONS/) }) - -test('getApiInflightLimit throws on a malformed env value', async () => { - process.env.E2B_API_INFLIGHT_REQUESTS = 'not-a-number' - - const { getApiInflightLimit } = await import('../../src/api/http2') - - expect(() => getApiInflightLimit()).toThrow(/E2B_API_INFLIGHT_REQUESTS/) -}) - -test('getApiInflightLimit returns 0 when explicitly disabled', async () => { - process.env.E2B_API_INFLIGHT_REQUESTS = '0' - - const { getApiInflightLimit } = await import('../../src/api/http2') - - expect(getApiInflightLimit()).toBe(0) -}) - -test('getApiInflightLimit throws on negative env value', async () => { - process.env.E2B_API_INFLIGHT_REQUESTS = '-5' - - const { getApiInflightLimit } = await import('../../src/api/http2') - - expect(() => getApiInflightLimit()).toThrow(/E2B_API_INFLIGHT_REQUESTS=-5/) -}) diff --git a/packages/js-sdk/tests/api/inflight.test.ts b/packages/js-sdk/tests/api/inflight.test.ts deleted file mode 100644 index bd23eb34ca..0000000000 --- a/packages/js-sdk/tests/api/inflight.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { expect, test, vi } from 'vitest' - -import { limitConcurrency } from '../../src/api/inflight' -import { foreignRequestClasses } from '../foreignPlatformObjects' - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -test('limitConcurrency queues requests over the cap and releases on response', async () => { - const gate = deferred() - let secondStarted = false - const inner = vi.fn(async (input: RequestInfo | URL) => { - if (String(input).endsWith('/first')) return gate.promise - secondStarted = true - return new Response('second') - }) as unknown as typeof fetch - - const limited = limitConcurrency(inner, 1) - const first = limited('https://example.com/first') - const second = limited('https://example.com/second') - - await Promise.resolve() - await Promise.resolve() - expect(secondStarted).toBe(false) - - gate.resolve(new Response('first')) - expect(await (await first).text()).toBe('first') - expect(await (await second).text()).toBe('second') - expect(secondStarted).toBe(true) -}) - -test('limitConcurrency releases when the underlying fetch rejects', async () => { - let calls = 0 - const inner = vi.fn(async () => { - calls++ - if (calls === 1) throw new Error('boom') - return new Response('ok') - }) as unknown as typeof fetch - - const limited = limitConcurrency(inner, 1) - await expect(limited('https://example.com/a')).rejects.toThrow('boom') - - // Slot should be free for the next request. - const res = await limited('https://example.com/b') - expect(await res.text()).toBe('ok') -}) - -test('limitConcurrency aborts queued requests when their signal fires', async () => { - const gate = deferred() - const inner = vi.fn(async () => gate.promise) as unknown as typeof fetch - const limited = limitConcurrency(inner, 1) - - // Occupy the only slot. - const first = limited('https://example.com/first') - - const controller = new AbortController() - const queued = limited('https://example.com/queued', { - signal: controller.signal, - }) - - // Abort the queued request before the slot frees. - controller.abort() - await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) - - // Release the first request to make sure cleanup did not break the slot. - gate.resolve(new Response('done')) - const resp = await first - expect(await resp.text()).toBe('done') -}) - -test('limitConcurrency honors the signal of a Request the global class disowns', async () => { - const { MintingRequest, GlobalShimRequest } = foreignRequestClasses() - - const inner = vi.fn(async () => new Response('ok')) as unknown as typeof fetch - const limited = limitConcurrency(inner, 1) - - const controller = new AbortController() - controller.abort() - const request = new MintingRequest('https://example.com/aborted', { - signal: controller.signal, - }) - - vi.stubGlobal('Request', GlobalShimRequest) - try { - expect(request instanceof globalThis.Request).toBe(false) - await expect(limited(request)).rejects.toMatchObject({ - name: 'AbortError', - }) - } finally { - vi.unstubAllGlobals() - } - - expect(inner).not.toHaveBeenCalled() -}) diff --git a/packages/js-sdk/tests/envd/http2.test.ts b/packages/js-sdk/tests/envd/http2.test.ts index 907c88cf15..022673196b 100644 --- a/packages/js-sdk/tests/envd/http2.test.ts +++ b/packages/js-sdk/tests/envd/http2.test.ts @@ -4,8 +4,6 @@ afterEach(() => { vi.restoreAllMocks() vi.resetModules() delete process.env.E2B_ENVD_RPC_CONNECTIONS - delete process.env.E2B_ENVD_INFLIGHT_REQUESTS - delete process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS }) test('uses undici with HTTP/2 enabled in Node', async () => { @@ -232,53 +230,6 @@ test('getEnvdRpcConnectionLimit throws on malformed env value', async () => { expect(() => getEnvdRpcConnectionLimit()).toThrow(/E2B_ENVD_RPC_CONNECTIONS/) }) -test('getEnvdInflightLimit throws on malformed env value', async () => { - process.env.E2B_ENVD_INFLIGHT_REQUESTS = 'bogus' - - const { getEnvdInflightLimit } = await import('../../src/envd/http2') - - expect(() => getEnvdInflightLimit()).toThrow(/E2B_ENVD_INFLIGHT_REQUESTS/) -}) - -test('getEnvdRpcInflightLimit throws on malformed env value', async () => { - process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = 'bogus' - - const { getEnvdRpcInflightLimit } = await import('../../src/envd/http2') - - expect(() => getEnvdRpcInflightLimit()).toThrow( - /E2B_ENVD_RPC_INFLIGHT_REQUESTS/ - ) -}) - -test('inflight limit env vars return 0 when explicitly disabled', async () => { - process.env.E2B_ENVD_INFLIGHT_REQUESTS = '0' - process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '0' - - const { getEnvdInflightLimit, getEnvdRpcInflightLimit } = - await import('../../src/envd/http2') - - expect(getEnvdInflightLimit()).toBe(0) - expect(getEnvdRpcInflightLimit()).toBe(0) -}) - -test('getEnvdInflightLimit throws on negative env value', async () => { - process.env.E2B_ENVD_INFLIGHT_REQUESTS = '-1' - - const { getEnvdInflightLimit } = await import('../../src/envd/http2') - - expect(() => getEnvdInflightLimit()).toThrow(/E2B_ENVD_INFLIGHT_REQUESTS=-1/) -}) - -test('getEnvdRpcInflightLimit throws on negative env value', async () => { - process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '-5' - - const { getEnvdRpcInflightLimit } = await import('../../src/envd/http2') - - expect(() => getEnvdRpcInflightLimit()).toThrow( - /E2B_ENVD_RPC_INFLIGHT_REQUESTS=-5/ - ) -}) - test('defers loading undici until the first Node request', async () => { const Agent = vi.fn() const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok'))) diff --git a/packages/js-sdk/tests/runtimes/browser/vitest.config.mts b/packages/js-sdk/tests/runtimes/browser/vitest.config.mts index 00f28616ca..7fb6853d27 100644 --- a/packages/js-sdk/tests/runtimes/browser/vitest.config.mts +++ b/packages/js-sdk/tests/runtimes/browser/vitest.config.mts @@ -81,7 +81,7 @@ export default defineConfig({ // Inspects the host-built dist/index.mjs via node:fs, which the browser // can never see; the Node unit project keeps running it. 'tests/bundle/**', - // Resolves the `undici`/`undici8` packages off `process.versions.node`. + // Resolves the `undici` package off `process.versions.node`. // The browser never takes that path — `createRuntimeFetch` late-binds // the global fetch outside Node — so there is nothing to cover here. 'tests/undici.test.ts', diff --git a/packages/js-sdk/tests/undici.test.ts b/packages/js-sdk/tests/undici.test.ts index 1d32d9c039..d3de0c29ce 100644 --- a/packages/js-sdk/tests/undici.test.ts +++ b/packages/js-sdk/tests/undici.test.ts @@ -14,10 +14,10 @@ import { } from './foreignPlatformObjects' test.each([ - ['20.20.2', ['undici']], - ['22.18.0', ['undici']], - ['22.19.0', ['undici8', 'undici']], - ['24.0.0', ['undici8', 'undici']], + ['20.20.2', []], + ['22.18.0', []], + ['22.19.0', ['undici']], + ['24.0.0', ['undici']], ])('selects the packages supported by Node %s', (version, expected) => { expect(getUndiciPackageCandidates(version as string)).toEqual(expected) }) @@ -83,7 +83,6 @@ test('takes apart a Request the current global Request class disowns', async () const fetcher = await buildDispatchedFetch({ connections: 1, - inflightLimit: 0, loadUndici: async () => fakeUndici, }) await fetcher(request) @@ -128,7 +127,6 @@ test('adopts the body of a Request from another fetch implementation', async () const fetcher = await buildDispatchedFetch({ connections: 1, - inflightLimit: 0, loadUndici: async () => fakeUndici, }) await fetcher(foreignRequest as unknown as Request) @@ -195,7 +193,6 @@ test.skipIf(runtime !== 'node')( try { const fetcher = await buildDispatchedFetch({ connections: 1, - inflightLimit: 0, loadUndici: async () => ({ ...undici, // buildDispatchedFetch builds its own dispatcher; hand it the mock. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14cc5af7e2..e0299b5107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,8 +300,8 @@ importers: specifier: ^7.5.22 version: 7.5.22 undici: - specifier: ^7.30.0 - version: 7.30.0 + specifier: ^8.11.2 + version: 8.11.2 devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.18.8 @@ -360,10 +360,6 @@ importers: wrangler: specifier: ^4.142.0 version: 4.142.0(@types/node@22.20.5)(bufferutil@4.0.8)(utf-8-validate@6.0.3) - optionalDependencies: - undici8: - specifier: npm:undici@8.11.2 - version: undici@8.11.2 packages/python-sdk: dependencies: @@ -8033,8 +8029,7 @@ snapshots: undici@7.30.0: {} - undici@8.11.2: - optional: true + undici@8.11.2: {} unenv@2.0.0-rc.24: dependencies: From 94ceaa95bc3acbfd0e098947f8abef59d5a735e6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:27:44 +0000 Subject: [PATCH 2/3] chore: bump Node to 22.23.3 in codegen image and @types/node to ^22.20.5 Co-Authored-By: mish@e2b.dev --- codegen.Dockerfile | 2 +- pnpm-lock.yaml | 2 +- pnpm-workspace.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codegen.Dockerfile b/codegen.Dockerfile index 7e5a853d24..d15838d79e 100644 --- a/codegen.Dockerfile +++ b/codegen.Dockerfile @@ -24,7 +24,7 @@ ENV PATH="/go/bin:${PATH}" RUN pip install black==26.3.1 e2b-openapi-python-client==0.26.2 datamodel-code-generator==0.64.0 protoc-gen-connectrpc==0.11.1 protoc-gen-py==0.1.1 # Install Node.js (pinned to match .tool-versions) -ENV NODE_VERSION=22.18.0 +ENV NODE_VERSION=22.23.3 RUN ARCH=$(uname -m) && \ case "$ARCH" in \ x86_64) NODE_ARCH="x64" ;; \ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0299b5107..a2d78ccdeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: catalogs: default: '@types/node': - specifier: ^22.20.4 + specifier: ^22.20.5 version: 22.20.5 '@typescript/native': specifier: npm:typescript@^7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ed44623d31..c0131182fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,7 +39,7 @@ overrides: tar@<7.5.19: ^7.5.19 catalog: - '@types/node': ^22.20.4 + '@types/node': ^22.20.5 '@typescript/native': npm:typescript@^7.0.2 '@vitest/browser': ^4.1.11 '@vitest/browser-playwright': ^4.1.11 From 78371b27fc0da0a27a1f64342643e24b935c2051 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:32:28 +0000 Subject: [PATCH 3/3] refactor(js-sdk): load undici directly instead of iterating candidates Co-Authored-By: mish@e2b.dev --- packages/js-sdk/src/template/buildApi.ts | 7 +++--- packages/js-sdk/src/undici.ts | 27 ++++++++++-------------- packages/js-sdk/tests/undici.test.ts | 13 ++++-------- 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index d04bb62cc7..6a9d375d4b 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -183,10 +183,9 @@ async function putFileStream( ): Promise<{ ok: boolean; statusText: string }> { // Prefer undici's fetch: it honors the explicit Content-Length on stream // bodies on every runtime, while Deno's native fetch ignores the header and - // falls back to chunked. Where undici isn't resolvable (e.g. bundled apps), - // fall back to the global fetch — Node's and Bun's native fetch also honor - // Content-Length on stream bodies. loadUndici tries undici 8 first and - // falls back to undici 7 where 8 doesn't import (Bun). + // falls back to chunked. Where undici doesn't load (e.g. bundled apps), + // the global fetch is used — Node's and Bun's native fetch also honor + // Content-Length on stream bodies. const undici = await loadUndici() const fetchImpl = (undici?.fetch as typeof fetch | undefined) ?? fetch diff --git a/packages/js-sdk/src/undici.ts b/packages/js-sdk/src/undici.ts index 752dfd65a9..9dacebd5ea 100644 --- a/packages/js-sdk/src/undici.ts +++ b/packages/js-sdk/src/undici.ts @@ -20,26 +20,21 @@ export type UndiciModule = { fetch: unknown } -const UNDICI_8_MIN_NODE = '22.19.0' +const UNDICI_MIN_NODE = '22.19.0' -export function getUndiciPackageCandidates(nodeVersion: string): string[] { - if (compareVersions(nodeVersion, UNDICI_8_MIN_NODE) >= 0) { - return ['undici'] +export async function loadUndici( + nodeVersion = globalThis.process?.versions?.node +): Promise { + if (!nodeVersion || compareVersions(nodeVersion, UNDICI_MIN_NODE) < 0) { + return undefined } - return [] -} - -export async function loadUndici(): Promise { - for (const packageName of getUndiciPackageCandidates(process.versions.node)) { - try { - return await dynamicImport(packageName) - } catch { - // Fall back to the global fetch when undici cannot load. - } + try { + return await dynamicImport('undici') + } catch { + // Fall back to the global fetch when undici cannot load (e.g. bundled apps). + return undefined } - - return undefined } /** diff --git a/packages/js-sdk/tests/undici.test.ts b/packages/js-sdk/tests/undici.test.ts index d3de0c29ce..e63d607c7d 100644 --- a/packages/js-sdk/tests/undici.test.ts +++ b/packages/js-sdk/tests/undici.test.ts @@ -3,7 +3,6 @@ import { expect, test, vi } from 'vitest' import { buildDispatchedFetch, createRuntimeFetch, - getUndiciPackageCandidates, loadUndici, type UndiciModule, } from '../src/undici' @@ -13,14 +12,10 @@ import { foreignRequestClasses, } from './foreignPlatformObjects' -test.each([ - ['20.20.2', []], - ['22.18.0', []], - ['22.19.0', ['undici']], - ['24.0.0', ['undici']], -])('selects the packages supported by Node %s', (version, expected) => { - expect(getUndiciPackageCandidates(version as string)).toEqual(expected) -}) +test.each(['20.20.2', '22.18.0'])( + 'does not load undici on Node %s', + async (v) => expect(await loadUndici(v)).toBeUndefined() +) test('a stale awaiter of a failed build does not clobber a newer successful build', async () => { let rejectFirstBuild: ((err: Error) => void) | undefined