diff --git a/.changeset/empty-fans-wave.md b/.changeset/empty-fans-wave.md new file mode 100644 index 0000000000..e71bfd79c4 --- /dev/null +++ b/.changeset/empty-fans-wave.md @@ -0,0 +1,5 @@ +--- +'e2b': patch +--- + +Use HTTP/2 for JS SDK envd/api requests and require Node.js 20.18.1 or newer. diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 96a903812d..ed3e458124 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -96,10 +96,11 @@ "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", - "tar": "^7.5.11" + "tar": "^7.5.11", + "undici": "^7.25.0" }, "engines": { - "node": ">=20" + "node": ">=20.18.1" }, "browserslist": [ "defaults" diff --git a/packages/js-sdk/src/envd/http2.ts b/packages/js-sdk/src/envd/http2.ts new file mode 100644 index 0000000000..ff5816e2c7 --- /dev/null +++ b/packages/js-sdk/src/envd/http2.ts @@ -0,0 +1,103 @@ +import { dynamicRequire, runtime } from '../utils' + +type Undici = typeof import('undici') +type UndiciDispatcher = InstanceType +type UndiciRequestInit = RequestInit & { + dispatcher: UndiciDispatcher + duplex?: 'half' +} +type EnvdFetchOptions = { + connectionLimit?: number +} + +let envdFetch: typeof fetch | undefined +let envdRpcFetch: typeof fetch | undefined + +export function createEnvdFetchForRuntime( + currentRuntime = runtime, + options: EnvdFetchOptions = { connectionLimit: 1 } +): typeof fetch { + if (currentRuntime !== 'node') { + return fetch + } + + const { Agent, fetch: undiciFetch } = dynamicRequire('undici') + const dispatcherOptions: { allowH2: true; connections?: number } = { + allowH2: true, + } + if (options.connectionLimit !== undefined) { + dispatcherOptions.connections = options.connectionLimit + } + const dispatcher = new Agent(dispatcherOptions) + const fetchWithDispatcher = undiciFetch as unknown as ( + input: RequestInfo | URL, + init?: UndiciRequestInit + ) => Promise + + return ((input, init) => { + const request = toRequestInput(input, init) + + return fetchWithDispatcher(request.input, { + ...request.init, + dispatcher, + }) + }) as typeof fetch +} + +export function createEnvdFetch(): typeof fetch { + if (envdFetch) { + return envdFetch + } + + // Keep one origin connection for short envd REST calls. If ALPN falls back + // to h1, this favors connection pressure over per-sandbox throughput. + envdFetch = createEnvdFetchForRuntime(runtime) + + return envdFetch +} + +export function createEnvdRpcFetch(): typeof fetch { + if (envdRpcFetch) { + return envdRpcFetch + } + + // RPC streams can stay open while follow-up RPCs run against the same + // sandbox, so they cannot share the REST client's single-connection cap. + envdRpcFetch = createEnvdFetchForRuntime(runtime, {}) + + return envdRpcFetch +} + +function toRequestInput( + input: RequestInfo | URL, + init?: RequestInit +): { input: RequestInfo | URL; init?: RequestInit & { duplex?: 'half' } } { + if (!(input instanceof Request)) { + return { input, init } + } + + const requestInit: RequestInit & { duplex?: 'half' } = { + body: input.body, + cache: input.cache, + credentials: input.credentials, + headers: input.headers, + integrity: input.integrity, + keepalive: input.keepalive, + method: input.method, + mode: input.mode, + redirect: input.redirect, + referrer: input.referrer, + referrerPolicy: input.referrerPolicy, + signal: input.signal, + ...init, + } + + if (requestInit.body) { + requestInit.duplex = 'half' + } + + return { + input: input.url, + init: requestInit, + } +} diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index c223c6b6f4..0b2115dec0 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -8,6 +8,7 @@ import { Username, } from '../connectionConfig' import { EnvdApiClient, handleEnvdApiError } from '../envd/api' +import { createEnvdFetch, createEnvdRpcFetch } from '../envd/http2' import { createRpcLogger } from '../logs' import { Commands, Pty } from './commands' import { Filesystem } from './filesystem' @@ -150,6 +151,8 @@ export class Sandbox extends SandboxApi { 'E2b-Sandbox-Id': this.sandboxId, 'E2b-Sandbox-Port': this.envdPort.toString(), } + const envdFetch = createEnvdFetch() + const envdRpcFetch = createEnvdRpcFetch() const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, @@ -179,7 +182,7 @@ export class Sandbox extends SandboxApi { redirect: 'follow', } - return fetch(url, options) + return envdRpcFetch(url, options) }, }) @@ -195,6 +198,7 @@ export class Sandbox extends SandboxApi { ? { 'X-Access-Token': this.envdAccessToken } : {}), }, + fetch: (request) => envdFetch(request), }, { version: opts.envdVersion, diff --git a/packages/js-sdk/tests/envd/http2.test.ts b/packages/js-sdk/tests/envd/http2.test.ts new file mode 100644 index 0000000000..b550f9e181 --- /dev/null +++ b/packages/js-sdk/tests/envd/http2.test.ts @@ -0,0 +1,101 @@ +import { afterEach, expect, test, vi } from 'vitest' + +afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('../../src/utils') +}) + +test('uses undici with HTTP/2 enabled in Node', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + const undiciFetch = vi.fn((input, init) => { + requests.push({ init }) + return Promise.resolve(new Response('ok')) + }) + + vi.doMock('../../src/utils', () => ({ + dynamicRequire: () => ({ Agent, fetch: undiciFetch }), + runtime: 'node', + })) + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node') + const res = await fetcher('https://example.com/status') + + expect(await res.text()).toBe('ok') + expect(agents).toEqual([{ allowH2: true, connections: 1 }]) + expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent) +}) + +test('passes Request objects to undici as URL plus init', async () => { + const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [] + + class Agent {} + + const undiciFetch = vi.fn((input, init) => { + requests.push({ input, init }) + return Promise.resolve(new Response('ok')) + }) + + vi.doMock('../../src/utils', () => ({ + dynamicRequire: () => ({ Agent, fetch: undiciFetch }), + runtime: 'node', + })) + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node') + const body = JSON.stringify({ ok: true }) + await fetcher( + new Request('https://example.com/rpc', { + body, + headers: { 'content-type': 'application/json' }, + method: 'POST', + }) + ) + + expect(requests[0].input).toBe('https://example.com/rpc') + expect(requests[0].init?.method).toBe('POST') + expect(requests[0].init?.headers).toBeInstanceOf(Headers) + expect(requests[0].init?.body).toBeInstanceOf(ReadableStream) +}) + +test('can create an uncapped dispatcher for RPC streams', async () => { + const agents: Array<{ allowH2?: boolean; connections?: number }> = [] + + class Agent { + constructor(options: { allowH2?: boolean; connections?: number }) { + agents.push(options) + } + } + + const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok'))) + + vi.doMock('../../src/utils', () => ({ + dynamicRequire: () => ({ Agent, fetch: undiciFetch }), + runtime: 'node', + })) + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + const fetcher = createEnvdFetchForRuntime('node', {}) + await fetcher('https://example.com/rpc') + + expect(agents).toEqual([{ allowH2: true }]) +}) + +test('uses global fetch outside Node', async () => { + const fallbackFetch = vi.fn() as unknown as typeof fetch + vi.stubGlobal('fetch', fallbackFetch) + + const { createEnvdFetchForRuntime } = await import('../../src/envd/http2') + + expect(createEnvdFetchForRuntime('browser')).toBe(fallbackFetch) + expect(createEnvdFetchForRuntime('vercel-edge')).toBe(fallbackFetch) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3485c7a4b..bab886b334 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: tar: specifier: ^7.5.11 version: 7.5.12 + undici: + specifier: ^7.25.0 + version: 7.25.0 devDependencies: '@testing-library/react': specifier: ^16.2.0 @@ -3437,6 +3440,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7130,6 +7137,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.25.0: {} + universalify@0.1.2: {} until-async@3.0.2: {}