Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/large-desks-bind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/desktop': minor
'@e2b/desktop-python': minor
---

Add an `E2B` client to the Desktop SDKs that binds the connection configuration explicitly, so the API key and domain no longer have to come from the environment variables: `new E2B({ apiKey, domain }).Sandbox.create()` in JavaScript and `E2B(api_key=..., domain=...).Sandbox.create()` in Python. The client exposes the package's own `Sandbox` together with the core `Volume`, `Template` and `Secret` resources, per-call options still take precedence, and multiple clients are isolated from each other and from the env-configured top-level exports.
24 changes: 24 additions & 0 deletions packages/desktop-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ console.log('Stream URL:', desktop.stream.getUrl({ authKey }))
// await desktop.kill()
```

### 4. Bind the configuration to a client

The top-level `Sandbox` export reads its configuration from the environment variables. To use an explicit configuration — e.g. several API keys or domains in one process — create an `E2B` client and use the resources it exposes:

```javascript
import { E2B } from '@e2b/desktop'

const client = new E2B({ apiKey: 'e2b_***', domain: 'e2b.dev' })

const desktop = await client.Sandbox.create()
await desktop.stream.start()

// The core resources are bound to the client's configuration as well.
const volume = await client.Volume.create('my-volume')
const exists = await client.Template.exists('my-template')
await client.Secret.create('openai-api-key', 'sk-***')

