Skip to content

Commit f7a97e6

Browse files
enable http2 for js sdk envd rpc/api traffic (#1311)
Enables HTTP/2 for JS SDK sandbox envd traffic in Node by routing envd RPC/API requests through undici with an HTTP/2-enabled dispatcher. Non-Node runtimes continue to use global fetch. Management API and volume clients are unchanged. Requires bumping node from >=20 to >= 20.18.1 for undici
1 parent 20ea715 commit f7a97e6

6 files changed

Lines changed: 226 additions & 3 deletions

File tree

.changeset/empty-fans-wave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'e2b': patch
3+
---
4+
5+
Use HTTP/2 for JS SDK envd/api requests and require Node.js 20.18.1 or newer.

packages/js-sdk/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@
9696
"glob": "^11.1.0",
9797
"openapi-fetch": "^0.14.1",
9898
"platform": "^1.3.6",
99-
"tar": "^7.5.11"
99+
"tar": "^7.5.11",
100+
"undici": "^7.25.0"
100101
},
101102
"engines": {
102-
"node": ">=20"
103+
"node": ">=20.18.1"
103104
},
104105
"browserslist": [
105106
"defaults"

packages/js-sdk/src/envd/http2.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { dynamicRequire, runtime } from '../utils'
2+
3+
type Undici = typeof import('undici')
4+
type UndiciDispatcher = InstanceType<Undici['Agent']>
5+
type UndiciRequestInit = RequestInit & {
6+
dispatcher: UndiciDispatcher
7+
duplex?: 'half'
8+
}
9+
type EnvdFetchOptions = {
10+
connectionLimit?: number
11+
}
12+
13+
let envdFetch: typeof fetch | undefined
14+
let envdRpcFetch: typeof fetch | undefined
15+
16+
export function createEnvdFetchForRuntime(
17+
currentRuntime = runtime,
18+
options: EnvdFetchOptions = { connectionLimit: 1 }
19+
): typeof fetch {
20+
if (currentRuntime !== 'node') {
21+
return fetch
22+
}
23+
24+
const { Agent, fetch: undiciFetch } = dynamicRequire<Undici>('undici')
25+
const dispatcherOptions: { allowH2: true; connections?: number } = {
26+
allowH2: true,
27+
}
28+
if (options.connectionLimit !== undefined) {
29+
dispatcherOptions.connections = options.connectionLimit
30+
}
31+
const dispatcher = new Agent(dispatcherOptions)
32+
const fetchWithDispatcher = undiciFetch as unknown as (
33+
input: RequestInfo | URL,
34+
init?: UndiciRequestInit
35+
) => Promise<Response>
36+
37+
return ((input, init) => {
38+
const request = toRequestInput(input, init)
39+
40+
return fetchWithDispatcher(request.input, {
41+
...request.init,
42+
dispatcher,
43+
})
44+
}) as typeof fetch
45+
}
46+
47+
export function createEnvdFetch(): typeof fetch {
48+
if (envdFetch) {
49+
return envdFetch
50+
}
51+
52+
// Keep one origin connection for short envd REST calls. If ALPN falls back
53+
// to h1, this favors connection pressure over per-sandbox throughput.
54+
envdFetch = createEnvdFetchForRuntime(runtime)
55+
56+
return envdFetch
57+
}
58+
59+
export function createEnvdRpcFetch(): typeof fetch {
60+
if (envdRpcFetch) {
61+
return envdRpcFetch
62+
}
63+
64+
// RPC streams can stay open while follow-up RPCs run against the same
65+
// sandbox, so they cannot share the REST client's single-connection cap.
66+
envdRpcFetch = createEnvdFetchForRuntime(runtime, {})
67+
68+
return envdRpcFetch
69+
}
70+
71+
function toRequestInput(
72+
input: RequestInfo | URL,
73+
init?: RequestInit
74+
): { input: RequestInfo | URL; init?: RequestInit & { duplex?: 'half' } } {
75+
if (!(input instanceof Request)) {
76+
return { input, init }
77+
}
78+
79+
const requestInit: RequestInit & { duplex?: 'half' } = {
80+
body: input.body,
81+
cache: input.cache,
82+
credentials: input.credentials,
83+
headers: input.headers,
84+
integrity: input.integrity,
85+
keepalive: input.keepalive,
86+
method: input.method,
87+
mode: input.mode,
88+
redirect: input.redirect,
89+
referrer: input.referrer,
90+
referrerPolicy: input.referrerPolicy,
91+
signal: input.signal,
92+
...init,
93+
}
94+
95+
if (requestInit.body) {
96+
requestInit.duplex = 'half'
97+
}
98+
99+
return {
100+
input: input.url,
101+
init: requestInit,
102+
}
103+
}

packages/js-sdk/src/sandbox/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Username,
99
} from '../connectionConfig'
1010
import { EnvdApiClient, handleEnvdApiError } from '../envd/api'
11+
import { createEnvdFetch, createEnvdRpcFetch } from '../envd/http2'
1112
import { createRpcLogger } from '../logs'
1213
import { Commands, Pty } from './commands'
1314
import { Filesystem } from './filesystem'
@@ -150,6 +151,8 @@ export class Sandbox extends SandboxApi {
150151
'E2b-Sandbox-Id': this.sandboxId,
151152
'E2b-Sandbox-Port': this.envdPort.toString(),
152153
}
154+
const envdFetch = createEnvdFetch()
155+
const envdRpcFetch = createEnvdRpcFetch()
153156