// The classes can be destructured and used like the top-level ones.
const { Sandbox } = client
const paginator = Sandbox.list()
```

Per-call options still take precedence over the client's options, and clients are isolated from each other and from the env-configured top-level exports.

## Features

### Streaming desktop's screen
Expand Down
92 changes: 92 additions & 0 deletions packages/desktop-js/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ConnectionOpts, E2B as CoreE2B, Secret, Template, Volume } from 'e2b'

import { Sandbox } from './sandbox'

/**
* Connection options bound to an {@link E2B} client.
*
* Same as {@link ConnectionOpts} without `signal`, which cancels a single
* request and therefore can only be passed per call.
*/
export type E2BClientOpts = Omit<ConnectionOpts, 'signal'>

/**
* E2B client with an explicitly bound connection configuration.
*
* The resources exposed by the client ({@link E2B.Sandbox},
* {@link E2B.Volume}, {@link E2B.Template}, {@link E2B.Secret}) behave exactly
* like the top-level `Sandbox` / `Volume` / `Template` / `Secret` exports,
* except the options passed to the client are used as the defaults instead of
* the environment variables.
* Per-call options still take precedence over the client's options.
*
* Multiple clients are fully isolated from each other and from the top-level
* env-configured exports.
*
* @example
* ```ts
* import { E2B } from '@e2b/desktop'
*
* const client = new E2B({ apiKey: 'e2b_...', domain: 'e2b.dev' })
*
* const desktop = await client.Sandbox.create()
* await desktop.stream.start()
* ```
*/
export class E2B {
/**
* Desktop `Sandbox` class bound to this client's connection configuration.
*/
readonly Sandbox: typeof Sandbox

/**
* `Volume` class bound to this client's connection configuration.
*/
readonly Volume: typeof Volume

/**
* `Template` bound to this client's connection configuration. Both the
* builder (`client.Template()`) and the statics
* (`client.Template.build(...)`, `client.Template.exists(...)`, …) work like
* the top-level `Template`.
*/
readonly Template: typeof Template

/**
* `Secret` class bound to this client's connection configuration.
*/
readonly Secret: typeof Secret

/**
* Create a new client with the connection options bound to it.
*
* @param opts connection options used as the defaults for every call made
* through this client's resource classes.
*/
constructor(opts?: E2BClientOpts) {
// Options are copied so later mutations of the caller's object cannot
// change the bound configuration. `signal` is dropped rather than only
// typed away, since it cancels a single request and a caller passing a
// wider-typed object (or plain JS) would otherwise bind it to every call.
const boundOpts: E2BClientOpts = { ...(opts ?? {}) }
delete (boundOpts as ConnectionOpts).signal

if (boundOpts.headers) {
boundOpts.headers = { ...boundOpts.headers }
}
if (boundOpts.apiHeaders) {
boundOpts.apiHeaders = { ...boundOpts.apiHeaders }
}

this.Sandbox = class extends Sandbox {
protected static override readonly boundOpts = boundOpts
}

// The resources that are not specific to the Desktop are bound by the core
// client.
const core = new CoreE2B(boundOpts)
this.Volume = core.Volume
this.Template = core.Template
this.Secret = core.Secret
}
}
2 changes: 2 additions & 0 deletions packages/desktop-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from 'e2b'

export { E2B } from './client'
export type { E2BClientOpts } from './client'
export { Sandbox } from './sandbox'
254 changes: 254 additions & 0 deletions packages/desktop-js/tests/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import {
createServer,
IncomingMessage,
Server,
ServerResponse,
} from 'node:http'
import { AddressInfo } from 'node:net'
import { afterAll, assert, beforeAll, beforeEach, test, vi } from 'vitest'

import { E2B, Sandbox, Volume } from '../src'

const API_KEY_A = `e2b_${'a'.repeat(40)}`
const API_KEY_B = `e2b_${'b'.repeat(40)}`
const ENV_API_KEY = `e2b_${'e'.repeat(40)}`

const DOMAIN_A = 'client-a.test'
const DOMAIN_B = 'client-b.test'
const DOMAIN_ENV = 'env.test'

interface RecordedRequest {
path: string
apiKey?: string
headers: Record<string, string | string[] | undefined>
body: unknown
}

let requests: RecordedRequest[] = []
let server: Server
let apiUrl: string

const lastRequest = () => requests[requests.length - 1]
const apiKeys = () => requests.map((request) => request.apiKey)

async function handler(req: IncomingMessage, res: ServerResponse) {
const chunks: Buffer[] = []
for await (const chunk of req) {
chunks.push(chunk as Buffer)
}
const raw = Buffer.concat(chunks).toString()

requests.push({
path: req.url ?? '',
apiKey: (req.headers['x-api-key'] as string | undefined) ?? undefined,
headers: { ...req.headers },
body: raw ? JSON.parse(raw) : undefined,
})

const respond = (status: number, body: unknown) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}

const path = req.url ?? ''
if (path.startsWith('/sandboxes')) {
respond(201, {
sandboxID: 'test-sandbox-id',
templateID: 'desktop',
envdVersion: '0.2.4',
})
} else if (path.startsWith('/volumes')) {
respond(201, {
volumeID: 'test-volume-id',
name: 'test-volume',
token: 'test-volume-token',
})
} else if (path.startsWith('/templates/aliases/')) {
respond(200, { aliases: [], templateID: 'test-template-id' })
} else if (path.startsWith('/secrets')) {
respond(201, {
secretID: 'test-secret-id',
name: 'test-secret',
currentVersion: 1,
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
} else {
respond(404, { code: 404, message: 'not found' })
}
}

const envBackup: Record<string, string | undefined> = {}
const envOverrides: Record<string, string | undefined> = {
E2B_API_KEY: ENV_API_KEY,
E2B_DOMAIN: DOMAIN_ENV,
E2B_API_URL: undefined,
E2B_SANDBOX_URL: undefined,
E2B_DEBUG: undefined,
}

beforeAll(async () => {
for (const [key, value] of Object.entries(envOverrides)) {
envBackup[key] = process.env[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}

// Starting the desktop environment talks to envd inside the sandbox, which
// does not exist for a mocked API. The API calls that carry the bound
// configuration all happen before it.
vi.spyOn(
Sandbox.prototype as unknown as { _start: () => Promise<void> },
'_start'
).mockResolvedValue(undefined)

server = createServer(handler)
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})

afterAll(async () => {
vi.restoreAllMocks()

await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
)

for (const [key, value] of Object.entries(envBackup)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
})

beforeEach(() => {
requests = []
})

test('client.Sandbox.create uses the client config instead of env vars', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const sandbox = await client.Sandbox.create()

assert.deepEqual(apiKeys(), [API_KEY_A])
assert.equal(sandbox.sandboxDomain, DOMAIN_A)
// The desktop template is still the default.
assert.equal(
(lastRequest().body as { templateID: string }).templateID,
'desktop'
)
})

test('client.Sandbox instances keep the desktop API', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

assert.notEqual(client.Sandbox, Sandbox)
assert.isTrue(client.Sandbox.prototype instanceof Sandbox)

const sandbox = await client.Sandbox.create()

assert.instanceOf(sandbox, Sandbox)
assert.instanceOf(sandbox, client.Sandbox)
assert.isFunction(sandbox.screenshot)
assert.isFunction(sandbox.stream.start)
assert.equal(sandbox.display, ':0')
})

test('per-call options take precedence over the client config', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const sandbox = await client.Sandbox.create({
apiKey: API_KEY_B,
domain: DOMAIN_B,
})

assert.deepEqual(apiKeys(), [API_KEY_B])
assert.equal(sandbox.sandboxDomain, DOMAIN_B)
})

test('per-call options explicitly set to undefined keep the client config', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const sandbox = await client.Sandbox.create({
apiKey: undefined,
domain: undefined,
})

assert.deepEqual(apiKeys(), [API_KEY_A])
assert.equal(sandbox.sandboxDomain, DOMAIN_A)
})

test('two clients with different configs stay isolated', async () => {
const clientA = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })
const clientB = new E2B({ apiKey: API_KEY_B, domain: DOMAIN_B, apiUrl })

const sandboxA = await clientA.Sandbox.create()
const sandboxB = await clientB.Sandbox.create()

assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_B])
assert.equal(sandboxA.sandboxDomain, DOMAIN_A)
assert.equal(sandboxB.sandboxDomain, DOMAIN_B)
})

test('mutating the options object does not change the bound config', async () => {
const opts = { apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl }
const client = new E2B(opts)
opts.apiKey = API_KEY_B

await client.Sandbox.create()

assert.deepEqual(apiKeys(), [API_KEY_A])
})

test('mutating the header map does not change the bound config', async () => {
const headers = { 'X-Test': 'bound' }
const client = new E2B({
apiKey: API_KEY_A,
domain: DOMAIN_A,
apiUrl,
headers,
})
headers['X-Test'] = 'mutated'

await client.Sandbox.create()

assert.equal(lastRequest().headers['x-test'], 'bound')
})

test('client.Sandbox can be rebound to a variable', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })
const S = client.Sandbox

const sandbox = await S.create()

assert.deepEqual(apiKeys(), [API_KEY_A])
assert.equal(sandbox.sandboxDomain, DOMAIN_A)
})

test('the core resources are bound to the client config as well', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })

const volume = await client.Volume.create('test-volume')
assert.instanceOf(volume, Volume)

assert.isTrue(await client.Template.exists('test-template'))
await client.Secret.create('test-secret', 'value')

assert.deepEqual(apiKeys(), [API_KEY_A, API_KEY_A, API_KEY_A])
})

test('the top-level Sandbox keeps using the environment configuration', async () => {
const client = new E2B({ apiKey: API_KEY_A, domain: DOMAIN_A, apiUrl })
await client.Sandbox.create()

const sandbox = await Sandbox.create({ apiUrl })

assert.equal(lastRequest().apiKey, ENV_API_KEY)
assert.equal(sandbox.sandboxDomain, DOMAIN_ENV)
})
Loading
Loading