154157
const rpcTransport = createConnectTransport({
155158
baseUrl: this.envdApiUrl,
@@ -179,7 +182,7 @@ export class Sandbox extends SandboxApi {
179182
redirect: 'follow',
180183
}
181184

182-
return fetch(url, options)
185+
return envdRpcFetch(url, options)
183186
},
184187
})
185188

@@ -195,6 +198,7 @@ export class Sandbox extends SandboxApi {
195198
? { 'X-Access-Token': this.envdAccessToken }
196199
: {}),
197200
},
201+
fetch: (request) => envdFetch(request),
198202
},
199203
{
200204
version: opts.envdVersion,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { afterEach, expect, test, vi } from 'vitest'
2+
3+
afterEach(() => {
4+
vi.restoreAllMocks()
5+
vi.resetModules()
6+
vi.doUnmock('../../src/utils')
7+
})
8+
9+
test('uses undici with HTTP/2 enabled in Node', async () => {
10+
const agents: Array<{ allowH2?: boolean; connections?: number }> = []
11+
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
12+
13+
class Agent {
14+
constructor(options: { allowH2?: boolean; connections?: number }) {
15+
agents.push(options)
16+
}
17+
}
18+
19+
const undiciFetch = vi.fn((input, init) => {
20+
requests.push({ init })
21+
return Promise.resolve(new Response('ok'))
22+
})
23+
24+
vi.doMock('../../src/utils', () => ({
25+
dynamicRequire: () => ({ Agent, fetch: undiciFetch }),
26+
runtime: 'node',
27+
}))
28+
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
29+
30+
const fetcher = createEnvdFetchForRuntime('node')
31+
const res = await fetcher('https://example.com/status')
32+
33+
expect(await res.text()).toBe('ok')
34+
expect(agents).toEqual([{ allowH2: true, connections: 1 }])
35+
expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent)
36+
})
37+
38+
test('passes Request objects to undici as URL plus init', async () => {
39+
const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
40+
41+
class Agent {}
42+
43+
const undiciFetch = vi.fn((input, init) => {
44+
requests.push({ input, init })
45+
return Promise.resolve(new Response('ok'))
46+
})
47+
48+
vi.doMock('../../src/utils', () => ({
49+
dynamicRequire: () => ({ Agent, fetch: undiciFetch }),
50+
runtime: 'node',
51+
}))
52+
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
53+
54+
const fetcher = createEnvdFetchForRuntime('node')
55+
const body = JSON.stringify({ ok: true })
56+
await fetcher(
57+
new Request('https://example.com/rpc', {
58+
body,
59+
headers: { 'content-type': 'application/json' },
60+
method: 'POST',
61+
})
62+
)
63+
64+
expect(requests[0].input).toBe('https://example.com/rpc')
65+
expect(requests[0].init?.method).toBe('POST')
66+
expect(requests[0].init?.headers).toBeInstanceOf(Headers)
67+
expect(requests[0].init?.body).toBeInstanceOf(ReadableStream)
68+
})
69+
70+
test('can create an uncapped dispatcher for RPC streams', async () => {
71+
const agents: Array<{ allowH2?: boolean; connections?: number }> = []
72+
73+
class Agent {
74+
constructor(options: { allowH2?: boolean; connections?: number }) {
75+
agents.push(options)
76+
}
77+
}
78+
79+
const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok')))
80+
81+
vi.doMock('../../src/utils', () => ({
82+
dynamicRequire: () => ({ Agent, fetch: undiciFetch }),
83+
runtime: 'node',
84+
}))
85+
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
86+
87+
const fetcher = createEnvdFetchForRuntime('node', {})
88+
await fetcher('https://example.com/rpc')
89+
90+
expect(agents).toEqual([{ allowH2: true }])
91+
})
92+
93+
test('uses global fetch outside Node', async () => {
94+
const fallbackFetch = vi.fn() as unknown as typeof fetch
95+
vi.stubGlobal('fetch', fallbackFetch)
96+
97+
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
98+
99+
expect(createEnvdFetchForRuntime('browser')).toBe(fallbackFetch)
100+
expect(createEnvdFetchForRuntime('vercel-edge')).toBe(fallbackFetch)
101+
})

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)