chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# generate output
dist
# compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
/test.md
logs.txt
deno.lock
__screenshots__
+1
View File
@@ -0,0 +1 @@
schema.gen.ts
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 FOUNDRYLABS, INC.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+68
View File
@@ -0,0 +1,68 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/e2b-dev/E2B/refs/heads/main/readme-assets/logo-white.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/e2b-dev/E2B/refs/heads/main/readme-assets/logo-black.png">
<img alt="E2B Logo" src="https://raw.githubusercontent.com/e2b-dev/E2B/refs/heads/main/readme-assets/logo-black.png" width="200">
</picture>
</p>
<h4 align="center">
<a href="https://www.npmjs.com/package/e2b">
<img alt="Last 1 month downloads for the JavaScript SDK" loading="lazy" width="200" height="20" decoding="async" data-nimg="1"
style="color:transparent;width:auto;height:100%" src="https://img.shields.io/npm/dm/e2b?label=NPM%20Downloads">
</a>
</h4>
<!---
<img width="100%" src="/readme-assets/preview.png" alt="Cover image">
--->
## What is E2B?
[E2B](https://www.e2b.dev/) is an open-source infrastructure that allows you to run AI-generated code in secure isolated sandboxes in the cloud. To start and control sandboxes, use our [JavaScript SDK](https://www.npmjs.com/package/e2b) or [Python SDK](https://pypi.org/project/e2b).
## Run your first Sandbox
### 1. Install SDK
```bash
npm i e2b
```
### 2. Get your E2B API key
1. Sign up to E2B [here](https://e2b.dev).
2. Get your API key [here](https://e2b.dev/dashboard?tab=keys).
3. Set environment variable with your API key
```
E2B_API_KEY=e2b_***
```
### 3. Start a sandbox and run commands
```ts
import Sandbox from 'e2b'
const sandbox = await Sandbox.create()
const result = await sandbox.commands.run('echo "Hello from E2B!"')
console.log(result.stdout) // Hello from E2B!
```
### 4. Code execution with Code Interpreter
If you need [`runCode()`](https://e2b.dev/docs/code-interpreting), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter):
```bash
npm i @e2b/code-interpreter
```
```ts
import { Sandbox } from '@e2b/code-interpreter'
const sandbox = await Sandbox.create()
const execution = await sandbox.runCode('x = 1; x += 1; x')
console.log(execution.text) // outputs 2
```
### 5. Check docs
Visit [E2B documentation](https://e2b.dev/docs).
### 6. E2B cookbook
Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks.
+11
View File
@@ -0,0 +1,11 @@
import { Sandbox } from './dist'
import { configDotenv } from 'dotenv'
configDotenv()
const sandbox = await Sandbox.create()
console.log('Sandbox created:', sandbox.sandboxId)
await sandbox.kill()
console.log('Sandbox killed')
+109
View File
@@ -0,0 +1,109 @@
{
"name": "e2b",
"version": "2.32.0",
"description": "E2B SDK that give agents cloud environments",
"homepage": "https://e2b.dev",
"license": "MIT",
"author": {
"name": "FoundryLabs, Inc.",
"email": "hello@e2b.dev",
"url": "https://e2b.dev"
},
"bugs": "https://github.com/e2b-dev/e2b/issues",
"repository": {
"type": "git",
"url": "https://github.com/e2b-dev/e2b",
"directory": "packages/js-sdk"
},
"publishConfig": {
"access": "public"
},
"sideEffects": false,
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"scripts": {
"prepublishOnly": "pnpm build",
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch",
"example": "tsx example.mts",
"test": "vitest run",
"generate": "npm-run-all generate:* && pnpm run format",
"generate:api": "python ./../../spec/remove_extra_tags.py sandboxes snapshots templates tags auth volumes && openapi-typescript ../../spec/openapi_generated.yml -x api_key --array-length --alphabetize --default-non-nullable false --output src/api/schema.gen.ts",
"generate:envd": "cd ../../spec/envd && buf generate --template buf-js.gen.yaml\n",
"generate:envd-api": "openapi-typescript ../../spec/envd/envd.yaml -x api_key --array-length --alphabetize --output src/envd/schema.gen.ts",
"generate:volume-api": "openapi-typescript ../../spec/openapi-volumecontent.yml -x api_key --array-length --alphabetize --output src/volume/schema.gen.ts",
"generate:mcp": "json2ts -i ./../../spec/mcp-server.json -o src/sandbox/mcp.d.ts --unreachableDefinitions --style.singleQuote --no-style.semi",
"check-deps": "knip",
"pretest": "npx playwright install --with-deps chromium",
"postPublish": "./scripts/post-publish.sh || true",
"test:bun": "bun test tests/runtimes/bun --env-file=.env",
"test:deno": "deno test tests/runtimes/deno/ --allow-net --allow-read --allow-env --unstable-sloppy-imports --trace-leaks",
"test:integration": "E2B_INTEGRATION_TEST=1 vitest run tests/integration/**",
"typecheck": "tsc --noEmit",
"lint": "oxlint --config ../../.oxlintrc.json src tests",
"format": "prettier --write src/ tests/ example.mts"
},
"devDependencies": {
"@testing-library/react": "^16.2.0",
"@types/node": "^20.19.19",
"@types/platform": "^1.3.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/browser": "^4.1.0",
"@vitest/browser-playwright": "^4.1.0",
"dotenv": "^16.4.5",
"json-schema-to-typescript": "^15.0.4",
"knip": "^5.43.6",
"msw": "^2.12.10",
"npm-run-all": "^4.1.5",
"openapi-typescript": "^7.9.1",
"playwright": "^1.55.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tsdown": "^0.22.3",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "^4.1.8",
"vitest-browser-react": "^2.2.0"
},
"files": [
"dist",
"README.md",
"package.json"
],
"keywords": [
"e2b",
"ai-agents",
"agents",
"ai",
"code-interpreter",
"sandbox",
"code",
"runtime",
"vm",
"nodejs",
"javascript",
"typescript"
],
"dependencies": {
"@bufbuild/protobuf": "^2.12.1",
"@connectrpc/connect": "^2.1.2",
"@connectrpc/connect-web": "^2.1.2",
"chalk": "^5.3.0",
"compare-versions": "^6.1.0",
"dockerfile-ast": "^0.7.1",
"glob": "^11.1.0",
"openapi-fetch": "^0.14.1",
"platform": "^1.3.6",
"tar": "^7.5.16",
"undici": "^7.28.0"
},
"engines": {
"node": ">=20.18.1 <21 || >=22"
},
"browserslist": [
"defaults"
]
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
npm pkg set 'name'='@e2b/sdk'
npm publish --no-git-checks
npm pkg set 'name'='e2b'
npm deprecate "@e2b/sdk@$(npm pkg get version | tr -d \")" "The package @e2b/sdk has been renamed to e2b. Please uninstall the old one and install the new by running following command: npm uninstall @e2b/sdk && npm install e2b"
+118
View File
@@ -0,0 +1,118 @@
import { runtime } from '../utils'
import { parseInflightLimitEnv, parsePositiveIntEnv } from './metadata'
import { limitConcurrency } from './inflight'
import {
loadUndici,
toUndiciRequestInput,
type UndiciModule,
type UndiciRequestInit,
} 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 so requests without a proxy keep sharing a
// single dispatcher while each distinct proxy URL gets its own.
const apiFetchers = new Map<string, typeof fetch>()
export function createApiFetch(proxy?: string): typeof fetch {
const key = proxy ?? ''
const cached = apiFetchers.get(key)
if (cached) {
return cached
}
const apiFetch = createApiFetchForRuntime(runtime, { proxy })
apiFetchers.set(key, apiFetch)
return apiFetch
}
export function createApiFetchForRuntime(
currentRuntime = runtime,
options: {
connectionLimit?: number
inflightLimit?: number
proxy?: string
loadUndici?: () => Promise<UndiciModule | undefined>
} = {}
): typeof fetch {
if (currentRuntime !== 'node') {
return fetch
}
let fetcherPromise: Promise<typeof fetch> | undefined
return (async (input, init) => {
fetcherPromise ??= buildApiFetcher(options)
const fetcher = await fetcherPromise
return fetcher(input, init)
}) as typeof fetch
}
async function buildApiFetcher(options: {
connectionLimit?: number
inflightLimit?: number
proxy?: string
loadUndici?: () => Promise<UndiciModule | undefined>
}): Promise<typeof fetch> {
const undici = await (options.loadUndici ?? loadUndici)()
const inflightLimit = options.inflightLimit ?? getApiInflightLimit()
if (!undici) {
return limitConcurrency(fetch, inflightLimit)
}
const { Agent, ProxyAgent, fetch: undiciFetch } = undici
const connections = options.connectionLimit ?? getApiConnectionLimit()
const dispatcher = options.proxy
? new ProxyAgent({
uri: options.proxy,
allowH2: true,
connections,
})
: new Agent({
allowH2: true,
connections,
})
const fetchWithDispatcher = undiciFetch as unknown as (
input: RequestInfo | URL,
init?: UndiciRequestInit
) => Promise<Response>
const wrapped: typeof fetch = ((input, init) => {
const request = toUndiciRequestInput(input, init)
return fetchWithDispatcher(request.input, {
...request.init,
dispatcher,
})
}) as typeof fetch
return limitConcurrency(wrapped, inflightLimit)
}
export function getApiConnectionLimit(): number {
return parsePositiveIntEnv(
'E2B_API_CONNECTIONS',
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 {@link DEFAULT_API_INFLIGHT_LIMIT} ({@link 1000}). 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
)
}
+117
View File
@@ -0,0 +1,117 @@
import createClient, { FetchResponse } from 'openapi-fetch'
import type { components, paths } from './schema.gen'
import { defaultHeaders } from './metadata'
import { createApiFetch } from './http2'
import { ConnectionConfig } from '../connectionConfig'
import { AuthenticationError, RateLimitError, SandboxError } from '../errors'
import { createApiLogger } from '../logs'
const API_KEY_PATTERN = /^e2b_[0-9a-f]+$/
const API_KEY_EXAMPLE = `e2b_${'0'.repeat(40)}`
/**
* Validates that an E2B API key has the expected `e2b_` prefix followed by
* hex characters. Throws `AuthenticationError` otherwise.
*/
export function validateApiKey(apiKey: string): void {
if (!API_KEY_PATTERN.test(apiKey)) {
throw new AuthenticationError(
`Invalid API key format: expected "e2b_" followed by hex characters (e.g. "${API_KEY_EXAMPLE}"). ` +
'Visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key.'
)
}
}
export function handleApiError(
response: FetchResponse<any, any, any>,
errorClass: new (
message: string,
stackTrace?: string
) => Error = SandboxError,
stackTrace?: string
): Error | undefined {
// openapi-fetch leaves `error` undefined for non-2xx responses with
// Content-Length: 0, so check the status instead
if (response.response.ok) {
return
}
if (response.response.status === 401) {
const message = 'Unauthorized, please check your credentials.'
const content = response.error?.message ?? response.error
if (content) {
return new AuthenticationError(`${message} - ${content}`)
}
return new AuthenticationError(message)
}
if (response.response.status === 429) {
const message = 'Rate limit exceeded, please try again later'
const content = response.error?.message ?? response.error
if (content) {
return new RateLimitError(`${message} - ${content}`)
}
return new RateLimitError(message)
}
const message =
response.error?.message || response.error || response.response.statusText
return new errorClass(`${response.response.status}: ${message}`, stackTrace)
}
/**
* Client for interacting with the E2B API.
*/
class ApiClient {
readonly api: ReturnType<typeof createClient<paths>>
constructor(
config: ConnectionConfig,
opts: {
requireApiKey?: boolean
} = {}
) {
if ((opts.requireApiKey ?? true) && !config.apiKey) {
throw new AuthenticationError(
'API key is required, please visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key. ' +
'You can either set the environment variable `E2B_API_KEY` ' +
"or you can pass it directly to the sandbox like Sandbox.create({ apiKey: 'e2b_...' })"
)
}
if (config.apiKey && config.validateApiKey) {
validateApiKey(config.apiKey)
}
this.api = createClient<paths>({
baseUrl: config.apiUrl,
fetch: createApiFetch(config.proxy),
// In HTTP 1.1, all connections are considered persistent unless declared otherwise
// keepalive: true,
headers: {
...defaultHeaders,
...(config.apiKey && { 'X-API-KEY': config.apiKey }),
...(config.accessToken && {
Authorization: `Bearer ${config.accessToken}`,
}),
...config.headers,
},
querySerializer: {
array: {
style: 'form',
explode: false,
},
},
})
if (config.logger) {
this.api.use(createApiLogger(config.logger))
}
}
}
export type { components, paths }
export { ApiClient }
+78
View File
@@ -0,0 +1,78 @@
/**
* 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) => {
const signal =
init?.signal ?? (input instanceof Request ? input.signal : undefined)
const release = await sem.acquire(signal)
try {
return await fetcher(input, init)
} finally {
release()
}
}) as typeof fetch
}
+85
View File
@@ -0,0 +1,85 @@
import platform from 'platform'
import { version } from '../../package.json'
import { runtime, runtimeVersion } from '../utils'
export { version }
export const defaultHeaders = {
browser: (typeof window !== 'undefined' && platform.name) || 'unknown',
lang: 'js',
lang_version: runtimeVersion,
package_version: version,
publisher: 'e2b',
sdk_runtime: runtime,
system: platform.os?.family || 'unknown',
}
export function getEnvVar(name: string) {
if (runtime === 'deno') {
// @ts-ignore
return Deno.env.get(name)
}
if (typeof process === 'undefined') {
return ''
}
return process.env[name]
}
/**
* Parse an env var as a base-10 integer, falling back to `defaultValue` when
* the env var is unset. Throws on non-integer input rather than silently
* falling back so misconfiguration is surfaced loudly.
*/
export function parseIntEnv(name: string, defaultValue: number): number {
const raw = getEnvVar(name)
if (!raw) return defaultValue
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) {
throw new Error(
`Invalid ${name}=${JSON.stringify(raw)}: expected an integer.`
)
}
return parsed
}
/**
* Parse an env var that must be a positive integer (>= 1). Throws on
* non-positive or non-integer input.
*/
export function parsePositiveIntEnv(
name: string,
defaultValue: number
): number {
const parsed = parseIntEnv(name, defaultValue)
if (parsed < 1) {
throw new Error(`Invalid ${name}=${parsed}: expected a positive integer.`)
}
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
}
File diff suppressed because it is too large Load Diff
+498
View File
@@ -0,0 +1,498 @@
import { Logger } from './logs'
import { getEnvVar, version } from './api/metadata'
import { runtime } from './utils'
// Remove once all deployments support sandbox subdomains
const supportedDomains = ['e2b.app', 'e2b.dev', 'e2b.pro', 'e2b-staging.dev']
export const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds
export const DEFAULT_SANDBOX_TIMEOUT_MS = 300_000 // 300 seconds
export const KEEPALIVE_PING_INTERVAL_SEC = 50 // 50 seconds
export const KEEPALIVE_PING_HEADER = 'Keepalive-Ping-Interval'
/**
* Connection options for requests to the API.
*/
export interface ConnectionOpts {
/**
* E2B API key to use for authentication.
*
* @default E2B_API_KEY // environment variable
*/
apiKey?: string
/**
* Whether to validate the format of the E2B API key on the client side.
* Disable this when your deployment issues API keys that don't match the
* default `e2b_` format.
*
* @default E2B_VALIDATE_API_KEY // environment variable or `true`
*/
validateApiKey?: boolean
/**
* E2B access token to use for authentication.
*
* @deprecated Pass the token through `apiHeaders` instead, e.g.
* `apiHeaders: { Authorization: \`Bearer ${token}\` }`.
*
* @default E2B_ACCESS_TOKEN // environment variable
*/
accessToken?: string
/**
* Domain to use for the API.
*
* @default E2B_DOMAIN // environment variable or `e2b.app`
*/
domain?: string
/**
* API Url to use for the API.
* @internal
* @default E2B_API_URL // environment variable or `https://api.${domain}`
*/
apiUrl?: string
/**
* Sandbox Url to use for the API.
* @internal
* @default E2B_SANDBOX_URL // environment variable, `https://sandbox.${domain}`
*/
sandboxUrl?: string
/**
* If true the SDK starts in the debug mode and connects to the local envd API server.
* @internal
* @default E2B_DEBUG // environment variable or `false`
*/
debug?: boolean
/**
* Timeout for requests to the API in **milliseconds**.
*
* @default 60_000 // 60 seconds
*/
requestTimeoutMs?: number
/**
* Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}.
*/
logger?: Logger
/**
* Additional headers to send with the request.
*
* @deprecated Use `apiHeaders` instead.
*/
headers?: Record<string, string>
/**
* Proxy URL to use for requests. In case of a sandbox it applies to all
* requests made to the returned sandbox.
*
* @example 'http://user:pass@127.0.0.1:8080'
*/
proxy?: string
/**
* Additional headers to send with E2B API requests.
*/
apiHeaders?: Record<string, string>
/**
* An optional `AbortSignal` that can be used to cancel the in-flight request.
* When the signal is aborted, the underlying `fetch` is aborted and the
* returned promise rejects with an `AbortError`.
*/
signal?: AbortSignal
}
/**
* Options accepted by `ConnectionConfig`.
*
* @deprecated Use `ConnectionOpts` instead.
*/
export type ConnectionConfigOpts = ConnectionOpts
/**
* Build an `AbortSignal` that combines an optional request-timeout signal
* (via `AbortSignal.timeout`) with an optional user-provided signal.
*
* Returns `undefined` when neither input would produce a signal.
*
* @internal
*/
export function buildRequestSignal(
requestTimeoutMs: number | undefined,
userSignal: AbortSignal | undefined
): AbortSignal | undefined {
// `0` (and `undefined`) disable the request timeout.
const timeoutSignal = requestTimeoutMs
? AbortSignal.timeout(requestTimeoutMs)
: undefined
if (timeoutSignal && userSignal) {
return AbortSignal.any([timeoutSignal, userSignal])
}
return timeoutSignal ?? userSignal
}
/**
* Set up an internal `AbortController` for a streaming request.
*
* Until `clearStartTimeout` is called, the controller aborts when either
* - the optional user signal aborts, or
* - the optional request timeout elapses (used to bound the initial
* handshake; long-lived streams should call `clearStartTimeout` once
* the handshake succeeds).
*
* The user-signal listener stays attached for the full stream lifetime
* so the caller can cancel a long-running stream by aborting the signal.
*
* `cleanup` is idempotent and detaches the listener, clears the handshake
* timer (if still pending), and aborts the controller. Call it when the
* stream finishes or when startup fails.
*
* @internal
*/
export function setupRequestController(
requestTimeoutMs: number | undefined,
userSignal: AbortSignal | undefined
): {
controller: AbortController
clearStartTimeout: () => void
cleanup: () => void
} {
const controller = new AbortController()
const onUserAbort = () => controller.abort(userSignal?.reason)
if (userSignal) {
if (userSignal.aborted) {
controller.abort(userSignal.reason)
} else {
userSignal.addEventListener('abort', onUserAbort, { once: true })
}
}
let reqTimeout: ReturnType<typeof setTimeout> | undefined = requestTimeoutMs
? setTimeout(
() =>
controller.abort(
new DOMException(
`Request handshake timed out after ${requestTimeoutMs}ms`,
'TimeoutError'
)
),
requestTimeoutMs
)
: undefined
const clearStartTimeout = () => {
if (reqTimeout) {
clearTimeout(reqTimeout)
reqTimeout = undefined
}
}
let cleaned = false
const cleanup = () => {
if (cleaned) return
cleaned = true
userSignal?.removeEventListener('abort', onUserAbort)
clearStartTimeout()
controller.abort()
}
return { controller, clearStartTimeout, cleanup }
}
/**
* Create a resettable idle-timeout that aborts `controller` when no progress is
* made within `idleTimeoutMs`. `arm` (re)starts the timer; call it on each
* chunk. `clear` stops it. `0`/`undefined` disables it (both are no-ops).
*
* @internal
*/
function createIdleAbort(
controller: AbortController,
idleTimeoutMs: number | undefined,
label: string
): { arm: () => void; clear: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined
const clear = () => {
if (timer) {
clearTimeout(timer)
timer = undefined
}
}
const arm = () => {
if (!idleTimeoutMs) return
clear()
timer = setTimeout(
() =>
controller.abort(
new DOMException(
`${label} idle for ${idleTimeoutMs}ms`,
'TimeoutError'
)
),
idleTimeoutMs
)
}
return { arm, clear }
}
/**
* Wrap a streaming response body so its pooled connection is released when the
* stream is fully read, cancelled, errors, or stays idle for too long.
*
* Clears the handshake timeout from {@link setupRequestController} (so
* consuming the body isn't killed by it) and replaces it with an idle-read
* timeout that bounds only the wire: it's armed while waiting on a network
* read and cleared the moment a chunk arrives, so a slow or paused consumer
* never trips it (only a server that stops sending mid-stream does). On expiry
* it aborts `controller`, tearing down the fetch and releasing the connection.
* Pass `0`/`undefined` to disable. Call once the handshake has succeeded.
*
* @internal
*/
export function wrapStreamWithConnectionCleanup(
body: ReadableStream<Uint8Array> | null,
{
clearStartTimeout,
cleanup,
controller,
idleTimeoutMs,
}: {
clearStartTimeout: () => void
cleanup: () => void
controller: AbortController
idleTimeoutMs?: number
}
): ReadableStream<Uint8Array> {
clearStartTimeout()
if (!body) {
cleanup()
return new Blob([]).stream()
}
const reader = body.getReader()
const idle = createIdleAbort(controller, idleTimeoutMs, 'Stream')
// Idempotent: safe to call from multiple stream callbacks.
const release = () => {
idle.clear()
cleanup()
}
return new ReadableStream<Uint8Array>({
async pull(streamController) {
// Bound only the wire: arm before reading from the network and clear the
// moment a chunk (or EOF) arrives, so a slow or paused consumer never
// counts against the idle timeout. A consumer that holds the stream but
// stops reading is never pulled here, so nothing arms—that case is
// reclaimed server-side, not by this timer.
idle.arm()
try {
const { done, value } = await reader.read()
idle.clear()
if (done) {
release()
streamController.close()
} else {
streamController.enqueue(value)
}
} catch (err) {
release()
streamController.error(err)
}
},
async cancel(reason) {
try {
await reader.cancel(reason)
} finally {
release()
}
},
})
}
/**
* Configuration for connecting to the API.
*/
export class ConnectionConfig {
public static envdPort = 49983
private static integration?: string
private static readonly sdkUserAgentPrefix = 'e2b-js-sdk/'
private static buildUserAgent() {
const userAgentParts = [`${ConnectionConfig.sdkUserAgentPrefix}${version}`]
if (ConnectionConfig.integration) {
userAgentParts.push(ConnectionConfig.integration)
}
return userAgentParts.join(' ')
}
/**
* Set the `User-Agent` on `headers`: an explicitly provided value always
* wins; otherwise the SDK-built one, tagged with the current integration.
*
* An SDK-built value carried over from an earlier config (configs are
* rebuilt via `new ConnectionConfig({ ...config })`) is recognized by its
* prefix and rebuilt, so it stays in sync with the current integration.
*/
private static applyUserAgent(headers: Record<string, string>) {
const userAgent = headers['User-Agent']
if (
userAgent !== undefined &&
!userAgent.startsWith(ConnectionConfig.sdkUserAgentPrefix)
) {
return
}
headers['User-Agent'] = ConnectionConfig.buildUserAgent()
}
/**
* Identify traffic from an integration wrapping the E2B SDK by appending
* `integration` (e.g. `'e2b-code-interpreter/0.1.0'`) to the `User-Agent`
* header of every request.
*
* Call once at startup, before any `ConnectionConfig` is constructed —
* configs read the value at construction time. Pass `undefined` to clear.
*
* @internal
* @hidden
* @hide
*/
static setIntegration(integration: string | undefined) {
ConnectionConfig.integration = integration
}
readonly debug: boolean
readonly domain: string
readonly apiUrl: string
readonly sandboxUrl?: string
readonly logger?: Logger
readonly requestTimeoutMs: number
readonly apiKey?: string
readonly validateApiKey: boolean
/**
* @deprecated Pass the token through `apiHeaders` instead.
*/
readonly accessToken?: string
readonly headers?: Record<string, string>
readonly proxy?: string
constructor(opts?: ConnectionOpts) {
this.apiKey = opts?.apiKey || ConnectionConfig.apiKey
this.validateApiKey =
opts?.validateApiKey ?? ConnectionConfig.validateApiKey
this.debug = opts?.debug ?? ConnectionConfig.debug
this.domain = opts?.domain || ConnectionConfig.domain
this.accessToken = opts?.accessToken || ConnectionConfig.accessToken
this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
this.logger = opts?.logger
this.headers = { ...(opts?.headers ?? {}), ...(opts?.apiHeaders ?? {}) }
ConnectionConfig.applyUserAgent(this.headers)
this.proxy = opts?.proxy
this.apiUrl =
opts?.apiUrl ||
ConnectionConfig.apiUrl ||
(this.debug ? 'http://localhost:3000' : `https://api.${this.domain}`)
this.sandboxUrl = opts?.sandboxUrl || ConnectionConfig.sandboxUrl
}
private static get domain() {
return getEnvVar('E2B_DOMAIN') || 'e2b.app'
}
private static get apiUrl() {
return getEnvVar('E2B_API_URL')
}
private static get sandboxUrl() {
return getEnvVar('E2B_SANDBOX_URL')
}
private static get debug() {
return (getEnvVar('E2B_DEBUG') || 'false').toLowerCase() === 'true'
}
private static get apiKey() {
return getEnvVar('E2B_API_KEY')
}
private static get validateApiKey() {
return (
(getEnvVar('E2B_VALIDATE_API_KEY') || 'true').toLowerCase() !== 'false'
)
}
private static get accessToken() {
return getEnvVar('E2B_ACCESS_TOKEN')
}
getSignal(requestTimeoutMs?: number, signal?: AbortSignal) {
return buildRequestSignal(requestTimeoutMs ?? this.requestTimeoutMs, signal)
}
getSandboxUrl(
sandboxId: string,
opts: { sandboxDomain: string; envdPort: number }
) {
if (this.sandboxUrl) {
return this.sandboxUrl
}
if (this.debug) {
return `http://${this.getHost(sandboxId, opts.envdPort, opts.sandboxDomain)}`
}
const sandboxDomain = opts.sandboxDomain ?? this.domain
// The stable sandbox host is only guaranteed for E2B prod; the various other hosted domains may not serve sandbox.<domain> yet and will follow up once those are updated.
// Issue with cors from browser so holding off on using in browser as well.
if (runtime !== 'browser' && supportedDomains.includes(sandboxDomain)) {
return `https://sandbox.${sandboxDomain}`
}
return `https://${this.getHost(sandboxId, opts.envdPort, sandboxDomain)}`
}
getSandboxDirectUrl(
sandboxId: string,
opts: { sandboxDomain: string; envdPort: number }
) {
if (this.sandboxUrl) {
return this.sandboxUrl
}
if (this.debug) {
return `http://${this.getHost(sandboxId, opts.envdPort, opts.sandboxDomain)}`
}
return `https://${this.getHost(sandboxId, opts.envdPort, opts.sandboxDomain)}`
}
getHost(sandboxId: string, port: number, sandboxDomain: string) {
if (this.debug) {
return `localhost:${port}`
}
return `${port}-${sandboxId}.${sandboxDomain ?? this.domain}`
}
}
/**
* User used for the operation in the sandbox.
*/
export const defaultUsername: Username = 'user'
export type Username = string
+231
View File
@@ -0,0 +1,231 @@
import createClient from 'openapi-fetch'
import type { components, paths } from './schema.gen'
import { ConnectionConfig } from '../connectionConfig'
import { createApiLogger } from '../logs'
import {
SandboxError,
InvalidArgumentError,
NotFoundError,
NotEnoughSpaceError,
SandboxNotFoundError,
formatSandboxTimeoutError,
AuthenticationError,
RateLimitError,
TimeoutError,
} from '../errors'
import { StartResponse, ConnectResponse } from './process/process_pb'
import { Code, ConnectError } from '@connectrpc/connect'
import { WatchDirResponse } from './filesystem/filesystem_pb'
import { isConnectionTerminatedMessage, SandboxHealthCheck } from './rpc'
type ApiError = { message?: string } | string
const DEFAULT_ERROR_MAP: Record<number, (message: string) => Error> = {
400: (message) => new InvalidArgumentError(message),
401: (message) => new AuthenticationError(message),
404: (message) => new NotFoundError(message),
429: (message) =>
new RateLimitError(`${message}: The requests are being rate limited.`),
502: formatSandboxTimeoutError,
507: (message) => new NotEnoughSpaceError(message),
}
const HEALTH_CHECK_TIMEOUT_MS = 5_000
/**
* Probes the sandbox's envd health endpoint.
*
* @param envdApi - The envd API client of the sandbox.
* @returns `true` if the sandbox is running, `false` if it is not, `undefined` if its state could not be determined.
*/
export async function checkSandboxHealth(
envdApi: EnvdApiClient
): Promise<boolean | undefined> {
try {
const res = await envdApi.api.GET('/health', {
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
})
if (res.response.status === 502) {
return false
}
if (res.response.ok) {
return true
}
return undefined
} catch {
return undefined
}
}
/**
* Handles transport-level fetch failures from envd API calls. When the connection was
* dropped mid-request, probes the sandbox health to tell apart the sandbox being killed
* from a transient network failure (e.g. a load balancer dropping the connection).
*
* @param err - The caught error, expected to be a fetch transport failure.
* @param checkHealth - Probe returning whether the sandbox is running, or `undefined` when unknown.
* @returns A `TimeoutError` when the connection was terminated mid-request and the sandbox is confirmed gone, or the original error otherwise.
*/
export async function handleEnvdApiFetchError(
err: unknown,
checkHealth?: SandboxHealthCheck
): Promise<Error> {
// A connection dropped mid-body surfaces as a fetch failure whose message varies
// by runtime (e.g. undici's 'terminated'); match every known variant.
if (err instanceof Error && isConnectionTerminatedMessage(err.message)) {
const running = checkHealth
? await checkHealth().catch(() => undefined)
: undefined
if (running === false) {
return new TimeoutError(
`${err.message}: The sandbox was killed or reached its end of life while the request was in flight.`
)
}
}
return err as Error
}
/**
* Handles errors from envd API responses by mapping HTTP status codes to specific error types.
*
* @param res - The API response object containing an optional error and the raw `Response`.
* @param errorMap - Optional map of HTTP status codes to error factory functions that override the defaults.
* @returns The corresponding `Error` instance if an error is present, or `undefined` if the response is successful.
*/
export async function handleEnvdApiError(
res: {
error?: ApiError
response: Response
},
errorMap?: Record<number, (message: string) => Error>
) {
// openapi-fetch leaves `error` empty for non-2xx responses without content
// (undefined for Content-Length: 0, '' for an empty body without the
// header), so check the status instead
if (res.response.ok) {
return
}
let message =
(typeof res.error === 'string' ? res.error : res.error?.message) ?? ''
// openapi-fetch consumes the body when parsing the error, except for
// responses without content
if (!message && !res.response.bodyUsed) {
try {
message = await res.response.text()
} catch {
// ignore unreadable bodies
}
}
message = message || res.response.statusText
// Check if a custom error mapping is provided for this error code
if (errorMap && res.response.status in errorMap) {
return errorMap[res.response.status]?.(message)
}
// Check if there is a default error mapping for this error code
if (res.response.status in DEFAULT_ERROR_MAP) {
return DEFAULT_ERROR_MAP[res.response.status]?.(message)
}
// Fallback to a generic SandboxError if no specific mapping is found
return new SandboxError(`${res.response.status}: ${message}`)
}
export async function handleProcessStartEvent(
events: AsyncIterable<StartResponse | ConnectResponse>
) {
let startEvent: StartResponse | ConnectResponse
try {
startEvent = (await events[Symbol.asyncIterator]().next()).value
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.Unavailable) {
throw new SandboxNotFoundError(
'Sandbox is probably not running anymore'
)
}
}
throw err
}
if (startEvent.event?.event.case !== 'start') {
throw new Error('Expected start event')
}
return startEvent.event.event.value.pid
}
export async function handleWatchDirStartEvent(
events: AsyncIterable<WatchDirResponse>
) {
let startEvent: WatchDirResponse
try {
startEvent = (await events[Symbol.asyncIterator]().next()).value
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.Unavailable) {
throw new SandboxNotFoundError(
'Sandbox is probably not running anymore'
)
}
}
throw err
}
if (startEvent.event?.case !== 'start') {
throw new Error('Expected start event')
}
return startEvent.event.value
}
class EnvdApiClient {
readonly api: ReturnType<typeof createClient<paths>>
readonly version: string
constructor(
config: Pick<ConnectionConfig, 'apiUrl' | 'logger'> & {
/**
* Sandbox-scoped envd access token, sent as the `X-Access-Token` header.
*/
envdAccessToken?: string
fetch?: (request: Request) => ReturnType<typeof fetch>
headers?: Record<string, string>
},
metadata: {
version: string
}
) {
this.api = createClient({
baseUrl: config.apiUrl,
fetch: config?.fetch,
headers: {
...config?.headers,
...(config.envdAccessToken && {
'X-Access-Token': config.envdAccessToken,
}),
},
// In HTTP 1.1, all connections are considered persistent unless declared otherwise
// keepalive: true,
})
this.version = metadata.version
if (config.logger) {
this.api.use(createApiLogger(config.logger))
}
}
}
export type { components, paths }
export { EnvdApiClient }
@@ -0,0 +1,118 @@
// @generated by protoc-gen-connect-es v1.6.1 with parameter "target=ts"
// @generated from file filesystem/filesystem.proto (package filesystem, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import {
CreateWatcherRequest,
CreateWatcherResponse,
GetWatcherEventsRequest,
GetWatcherEventsResponse,
ListDirRequest,
ListDirResponse,
MakeDirRequest,
MakeDirResponse,
MoveRequest,
MoveResponse,
RemoveRequest,
RemoveResponse,
RemoveWatcherRequest,
RemoveWatcherResponse,
StatRequest,
StatResponse,
WatchDirRequest,
WatchDirResponse,
} from './filesystem_pb.js'
import { MethodKind } from '@bufbuild/protobuf'
/**
* @generated from service filesystem.Filesystem
*/
export const Filesystem = {
typeName: 'filesystem.Filesystem',
methods: {
/**
* @generated from rpc filesystem.Filesystem.Stat
*/
stat: {
name: 'Stat',
I: StatRequest,
O: StatResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.MakeDir
*/
makeDir: {
name: 'MakeDir',
I: MakeDirRequest,
O: MakeDirResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.Move
*/
move: {
name: 'Move',
I: MoveRequest,
O: MoveResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.ListDir
*/
listDir: {
name: 'ListDir',
I: ListDirRequest,
O: ListDirResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.Remove
*/
remove: {
name: 'Remove',
I: RemoveRequest,
O: RemoveResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.WatchDir
*/
watchDir: {
name: 'WatchDir',
I: WatchDirRequest,
O: WatchDirResponse,
kind: MethodKind.ServerStreaming,
},
/**
* Non-streaming versions of WatchDir
*
* @generated from rpc filesystem.Filesystem.CreateWatcher
*/
createWatcher: {
name: 'CreateWatcher',
I: CreateWatcherRequest,
O: CreateWatcherResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.GetWatcherEvents
*/
getWatcherEvents: {
name: 'GetWatcherEvents',
I: GetWatcherEventsRequest,
O: GetWatcherEventsResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc filesystem.Filesystem.RemoveWatcher
*/
removeWatcher: {
name: 'RemoveWatcher',
I: RemoveWatcherRequest,
O: RemoveWatcherResponse,
kind: MethodKind.Unary,
},
},
} as const
@@ -0,0 +1,704 @@
// @generated by protoc-gen-es v2.6.2 with parameter "target=ts"
// @generated from file filesystem/filesystem.proto (package filesystem, syntax proto3)
/* eslint-disable */
import type {
GenEnum,
GenFile,
GenMessage,
GenService,
} from '@bufbuild/protobuf/codegenv2'
import {
enumDesc,
fileDesc,
messageDesc,
serviceDesc,
} from '@bufbuild/protobuf/codegenv2'
import type { Timestamp } from '@bufbuild/protobuf/wkt'
import { file_google_protobuf_timestamp } from '@bufbuild/protobuf/wkt'
import type { Message } from '@bufbuild/protobuf'
/**
* Describes the file filesystem/filesystem.proto.
*/
export const file_filesystem_filesystem: GenFile =
/*@__PURE__*/
fileDesc(
'ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8i5QIKCUVudHJ5SW5mbxIMCgRuYW1lGAEgASgJEiIKBHR5cGUYAiABKA4yFC5maWxlc3lzdGVtLkZpbGVUeXBlEgwKBHBhdGgYAyABKAkSDAoEc2l6ZRgEIAEoAxIMCgRtb2RlGAUgASgNEhMKC3Blcm1pc3Npb25zGAYgASgJEg0KBW93bmVyGAcgASgJEg0KBWdyb3VwGAggASgJEjEKDW1vZGlmaWVkX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKDnN5bWxpbmtfdGFyZ2V0GAogASgJSACIAQESNQoIbWV0YWRhdGEYCyADKAsyIy5maWxlc3lzdGVtLkVudHJ5SW5mby5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIRCg9fc3ltbGlua190YXJnZXQiLQoOTGlzdERpclJlcXVlc3QSDAoEcGF0aBgBIAEoCRINCgVkZXB0aBgCIAEoDSI5Cg9MaXN0RGlyUmVzcG9uc2USJgoHZW50cmllcxgBIAMoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvImcKD1dhdGNoRGlyUmVxdWVzdBIMCgRwYXRoGAEgASgJEhEKCXJlY3Vyc2l2ZRgCIAEoCBIVCg1pbmNsdWRlX2VudHJ5GAMgASgIEhwKFGFsbG93X25ldHdvcmtfbW91bnRzGAQgASgIInkKD0ZpbGVzeXN0ZW1FdmVudBIMCgRuYW1lGAEgASgJEiMKBHR5cGUYAiABKA4yFS5maWxlc3lzdGVtLkV2ZW50VHlwZRIpCgVlbnRyeRgDIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvSACIAQFCCAoGX2VudHJ5IuABChBXYXRjaERpclJlc3BvbnNlEjgKBXN0YXJ0GAEgASgLMicuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlLlN0YXJ0RXZlbnRIABIxCgpmaWxlc3lzdGVtGAIgASgLMhsuZmlsZXN5c3RlbS5GaWxlc3lzdGVtRXZlbnRIABI7CglrZWVwYWxpdmUYAyABKAsyJi5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UuS2VlcEFsaXZlSAAaDAoKU3RhcnRFdmVudBoLCglLZWVwQWxpdmVCBwoFZXZlbnQibAoUQ3JlYXRlV2F0Y2hlclJlcXVlc3QSDAoEcGF0aBgBIAEoCRIRCglyZWN1cnNpdmUYAiABKAgSFQoNaW5jbHVkZV9lbnRyeRgDIAEoCBIcChRhbGxvd19uZXR3b3JrX21vdW50cxgEIAEoCCIrChVDcmVhdGVXYXRjaGVyUmVzcG9uc2USEgoKd2F0Y2hlcl9pZBgBIAEoCSItChdHZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIkcKGEdldFdhdGNoZXJFdmVudHNSZXNwb25zZRIrCgZldmVudHMYASADKAsyGy5maWxlc3lzdGVtLkZpbGVzeXN0ZW1FdmVudCIqChRSZW1vdmVXYXRjaGVyUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIhcKFVJlbW92ZVdhdGNoZXJSZXNwb25zZSpSCghGaWxlVHlwZRIZChVGSUxFX1RZUEVfVU5TUEVDSUZJRUQQABISCg5GSUxFX1RZUEVfRklMRRABEhcKE0ZJTEVfVFlQRV9ESVJFQ1RPUlkQAiqYAQoJRXZlbnRUeXBlEhoKFkVWRU5UX1RZUEVfVU5TUEVDSUZJRUQQABIVChFFVkVOVF9UWVBFX0NSRUFURRABEhQKEEVWRU5UX1RZUEVfV1JJVEUQAhIVChFFVkVOVF9UWVBFX1JFTU9WRRADEhUKEUVWRU5UX1RZUEVfUkVOQU1FEAQSFAoQRVZFTlRfVFlQRV9DSE1PRBAFMp8FCgpGaWxlc3lzdGVtEjkKBFN0YXQSFy5maWxlc3lzdGVtLlN0YXRSZXF1ZXN0GhguZmlsZXN5c3RlbS5TdGF0UmVzcG9uc2USQgoHTWFrZURpchIaLmZpbGVzeXN0ZW0uTWFrZURpclJlcXVlc3QaGy5maWxlc3lzdGVtLk1ha2VEaXJSZXNwb25zZRI5CgRNb3ZlEhcuZmlsZXN5c3RlbS5Nb3ZlUmVxdWVzdBoYLmZpbGVzeXN0ZW0uTW92ZVJlc3BvbnNlEkIKB0xpc3REaXISGi5maWxlc3lzdGVtLkxpc3REaXJSZXF1ZXN0GhsuZmlsZXN5c3RlbS5MaXN0RGlyUmVzcG9uc2USPwoGUmVtb3ZlEhkuZmlsZXN5c3RlbS5SZW1vdmVSZXF1ZXN0GhouZmlsZXN5c3RlbS5SZW1vdmVSZXNwb25zZRJHCghXYXRjaERpchIbLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXF1ZXN0GhwuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlMAESVAoNQ3JlYXRlV2F0Y2hlchIgLmZpbGVzeXN0ZW0uQ3JlYXRlV2F0Y2hlclJlcXVlc3QaIS5maWxlc3lzdGVtLkNyZWF0ZVdhdGNoZXJSZXNwb25zZRJdChBHZXRXYXRjaGVyRXZlbnRzEiMuZmlsZXN5c3RlbS5HZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBokLmZpbGVzeXN0ZW0uR2V0V2F0Y2hlckV2ZW50c1Jlc3BvbnNlElQKDVJlbW92ZVdhdGNoZXISIC5maWxlc3lzdGVtLlJlbW92ZVdhdGNoZXJSZXF1ZXN0GiEuZmlsZXN5c3RlbS5SZW1vdmVXYXRjaGVyUmVzcG9uc2VCaQoOY29tLmZpbGVzeXN0ZW1CD0ZpbGVzeXN0ZW1Qcm90b1ABogIDRlhYqgIKRmlsZXN5c3RlbcoCCkZpbGVzeXN0ZW3iAhZGaWxlc3lzdGVtXEdQQk1ldGFkYXRh6gIKRmlsZXN5c3RlbWIGcHJvdG8z',
[file_google_protobuf_timestamp]
)
/**
* @generated from message filesystem.MoveRequest
*/
export type MoveRequest = Message<'filesystem.MoveRequest'> & {
/**
* @generated from field: string source = 1;
*/
source: string
/**
* @generated from field: string destination = 2;
*/
destination: string
}
/**
* Describes the message filesystem.MoveRequest.
* Use `create(MoveRequestSchema)` to create a new message.
*/
export const MoveRequestSchema: GenMessage<MoveRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 0)
/**
* @generated from message filesystem.MoveResponse
*/
export type MoveResponse = Message<'filesystem.MoveResponse'> & {
/**
* @generated from field: filesystem.EntryInfo entry = 1;
*/
entry?: EntryInfo
}
/**
* Describes the message filesystem.MoveResponse.
* Use `create(MoveResponseSchema)` to create a new message.
*/
export const MoveResponseSchema: GenMessage<MoveResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 1)
/**
* @generated from message filesystem.MakeDirRequest
*/
export type MakeDirRequest = Message<'filesystem.MakeDirRequest'> & {
/**
* @generated from field: string path = 1;
*/
path: string
}
/**
* Describes the message filesystem.MakeDirRequest.
* Use `create(MakeDirRequestSchema)` to create a new message.
*/
export const MakeDirRequestSchema: GenMessage<MakeDirRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 2)
/**
* @generated from message filesystem.MakeDirResponse
*/
export type MakeDirResponse = Message<'filesystem.MakeDirResponse'> & {
/**
* @generated from field: filesystem.EntryInfo entry = 1;
*/
entry?: EntryInfo
}
/**
* Describes the message filesystem.MakeDirResponse.
* Use `create(MakeDirResponseSchema)` to create a new message.
*/
export const MakeDirResponseSchema: GenMessage<MakeDirResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 3)
/**
* @generated from message filesystem.RemoveRequest
*/
export type RemoveRequest = Message<'filesystem.RemoveRequest'> & {
/**
* @generated from field: string path = 1;
*/
path: string
}
/**
* Describes the message filesystem.RemoveRequest.
* Use `create(RemoveRequestSchema)` to create a new message.
*/
export const RemoveRequestSchema: GenMessage<RemoveRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 4)
/**
* @generated from message filesystem.RemoveResponse
*/
export type RemoveResponse = Message<'filesystem.RemoveResponse'> & {}
/**
* Describes the message filesystem.RemoveResponse.
* Use `create(RemoveResponseSchema)` to create a new message.
*/
export const RemoveResponseSchema: GenMessage<RemoveResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 5)
/**
* @generated from message filesystem.StatRequest
*/
export type StatRequest = Message<'filesystem.StatRequest'> & {
/**
* @generated from field: string path = 1;
*/
path: string
}
/**
* Describes the message filesystem.StatRequest.
* Use `create(StatRequestSchema)` to create a new message.
*/
export const StatRequestSchema: GenMessage<StatRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 6)
/**
* @generated from message filesystem.StatResponse
*/
export type StatResponse = Message<'filesystem.StatResponse'> & {
/**
* @generated from field: filesystem.EntryInfo entry = 1;
*/
entry?: EntryInfo
}
/**
* Describes the message filesystem.StatResponse.
* Use `create(StatResponseSchema)` to create a new message.
*/
export const StatResponseSchema: GenMessage<StatResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 7)
/**
* @generated from message filesystem.EntryInfo
*/
export type EntryInfo = Message<'filesystem.EntryInfo'> & {
/**
* @generated from field: string name = 1;
*/
name: string
/**
* @generated from field: filesystem.FileType type = 2;
*/
type: FileType
/**
* @generated from field: string path = 3;
*/
path: string
/**
* @generated from field: int64 size = 4;
*/
size: bigint
/**
* @generated from field: uint32 mode = 5;
*/
mode: number
/**
* @generated from field: string permissions = 6;
*/
permissions: string
/**
* @generated from field: string owner = 7;
*/
owner: string
/**
* @generated from field: string group = 8;
*/
group: string
/**
* @generated from field: google.protobuf.Timestamp modified_time = 9;
*/
modifiedTime?: Timestamp
/**
* If the entry is a symlink, this field contains the target of the symlink.
*
* @generated from field: optional string symlink_target = 10;
*/
symlinkTarget?: string
/**
* User-defined metadata stored as extended attributes (xattrs) on the file.
* Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here.
* Plain `user.*` xattrs written by other tooling are not reflected.
*
* @generated from field: map<string, string> metadata = 11;
*/
metadata: { [key: string]: string }
}
/**
* Describes the message filesystem.EntryInfo.
* Use `create(EntryInfoSchema)` to create a new message.
*/
export const EntryInfoSchema: GenMessage<EntryInfo> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 8)
/**
* @generated from message filesystem.ListDirRequest
*/
export type ListDirRequest = Message<'filesystem.ListDirRequest'> & {
/**
* @generated from field: string path = 1;
*/
path: string
/**
* @generated from field: uint32 depth = 2;
*/
depth: number
}
/**
* Describes the message filesystem.ListDirRequest.
* Use `create(ListDirRequestSchema)` to create a new message.
*/
export const ListDirRequestSchema: GenMessage<ListDirRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 9)
/**
* @generated from message filesystem.ListDirResponse
*/
export type ListDirResponse = Message<'filesystem.ListDirResponse'> & {
/**
* @generated from field: repeated filesystem.EntryInfo entries = 1;
*/
entries: EntryInfo[]
}
/**
* Describes the message filesystem.ListDirResponse.
* Use `create(ListDirResponseSchema)` to create a new message.
*/
export const ListDirResponseSchema: GenMessage<ListDirResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 10)
/**
* @generated from message filesystem.WatchDirRequest
*/
export type WatchDirRequest = Message<'filesystem.WatchDirRequest'> & {
/**
* @generated from field: string path = 1;
*/
path: string
/**
* @generated from field: bool recursive = 2;
*/
recursive: boolean
/**
* If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available.
*
* @generated from field: bool include_entry = 3;
*/
includeEntry: boolean
/**
* If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE).
* Events on network mounts may be unreliable or not delivered at all.
*
* @generated from field: bool allow_network_mounts = 4;
*/
allowNetworkMounts: boolean
}
/**
* Describes the message filesystem.WatchDirRequest.
* Use `create(WatchDirRequestSchema)` to create a new message.
*/
export const WatchDirRequestSchema: GenMessage<WatchDirRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 11)
/**
* @generated from message filesystem.FilesystemEvent
*/
export type FilesystemEvent = Message<'filesystem.FilesystemEvent'> & {
/**
* @generated from field: string name = 1;
*/
name: string
/**
* @generated from field: filesystem.EventType type = 2;
*/
type: EventType
/**
* Info of the entry that triggered the event. Only populated when include_entry
* was requested and the entry could be stat-ed (e.g. not set for remove/rename-away
* events, where the entry no longer exists at this path).
*
* @generated from field: optional filesystem.EntryInfo entry = 3;
*/
entry?: EntryInfo
}
/**
* Describes the message filesystem.FilesystemEvent.
* Use `create(FilesystemEventSchema)` to create a new message.
*/
export const FilesystemEventSchema: GenMessage<FilesystemEvent> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 12)
/**
* @generated from message filesystem.WatchDirResponse
*/
export type WatchDirResponse = Message<'filesystem.WatchDirResponse'> & {
/**
* @generated from oneof filesystem.WatchDirResponse.event
*/
event:
| {
/**
* @generated from field: filesystem.WatchDirResponse.StartEvent start = 1;
*/
value: WatchDirResponse_StartEvent
case: 'start'
}
| {
/**
* @generated from field: filesystem.FilesystemEvent filesystem = 2;
*/
value: FilesystemEvent
case: 'filesystem'
}
| {
/**
* @generated from field: filesystem.WatchDirResponse.KeepAlive keepalive = 3;
*/
value: WatchDirResponse_KeepAlive
case: 'keepalive'
}
| { case: undefined; value?: undefined }
}
/**
* Describes the message filesystem.WatchDirResponse.
* Use `create(WatchDirResponseSchema)` to create a new message.
*/
export const WatchDirResponseSchema: GenMessage<WatchDirResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 13)
/**
* @generated from message filesystem.WatchDirResponse.StartEvent
*/
export type WatchDirResponse_StartEvent =
Message<'filesystem.WatchDirResponse.StartEvent'> & {}
/**
* Describes the message filesystem.WatchDirResponse.StartEvent.
* Use `create(WatchDirResponse_StartEventSchema)` to create a new message.
*/
export const WatchDirResponse_StartEventSchema: GenMessage<WatchDirResponse_StartEvent> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 13, 0)
/**
* @generated from message filesystem.WatchDirResponse.KeepAlive
*/
export type WatchDirResponse_KeepAlive =
Message<'filesystem.WatchDirResponse.KeepAlive'> & {}
/**
* Describes the message filesystem.WatchDirResponse.KeepAlive.
* Use `create(WatchDirResponse_KeepAliveSchema)` to create a new message.
*/
export const WatchDirResponse_KeepAliveSchema: GenMessage<WatchDirResponse_KeepAlive> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 13, 1)
/**
* @generated from message filesystem.CreateWatcherRequest
*/
export type CreateWatcherRequest =
Message<'filesystem.CreateWatcherRequest'> & {
/**
* @generated from field: string path = 1;
*/
path: string
/**
* @generated from field: bool recursive = 2;
*/
recursive: boolean
/**
* If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available.
*
* @generated from field: bool include_entry = 3;
*/
includeEntry: boolean
/**
* If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE).
* Events on network mounts may be unreliable or not delivered at all.
*
* @generated from field: bool allow_network_mounts = 4;
*/
allowNetworkMounts: boolean
}
/**
* Describes the message filesystem.CreateWatcherRequest.
* Use `create(CreateWatcherRequestSchema)` to create a new message.
*/
export const CreateWatcherRequestSchema: GenMessage<CreateWatcherRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 14)
/**
* @generated from message filesystem.CreateWatcherResponse
*/
export type CreateWatcherResponse =
Message<'filesystem.CreateWatcherResponse'> & {
/**
* @generated from field: string watcher_id = 1;
*/
watcherId: string
}
/**
* Describes the message filesystem.CreateWatcherResponse.
* Use `create(CreateWatcherResponseSchema)` to create a new message.
*/
export const CreateWatcherResponseSchema: GenMessage<CreateWatcherResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 15)
/**
* @generated from message filesystem.GetWatcherEventsRequest
*/
export type GetWatcherEventsRequest =
Message<'filesystem.GetWatcherEventsRequest'> & {
/**
* @generated from field: string watcher_id = 1;
*/
watcherId: string
}
/**
* Describes the message filesystem.GetWatcherEventsRequest.
* Use `create(GetWatcherEventsRequestSchema)` to create a new message.
*/
export const GetWatcherEventsRequestSchema: GenMessage<GetWatcherEventsRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 16)
/**
* @generated from message filesystem.GetWatcherEventsResponse
*/
export type GetWatcherEventsResponse =
Message<'filesystem.GetWatcherEventsResponse'> & {
/**
* @generated from field: repeated filesystem.FilesystemEvent events = 1;
*/
events: FilesystemEvent[]
}
/**
* Describes the message filesystem.GetWatcherEventsResponse.
* Use `create(GetWatcherEventsResponseSchema)` to create a new message.
*/
export const GetWatcherEventsResponseSchema: GenMessage<GetWatcherEventsResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 17)
/**
* @generated from message filesystem.RemoveWatcherRequest
*/
export type RemoveWatcherRequest =
Message<'filesystem.RemoveWatcherRequest'> & {
/**
* @generated from field: string watcher_id = 1;
*/
watcherId: string
}
/**
* Describes the message filesystem.RemoveWatcherRequest.
* Use `create(RemoveWatcherRequestSchema)` to create a new message.
*/
export const RemoveWatcherRequestSchema: GenMessage<RemoveWatcherRequest> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 18)
/**
* @generated from message filesystem.RemoveWatcherResponse
*/
export type RemoveWatcherResponse =
Message<'filesystem.RemoveWatcherResponse'> & {}
/**
* Describes the message filesystem.RemoveWatcherResponse.
* Use `create(RemoveWatcherResponseSchema)` to create a new message.
*/
export const RemoveWatcherResponseSchema: GenMessage<RemoveWatcherResponse> =
/*@__PURE__*/
messageDesc(file_filesystem_filesystem, 19)
/**
* @generated from enum filesystem.FileType
*/
export enum FileType {
/**
* @generated from enum value: FILE_TYPE_UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* @generated from enum value: FILE_TYPE_FILE = 1;
*/
FILE = 1,
/**
* @generated from enum value: FILE_TYPE_DIRECTORY = 2;
*/
DIRECTORY = 2,
}
/**
* Describes the enum filesystem.FileType.
*/
export const FileTypeSchema: GenEnum<FileType> =
/*@__PURE__*/
enumDesc(file_filesystem_filesystem, 0)
/**
* @generated from enum filesystem.EventType
*/
export enum EventType {
/**
* @generated from enum value: EVENT_TYPE_UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* @generated from enum value: EVENT_TYPE_CREATE = 1;
*/
CREATE = 1,
/**
* @generated from enum value: EVENT_TYPE_WRITE = 2;
*/
WRITE = 2,
/**
* @generated from enum value: EVENT_TYPE_REMOVE = 3;
*/
REMOVE = 3,
/**
* @generated from enum value: EVENT_TYPE_RENAME = 4;
*/
RENAME = 4,
/**
* @generated from enum value: EVENT_TYPE_CHMOD = 5;
*/
CHMOD = 5,
}
/**
* Describes the enum filesystem.EventType.
*/
export const EventTypeSchema: GenEnum<EventType> =
/*@__PURE__*/
enumDesc(file_filesystem_filesystem, 1)
/**
* @generated from service filesystem.Filesystem
*/
export const Filesystem: GenService<{
/**
* @generated from rpc filesystem.Filesystem.Stat
*/
stat: {
methodKind: 'unary'
input: typeof StatRequestSchema
output: typeof StatResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.MakeDir
*/
makeDir: {
methodKind: 'unary'
input: typeof MakeDirRequestSchema
output: typeof MakeDirResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.Move
*/
move: {
methodKind: 'unary'
input: typeof MoveRequestSchema
output: typeof MoveResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.ListDir
*/
listDir: {
methodKind: 'unary'
input: typeof ListDirRequestSchema
output: typeof ListDirResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.Remove
*/
remove: {
methodKind: 'unary'
input: typeof RemoveRequestSchema
output: typeof RemoveResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.WatchDir
*/
watchDir: {
methodKind: 'server_streaming'
input: typeof WatchDirRequestSchema
output: typeof WatchDirResponseSchema
}
/**
* Non-streaming versions of WatchDir
*
* @generated from rpc filesystem.Filesystem.CreateWatcher
*/
createWatcher: {
methodKind: 'unary'
input: typeof CreateWatcherRequestSchema
output: typeof CreateWatcherResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.GetWatcherEvents
*/
getWatcherEvents: {
methodKind: 'unary'
input: typeof GetWatcherEventsRequestSchema
output: typeof GetWatcherEventsResponseSchema
}
/**
* @generated from rpc filesystem.Filesystem.RemoveWatcher
*/
removeWatcher: {
methodKind: 'unary'
input: typeof RemoveWatcherRequestSchema
output: typeof RemoveWatcherResponseSchema
}
}> = /*@__PURE__*/ serviceDesc(file_filesystem_filesystem, 0)
+159
View File
@@ -0,0 +1,159 @@
import { runtime } from '../utils'
import { parseInflightLimitEnv, parsePositiveIntEnv } from '../api/metadata'
import { limitConcurrency } from '../api/inflight'
import {
loadUndici,
toUndiciRequestInput,
type UndiciModule,
type UndiciRequestInit,
} from '../undici'
type EnvdFetchOptions = {
connectionLimit?: number
inflightLimit?: number
proxy?: string
loadUndici?: () => Promise<UndiciModule | undefined>
}
// Fetchers are cached per proxy so requests without a proxy keep sharing a
// single dispatcher while each distinct proxy URL gets its own.
const envdFetchers = new Map<string, typeof fetch>()
const envdRpcFetchers = new Map<string, typeof fetch>()
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,
options: EnvdFetchOptions = {}
): typeof fetch {
if (currentRuntime !== 'node') {
return fetch
}
let fetcherPromise: Promise<typeof fetch> | undefined
return (async (input, init) => {
fetcherPromise ??= buildEnvdFetcher(options)
const fetcher = await fetcherPromise
return fetcher(input, init)
}) as typeof fetch
}
async function buildEnvdFetcher(
options: EnvdFetchOptions
): Promise<typeof fetch> {
const undici = await (options.loadUndici ?? loadUndici)()
const inflightLimit = options.inflightLimit ?? 0
if (!undici) {
return limitConcurrency(fetch, inflightLimit)
}
const { Agent, ProxyAgent, fetch: undiciFetch } = undici
const connections = options.connectionLimit ?? DEFAULT_ENVD_CONNECTION_LIMIT
const dispatcher = options.proxy
? new ProxyAgent({
uri: options.proxy,
allowH2: true,
connections,
})
: new Agent({
allowH2: true,
connections,
})
const fetchWithDispatcher = undiciFetch as unknown as (
input: RequestInfo | URL,
init?: UndiciRequestInit
) => Promise<Response>
const wrapped: typeof fetch = ((input, init) => {
const request = toUndiciRequestInput(input, init)
return fetchWithDispatcher(request.input, {
...request.init,
dispatcher,
})
}) as typeof fetch
return limitConcurrency(wrapped, inflightLimit)
}
export function createEnvdFetch(proxy?: string): typeof fetch {
const key = proxy ?? ''
const cached = envdFetchers.get(key)
if (cached) {
return cached
}
// 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,
})
envdFetchers.set(key, envdFetch)
return envdFetch
}
export function createEnvdRpcFetch(proxy?: string): typeof fetch {
const key = proxy ?? ''
const cached = envdRpcFetchers.get(key)
if (cached) {
return cached
}
const envdRpcFetch = createEnvdFetchForRuntime(runtime, {
connectionLimit: getEnvdRpcConnectionLimit(),
inflightLimit: getEnvdRpcInflightLimit(),
proxy,
})
envdRpcFetchers.set(key, envdRpcFetch)
return envdRpcFetch
}
export function getEnvdRpcConnectionLimit(): number {
return parsePositiveIntEnv(
'E2B_ENVD_RPC_CONNECTIONS',
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 {@link DEFAULT_ENVD_INFLIGHT_LIMIT} ({@link 2000}). 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 {@link DEFAULT_ENVD_RPC_INFLIGHT_LIMIT} ({@link 2000}). 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
)
}
@@ -0,0 +1,110 @@
// @generated by protoc-gen-connect-es v1.6.1 with parameter "target=ts"
// @generated from file process/process.proto (package process, syntax proto3)
/* eslint-disable */
// @ts-nocheck
import {
CloseStdinRequest,
CloseStdinResponse,
ConnectRequest,
ConnectResponse,
ListRequest,
ListResponse,
SendInputRequest,
SendInputResponse,
SendSignalRequest,
SendSignalResponse,
StartRequest,
StartResponse,
StreamInputRequest,
StreamInputResponse,
UpdateRequest,
UpdateResponse,
} from './process_pb.js'
import { MethodKind } from '@bufbuild/protobuf'
/**
* @generated from service process.Process
*/
export const Process = {
typeName: 'process.Process',
methods: {
/**
* @generated from rpc process.Process.List
*/
list: {
name: 'List',
I: ListRequest,
O: ListResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc process.Process.Connect
*/
connect: {
name: 'Connect',
I: ConnectRequest,
O: ConnectResponse,
kind: MethodKind.ServerStreaming,
},
/**
* @generated from rpc process.Process.Start
*/
start: {
name: 'Start',
I: StartRequest,
O: StartResponse,
kind: MethodKind.ServerStreaming,
},
/**
* @generated from rpc process.Process.Update
*/
update: {
name: 'Update',
I: UpdateRequest,
O: UpdateResponse,
kind: MethodKind.Unary,
},
/**
* Client input stream ensures ordering of messages
*
* @generated from rpc process.Process.StreamInput
*/
streamInput: {
name: 'StreamInput',
I: StreamInputRequest,
O: StreamInputResponse,
kind: MethodKind.ClientStreaming,
},
/**
* @generated from rpc process.Process.SendInput
*/
sendInput: {
name: 'SendInput',
I: SendInputRequest,
O: SendInputResponse,
kind: MethodKind.Unary,
},
/**
* @generated from rpc process.Process.SendSignal
*/
sendSignal: {
name: 'SendSignal',
I: SendSignalRequest,
O: SendSignalResponse,
kind: MethodKind.Unary,
},
/**
* Close stdin to signal EOF to the process.
* Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead.
*
* @generated from rpc process.Process.CloseStdin
*/
closeStdin: {
name: 'CloseStdin',
I: CloseStdinRequest,
O: CloseStdinResponse,
kind: MethodKind.Unary,
},
},
} as const
@@ -0,0 +1,812 @@
// @generated by protoc-gen-es v2.6.2 with parameter "target=ts"
// @generated from file process/process.proto (package process, syntax proto3)
/* eslint-disable */
import type {
GenEnum,
GenFile,
GenMessage,
GenService,
} from '@bufbuild/protobuf/codegenv2'
import {
enumDesc,
fileDesc,
messageDesc,
serviceDesc,
} from '@bufbuild/protobuf/codegenv2'
import type { Message } from '@bufbuild/protobuf'
/**
* Describes the file process/process.proto.
*/
export const file_process_process: GenFile =
/*@__PURE__*/
fileDesc(
'ChVwcm9jZXNzL3Byb2Nlc3MucHJvdG8SB3Byb2Nlc3MiSgoDUFRZEh8KBHNpemUYASABKAsyES5wcm9jZXNzLlBUWS5TaXplGiIKBFNpemUSDAoEY29scxgBIAEoDRIMCgRyb3dzGAIgASgNIqEBCg1Qcm9jZXNzQ29uZmlnEgsKA2NtZBgBIAEoCRIMCgRhcmdzGAIgAygJEi4KBGVudnMYAyADKAsyIC5wcm9jZXNzLlByb2Nlc3NDb25maWcuRW52c0VudHJ5EhAKA2N3ZBgEIAEoCUgAiAEBGisKCUVudnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQgYKBF9jd2QiDQoLTGlzdFJlcXVlc3QiXAoLUHJvY2Vzc0luZm8SJgoGY29uZmlnGAEgASgLMhYucHJvY2Vzcy5Qcm9jZXNzQ29uZmlnEgsKA3BpZBgCIAEoDRIQCgN0YWcYAyABKAlIAIgBAUIGCgRfdGFnIjcKDExpc3RSZXNwb25zZRInCglwcm9jZXNzZXMYASADKAsyFC5wcm9jZXNzLlByb2Nlc3NJbmZvIpcBCgxTdGFydFJlcXVlc3QSJwoHcHJvY2VzcxgBIAEoCzIWLnByb2Nlc3MuUHJvY2Vzc0NvbmZpZxIeCgNwdHkYAiABKAsyDC5wcm9jZXNzLlBUWUgAiAEBEhAKA3RhZxgDIAEoCUgBiAEBEhIKBXN0ZGluGAQgASgISAKIAQFCBgoEX3B0eUIGCgRfdGFnQggKBl9zdGRpbiJiCg1VcGRhdGVSZXF1ZXN0EikKB3Byb2Nlc3MYASABKAsyGC5wcm9jZXNzLlByb2Nlc3NTZWxlY3RvchIeCgNwdHkYAiABKAsyDC5wcm9jZXNzLlBUWUgAiAEBQgYKBF9wdHkiEAoOVXBkYXRlUmVzcG9uc2UirwMKDFByb2Nlc3NFdmVudBIxCgVzdGFydBgBIAEoCzIgLnByb2Nlc3MuUHJvY2Vzc0V2ZW50LlN0YXJ0RXZlbnRIABIvCgRkYXRhGAIgASgLMh8ucHJvY2Vzcy5Qcm9jZXNzRXZlbnQuRGF0YUV2ZW50SAASLQoDZW5kGAMgASgLMh4ucHJvY2Vzcy5Qcm9jZXNzRXZlbnQuRW5kRXZlbnRIABI0CglrZWVwYWxpdmUYBCABKAsyHy5wcm9jZXNzLlByb2Nlc3NFdmVudC5LZWVwQWxpdmVIABoZCgpTdGFydEV2ZW50EgsKA3BpZBgBIAEoDRpICglEYXRhRXZlbnQSEAoGc3Rkb3V0GAEgASgMSAASEAoGc3RkZXJyGAIgASgMSAASDQoDcHR5GAMgASgMSABCCAoGb3V0cHV0GlsKCEVuZEV2ZW50EhEKCWV4aXRfY29kZRgBIAEoERIOCgZleGl0ZWQYAiABKAgSDgoGc3RhdHVzGAMgASgJEhIKBWVycm9yGAQgASgJSACIAQFCCAoGX2Vycm9yGgsKCUtlZXBBbGl2ZUIHCgVldmVudCI1Cg1TdGFydFJlc3BvbnNlEiQKBWV2ZW50GAEgASgLMhUucHJvY2Vzcy5Qcm9jZXNzRXZlbnQiNwoPQ29ubmVjdFJlc3BvbnNlEiQKBWV2ZW50GAEgASgLMhUucHJvY2Vzcy5Qcm9jZXNzRXZlbnQiYwoQU2VuZElucHV0UmVxdWVzdBIpCgdwcm9jZXNzGAEgASgLMhgucHJvY2Vzcy5Qcm9jZXNzU2VsZWN0b3ISJAoFaW5wdXQYAiABKAsyFS5wcm9jZXNzLlByb2Nlc3NJbnB1dCITChFTZW5kSW5wdXRSZXNwb25zZSI3CgxQcm9jZXNzSW5wdXQSDwoFc3RkaW4YASABKAxIABINCgNwdHkYAiABKAxIAEIHCgVpbnB1dCLCAgoSU3RyZWFtSW5wdXRSZXF1ZXN0EjcKBXN0YXJ0GAEgASgLMiYucHJvY2Vzcy5TdHJlYW1JbnB1dFJlcXVlc3QuU3RhcnRFdmVudEgAEjUKBGRhdGEYAiABKAsyJS5wcm9jZXNzLlN0cmVhbUlucHV0UmVxdWVzdC5EYXRhRXZlbnRIABI6CglrZWVwYWxpdmUYAyABKAsyJS5wcm9jZXNzLlN0cmVhbUlucHV0UmVxdWVzdC5LZWVwQWxpdmVIABo3CgpTdGFydEV2ZW50EikKB3Byb2Nlc3MYASABKAsyGC5wcm9jZXNzLlByb2Nlc3NTZWxlY3RvchoxCglEYXRhRXZlbnQSJAoFaW5wdXQYAiABKAsyFS5wcm9jZXNzLlByb2Nlc3NJbnB1dBoLCglLZWVwQWxpdmVCBwoFZXZlbnQiFQoTU3RyZWFtSW5wdXRSZXNwb25zZSJfChFTZW5kU2lnbmFsUmVxdWVzdBIpCgdwcm9jZXNzGAEgASgLMhgucHJvY2Vzcy5Qcm9jZXNzU2VsZWN0b3ISHwoGc2lnbmFsGAIgASgOMg8ucHJvY2Vzcy5TaWduYWwiFAoSU2VuZFNpZ25hbFJlc3BvbnNlIj4KEUNsb3NlU3RkaW5SZXF1ZXN0EikKB3Byb2Nlc3MYASABKAsyGC5wcm9jZXNzLlByb2Nlc3NTZWxlY3RvciIUChJDbG9zZVN0ZGluUmVzcG9uc2UiOwoOQ29ubmVjdFJlcXVlc3QSKQoHcHJvY2VzcxgBIAEoCzIYLnByb2Nlc3MuUHJvY2Vzc1NlbGVjdG9yIjsKD1Byb2Nlc3NTZWxlY3RvchINCgNwaWQYASABKA1IABINCgN0YWcYAiABKAlIAEIKCghzZWxlY3RvcipICgZTaWduYWwSFgoSU0lHTkFMX1VOU1BFQ0lGSUVEEAASEgoOU0lHTkFMX1NJR1RFUk0QDxISCg5TSUdOQUxfU0lHS0lMTBAJMpEECgdQcm9jZXNzEjMKBExpc3QSFC5wcm9jZXNzLkxpc3RSZXF1ZXN0GhUucHJvY2Vzcy5MaXN0UmVzcG9uc2USPgoHQ29ubmVjdBIXLnByb2Nlc3MuQ29ubmVjdFJlcXVlc3QaGC5wcm9jZXNzLkNvbm5lY3RSZXNwb25zZTABEjgKBVN0YXJ0EhUucHJvY2Vzcy5TdGFydFJlcXVlc3QaFi5wcm9jZXNzLlN0YXJ0UmVzcG9uc2UwARI5CgZVcGRhdGUSFi5wcm9jZXNzLlVwZGF0ZVJlcXVlc3QaFy5wcm9jZXNzLlVwZGF0ZVJlc3BvbnNlEkoKC1N0cmVhbUlucHV0EhsucHJvY2Vzcy5TdHJlYW1JbnB1dFJlcXVlc3QaHC5wcm9jZXNzLlN0cmVhbUlucHV0UmVzcG9uc2UoARJCCglTZW5kSW5wdXQSGS5wcm9jZXNzLlNlbmRJbnB1dFJlcXVlc3QaGi5wcm9jZXNzLlNlbmRJbnB1dFJlc3BvbnNlEkUKClNlbmRTaWduYWwSGi5wcm9jZXNzLlNlbmRTaWduYWxSZXF1ZXN0GhsucHJvY2Vzcy5TZW5kU2lnbmFsUmVzcG9uc2USRQoKQ2xvc2VTdGRpbhIaLnByb2Nlc3MuQ2xvc2VTdGRpblJlcXVlc3QaGy5wcm9jZXNzLkNsb3NlU3RkaW5SZXNwb25zZUJXCgtjb20ucHJvY2Vzc0IMUHJvY2Vzc1Byb3RvUAGiAgNQWFiqAgdQcm9jZXNzygIHUHJvY2Vzc+ICE1Byb2Nlc3NcR1BCTWV0YWRhdGHqAgdQcm9jZXNzYgZwcm90bzM'
)
/**
* @generated from message process.PTY
*/
export type PTY = Message<'process.PTY'> & {
/**
* @generated from field: process.PTY.Size size = 1;
*/
size?: PTY_Size
}
/**
* Describes the message process.PTY.
* Use `create(PTYSchema)` to create a new message.
*/
export const PTYSchema: GenMessage<PTY> =
/*@__PURE__*/
messageDesc(file_process_process, 0)
/**
* @generated from message process.PTY.Size
*/
export type PTY_Size = Message<'process.PTY.Size'> & {
/**
* @generated from field: uint32 cols = 1;
*/
cols: number
/**
* @generated from field: uint32 rows = 2;
*/
rows: number
}
/**
* Describes the message process.PTY.Size.
* Use `create(PTY_SizeSchema)` to create a new message.
*/
export const PTY_SizeSchema: GenMessage<PTY_Size> =
/*@__PURE__*/
messageDesc(file_process_process, 0, 0)
/**
* @generated from message process.ProcessConfig
*/
export type ProcessConfig = Message<'process.ProcessConfig'> & {
/**
* @generated from field: string cmd = 1;
*/
cmd: string
/**
* @generated from field: repeated string args = 2;
*/
args: string[]
/**
* @generated from field: map<string, string> envs = 3;
*/
envs: { [key: string]: string }
/**
* @generated from field: optional string cwd = 4;
*/
cwd?: string
}
/**
* Describes the message process.ProcessConfig.
* Use `create(ProcessConfigSchema)` to create a new message.
*/
export const ProcessConfigSchema: GenMessage<ProcessConfig> =
/*@__PURE__*/
messageDesc(file_process_process, 1)
/**
* @generated from message process.ListRequest
*/
export type ListRequest = Message<'process.ListRequest'> & {}
/**
* Describes the message process.ListRequest.
* Use `create(ListRequestSchema)` to create a new message.
*/
export const ListRequestSchema: GenMessage<ListRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 2)
/**
* @generated from message process.ProcessInfo
*/
export type ProcessInfo = Message<'process.ProcessInfo'> & {
/**
* @generated from field: process.ProcessConfig config = 1;
*/
config?: ProcessConfig
/**
* @generated from field: uint32 pid = 2;
*/
pid: number
/**
* @generated from field: optional string tag = 3;
*/
tag?: string
}
/**
* Describes the message process.ProcessInfo.
* Use `create(ProcessInfoSchema)` to create a new message.
*/
export const ProcessInfoSchema: GenMessage<ProcessInfo> =
/*@__PURE__*/
messageDesc(file_process_process, 3)
/**
* @generated from message process.ListResponse
*/
export type ListResponse = Message<'process.ListResponse'> & {
/**
* @generated from field: repeated process.ProcessInfo processes = 1;
*/
processes: ProcessInfo[]
}
/**
* Describes the message process.ListResponse.
* Use `create(ListResponseSchema)` to create a new message.
*/
export const ListResponseSchema: GenMessage<ListResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 4)
/**
* @generated from message process.StartRequest
*/
export type StartRequest = Message<'process.StartRequest'> & {
/**
* @generated from field: process.ProcessConfig process = 1;
*/
process?: ProcessConfig
/**
* @generated from field: optional process.PTY pty = 2;
*/
pty?: PTY
/**
* @generated from field: optional string tag = 3;
*/
tag?: string
/**
* @generated from field: optional bool stdin = 4;
*/
stdin?: boolean
}
/**
* Describes the message process.StartRequest.
* Use `create(StartRequestSchema)` to create a new message.
*/
export const StartRequestSchema: GenMessage<StartRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 5)
/**
* @generated from message process.UpdateRequest
*/
export type UpdateRequest = Message<'process.UpdateRequest'> & {
/**
* @generated from field: process.ProcessSelector process = 1;
*/
process?: ProcessSelector
/**
* @generated from field: optional process.PTY pty = 2;
*/
pty?: PTY
}
/**
* Describes the message process.UpdateRequest.
* Use `create(UpdateRequestSchema)` to create a new message.
*/
export const UpdateRequestSchema: GenMessage<UpdateRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 6)
/**
* @generated from message process.UpdateResponse
*/
export type UpdateResponse = Message<'process.UpdateResponse'> & {}
/**
* Describes the message process.UpdateResponse.
* Use `create(UpdateResponseSchema)` to create a new message.
*/
export const UpdateResponseSchema: GenMessage<UpdateResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 7)
/**
* @generated from message process.ProcessEvent
*/
export type ProcessEvent = Message<'process.ProcessEvent'> & {
/**
* @generated from oneof process.ProcessEvent.event
*/
event:
| {
/**
* @generated from field: process.ProcessEvent.StartEvent start = 1;
*/
value: ProcessEvent_StartEvent
case: 'start'
}
| {
/**
* @generated from field: process.ProcessEvent.DataEvent data = 2;
*/
value: ProcessEvent_DataEvent
case: 'data'
}
| {
/**
* @generated from field: process.ProcessEvent.EndEvent end = 3;
*/
value: ProcessEvent_EndEvent
case: 'end'
}
| {
/**
* @generated from field: process.ProcessEvent.KeepAlive keepalive = 4;
*/
value: ProcessEvent_KeepAlive
case: 'keepalive'
}
| { case: undefined; value?: undefined }
}
/**
* Describes the message process.ProcessEvent.
* Use `create(ProcessEventSchema)` to create a new message.
*/
export const ProcessEventSchema: GenMessage<ProcessEvent> =
/*@__PURE__*/
messageDesc(file_process_process, 8)
/**
* @generated from message process.ProcessEvent.StartEvent
*/
export type ProcessEvent_StartEvent =
Message<'process.ProcessEvent.StartEvent'> & {
/**
* @generated from field: uint32 pid = 1;
*/
pid: number
}
/**
* Describes the message process.ProcessEvent.StartEvent.
* Use `create(ProcessEvent_StartEventSchema)` to create a new message.
*/
export const ProcessEvent_StartEventSchema: GenMessage<ProcessEvent_StartEvent> =
/*@__PURE__*/
messageDesc(file_process_process, 8, 0)
/**
* @generated from message process.ProcessEvent.DataEvent
*/
export type ProcessEvent_DataEvent =
Message<'process.ProcessEvent.DataEvent'> & {
/**
* @generated from oneof process.ProcessEvent.DataEvent.output
*/
output:
| {
/**
* @generated from field: bytes stdout = 1;
*/
value: Uint8Array
case: 'stdout'
}
| {
/**
* @generated from field: bytes stderr = 2;
*/
value: Uint8Array
case: 'stderr'
}
| {
/**
* @generated from field: bytes pty = 3;
*/
value: Uint8Array
case: 'pty'
}
| { case: undefined; value?: undefined }
}
/**
* Describes the message process.ProcessEvent.DataEvent.
* Use `create(ProcessEvent_DataEventSchema)` to create a new message.
*/
export const ProcessEvent_DataEventSchema: GenMessage<ProcessEvent_DataEvent> =
/*@__PURE__*/
messageDesc(file_process_process, 8, 1)
/**
* @generated from message process.ProcessEvent.EndEvent
*/
export type ProcessEvent_EndEvent = Message<'process.ProcessEvent.EndEvent'> & {
/**
* @generated from field: sint32 exit_code = 1;
*/
exitCode: number
/**
* @generated from field: bool exited = 2;
*/
exited: boolean
/**
* @generated from field: string status = 3;
*/
status: string
/**
* @generated from field: optional string error = 4;
*/
error?: string
}
/**
* Describes the message process.ProcessEvent.EndEvent.
* Use `create(ProcessEvent_EndEventSchema)` to create a new message.
*/
export const ProcessEvent_EndEventSchema: GenMessage<ProcessEvent_EndEvent> =
/*@__PURE__*/
messageDesc(file_process_process, 8, 2)
/**
* @generated from message process.ProcessEvent.KeepAlive
*/
export type ProcessEvent_KeepAlive =
Message<'process.ProcessEvent.KeepAlive'> & {}
/**
* Describes the message process.ProcessEvent.KeepAlive.
* Use `create(ProcessEvent_KeepAliveSchema)` to create a new message.
*/
export const ProcessEvent_KeepAliveSchema: GenMessage<ProcessEvent_KeepAlive> =
/*@__PURE__*/
messageDesc(file_process_process, 8, 3)
/**
* @generated from message process.StartResponse
*/
export type StartResponse = Message<'process.StartResponse'> & {
/**
* @generated from field: process.ProcessEvent event = 1;
*/
event?: ProcessEvent
}
/**
* Describes the message process.StartResponse.
* Use `create(StartResponseSchema)` to create a new message.
*/
export const StartResponseSchema: GenMessage<StartResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 9)
/**
* @generated from message process.ConnectResponse
*/
export type ConnectResponse = Message<'process.ConnectResponse'> & {
/**
* @generated from field: process.ProcessEvent event = 1;
*/
event?: ProcessEvent
}
/**
* Describes the message process.ConnectResponse.
* Use `create(ConnectResponseSchema)` to create a new message.
*/
export const ConnectResponseSchema: GenMessage<ConnectResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 10)
/**
* @generated from message process.SendInputRequest
*/
export type SendInputRequest = Message<'process.SendInputRequest'> & {
/**
* @generated from field: process.ProcessSelector process = 1;
*/
process?: ProcessSelector
/**
* @generated from field: process.ProcessInput input = 2;
*/
input?: ProcessInput
}
/**
* Describes the message process.SendInputRequest.
* Use `create(SendInputRequestSchema)` to create a new message.
*/
export const SendInputRequestSchema: GenMessage<SendInputRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 11)
/**
* @generated from message process.SendInputResponse
*/
export type SendInputResponse = Message<'process.SendInputResponse'> & {}
/**
* Describes the message process.SendInputResponse.
* Use `create(SendInputResponseSchema)` to create a new message.
*/
export const SendInputResponseSchema: GenMessage<SendInputResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 12)
/**
* @generated from message process.ProcessInput
*/
export type ProcessInput = Message<'process.ProcessInput'> & {
/**
* @generated from oneof process.ProcessInput.input
*/
input:
| {
/**
* @generated from field: bytes stdin = 1;
*/
value: Uint8Array
case: 'stdin'
}
| {
/**
* @generated from field: bytes pty = 2;
*/
value: Uint8Array
case: 'pty'
}
| { case: undefined; value?: undefined }
}
/**
* Describes the message process.ProcessInput.
* Use `create(ProcessInputSchema)` to create a new message.
*/
export const ProcessInputSchema: GenMessage<ProcessInput> =
/*@__PURE__*/
messageDesc(file_process_process, 13)
/**
* @generated from message process.StreamInputRequest
*/
export type StreamInputRequest = Message<'process.StreamInputRequest'> & {
/**
* @generated from oneof process.StreamInputRequest.event
*/
event:
| {
/**
* @generated from field: process.StreamInputRequest.StartEvent start = 1;
*/
value: StreamInputRequest_StartEvent
case: 'start'
}
| {
/**
* @generated from field: process.StreamInputRequest.DataEvent data = 2;
*/
value: StreamInputRequest_DataEvent
case: 'data'
}
| {
/**
* @generated from field: process.StreamInputRequest.KeepAlive keepalive = 3;
*/
value: StreamInputRequest_KeepAlive
case: 'keepalive'
}
| { case: undefined; value?: undefined }
}
/**
* Describes the message process.StreamInputRequest.
* Use `create(StreamInputRequestSchema)` to create a new message.
*/
export const StreamInputRequestSchema: GenMessage<StreamInputRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 14)
/**
* @generated from message process.StreamInputRequest.StartEvent
*/
export type StreamInputRequest_StartEvent =
Message<'process.StreamInputRequest.StartEvent'> & {
/**
* @generated from field: process.ProcessSelector process = 1;
*/
process?: ProcessSelector
}
/**
* Describes the message process.StreamInputRequest.StartEvent.
* Use `create(StreamInputRequest_StartEventSchema)` to create a new message.
*/
export const StreamInputRequest_StartEventSchema: GenMessage<StreamInputRequest_StartEvent> =
/*@__PURE__*/
messageDesc(file_process_process, 14, 0)
/**
* @generated from message process.StreamInputRequest.DataEvent
*/
export type StreamInputRequest_DataEvent =
Message<'process.StreamInputRequest.DataEvent'> & {
/**
* @generated from field: process.ProcessInput input = 2;
*/
input?: ProcessInput
}
/**
* Describes the message process.StreamInputRequest.DataEvent.
* Use `create(StreamInputRequest_DataEventSchema)` to create a new message.
*/
export const StreamInputRequest_DataEventSchema: GenMessage<StreamInputRequest_DataEvent> =
/*@__PURE__*/
messageDesc(file_process_process, 14, 1)
/**
* @generated from message process.StreamInputRequest.KeepAlive
*/
export type StreamInputRequest_KeepAlive =
Message<'process.StreamInputRequest.KeepAlive'> & {}
/**
* Describes the message process.StreamInputRequest.KeepAlive.
* Use `create(StreamInputRequest_KeepAliveSchema)` to create a new message.
*/
export const StreamInputRequest_KeepAliveSchema: GenMessage<StreamInputRequest_KeepAlive> =
/*@__PURE__*/
messageDesc(file_process_process, 14, 2)
/**
* @generated from message process.StreamInputResponse
*/
export type StreamInputResponse = Message<'process.StreamInputResponse'> & {}
/**
* Describes the message process.StreamInputResponse.
* Use `create(StreamInputResponseSchema)` to create a new message.
*/
export const StreamInputResponseSchema: GenMessage<StreamInputResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 15)
/**
* @generated from message process.SendSignalRequest
*/
export type SendSignalRequest = Message<'process.SendSignalRequest'> & {
/**
* @generated from field: process.ProcessSelector process = 1;
*/
process?: ProcessSelector
/**
* @generated from field: process.Signal signal = 2;
*/
signal: Signal
}
/**
* Describes the message process.SendSignalRequest.
* Use `create(SendSignalRequestSchema)` to create a new message.
*/
export const SendSignalRequestSchema: GenMessage<SendSignalRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 16)
/**
* @generated from message process.SendSignalResponse
*/
export type SendSignalResponse = Message<'process.SendSignalResponse'> & {}
/**
* Describes the message process.SendSignalResponse.
* Use `create(SendSignalResponseSchema)` to create a new message.
*/
export const SendSignalResponseSchema: GenMessage<SendSignalResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 17)
/**
* @generated from message process.CloseStdinRequest
*/
export type CloseStdinRequest = Message<'process.CloseStdinRequest'> & {
/**
* @generated from field: process.ProcessSelector process = 1;
*/
process?: ProcessSelector
}
/**
* Describes the message process.CloseStdinRequest.
* Use `create(CloseStdinRequestSchema)` to create a new message.
*/
export const CloseStdinRequestSchema: GenMessage<CloseStdinRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 18)
/**
* @generated from message process.CloseStdinResponse
*/
export type CloseStdinResponse = Message<'process.CloseStdinResponse'> & {}
/**
* Describes the message process.CloseStdinResponse.
* Use `create(CloseStdinResponseSchema)` to create a new message.
*/
export const CloseStdinResponseSchema: GenMessage<CloseStdinResponse> =
/*@__PURE__*/
messageDesc(file_process_process, 19)
/**
* @generated from message process.ConnectRequest
*/
export type ConnectRequest = Message<'process.ConnectRequest'> & {
/**
* @generated from field: process.ProcessSelector process = 1;
*/
process?: ProcessSelector
}
/**
* Describes the message process.ConnectRequest.
* Use `create(ConnectRequestSchema)` to create a new message.
*/
export const ConnectRequestSchema: GenMessage<ConnectRequest> =
/*@__PURE__*/
messageDesc(file_process_process, 20)
/**
* @generated from message process.ProcessSelector
*/
export type ProcessSelector = Message<'process.ProcessSelector'> & {
/**
* @generated from oneof process.ProcessSelector.selector
*/
selector:
| {
/**
* @generated from field: uint32 pid = 1;
*/
value: number
case: 'pid'
}
| {
/**
* @generated from field: string tag = 2;
*/
value: string
case: 'tag'
}
| { case: undefined; value?: undefined }
}
/**
* Describes the message process.ProcessSelector.
* Use `create(ProcessSelectorSchema)` to create a new message.
*/
export const ProcessSelectorSchema: GenMessage<ProcessSelector> =
/*@__PURE__*/
messageDesc(file_process_process, 21)
/**
* @generated from enum process.Signal
*/
export enum Signal {
/**
* @generated from enum value: SIGNAL_UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* @generated from enum value: SIGNAL_SIGTERM = 15;
*/
SIGTERM = 15,
/**
* @generated from enum value: SIGNAL_SIGKILL = 9;
*/
SIGKILL = 9,
}
/**
* Describes the enum process.Signal.
*/
export const SignalSchema: GenEnum<Signal> =
/*@__PURE__*/
enumDesc(file_process_process, 0)
/**
* @generated from service process.Process
*/
export const Process: GenService<{
/**
* @generated from rpc process.Process.List
*/
list: {
methodKind: 'unary'
input: typeof ListRequestSchema
output: typeof ListResponseSchema
}
/**
* @generated from rpc process.Process.Connect
*/
connect: {
methodKind: 'server_streaming'
input: typeof ConnectRequestSchema
output: typeof ConnectResponseSchema
}
/**
* @generated from rpc process.Process.Start
*/
start: {
methodKind: 'server_streaming'
input: typeof StartRequestSchema
output: typeof StartResponseSchema
}
/**
* @generated from rpc process.Process.Update
*/
update: {
methodKind: 'unary'
input: typeof UpdateRequestSchema
output: typeof UpdateResponseSchema
}
/**
* Client input stream ensures ordering of messages
*
* @generated from rpc process.Process.StreamInput
*/
streamInput: {
methodKind: 'client_streaming'
input: typeof StreamInputRequestSchema
output: typeof StreamInputResponseSchema
}
/**
* @generated from rpc process.Process.SendInput
*/
sendInput: {
methodKind: 'unary'
input: typeof SendInputRequestSchema
output: typeof SendInputResponseSchema
}
/**
* @generated from rpc process.Process.SendSignal
*/
sendSignal: {
methodKind: 'unary'
input: typeof SendSignalRequestSchema
output: typeof SendSignalResponseSchema
}
/**
* Close stdin to signal EOF to the process.
* Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead.
*
* @generated from rpc process.Process.CloseStdin
*/
closeStdin: {
methodKind: 'unary'
input: typeof CloseStdinRequestSchema
output: typeof CloseStdinResponseSchema
}
}> = /*@__PURE__*/ serviceDesc(file_process_process, 0)
+178
View File
@@ -0,0 +1,178 @@
import { Code, ConnectError } from '@connectrpc/connect'
import { runtime } from '../utils'
import { compareVersions } from 'compare-versions'
import { defaultUsername } from '../connectionConfig'
import {
AuthenticationError,
formatSandboxTimeoutError,
InvalidArgumentError,
NotFoundError,
RateLimitError,
SandboxError,
TimeoutError,
} from '../errors'
import { ENVD_DEFAULT_USER } from './versions'
/**
* Result of a sandbox health probe: `true` if the sandbox is running, `false` if it is not,
* `undefined` if its state could not be determined.
*/
export type SandboxHealthCheck = () => Promise<boolean | undefined>
/**
* Message fragments different JS runtimes use when the connection to the sandbox
* is dropped mid-request. The transport surfaces a dropped connection (e.g. an
* HTTP/2 stream reset) with runtime- and version-specific wording, so we match
* every known variant:
* - Node (undici): `terminated`
* - Bun: `The socket connection was closed unexpectedly`
* - Deno: `error reading a body from connection`
*/
const CONNECTION_TERMINATED_MESSAGES = [
'terminated',
'The socket connection was closed unexpectedly',
'error reading a body from connection',
]
/**
* Checks whether a message matches any known runtime variant of the connection to
* the sandbox being dropped mid-request (see {@link CONNECTION_TERMINATED_MESSAGES}).
*/
export function isConnectionTerminatedMessage(
message: string | undefined
): boolean {
if (!message) {
return false
}
return CONNECTION_TERMINATED_MESSAGES.some((fragment) =>
message.includes(fragment)
)
}
/**
* Checks whether the error is the signature of the connection to the sandbox being
* dropped mid-request — an HTTP/2 stream reset surfaced by connect as `Code.Unknown`
* with one of the runtime-specific connection-dropped messages.
*/
export function isConnectionTerminatedError(err: unknown): boolean {
return (
err instanceof ConnectError &&
err.code === Code.Unknown &&
isConnectionTerminatedMessage(err.rawMessage)
)
}
const DEFAULT_ERROR_MAP: Partial<Record<Code, (message: string) => Error>> = {
[Code.InvalidArgument]: (message) => new InvalidArgumentError(message),
[Code.Unauthenticated]: (message) => new AuthenticationError(message),
[Code.NotFound]: (message) => new NotFoundError(message),
[Code.ResourceExhausted]: (message) =>
new RateLimitError(
`${message}: Rate limit exceeded, please try again later.`
),
[Code.Unavailable]: formatSandboxTimeoutError,
[Code.Canceled]: (message) =>
new TimeoutError(
`${message}: This error is likely due to exceeding 'requestTimeoutMs'. You can pass the request timeout value as an option when making the request.`
),
[Code.DeadlineExceeded]: (message) =>
new TimeoutError(
`${message}: This error is likely due to exceeding 'timeoutMs' — the total time a long running request (like command execution or directory watch) can be active. It can be modified by passing 'timeoutMs' when making the request. Use '0' to disable the timeout.`
),
}
/**
* Handles errors from envd RPC calls by mapping gRPC status codes to specific error types.
*
* @param err - The caught error, expected to be a `ConnectError` from the gRPC transport.
* @param errorMap - Optional map of gRPC `Code` values to error factory functions that override the defaults.
* @returns The corresponding `Error` instance mapped from the gRPC status code, or the original error if it is not a `ConnectError`.
*/
export function handleRpcError(
err: unknown,
errorMap?: Partial<Record<Code, (message: string) => Error>>
): Error {
if (err instanceof ConnectError) {
// Check if a custom error mapping is provided for this error code
if (errorMap && err.code in errorMap) {
return errorMap[err.code]!(err.message)
}
// Check if there is a default error mapping for this error code
if (err.code in DEFAULT_ERROR_MAP) {
return DEFAULT_ERROR_MAP[err.code]!(err.message)
}
// Fallback to a generic SandboxError if no specific mapping is found
return new SandboxError(`${err.code}: ${err.message}`)
}
return err as Error
}
/**
* Like {@link handleRpcError}, but when the connection to the sandbox was dropped
* mid-request it probes the sandbox health to tell apart the sandbox being killed
* from a transient network failure (e.g. a load balancer dropping the connection).
* When the probe confirms the sandbox is gone, a `TimeoutError` is returned —
* consistent with how requests to an already-dead sandbox surface.
*
* @param err - The caught error, expected to be a `ConnectError` from the gRPC transport.
* @param checkHealth - Probe returning whether the sandbox is running, or `undefined` when unknown.
* @param errorMap - Optional map of gRPC `Code` values to error factory functions that override the defaults.
* @returns The corresponding `Error` instance.
*/
export async function handleRpcErrorWithHealthCheck(
err: unknown,
checkHealth?: SandboxHealthCheck,
errorMap?: Partial<Record<Code, (message: string) => Error>>
): Promise<Error> {
if (isConnectionTerminatedError(err) && checkHealth) {
const running = await checkHealth().catch(() => undefined)
if (running === false) {
return new TimeoutError(
`${(err as ConnectError).message}: The sandbox was killed or reached its end of life while the request was in flight.`
)
}
}
return handleRpcError(err, errorMap)
}
function encode64(value: string): string {
switch (runtime) {
case 'deno':
return btoa(value)
case 'node':
return Buffer.from(value).toString('base64')
case 'bun':
return Buffer.from(value).toString('base64')
default:
return btoa(value)
}
}
export function authenticationHeader(
envdVersion: string,
username: string | undefined
): Record<string, string> {
if (
username == undefined &&
compareVersions(envdVersion, ENVD_DEFAULT_USER) < 0
) {
username = defaultUsername
}
if (!username) {
return {}
}
const value = `${username}:`
const encoded = encode64(value)
return { Authorization: `Basic ${encoded}` }
}
+397
View File
@@ -0,0 +1,397 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/envs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get the environment variables */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Environment variables */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EnvVars"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/files": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Download a file */
get: {
parameters: {
query?: {
/** @description Path to the file, URL encoded. Can be relative to user's home directory. */
path?: components["parameters"]["FilePath"];
/** @description Signature used for file access permission verification. */
signature?: components["parameters"]["Signature"];
/** @description Signature expiration used for defining the expiration time of the signature. */
signature_expiration?: components["parameters"]["SignatureExpiration"];
/** @description User used for setting the owner, or resolving relative paths. */
username?: components["parameters"]["User"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["DownloadSuccess"];
400: components["responses"]["InvalidPath"];
401: components["responses"]["InvalidUser"];
404: components["responses"]["FileNotFound"];
500: components["responses"]["InternalServerError"];
};
};
put?: never;
/**
* Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten.
* @description Any request header of the form `X-Metadata-<key>: <value>` is persisted
* as a user-defined extended attribute on the uploaded file. The
* `X-Metadata-` prefix is stripped and the remaining header name is
* lowercased to form the metadata key; the resulting map is returned on
* `EntryInfo` lookups (e.g. `Stat`, `ListDir`).
*
* Each upload replaces the file's metadata with the keys provided in
* that request: keys previously stored but absent from the new request
* are removed, and an upload that sends no `X-Metadata-*` header clears
* all existing metadata.
*
* Both keys and values must be printable US-ASCII (bytes `0x20`-`0x7E`)
* and are rejected with HTTP 400 otherwise. Each key is capped at 246
* bytes (the Linux VFS xattr-name limit minus the namespace prefix), and
* the combined size of all metadata on a file (keys plus values, with the
* namespace prefix counted per key) is capped at 4096 bytes to stay within
* the filesystem's per-inode xattr budget. Multiple files in a single
* multipart upload receive the same metadata. If the same
* `X-Metadata-<key>` header is sent more than once, only the first
* value is used.
*
*/
post: {
parameters: {
query?: {
/** @description Path to the file, URL encoded. Can be relative to user's home directory. */
path?: components["parameters"]["FilePath"];
/** @description Signature used for file access permission verification. */
signature?: components["parameters"]["Signature"];
/** @description Signature expiration used for defining the expiration time of the signature. */
signature_expiration?: components["parameters"]["SignatureExpiration"];
/** @description User used for setting the owner, or resolving relative paths. */
username?: components["parameters"]["User"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody: components["requestBodies"]["File"];
responses: {
200: components["responses"]["UploadSuccess"];
400: components["responses"]["InvalidPath"];
401: components["responses"]["InvalidUser"];
500: components["responses"]["InternalServerError"];
507: components["responses"]["NotEnoughDiskSpace"];
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Check the health of the service */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The service is healthy */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/init": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Set initial vars, ensure the time and metadata is synced with the host */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/** @description Access token for secure access to envd service */
accessToken?: string;
/** @description The default user to use for operations */
defaultUser?: string;
/** @description The default working directory to use for operations */
defaultWorkdir?: string;
envVars?: components["schemas"]["EnvVars"];
/** @description IP address of the hyperloop server to connect to */
hyperloopIP?: string;
/**
* Format: date-time
* @description The current timestamp in RFC3339 format
*/
timestamp?: string;
};
};
};
responses: {
/** @description Env vars set, the time and metadata is synced with the host */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/metrics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get the stats of the service */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The resource usage metrics of the service */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Metrics"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
EntryInfo: {
/** @description User-defined metadata stored as extended attributes on the file. */
metadata?: {
[key: string]: string;
};
/** @description Name of the file */
name: string;
/** @description Path to the file */
path: string;
/**
* @description Type of the file
* @enum {string}
*/
type: "file";
};
/** @description Environment variables to set */
EnvVars: {
[key: string]: string;
};
Error: {
/** @description Error code */
code: number;
/** @description Error message */
message: string;
};
/** @description Resource usage metrics */
Metrics: {
/** @description Number of CPU cores */
cpu_count?: number;
/**
* Format: float
* @description CPU usage percentage
*/
cpu_used_pct?: number;
/** @description Total disk space in bytes */
disk_total?: number;
/** @description Used disk space in bytes */
disk_used?: number;
/** @description Total virtual memory in bytes */
mem_total?: number;
/** @description Used virtual memory in bytes */
mem_used?: number;
/**
* Format: int64
* @description Unix timestamp in UTC for current sandbox time
*/
ts?: number;
};
};
responses: {
/** @description Entire file downloaded successfully. */
DownloadSuccess: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description File not found */
FileNotFound: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Internal server error */
InternalServerError: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Invalid path */
InvalidPath: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Invalid user */
InvalidUser: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not enough disk space */
NotEnoughDiskSpace: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The file was uploaded successfully. */
UploadSuccess: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EntryInfo"][];
};
};
};
parameters: {
/** @description Path to the file, URL encoded. Can be relative to user's home directory. */
FilePath: string;
/** @description Signature used for file access permission verification. */
Signature: string;
/** @description Signature expiration used for defining the expiration time of the signature. */
SignatureExpiration: number;
/** @description User used for setting the owner, or resolving relative paths. */
User: string;
};
requestBodies: {
File: {
content: {
"multipart/form-data": {
/** Format: binary */
file?: string;
};
};
};
};
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
+9
View File
@@ -0,0 +1,9 @@
export const ENVD_VERSION_RECURSIVE_WATCH = '0.1.4'
export const ENVD_DEBUG_FALLBACK = '99.99.99'
export const ENVD_COMMANDS_STDIN = '0.3.0'
export const ENVD_DEFAULT_USER = '0.4.0'
export const ENVD_ENVD_CLOSE = '0.5.2'
export const ENVD_OCTET_STREAM_UPLOAD = '0.5.7'
export const ENVD_FILE_METADATA = '0.6.2'
export const ENVD_VERSION_FS_EVENT_ENTRY_INFO = '0.6.3'
export const ENVD_VERSION_WATCH_NETWORK_MOUNTS = '0.6.4'
+176
View File
@@ -0,0 +1,176 @@
// This is the message for the sandbox timeout error when the response code is 502/Unavailable
export function formatSandboxTimeoutError(message: string) {
return new TimeoutError(
`${message}: This error is likely due to sandbox timeout. You can modify the sandbox timeout by passing 'timeoutMs' when starting the sandbox or calling '.setTimeout' on the sandbox with the desired timeout.`
)
}
/**
* Base class for all sandbox errors.
*
* Thrown when general sandbox errors occur.
*/
export class SandboxError extends Error {
constructor(message?: string, stackTrace?: string) {
super(message)
this.name = 'SandboxError'
if (stackTrace) {
this.stack = stackTrace
}
}
}
/**
* Thrown when a timeout error occurs.
*
* The [unavailable] error type is caused by sandbox timeout.
*
* The [canceled] error type is caused by exceeding request timeout.
*
* The [deadline_exceeded] error type is caused by exceeding the timeout for command execution, watch, etc.
*
* The [unknown] error type is sometimes caused by the sandbox timeout when the request is not processed correctly.
*/
export class TimeoutError extends SandboxError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'TimeoutError'
}
}
/**
* Thrown when an invalid argument is provided.
*/
export class InvalidArgumentError extends SandboxError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'InvalidArgumentError'
}
}
/**
* Thrown when there is not enough disk space.
*/
export class NotEnoughSpaceError extends SandboxError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'NotEnoughSpaceError'
}
}
/**
* Thrown when a resource is not found.
*
* @deprecated Use {@link FileNotFoundError} or {@link SandboxNotFoundError} instead. This class will be removed in the next major version.
*/
export class NotFoundError extends SandboxError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'NotFoundError'
}
}
/**
* Thrown when a file or directory is not found inside a sandbox.
*/
export class FileNotFoundError extends NotFoundError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'FileNotFoundError'
}
}
/**
* Thrown when a sandbox is not found (e.g. it doesn't exist or is no longer running).
*/
export class SandboxNotFoundError extends NotFoundError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'SandboxNotFoundError'
}
}
/**
* Thrown when authentication fails.
*/
export class AuthenticationError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthenticationError'
}
}
/**
* Thrown when git authentication fails.
*/
export class GitAuthError extends AuthenticationError {
constructor(message: string) {
super(message)
this.name = 'GitAuthError'
}
}
/**
* Thrown when git upstream tracking is missing.
*/
export class GitUpstreamError extends SandboxError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'GitUpstreamError'
}
}
/**
* Thrown when the template uses old envd version. It isn't compatible with the new SDK.
*/
export class TemplateError extends SandboxError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'TemplateError'
}
}
/**
* Thrown when the API rate limit is exceeded.
*/
export class RateLimitError extends SandboxError {
constructor(message: string) {
super(message)
this.name = 'RateLimitError'
}
}
/**
* Thrown when the build fails.
*/
export class BuildError extends Error {
constructor(message: string, stackTrace?: string) {
super(message)
this.name = 'BuildError'
if (stackTrace) {
this.stack = stackTrace
}
}
}
/**
* Thrown when the file upload fails.
*/
export class FileUploadError extends BuildError {
constructor(message: string, stackTrace?: string) {
super(message, stackTrace)
this.name = 'FileUploadError'
}
}
/**
* Base class for all volume errors.
*
* Thrown when general volume errors occur.
*/
export class VolumeError extends Error {
constructor(message: string) {
super(message)
this.name = 'VolumeError'
}
}
+151
View File
@@ -0,0 +1,151 @@
export { ApiClient } from './api'
export type { components, paths } from './api'
export { ConnectionConfig } from './connectionConfig'
export type {
ConnectionConfigOpts,
ConnectionOpts,
Username,
} from './connectionConfig'
export {
AuthenticationError,
FileNotFoundError,
GitAuthError,
GitUpstreamError,
InvalidArgumentError,
NotEnoughSpaceError,
NotFoundError,
SandboxError,
SandboxNotFoundError,
TemplateError,
TimeoutError,
RateLimitError,
BuildError,
FileUploadError,
VolumeError,
} from './errors'
export type { Logger } from './logs'
export { getSignature } from './sandbox/signature'
export { FileType } from './sandbox/filesystem'
export type {
WriteInfo,
EntryInfo,
Filesystem,
FilesystemWriteOpts,
FilesystemReadOpts,
} from './sandbox/filesystem'
export { FilesystemEventType } from './sandbox/filesystem/watchHandle'
export type {
FilesystemEvent,
WatchHandle,
} from './sandbox/filesystem/watchHandle'
export { CommandExitError } from './sandbox/commands/commandHandle'
export type {
CommandResult,
Stdout,
Stderr,
PtyOutput,
CommandHandle,
} from './sandbox/commands/commandHandle'
export type {
SandboxInfo,
SandboxMetrics,
SandboxOpts,
SandboxApiOpts,
SandboxConnectOpts,
SandboxMetricsOpts,
SandboxPauseOpts,
SandboxState,
SandboxListOpts,
SandboxPaginator,
SandboxNetworkOpts,
SandboxNetworkInfo,
SandboxNetworkSelector,
SandboxNetworkSelectorContext,
SandboxNetworkRule,
SandboxNetworkRuleInfo,
SandboxNetworkRules,
SandboxNetworkTransform,
SandboxNetworkUpdate,
SandboxOnTimeout,
SandboxLifecycle,
SandboxInfoLifecycle,
SnapshotInfo,
SnapshotListOpts,
SnapshotPaginator,
CreateSnapshotOpts,
} from './sandbox/sandboxApi'
export type { McpServer } from './sandbox/mcp'
export { ALL_TRAFFIC } from './sandbox/network'
export type {
ProcessInfo,
CommandRequestOpts,
CommandConnectOpts,
CommandStartOpts,
Commands,
Pty,
} from './sandbox/commands'
export { Git } from './sandbox/git'
export type {
GitRequestOpts,
GitCloneOpts,
GitInitOpts,
GitRemoteAddOpts,
GitCommitOpts,
GitAddOpts,
GitDeleteBranchOpts,
GitPushOpts,
GitPullOpts,
GitDangerouslyAuthenticateOpts,
GitConfigOpts,
GitConfigScope,
GitBranches,
GitFileStatus,
GitStatus,
} from './sandbox/git'
export { Volume, VolumeFileType } from './volume'
export type {
VolumeInfo,
VolumeAndToken,
VolumeEntryStat,
VolumeMetadataOpts,
VolumeReadOpts,
VolumeWriteOpts,
VolumeApiOpts,
VolumeConnectionConfig,
// Deprecated aliases, kept for backwards compatibility.
VolumeMetadataOptions,
VolumeWriteOptions,
} from './volume'
export { Sandbox }
import { Sandbox } from './sandbox'
export default Sandbox
export * from './template'
export {
ReadyCmd,
waitForPort,
waitForURL,
waitForProcess,
waitForFile,
waitForTimeout,
} from './template/readycmd'
export {
LogEntry,
LogEntryStart,
LogEntryEnd,
type LogEntryLevel,
defaultBuildLogger,
} from './template/logger'
+77
View File
@@ -0,0 +1,77 @@
import type { Interceptor } from '@connectrpc/connect'
import type { Middleware } from 'openapi-fetch'
/**
* Logger interface compatible with {@link console} used for logging Sandbox messages.
*/
export interface Logger {
/**
* Debug level logging method.
*/
debug?: (...args: any[]) => void
/**
* Info level logging method.
*/
info?: (...args: any[]) => void
/**
* Warn level logging method.
*/
warn?: (...args: any[]) => void
/**
* Error level logging method.
*/
error?: (...args: any[]) => void
}
function formatLog(log: any) {
// Protobuf int64 fields are represented as bigint, which JSON.stringify
// can't serialize without a replacer.
return JSON.parse(
JSON.stringify(log, (_, value) =>
typeof value === 'bigint' ? value.toString() : value
)
)
}
export function createRpcLogger(logger: Logger): Interceptor {
async function* logEach(stream: AsyncIterable<any>) {
for await (const m of stream) {
logger.debug?.('Response stream:', formatLog(m))
yield m
}
}
return (next) => async (req) => {
logger.info?.(`Request: POST ${req.url}`)
const res = await next(req)
if (res.stream) {
return {
...res,
message: logEach(res.message),
}
} else {
logger.info?.('Response:', formatLog(res.message))
}
return res
}
}
export function createApiLogger(logger: Logger): Middleware {
return {
async onRequest({ request }) {
logger.info?.(`Request ${request.method} ${request.url}`)
return request
},
async onResponse({ response }) {
if (response.status >= 400) {
logger.error?.('Response:', response.status, response.statusText)
} else {
logger.info?.('Response:', response.status, response.statusText)
}
return response
},
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { ConnectionOpts } from './connectionConfig'
/**
* Generic, reusable paginator for cursor-based list endpoints.
*
* The base owns the shared pagination state — `hasNext`, `nextToken`, and the
* reading of the `x-next-token` response header (via {@link Paginator.updatePagination}).
* Each concrete paginator implements {@link Paginator.nextItems} to do the
* actual fetching for its endpoint, so any model can expose pagination by
* subclassing this without reimplementing the bookkeeping.
*
* The optional `O` type parameter is the per-call options type accepted by
* `nextItems` (e.g. connection options for a given API).
*
* @example
* ```ts
* const paginator = Sandbox.list()
* while (paginator.hasNext) {
* const items = await paginator.nextItems()
* console.log(items)
* }
* ```
*/
export abstract class Paginator<T, O extends ConnectionOpts = ConnectionOpts> {
protected readonly opts?: O
protected readonly limit?: number
private _hasNext: boolean
private _nextToken?: string
constructor(opts?: O, limit?: number, nextToken?: string) {
this.opts = opts
this.limit = limit
this._hasNext = true
this._nextToken = nextToken
}
/**
* Returns true if there are more items to fetch.
*/
get hasNext(): boolean {
return this._hasNext
}
/**
* Returns the next token to use for pagination.
*/
get nextToken(): string | undefined {
return this._nextToken
}
/**
* Update the pagination state from a response, reading the `x-next-token`
* header. Concrete paginators call this from {@link Paginator.nextItems}
* after fetching a page.
*/
protected updatePagination(response: Response) {
this._nextToken = response.headers.get('x-next-token') || undefined
this._hasNext = !!this._nextToken
}
/**
* Get the next page of items.
*
* @param opts per-call connection options. When provided, this call uses
* these options (e.g. `apiKey`, `domain`, `headers`, `requestTimeoutMs`,
* `signal`) instead of the ones the paginator was constructed with.
* Aborting a page via `signal` does not affect subsequent {@link Paginator.nextItems}
* calls — pass a fresh signal each call you want to be cancellable.
*
* @throws Error if there are no more items to fetch. Call this method only if `hasNext` is `true`.
*
* @returns List of items
*/
abstract nextItems(opts?: O): Promise<T[]>
}
@@ -0,0 +1,375 @@
import {
handleRpcErrorWithHealthCheck,
SandboxHealthCheck,
} from '../../envd/rpc'
import { SandboxError } from '../../errors'
import { ConnectResponse, StartResponse } from '../../envd/process/process_pb'
import type { CommandRequestOpts } from '.'
declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }
export type Branded<T, B> = T & Brand<B>
export type Stdout = Branded<string, 'stdout'>
export type Stderr = Branded<string, 'stderr'>
export type PtyOutput = Branded<Uint8Array, 'pty'>
/**
* Command execution result.
*/
export interface CommandResult {
/**
* Command execution exit code.
* `0` if the command finished successfully.
*/
exitCode: number
/**
* Error message from command execution if it failed.
*/
error?: string
/**
* Command stdout output.
*/
stdout: string
/**
* Command stderr output.
*/
stderr: string
}
/**
* Error thrown when a command exits with a non-zero exit code.
*/
export class CommandExitError extends SandboxError implements CommandResult {
constructor(private readonly result: CommandResult) {
super(result.error)
this.name = 'CommandExitError'
}
/**
* Command execution exit code.
* `0` if the command finished successfully.
*/
get exitCode() {
return this.result.exitCode
}
/**
* Error message from command execution.
*/
get error() {
return this.result.error
}
/**
* Command execution stdout output.
*/
get stdout() {
return this.result.stdout
}
/**
* Command execution stderr output.
*/
get stderr() {
return this.result.stderr
}
}
/**
* Command execution handle.
*
* It provides methods for waiting for the command to finish, retrieving stdout/stderr, and killing the command.
*
* @property {number} pid process ID of the command.
*/
export class CommandHandle
implements
Omit<CommandResult, 'exitCode' | 'error'>,
Partial<Pick<CommandResult, 'exitCode' | 'error'>>
{
private _stdout = ''
private _stderr = ''
private readonly stdoutDecoder = new TextDecoder()
private readonly stderrDecoder = new TextDecoder()
private result?: CommandResult
private iterationError?: Error
private disconnected = false
private readonly _wait: Promise<void>
/**
* @hidden
* @internal
* @access protected
*/
constructor(
readonly pid: number,
private readonly handleDisconnect: () => void,
private readonly handleKill: () => Promise<boolean>,
private readonly events: AsyncIterable<ConnectResponse | StartResponse>,
private readonly onStdout?: (stdout: string) => void | Promise<void>,
private readonly onStderr?: (stderr: string) => void | Promise<void>,
private readonly onPty?: (pty: Uint8Array) => void | Promise<void>,
private readonly handleSendStdin?: (
data: string | Uint8Array,
opts?: CommandRequestOpts
) => Promise<void>,
private readonly handleCloseStdin?: (
opts?: CommandRequestOpts
) => Promise<void>,
private readonly checkHealth?: SandboxHealthCheck
) {
this._wait = this.handleEvents()
}
/**
* Command execution exit code.
* `0` if the command finished successfully.
*
* It is `undefined` if the command is still running.
*/
get exitCode() {
return this.result?.exitCode
}
/**
* Error message from command execution.
*/
get error() {
return this.result?.error
}
/**
* Command execution stderr output.
*/
get stderr() {
return this._stderr
}
/**
* Command execution stdout output.
*/
get stdout() {
return this._stdout
}
/**
* Wait for the command to finish and return the result.
* If the command exits with a non-zero exit code, it throws a `CommandExitError`.
*
* @returns `CommandResult` result of command execution.
*/
async wait() {
await this._wait
if (this.iterationError) {
throw this.iterationError
}
if (!this.result) {
throw new SandboxError('Process exited without a result')
}
if (this.result.exitCode !== 0) {
throw new CommandExitError(this.result)
}
return this.result
}
/**
* Disconnect from the command.
*
* The command is not killed, but SDK stops receiving events from the command.
* You can reconnect to the command using {@link Commands.connect}.
*
* Once it returns, the `onStdout`/`onStderr`/`onPty` callbacks are guaranteed
* not to fire for output produced after this call. It does not wait for the
* event handler to drain, so it returns promptly even for an idle command
* whose stream produces no further output.
*/
async disconnect() {
this.disconnected = true
this.handleDisconnect()
}
/**
* Kill the command.
* It uses `SIGKILL` signal to kill the command.
*
* @returns `true` if the command was killed successfully, `false` if the command was not found.
*/
async kill() {
return await this.handleKill()
}
/**
* Send data to the command stdin.
*
* The command must have been started with `stdin: true`.
*
* @param data data to send to the command.
* @param opts connection options.
*/
async sendStdin(
data: string | Uint8Array,
opts?: CommandRequestOpts
): Promise<void> {
if (!this.handleSendStdin) {
throw new SandboxError(
'Sending stdin is not supported for this command handle.'
)
}
await this.handleSendStdin(data, opts)
}
/**
* Close the command stdin.
*
* This signals EOF to the command. The command must have been started with
* `stdin: true`.
*
* @param opts connection options.
*/
async closeStdin(opts?: CommandRequestOpts): Promise<void> {
if (!this.handleCloseStdin) {
throw new SandboxError(
'Closing stdin is not supported for this command handle.'
)
}
await this.handleCloseStdin(opts)
}
/**
* Flush any bytes still buffered in the stream decoders.
*
* Incomplete trailing UTF-8 sequences are emitted as replacement
* characters, matching the per-chunk decoding behavior.
*/
private *flushDecoders(): Generator<
[Stdout, null, null] | [null, Stderr, null]
> {
const stdoutRest = this.stdoutDecoder.decode()
if (stdoutRest) {
this._stdout += stdoutRest
yield [stdoutRest as Stdout, null, null]
}
const stderrRest = this.stderrDecoder.decode()
if (stderrRest) {
this._stderr += stderrRest
yield [null, stderrRest as Stderr, null]
}
}
private async *iterateEvents(): AsyncGenerator<
[Stdout, null, null] | [null, Stderr, null] | [null, null, PtyOutput]
> {
try {
for await (const event of this.events) {
const e = event?.event?.event
let out: string | undefined
switch (e?.case) {
case 'data':
switch (e.value.output.case) {
case 'stdout':
out = this.stdoutDecoder.decode(e.value.output.value, {
stream: true,
})
if (out) {
this._stdout += out
yield [out as Stdout, null, null]
}
break
case 'stderr':
out = this.stderrDecoder.decode(e.value.output.value, {
stream: true,
})
if (out) {
this._stderr += out
yield [null, out as Stderr, null]
}
break
case 'pty':
yield [null, null, e.value.output.value as PtyOutput]
break
}
break
case 'end': {
// Flush trailing decoder bytes into the accumulators and record the
// result *before* yielding the flushed chunks. A disconnected
// consumer breaks out of `handleEvents` on the first yielded chunk,
// which would otherwise abort this generator before `this.result`
// is assigned and make `wait()` fail as if the process never
// produced a result.
const flushed = [...this.flushDecoders()]
this.result = {
exitCode: e.value.exitCode,
error: e.value.error,
stdout: this.stdout,
stderr: this.stderr,
}
for (const chunk of flushed) {
yield chunk
}
break
}
}
// TODO: Handle empty events like in python SDK
}
} catch (e) {
// The stream raised before an `end` event (e.g. disconnect or RPC
// failure). Flush any bytes still buffered in the decoders so incomplete
// trailing sequences surface as replacement characters instead of being
// silently dropped, then re-raise so the error is still surfaced.
yield* this.flushDecoders()
throw e
}
// If the stream closed without an `end` event (e.g. disconnect or a
// dropped connection), flush any bytes still buffered in the decoders so
// incomplete trailing sequences surface as replacement characters instead
// of being silently dropped.
if (this.result === undefined) {
yield* this.flushDecoders()
}
}
private async handleEvents() {
try {
for await (const [stdout, stderr, pty] of this.iterateEvents()) {
// The handle was disconnected — stop dispatching to the callbacks. The
// flag is checked before every dispatch, so no callback fires for
// output that arrives (or was buffered) after disconnect() was called,
// even if the underlying abort hasn't torn the stream down yet. There
// is no synchronous suspension point between this check and the
// dispatch below, so disconnect() cannot interleave to let a late event
// slip through.
if (this.disconnected) {
break
}
if (stdout !== null) {
await this.onStdout?.(stdout)
} else if (stderr !== null) {
await this.onStderr?.(stderr)
} else if (pty) {
await this.onPty?.(pty)
}
}
} catch (e) {
this.iterationError = await handleRpcErrorWithHealthCheck(
e,
this.checkHealth
)
} finally {
this.handleDisconnect()
}
}
}
@@ -0,0 +1,488 @@
import {
Client,
Code,
ConnectError,
Transport,
createClient,
} from '@connectrpc/connect'
import { compareVersions } from 'compare-versions'
import {
ConnectionConfig,
ConnectionOpts,
KEEPALIVE_PING_HEADER,
KEEPALIVE_PING_INTERVAL_SEC,
setupRequestController,
Username,
} from '../../connectionConfig'
import {
checkSandboxHealth,
EnvdApiClient,
handleProcessStartEvent,
} from '../../envd/api'
import {
Process as ProcessService,
Signal,
} from '../../envd/process/process_pb'
import {
authenticationHeader,
handleRpcErrorWithHealthCheck,
SandboxHealthCheck,
} from '../../envd/rpc'
import { ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE } from '../../envd/versions'
import { SandboxError } from '../../errors'
import { CommandHandle, CommandResult } from './commandHandle'
export { Pty } from './pty'
/**
* Options for sending a command request.
*/
export interface CommandRequestOpts
extends Partial<Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>> {}
/**
* Options for starting a new command.
*/
export interface CommandStartOpts extends CommandRequestOpts {
/**
* If true, starts command in the background and the method returns immediately.
* You can use {@link CommandHandle.wait} to wait for the command to finish.
*/
background?: boolean
/**
* Working directory for the command.
*
* @default // home directory of the user used to start the command
*/
cwd?: string
/**
* User to run the command as.
*
* @default `default Sandbox user (as specified in the template)`
*/
user?: Username
/**
* Environment variables used for the command.
*
* This overrides the default environment variables from `Sandbox` constructor.
*
* @default `{}`
*/
envs?: Record<string, string>
/**
* Callback for command stdout output.
*/
onStdout?: (data: string) => void | Promise<void>
/**
* Callback for command stderr output.
*/
onStderr?: (data: string) => void | Promise<void>
/**
* If true, command stdin is kept open and you can send data to it using {@link Commands.sendStdin} or {@link CommandHandle.sendStdin}.
* @default false
*/
stdin?: boolean
/**
* Timeout for the command in **milliseconds**.
*
* @default 60_000 // 60 seconds
*/
timeoutMs?: number
}
/**
* Options for connecting to a command.
*/
export type CommandConnectOpts = Pick<
CommandStartOpts,
'onStderr' | 'onStdout' | 'timeoutMs'
> &
CommandRequestOpts
/**
* Information about a command, PTY session or start command running in the sandbox as process.
*/
export interface ProcessInfo {
/**
* Process ID.
*/
pid: number
/**
* Custom tag used for identifying special commands like start command in the custom template.
*/
tag?: string
/**
* Command that was executed.
*/
cmd: string
/**
* Command arguments.
*/
args: string[]
/**
* Environment variables used for the command.
*/
envs: Record<string, string>
/**
* Executed command working directory.
*/
cwd?: string
}
/**
* Module for starting and interacting with commands in the sandbox.
*/
export class Commands {
protected readonly rpc: Client<typeof ProcessService>
private readonly defaultProcessConnectionTimeout = 60_000 // 60 seconds
private readonly envdVersion: string
private readonly checkHealth: SandboxHealthCheck
constructor(
transport: Transport,
private readonly envdApi: EnvdApiClient,
private readonly connectionConfig: ConnectionConfig
) {
this.rpc = createClient(ProcessService, transport)
this.envdVersion = envdApi.version
this.checkHealth = () => checkSandboxHealth(this.envdApi)
}
/**
* @hidden
* @internal
*/
get supportsStdinClose(): boolean {
return compareVersions(this.envdVersion, ENVD_ENVD_CLOSE) >= 0
}
/**
* List all running commands and PTY sessions.
*
* @param opts connection options.
*
* @returns list of running commands and PTY sessions.
*/
async list(opts?: CommandRequestOpts): Promise<ProcessInfo[]> {
try {
const res = await this.rpc.list(
{},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
return res.processes.map((p) => ({
pid: p.pid,
...(p.tag && { tag: p.tag }),
args: p.config!.args,
envs: p.config!.envs,
cmd: p.config!.cmd,
...(p.config!.cwd && { cwd: p.config!.cwd }),
}))
} catch (err) {
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Send data to command stdin.
*
* @param pid process ID of the command. You can get the list of running commands using {@link Commands.list}.
* @param data data to send to the command.
* @param opts connection options.
*/
async sendStdin(
pid: number,
data: string | Uint8Array,
opts?: CommandRequestOpts
): Promise<void> {
try {
const payload =
typeof data === 'string' ? new TextEncoder().encode(data) : data
await this.rpc.sendInput(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
input: {
input: {
case: 'stdin',
value: payload,
},
},
},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
} catch (err) {
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Close command stdin.
*
* This signals EOF to the command. The command must have been started with `stdin: true`.
*
* @param pid process ID of the command. You can get the list of running commands using {@link Commands.list}.
* @param opts connection options.
*/
async closeStdin(pid: number, opts?: CommandRequestOpts): Promise<void> {
if (!this.supportsStdinClose) {
throw new SandboxError(
`Sandbox envd version ${this.envdVersion} doesn't support closeStdin. Please rebuild your template to pick up the latest sandbox version.`
)
}
try {
await this.rpc.closeStdin(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
} catch (err) {
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Kill a running command specified by its process ID.
* It uses `SIGKILL` signal to kill the command.
*
* @param pid process ID of the command. You can get the list of running commands using {@link Commands.list}.
* @param opts connection options.
*
* @returns `true` if the command was killed, `false` if the command was not found.
*/
async kill(pid: number, opts?: CommandRequestOpts): Promise<boolean> {
try {
await this.rpc.sendSignal(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
signal: Signal.SIGKILL,
},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
return true
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.NotFound) {
return false
}
}
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Connect to a running command.
* You can use {@link CommandHandle.wait} to wait for the command to finish and get execution results.
*
* @param pid process ID of the command to connect to. You can get the list of running commands using {@link Commands.list}.
* @param opts connection options.
*
* @returns `CommandHandle` handle to interact with the running command.
*/
async connect(
pid: number,
opts?: CommandConnectOpts
): Promise<CommandHandle> {
const requestTimeoutMs =
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
const { controller, clearStartTimeout, cleanup } = setupRequestController(
requestTimeoutMs,
opts?.signal
)
const events = this.rpc.connect(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
},
{
signal: controller.signal,
headers: {
[KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(),
},
timeoutMs: opts?.timeoutMs ?? this.defaultProcessConnectionTimeout,
}
)
try {
const pid = await handleProcessStartEvent(events)
clearStartTimeout()
return new CommandHandle(
pid,
cleanup,
() => this.kill(pid),
events,
opts?.onStdout,
opts?.onStderr,
undefined,
(data, stdinOpts) => this.sendStdin(pid, data, stdinOpts),
(stdinOpts) => this.closeStdin(pid, stdinOpts),
this.checkHealth
)
} catch (err) {
cleanup()
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Start a new command and wait until it finishes executing.
*
* @param cmd command to execute.
* @param opts options for starting the command.
*
* @returns `CommandResult` result of the command execution.
*/
async run(
cmd: string,
opts?: CommandStartOpts & { background?: false }
): Promise<CommandResult>
/**
* Start a new command in the background.
* You can use {@link CommandHandle.wait} to wait for the command to finish and get its result.
*
* @param cmd command to execute.
* @param opts options for starting the command
*
* @returns `CommandHandle` handle to interact with the running command.
*/
async run(
cmd: string,
opts: CommandStartOpts & { background: true }
): Promise<CommandHandle>
// NOTE - The following overload seems redundant, but it's required to make the type inference work correctly.
/**
* Start a new command.
*
* @param cmd command to execute.
* @param opts options for starting the command.
* - `opts.background: true` - runs in background, returns `CommandHandle`
* - `opts.background: false | undefined` - waits for completion, returns `CommandResult`
*
* @returns Either a `CommandHandle` or a `CommandResult` (depending on `opts.background`).
*/
async run(
cmd: string,
opts?: CommandStartOpts & { background?: boolean }
): Promise<CommandHandle | CommandResult>
async run(
cmd: string,
opts?: CommandStartOpts & { background?: boolean }
): Promise<CommandHandle | CommandResult> {
const proc = await this.start(cmd, opts)
return opts?.background ? proc : proc.wait()
}
private async start(
cmd: string,
opts?: CommandStartOpts
): Promise<CommandHandle> {
if (
opts?.stdin === false &&
compareVersions(this.envdVersion, ENVD_COMMANDS_STDIN) < 0
) {
throw new SandboxError(
`Sandbox envd version ${this.envdVersion} can't specify stdin, it's always turned on. Please rebuild your template if you need this feature.`
)
}
const requestTimeoutMs =
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
const { controller, clearStartTimeout, cleanup } = setupRequestController(
requestTimeoutMs,
opts?.signal
)
const events = this.rpc.start(
{
process: {
cmd: '/bin/bash',
cwd: opts?.cwd,
envs: opts?.envs,
args: ['-l', '-c', cmd],
},
stdin: opts?.stdin || false,
},
{
headers: {
...authenticationHeader(this.envdVersion, opts?.user),
[KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(),
},
signal: controller.signal,
timeoutMs: opts?.timeoutMs ?? this.defaultProcessConnectionTimeout,
}
)
try {
const pid = await handleProcessStartEvent(events)
clearStartTimeout()
return new CommandHandle(
pid,
cleanup,
() => this.kill(pid),
events,
opts?.onStdout,
opts?.onStderr,
undefined,
(data, stdinOpts) => this.sendStdin(pid, data, stdinOpts),
(stdinOpts) => this.closeStdin(pid, stdinOpts),
this.checkHealth
)
} catch (err) {
cleanup()
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
}
+347
View File
@@ -0,0 +1,347 @@
import {
Code,
ConnectError,
createClient,
Client,
Transport,
} from '@connectrpc/connect'
import {
Signal,
Process as ProcessService,
} from '../../envd/process/process_pb'
import {
ConnectionConfig,
ConnectionOpts,
Username,
KEEPALIVE_PING_HEADER,
KEEPALIVE_PING_INTERVAL_SEC,
setupRequestController,
} from '../../connectionConfig'
import { CommandHandle } from './commandHandle'
import {
authenticationHeader,
handleRpcErrorWithHealthCheck,
SandboxHealthCheck,
} from '../../envd/rpc'
import {
checkSandboxHealth,
EnvdApiClient,
handleProcessStartEvent,
} from '../../envd/api'
export interface PtyCreateOpts
extends Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'> {
/**
* Number of columns for the PTY.
*/
cols: number
/**
* Number of rows for the PTY.
*/
rows: number
/**
* Callback to handle PTY data.
*/
onData: (data: Uint8Array) => void | Promise<void>
/**
* Timeout for the PTY in **milliseconds**.
*
* @default 60_000 // 60 seconds
*/
timeoutMs?: number
/**
* User to use for the PTY.
*
* @default `default Sandbox user (as specified in the template)`
*/
user?: Username
/**
* Environment variables for the PTY.
*
* @default {}
*/
envs?: Record<string, string>
/**
* Working directory for the PTY.
*
* @default // home directory of the user used to start the PTY
*/
cwd?: string
}
/**
* Options for connecting to a command.
*/
export type PtyConnectOpts = Pick<PtyCreateOpts, 'onData' | 'timeoutMs'> &
Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>
/**
* Module for interacting with PTYs (pseudo-terminals) in the sandbox.
*/
export class Pty {
private readonly rpc: Client<typeof ProcessService>
private readonly envdVersion: string
private readonly checkHealth: SandboxHealthCheck
private readonly defaultPtyConnectionTimeout = 60_000 // 60 seconds
constructor(
private readonly transport: Transport,
private readonly envdApi: EnvdApiClient,
private readonly connectionConfig: ConnectionConfig
) {
this.rpc = createClient(ProcessService, this.transport)
this.envdVersion = envdApi.version
this.checkHealth = () => checkSandboxHealth(this.envdApi)
}
/**
* Create a new PTY (pseudo-terminal).
*
* @param opts options for creating the PTY.
*
* @returns handle to interact with the PTY.
*/
async create(opts: PtyCreateOpts) {
const requestTimeoutMs =
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
const envs = { ...(opts?.envs ?? {}) }
envs.TERM = envs.TERM ?? 'xterm-256color'
envs.LANG = envs.LANG ?? 'C.UTF-8'
envs.LC_ALL = envs.LC_ALL ?? 'C.UTF-8'
const { controller, clearStartTimeout, cleanup } = setupRequestController(
requestTimeoutMs,
opts?.signal
)
const events = this.rpc.start(
{
process: {
cmd: '/bin/bash',
args: ['-i', '-l'],
envs: envs,
cwd: opts?.cwd,
},
pty: {
size: {
cols: opts.cols,
rows: opts.rows,
},
},
},
{
headers: {
...authenticationHeader(this.envdVersion, opts?.user),
[KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(),
},
signal: controller.signal,
timeoutMs: opts?.timeoutMs ?? this.defaultPtyConnectionTimeout,
}
)
try {
const pid = await handleProcessStartEvent(events)
clearStartTimeout()
return new CommandHandle(
pid,
cleanup,
() => this.kill(pid),
events,
undefined,
undefined,
opts.onData,
undefined,
undefined,
this.checkHealth
)
} catch (err) {
cleanup()
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Connect to a running PTY.
*
* @param pid process ID of the PTY to connect to. You can get the list of running PTYs using {@link Commands.list}.
* @param opts connection options.
*
* @returns handle to interact with the PTY.
*/
async connect(pid: number, opts?: PtyConnectOpts): Promise<CommandHandle> {
const requestTimeoutMs =
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
const { controller, clearStartTimeout, cleanup } = setupRequestController(
requestTimeoutMs,
opts?.signal
)
const events = this.rpc.connect(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
},
{
signal: controller.signal,
headers: {
[KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(),
},
timeoutMs: opts?.timeoutMs ?? this.defaultPtyConnectionTimeout,
}
)
try {
const pid = await handleProcessStartEvent(events)
clearStartTimeout()
return new CommandHandle(
pid,
cleanup,
() => this.kill(pid),
events,
undefined,
undefined,
opts?.onData,
undefined,
undefined,
this.checkHealth
)
} catch (err) {
cleanup()
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Send input to a PTY.
*
* @param pid process ID of the PTY.
* @param data input data to send to the PTY.
* @param opts connection options.
*/
async sendInput(
pid: number,
data: Uint8Array,
opts?: Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>
): Promise<void> {
try {
await this.rpc.sendInput(
{
input: {
input: {
case: 'pty',
value: data,
},
},
process: {
selector: {
case: 'pid',
value: pid,
},
},
},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
} catch (err) {
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Resize PTY.
* Call this when the terminal window is resized and the number of columns and rows has changed.
*
* @param pid process ID of the PTY.
* @param size new size of the PTY.
* @param opts connection options.
*/
async resize(
pid: number,
size: {
cols: number
rows: number
},
opts?: Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>
): Promise<void> {
try {
await this.rpc.update(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
pty: {
size,
},
},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
} catch (err) {
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
/**
* Kill a running PTY specified by process ID.
* It uses `SIGKILL` signal to kill the PTY.
*
* @param pid process ID of the PTY.
* @param opts connection options.
*
* @returns `true` if the PTY was killed, `false` if the PTY was not found.
*/
async kill(
pid: number,
opts?: Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>
): Promise<boolean> {
try {
await this.rpc.sendSignal(
{
process: {
selector: {
case: 'pid',
value: pid,
},
},
signal: Signal.SIGKILL,
},
{
signal: this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
),
}
)
return true
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.NotFound) {
return false
}
}
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import {
handleRpcErrorWithHealthCheck,
SandboxHealthCheck,
} from '../../envd/rpc'
import {
EventType,
WatchDirResponse,
} from '../../envd/filesystem/filesystem_pb'
import { EntryInfo, mapEntryInfo } from './index'
/**
* Sandbox filesystem event types.
*/
export enum FilesystemEventType {
/**
* Filesystem object permissions were changed.
*/
CHMOD = 'chmod',
/**
* Filesystem object was created.
*/
CREATE = 'create',
/**
* Filesystem object was removed.
*/
REMOVE = 'remove',
/**
* Filesystem object was renamed.
*/
RENAME = 'rename',
/**
* Filesystem object was written to.
*/
WRITE = 'write',
}
function mapEventType(type: EventType) {
switch (type) {
case EventType.CHMOD:
return FilesystemEventType.CHMOD
case EventType.CREATE:
return FilesystemEventType.CREATE
case EventType.REMOVE:
return FilesystemEventType.REMOVE
case EventType.RENAME:
return FilesystemEventType.RENAME
case EventType.WRITE:
return FilesystemEventType.WRITE
}
}
/**
* Information about a filesystem event.
*/
export interface FilesystemEvent {
/**
* Relative path to the filesystem object.
*/
name: string
/**
* Filesystem operation event type.
*/
type: FilesystemEventType
/**
* Information about the entry that triggered the event.
*
* Only populated when the watch was started with `includeEntry: true` and the
* sandbox's envd version supports it. It may be `undefined` for events where the
* entry no longer exists at the path (e.g. remove or rename-away events).
*/
entry?: EntryInfo
}
/**
* Handle for watching a directory in the sandbox filesystem.
*
* Use {@link WatchHandle.stop} to stop watching the directory.
*/
export class WatchHandle {
constructor(
private readonly handleStop: () => void,
private readonly events: AsyncIterable<WatchDirResponse>,
private readonly onEvent?: (event: FilesystemEvent) => void | Promise<void>,
private readonly onExit?: (err?: Error) => void | Promise<void>,
private readonly checkHealth?: SandboxHealthCheck
) {
this.handleEvents()
}
/**
* Stop watching the directory.
*/
async stop() {
this.handleStop()
}
private async *iterateEvents() {
try {
for await (const event of this.events) {
switch (event.event.case) {
case 'filesystem':
yield event.event
break
}
}
} catch (err) {
throw await handleRpcErrorWithHealthCheck(err, this.checkHealth)
}
}
private async handleEvents() {
let iterationError: Error | undefined
try {
for await (const event of this.iterateEvents()) {
const eventType = mapEventType(event.value.type)
if (eventType === undefined) {
continue
}
// Await the callback so an async `onEvent` that rejects is routed to
// `onExit` below (instead of becoming an unhandled promise rejection
// that can crash Node) and so the user can apply backpressure. Mirrors
// `CommandHandle.handleEvents`.
await this.onEvent?.({
name: event.value.name,
type: eventType,
entry: event.value.entry
? mapEntryInfo(event.value.entry)
: undefined,
})
}
} catch (err) {
iterationError = err as Error
}
try {
// Invoke `onExit` exactly once: with the error when the watch ended
// because of one, or with no argument on a clean end.
if (iterationError) {
await this.onExit?.(iterationError)
} else {
await this.onExit?.()
}
} catch {
// `onExit` is the terminal callback; an error it throws has nowhere to
// propagate in this detached handler, so it's swallowed to avoid an
// unhandled promise rejection (the failure mode this guards against).
} finally {
this.handleStop()
}
}
}
File diff suppressed because it is too large Load Diff
+587
View File
@@ -0,0 +1,587 @@
import { InvalidArgumentError } from '../../errors'
import { shellQuote } from '../../utils'
import { CommandExitError } from '../commands/commandHandle'
/**
* Parsed git status entry for a file.
*/
export interface GitFileStatus {
/**
* Path relative to the repository root.
*/
name: string
/**
* Normalized status string (for example, `"modified"` or `"added"`).
*/
status: GitStatusLabel
/**
* Index status character from porcelain output.
*/
indexStatus: string
/**
* Working tree status character from porcelain output.
*/
workingTreeStatus: string
/**
* Whether the change is staged.
*/
staged: boolean
/**
* Original path when the file was renamed.
*/
renamedFrom?: string
}
/**
* Supported normalized git status labels.
*/
export type GitStatusLabel =
| 'conflict'
| 'renamed'
| 'copied'
| 'deleted'
| 'added'
| 'modified'
| 'typechange'
| 'untracked'
| 'unknown'
/**
* Scope for git config operations.
*/
export type GitConfigScope = 'global' | 'local' | 'system'
/**
* Parsed git repository status.
*/
export interface GitStatus {
/**
* Current branch name, if available.
*/
currentBranch?: string
/**
* Upstream branch name, if available.
*/
upstream?: string
/**
* Number of commits the branch is ahead of upstream.
*/
ahead: number
/**
* Number of commits the branch is behind upstream.
*/
behind: number
/**
* Whether HEAD is detached.
*/
detached: boolean
/**
* List of file status entries.
*/
fileStatus: GitFileStatus[]
/**
* Whether the repository has no tracked or untracked file changes.
*/
isClean: boolean
/**
* Whether the repository has any tracked or untracked file changes.
*/
hasChanges: boolean
/**
* Whether there are staged changes.
*/
hasStaged: boolean
/**
* Whether there are untracked files.
*/
hasUntracked: boolean
/**
* Whether there are merge conflicts.
*/
hasConflicts: boolean
/**
* Total number of changed files.
*/
totalCount: number
/**
* Number of files with staged changes.
*/
stagedCount: number
/**
* Number of files with unstaged changes.
*/
unstagedCount: number
/**
* Number of untracked files.
*/
untrackedCount: number
/**
* Number of files with merge conflicts.
*/
conflictCount: number
}
/**
* Parsed git branch list.
*/
export interface GitBranches {
/**
* List of branch names.
*/
branches: string[]
/**
* Current branch name, if available.
*/
currentBranch?: string
}
/**
* Add HTTP(S) credentials to a Git URL.
*
* @param url Git repository URL.
* @param username Username for HTTP(S) authentication.
* @param password Password or token for HTTP(S) authentication.
* @returns URL with embedded credentials.
*/
export function withCredentials(
url: string,
username?: string,
password?: string
): string {
if (!username && !password) {
return url
}
if (!username || !password) {
throw new InvalidArgumentError(
'Both username and password are required when using Git credentials.'
)
}
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new InvalidArgumentError(`Invalid Git URL: ${url}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new InvalidArgumentError(
'Only http(s) Git URLs support username/password credentials.'
)
}
parsed.username = username
parsed.password = password
return parsed.toString()
}
/**
* Strip HTTP(S) credentials from a Git URL.
*
* @param url Git repository URL.
* @returns URL without embedded credentials.
*/
export function stripCredentials(url: string): string {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return url
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return url
}
if (!parsed.username && !parsed.password) {
return url
}
parsed.username = ''
parsed.password = ''
return parsed.toString()
}
/**
* Derive the default repository directory name from a Git URL.
*
* @param url Git repository URL.
* @returns Repository directory name, if it can be determined.
*/
export function deriveRepoDirFromUrl(url: string): string | undefined {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return undefined
}
const trimmedPath = parsed.pathname.replace(/\/+$/, '')
const lastSegment = trimmedPath.split('/').pop()
if (!lastSegment) {
return undefined
}
return lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment
}
/**
* Build a shell-safe git command string.
*
* @param args Git command arguments.
* @param repoPath Repository path for `git -C`, if provided.
* @returns Shell-safe git command.
*/
export function buildGitCommand(args: string[], repoPath?: string): string {
const parts = ['git']
if (repoPath) {
parts.push('-C', repoPath)
}
parts.push(...args)
return parts.map((part) => shellQuote(part)).join(' ')
}
type GitPushArgsOptions = {
remote?: string
branch?: string
setUpstream: boolean
}
export function buildPushArgs(
remoteName: string | undefined,
opts: GitPushArgsOptions
): string[] {
const { remote, branch, setUpstream } = opts
const args = ['push']
const targetRemote = remoteName ?? remote
if (setUpstream && targetRemote) {
args.push('--set-upstream')
}
if (targetRemote) {
args.push(targetRemote)
}
if (branch) {
args.push(branch)
}
return args
}
function parseAheadBehind(segment?: string): { ahead: number; behind: number } {
if (!segment) {
return { ahead: 0, behind: 0 }
}
let ahead = 0
let behind = 0
if (segment.includes('ahead')) {
try {
ahead = Number.parseInt(
segment.split('ahead')[1].split(',')[0].trim(),
10
)
} catch {
ahead = 0
}
}
if (segment.includes('behind')) {
try {
behind = Number.parseInt(
segment.split('behind')[1].split(',')[0].trim(),
10
)
} catch {
behind = 0
}
}
return { ahead, behind }
}
function normalizeBranchName(name: string): string {
if (name.startsWith('HEAD (detached at ')) {
return name.replace('HEAD (detached at ', '').replace(/\)$/, '')
}
return name
.replace('HEAD (no branch)', 'HEAD')
.replace('No commits yet on ', '')
.replace('Initial commit on ', '')
}
function deriveStatus(
indexStatus: string,
workingStatus: string
): GitStatusLabel {
const statuses = new Set([indexStatus, workingStatus])
if (statuses.has('U')) return 'conflict'
if (statuses.has('R')) return 'renamed'
if (statuses.has('C')) return 'copied'
if (statuses.has('D')) return 'deleted'
if (statuses.has('A')) return 'added'
if (statuses.has('M')) return 'modified'
if (statuses.has('T')) return 'typechange'
if (statuses.has('?')) return 'untracked'
return 'unknown'
}
/**
* Parse `git status --porcelain=1 -b` output into a structured object.
*
* @param output Git status output.
* @returns Parsed {@link GitStatus}.
*/
export function parseGitStatus(output: string): GitStatus {
const lines = output
.split('\n')
.map((line) => line.replace(/\r$/, ''))
.filter((line) => line.trim().length > 0)
let currentBranch: string | undefined
let upstream: string | undefined
let ahead = 0
let behind = 0
let detached = false
const fileStatus: GitFileStatus[] = []
if (lines.length === 0) {
return {
currentBranch,
upstream,
ahead,
behind,
detached,
fileStatus,
isClean: true,
hasChanges: false,
hasStaged: false,
hasUntracked: false,
hasConflicts: false,
totalCount: 0,
stagedCount: 0,
unstagedCount: 0,
untrackedCount: 0,
conflictCount: 0,
}
}
const branchLine = lines[0]
if (branchLine.startsWith('## ')) {
const branchInfo = branchLine.slice(3)
const aheadStart = branchInfo.indexOf(' [')
const branchPart =
aheadStart === -1 ? branchInfo : branchInfo.slice(0, aheadStart)
const aheadPart =
aheadStart === -1 ? undefined : branchInfo.slice(aheadStart + 2, -1)
const normalizedBranch = normalizeBranchName(branchPart)
const rawBranch = branchPart
const isDetached =
rawBranch.startsWith('HEAD (detached at ') ||
rawBranch.includes('detached')
if (isDetached || normalizedBranch.startsWith('HEAD')) {
detached = true
} else if (normalizedBranch.includes('...')) {
const [branch, upstreamBranch] = normalizedBranch.split('...')
currentBranch = branch || undefined
upstream = upstreamBranch || undefined
} else {
currentBranch = normalizedBranch || undefined
}
const aheadBehind = parseAheadBehind(aheadPart)
ahead = aheadBehind.ahead
behind = aheadBehind.behind
}
for (const line of lines.slice(1)) {
if (line.startsWith('?? ')) {
const name = line.slice(3)
fileStatus.push({
name,
status: 'untracked',
indexStatus: '?',
workingTreeStatus: '?',
staged: false,
})
continue
}
if (line.length < 3) {
continue
}
const indexStatus = line[0]
const workingTreeStatus = line[1]
const path = line.slice(3)
let renamedFrom: string | undefined
let name = path
if (path.includes(' -> ')) {
const parts = path.split(' -> ')
renamedFrom = parts[0]
name = parts.slice(1).join(' -> ')
}
fileStatus.push({
name,
status: deriveStatus(indexStatus, workingTreeStatus),
indexStatus,
workingTreeStatus,
staged: indexStatus !== ' ' && indexStatus !== '?',
...(renamedFrom ? { renamedFrom } : {}),
})
}
const totalCount = fileStatus.length
const stagedCount = fileStatus.filter((item) => item.staged).length
const untrackedCount = fileStatus.filter(
(item) => item.status === 'untracked'
).length
const conflictCount = fileStatus.filter(
(item) => item.status === 'conflict'
).length
const unstagedCount = totalCount - stagedCount
return {
currentBranch,
upstream,
ahead,
behind,
detached,
fileStatus,
isClean: totalCount === 0,
hasChanges: totalCount > 0,
hasStaged: stagedCount > 0,
hasUntracked: untrackedCount > 0,
hasConflicts: conflictCount > 0,
totalCount,
stagedCount,
unstagedCount,
untrackedCount,
conflictCount,
}
}
/**
* Parse `git branch --format=%(refname:short)\t%(HEAD)` output.
*
* @param output Git branch output.
* @returns Parsed {@link GitBranches}.
*/
export function parseGitBranches(output: string): GitBranches {
const branches: string[] = []
let currentBranch: string | undefined
const lines = output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
for (const line of lines) {
const parts = line.split('\t')
const name = parts[0]
branches.push(name)
if (parts.length > 1 && parts[1] === '*') {
currentBranch = name
}
}
return { branches, currentBranch }
}
export function isAuthFailure(err: unknown): boolean {
if (!(err instanceof CommandExitError)) {
return false
}
const message = `${err.stderr}\n${err.stdout}`.toLowerCase()
const authSnippets = [
'authentication failed',
'terminal prompts disabled',
'could not read username',
'invalid username or password',
'access denied',
'permission denied',
'not authorized',
]
return authSnippets.some((snippet) => message.includes(snippet))
}
export function getScopeFlag(scope: GitConfigScope): `--${GitConfigScope}` {
if (scope !== 'global' && scope !== 'local' && scope !== 'system') {
throw new InvalidArgumentError(
'Git config scope must be one of: global, local, system.'
)
}
return `--${scope}`
}
export function isMissingUpstream(err: unknown): boolean {
if (!(err instanceof CommandExitError)) {
return false
}
const message = `${err.stderr}\n${err.stdout}`.toLowerCase()
const upstreamSnippets = [
'has no upstream branch',
'no upstream branch',
'no upstream configured',
'no tracking information for the current branch',
'no tracking information',
'set the remote as upstream',
'set the upstream branch',
'please specify which branch you want to merge with',
]
return upstreamSnippets.some((snippet) => message.includes(snippet))
}
export function buildAuthErrorMessage(
action: 'clone' | 'push' | 'pull',
missingPassword: boolean
): string {
if (missingPassword) {
return `Git ${action} requires a password/token for private repositories.`
}
return `Git ${action} requires credentials for private repositories.`
}
export function buildUpstreamErrorMessage(action: 'push' | 'pull'): string {
if (action === 'push') {
return (
'Git push failed because no upstream branch is configured. ' +
'Set upstream once with { setUpstream: true } (and optional remote/branch), ' +
'or pass remote and branch explicitly.'
)
}
return (
'Git pull failed because no upstream branch is configured. ' +
'Pass remote and branch explicitly, or set upstream once (push with { setUpstream: true } ' +
'or run: git branch --set-upstream-to=origin/<branch> <branch>).'
)
}
export function getRepoPathForScope(
scope: GitConfigScope,
path?: string
): string | undefined {
if (scope !== 'local') {
return undefined
}
if (!path) {
throw new InvalidArgumentError(
'A repository path is required when using scope "local".'
)
}
return path
}
+814
View File
@@ -0,0 +1,814 @@
import { createConnectTransport } from '@connectrpc/connect-web'
import {
ConnectionConfig,
ConnectionOpts,
DEFAULT_SANDBOX_TIMEOUT_MS,
defaultUsername,
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'
import { Git } from './git'
import {
SandboxOpts,
SandboxConnectOpts,
SandboxMetricsOpts,
SandboxApi,
SandboxListOpts,
SandboxNetworkUpdate,
SandboxPaginator,
SnapshotListOpts,
SnapshotInfo,
SnapshotPaginator,
CreateSnapshotOpts,
SandboxPauseOpts,
} from './sandboxApi'
import { getSignature } from './signature'
import { compareVersions } from 'compare-versions'
import { InvalidArgumentError, TemplateError } from '../errors'
import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions'
import { shellQuote } from '../utils'
/**
* Options for sandbox upload/download URL generation.
*/
export interface SandboxUrlOpts {
/**
* Use signature expiration for the URL.
* Optional parameter to set the expiration time for the signature in seconds.
*/
useSignatureExpiration?: number
/**
* User that will be used to access the file.
*/
user?: Username
}
/**
* E2B cloud sandbox is a secure and isolated cloud environment.
*
* The sandbox allows you to:
* - Access Linux OS
* - Create, list, and delete files and directories
* - Run commands
* - Run git operations
* - Run isolated code
* - Access the internet
*
* Check docs [here](https://e2b.dev/docs).
*
* Use {@link Sandbox.create} to create a new sandbox.
*
* @example
* ```ts
* import { Sandbox } from 'e2b'
*
* const sandbox = await Sandbox.create()
* ```
*/
export class Sandbox extends SandboxApi {
protected static readonly defaultTemplate: string = 'base'
protected static readonly defaultMcpTemplate: string = 'mcp-gateway'
protected static readonly defaultSandboxTimeoutMs = DEFAULT_SANDBOX_TIMEOUT_MS
/**
* Module for interacting with the sandbox filesystem
*/
readonly files: Filesystem
/**
* Module for running commands in the sandbox
*/
readonly commands: Commands
/**
* Module for interacting with the sandbox pseudo-terminals
*/
readonly pty: Pty
/**
* Module for running git operations in the sandbox
*/
readonly git: Git
/**
* Unique identifier of the sandbox.
*/
readonly sandboxId: string
/**
* Domain where the sandbox is hosted.
*/
readonly sandboxDomain: string
/**
* Traffic access token for accessing sandbox services with restricted public traffic.
*/
readonly trafficAccessToken?: string
protected readonly envdPort = 49983
protected readonly mcpPort = 50005
protected readonly connectionConfig: ConnectionConfig
protected readonly envdAccessToken?: string
private readonly envdApiUrl: string
private readonly envdDirectUrl: string
private readonly envdApi: EnvdApiClient
private mcpToken?: string
/**
* Use {@link Sandbox.create} to create a new Sandbox instead.
*
* @hidden
* @hide
* @internal
* @access protected
*/
constructor(
opts: SandboxConnectOpts & {
sandboxId: string
sandboxDomain?: string
envdVersion: string
envdAccessToken?: string
trafficAccessToken?: string
}
) {
super()
this.connectionConfig = new ConnectionConfig(opts)
this.sandboxId = opts.sandboxId
this.sandboxDomain = opts.sandboxDomain ?? this.connectionConfig.domain
this.envdAccessToken = opts.envdAccessToken
this.trafficAccessToken = opts.trafficAccessToken
this.envdApiUrl = this.connectionConfig.getSandboxUrl(this.sandboxId, {
sandboxDomain: this.sandboxDomain,
envdPort: this.envdPort,
})
this.envdDirectUrl = this.connectionConfig.getSandboxDirectUrl(
this.sandboxId,
{
sandboxDomain: this.sandboxDomain,
envdPort: this.envdPort,
}
)
const sandboxHeaders = {
'E2b-Sandbox-Id': this.sandboxId,
'E2b-Sandbox-Port': this.envdPort.toString(),
}
const envdFetch = createEnvdFetch(this.connectionConfig.proxy)
const envdRpcFetch = createEnvdRpcFetch(this.connectionConfig.proxy)
const rpcTransport = createConnectTransport({
baseUrl: this.envdApiUrl,
useBinaryFormat: false,
interceptors: opts?.logger ? [createRpcLogger(opts.logger)] : undefined,
fetch: (url, options) => {
// Patch fetch to always use redirect: "follow"
// connect-web doesn't allow to configure redirect option - https://github.com/connectrpc/connect-es/pull/1082
// connect-web package uses redirect: "error" which is not supported in edge runtimes
// E2B endpoints should be safe to use with redirect: "follow" https://github.com/e2b-dev/E2B/issues/531#issuecomment-2779492867
const headers = new Headers({
'User-Agent': this.connectionConfig.headers?.['User-Agent'] ?? '',
})
new Headers(options?.headers).forEach((value, key) =>
headers.append(key, value)
)
new Headers(sandboxHeaders).forEach((value, key) =>
headers.append(key, value)
)
if (this.envdAccessToken) {
headers.append('X-Access-Token', this.envdAccessToken)
}
options = {
...(options ?? {}),
headers: headers,
redirect: 'follow',
}
return envdRpcFetch(url, options)
},
})
this.envdApi = new EnvdApiClient(
{
apiUrl: this.envdApiUrl,
logger: opts?.logger,
envdAccessToken: this.envdAccessToken,
headers: {
'User-Agent': this.connectionConfig.headers?.['User-Agent'] ?? '',
...sandboxHeaders,
},
fetch: (request) => envdFetch(request),
},
{
version: opts.envdVersion,
}
)
this.files = new Filesystem(
rpcTransport,
this.envdApi,
this.connectionConfig
)
this.commands = new Commands(
rpcTransport,
this.envdApi,
this.connectionConfig
)
this.pty = new Pty(rpcTransport, this.envdApi, this.connectionConfig)
this.git = new Git(this.commands)
}
/**
* List sandboxes.
*
* By default (no `query.state` set in `opts`), returns sandboxes in both
* `running` and `paused` states. To filter by state, pass
* `opts.query.state = [...]`.
*
* @param opts connection options, plus optional `query` to filter by
* metadata / state and `limit` / `nextToken` for pagination.
*
* @returns a {@link SandboxPaginator} that yields pages of sandboxes
* (running and paused by default). Iterate pages via
* `await paginator.nextItems()` while `paginator.hasNext` is `true`.
*/
static list(opts?: SandboxListOpts): SandboxPaginator {
return new SandboxPaginator(opts)
}
/**
* Create a new sandbox from the default `base` sandbox template.
*
* @param opts connection options.
*
* @returns sandbox instance for the new sandbox.
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* ```
* @constructs {@link Sandbox}
*/
static async create<S extends typeof Sandbox>(
this: S,
opts?: SandboxOpts
): Promise<InstanceType<S>>
/**
* Create a new sandbox from the specified sandbox template.
*
* @param template sandbox template name or ID.
* @param opts connection options.
*
* @returns sandbox instance for the new sandbox.
*
* @example
* ```ts
* const sandbox = await Sandbox.create('<template-name-or-id>')
* ```
* @constructs {@link Sandbox}
*/
static async create<S extends typeof Sandbox>(
this: S,
template: string,
opts?: SandboxOpts
): Promise<InstanceType<S>>
static async create<S extends typeof Sandbox>(
this: S,
templateOrOpts?: SandboxOpts | string,
opts?: SandboxOpts
): Promise<InstanceType<S>> {
const { template, sandboxOpts } =
typeof templateOrOpts === 'string'
? {
template: templateOrOpts,
sandboxOpts: opts,
}
: {
template:
templateOrOpts?.template ??
(templateOrOpts?.mcp
? this.defaultMcpTemplate
: this.defaultTemplate),
sandboxOpts: templateOrOpts,
}
const config = new ConnectionConfig(sandboxOpts)
if (config.debug) {
return new this({
sandboxId: 'debug_sandbox_id',
envdVersion: ENVD_DEBUG_FALLBACK,
...config,
}) as InstanceType<S>
}
const sandboxInfo = await SandboxApi.createSandbox(
template,
sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs,
sandboxOpts
)
const sandbox = new this({ ...sandboxInfo, ...config }) as InstanceType<S>
if (sandboxOpts?.mcp) {
sandbox.mcpToken = crypto.randomUUID()
const res = await sandbox.commands.run(
`mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`,
{
user: 'root',
envs: {
GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '',
},
}
)
if (res.exitCode !== 0) {
throw new Error(`Failed to start MCP gateway: ${res.stderr}`)
}
}
return sandbox
}
/**
* Connect to a sandbox. If the sandbox is paused, it will be automatically resumed.
* Sandbox must be either running or be paused.
*
* With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc).
*
* @param sandboxId sandbox ID.
* @param opts connection options.
*
* @returns A running sandbox instance
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* const sandboxId = sandbox.sandboxId
*
* // Connect to the same sandbox.
* const sameSandbox = await Sandbox.connect(sandboxId)
* ```
*/
static async connect<S extends typeof Sandbox>(
this: S,
sandboxId: string,
opts?: SandboxConnectOpts
): Promise<InstanceType<S>> {
const config = new ConnectionConfig(opts)
if (config.debug) {
return new this({
sandboxId,
envdVersion: ENVD_DEBUG_FALLBACK,
...config,
}) as InstanceType<S>
}
const sandbox = await SandboxApi.connectSandbox(sandboxId, opts)
return new this({
sandboxId,
sandboxDomain: sandbox.sandboxDomain,
envdAccessToken: sandbox.envdAccessToken,
trafficAccessToken: sandbox.trafficAccessToken,
envdVersion: sandbox.envdVersion,
...config,
}) as InstanceType<S>
}
/**
* Connect to a sandbox. If the sandbox is paused, it will be automatically resumed.
* Sandbox must be either running or be paused.
*
* With sandbox ID you can connect to the same sandbox from different places or environments (serverless functions, etc).
*
* @param opts connection options.
*
* @returns A running sandbox instance
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* await sandbox.betaPause()
*
* // Connect to the same sandbox.
* const sameSandbox = await sandbox.connect()
* ```
*/
async connect(opts?: SandboxConnectOpts): Promise<this> {
if (this.connectionConfig.debug) {
// Skip connecting to the sandbox in debug mode
return this
}
await SandboxApi.connectSandbox(this.sandboxId, this.resolveApiOpts(opts))
return this
}
/**
* Get the host address for the specified sandbox port.
* You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket.
*
* @param port number of the port in the sandbox.
*
* @returns host address of the sandbox port.
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* // Start an HTTP server
* await sandbox.commands.exec('python3 -m http.server 3000')
* // Get the hostname of the HTTP server
* const serverURL = sandbox.getHost(3000)
* ```
*/
getHost(port: number) {
return this.connectionConfig.getHost(
this.sandboxId,
port,
this.sandboxDomain
)
}
/**
* Check if the sandbox is running.
*
* @returns `true` if the sandbox is running, `false` otherwise.
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* await sandbox.isRunning() // Returns true
*
* await sandbox.kill()
* await sandbox.isRunning() // Returns false
* ```
*/
async isRunning(
opts?: Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>
): Promise<boolean> {
const signal = this.connectionConfig.getSignal(
opts?.requestTimeoutMs,
opts?.signal
)
const res = await this.envdApi.api.GET('/health', {
signal,
})
if (res.response.status == 502) {
return false
}
const err = await handleEnvdApiError(res)
if (err) {
throw err
}
return true
}
/**
* Set the timeout of the sandbox.
*
* This method can extend or reduce the sandbox timeout set when creating the sandbox or from the last call to `.setTimeout`.
* Maximum time a sandbox can be kept alive is 24 hours (86_400_000 milliseconds) for Pro users and 1 hour (3_600_000 milliseconds) for Hobby users.
*
* @param timeoutMs timeout in **milliseconds**.
* @param opts connection options.
*/
async setTimeout(
timeoutMs: number,
opts?: Pick<SandboxOpts, 'requestTimeoutMs' | 'signal'>
) {
if (this.connectionConfig.debug) {
// Skip timeout in debug mode
return
}
await SandboxApi.setTimeout(
this.sandboxId,
timeoutMs,
this.resolveApiOpts(opts)
)
}
/**
* Update the network configuration of the sandbox.
*
* Replaces the current egress configuration atomically — fields that are
* omitted are cleared on the server.
*
* @param network new network configuration.
* @param opts connection options.
*/
async updateNetwork(
network: SandboxNetworkUpdate,
opts?: Pick<SandboxOpts, 'requestTimeoutMs' | 'signal'>
) {
await SandboxApi.updateNetwork(
this.sandboxId,
network,
this.resolveApiOpts(opts)
)
}
/**
* Kill the sandbox.
*
* @param opts connection options.
*
* @returns `true` if the sandbox was killed, `false` if the sandbox was not found.
*/
async kill(
opts?: Pick<SandboxOpts, 'requestTimeoutMs' | 'signal'>
): Promise<boolean> {
if (this.connectionConfig.debug) {
// Skip killing the sandbox in debug mode
return true
}
return await SandboxApi.kill(this.sandboxId, this.resolveApiOpts(opts))
}
/**
* Pause a sandbox by its ID.
*
* @param opts connection options, plus `keepMemory` to control the snapshot
* kind. When `opts.keepMemory` is `false`, the in-memory state is dropped and
* only the filesystem is persisted (a filesystem-only snapshot); resuming such
* a sandbox cold-boots (reboots) it from disk, losing running processes and
* open connections. Defaults to `true` (full memory snapshot).
*
* @returns `true` if the sandbox got paused, `false` if the sandbox was already paused.
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* await sandbox.pause()
*
* // filesystem-only snapshot (resume reboots the sandbox)
* await sandbox.pause({ keepMemory: false })
* ```
*/
async pause(opts?: SandboxPauseOpts): Promise<boolean> {
return await SandboxApi.pause(this.sandboxId, this.resolveApiOpts(opts))
}
/**
* @deprecated Use {@link Sandbox.pause} instead.
*/
async betaPause(opts?: SandboxPauseOpts): Promise<boolean> {
return await SandboxApi.betaPause(this.sandboxId, this.resolveApiOpts(opts))
}
/**
* Create a snapshot of the sandbox's current state.
*
* The sandbox will be paused while the snapshot is being created.
* The snapshot can be used to create new sandboxes with the same filesystem and state.
* Snapshots are persistent and survive sandbox deletion.
*
* Use the returned `snapshotId` with `Sandbox.create(snapshotId)` to create a new sandbox from the snapshot.
*
* @param opts snapshot creation options including optional name and connection options.
*
* @returns snapshot information including the snapshot ID.
*
* @example
* ```ts
* const sandbox = await Sandbox.create()
* await sandbox.files.write('/app/state.json', '{"step": 1}')
*
* // Create a snapshot
* const snapshot = await sandbox.createSnapshot({ name: 'my-snapshot' })
*
* // Create a new sandbox from the snapshot
* const newSandbox = await Sandbox.create(snapshot.snapshotId)
* ```
*/
async createSnapshot(opts?: CreateSnapshotOpts): Promise<SnapshotInfo> {
return await SandboxApi.createSnapshot(this.sandboxId, {
...this.resolveApiOpts(opts),
name: opts?.name,
})
}
/**
* List all snapshots created from this sandbox.
*
* @param opts list options.
*
* @returns paginator for listing snapshots from this sandbox.
*/
listSnapshots(opts?: Omit<SnapshotListOpts, 'sandboxId'>): SnapshotPaginator {
return SandboxApi.listSnapshots({
...this.resolveApiOpts(opts),
sandboxId: this.sandboxId,
})
}
/**
*
* Get the MCP URL for the sandbox.
*
* @returns MCP URL for the sandbox.
*/
getMcpUrl(): string {
return `https://${this.getHost(this.mcpPort)}/mcp`
}
/**
* Get the MCP token for the sandbox.
*
* @returns MCP token for the sandbox, or undefined if MCP is not enabled.
*/
async getMcpToken(): Promise<string | undefined> {
if (!this.mcpToken) {
this.mcpToken = await this.files.read('/etc/mcp-gateway/.token', {
user: 'root',
})
}
return this.mcpToken
}
/**
* Get the URL to upload a file to the sandbox.
*
* You have to send a POST request to this URL with the file as multipart/form-data.
*
* @param path path to the file in the sandbox.
*
* @param opts download url options.
*
* @returns URL for uploading file.
*/
async uploadUrl(path?: string, opts?: SandboxUrlOpts) {
opts = opts ?? {}
const useSignature = !!this.envdAccessToken
if (!useSignature && opts.useSignatureExpiration != undefined) {
throw new InvalidArgumentError(
'Signature expiration can be used only when sandbox is created as secured.'
)
}
let username = opts.user
if (
username == undefined &&
compareVersions(this.envdApi.version, ENVD_DEFAULT_USER) < 0
) {
username = defaultUsername
}
const filePath = path ?? ''
const fileUrl = this.fileUrl(filePath, username)
if (useSignature) {
const url = new URL(fileUrl)
const sig = await getSignature({
path: filePath,
operation: 'write',
user: username,
expirationInSeconds: opts.useSignatureExpiration,
envdAccessToken: this.envdAccessToken,
})
url.searchParams.set('signature', sig.signature)
if (sig.expiration) {
url.searchParams.set('signature_expiration', sig.expiration.toString())
}
return url.toString()
}
return fileUrl
}
/**
* Get the URL to download a file from the sandbox.
*
* @param path path to the file in the sandbox.
*
* @param opts download url options.
*
* @returns URL for downloading file.
*/
async downloadUrl(path: string, opts?: SandboxUrlOpts) {
opts = opts ?? {}
const useSignature = !!this.envdAccessToken
if (!useSignature && opts.useSignatureExpiration != undefined) {
throw new InvalidArgumentError(
'Signature expiration can be used only when sandbox is created as secured.'
)
}
let username = opts.user
if (
username == undefined &&
compareVersions(this.envdApi.version, ENVD_DEFAULT_USER) < 0
) {
username = defaultUsername
}
const fileUrl = this.fileUrl(path, username)
if (useSignature) {
const url = new URL(fileUrl)
const sig = await getSignature({
path,
operation: 'read',
user: username,
expirationInSeconds: opts.useSignatureExpiration,
envdAccessToken: this.envdAccessToken,
})
url.searchParams.set('signature', sig.signature)
if (sig.expiration) {
url.searchParams.set('signature_expiration', sig.expiration.toString())
}
return url.toString()
}
return fileUrl
}
/**
* Get sandbox information like sandbox ID, template, metadata, started at/end at date.
*
* @param opts connection options.
*
* @returns information about the sandbox
*/
async getInfo(opts?: Pick<SandboxOpts, 'requestTimeoutMs' | 'signal'>) {
return await SandboxApi.getInfo(this.sandboxId, this.resolveApiOpts(opts))
}
/**
* Get the metrics of the sandbox.
*
* @param opts connection options.
*
* @returns List of sandbox metrics containing CPU, memory and disk usage information.
*/
async getMetrics(opts?: SandboxMetricsOpts) {
if (this.connectionConfig.debug) {
// Skip getting the metrics in debug mode
return []
}
if (this.envdApi.version) {
if (compareVersions(this.envdApi.version, '0.1.5') < 0) {
throw new TemplateError(
'You need to update the template to use the new SDK.'
)
}
if (compareVersions(this.envdApi.version, '0.2.4') < 0) {
this.connectionConfig.logger?.warn?.(
'Disk metrics are not supported in this version of the sandbox, please rebuild the template to get disk metrics.'
)
}
}
return await SandboxApi.getMetrics(
this.sandboxId,
this.resolveApiOpts(opts)
)
}
private resolveApiOpts<T extends ConnectionOpts>(
opts?: T
): ConnectionOpts & T {
return {
...this.connectionConfig,
...(opts ?? {}),
} as ConnectionOpts & T
}
private fileUrl(path: string | undefined, username: string | undefined) {
const url = new URL('/files', this.envdDirectUrl)
if (username) {
url.searchParams.set('username', username)
}
if (path) {
url.searchParams.set('path', path)
}
return url.toString()
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
/**
* CIDR range that represents all traffic.
*/
export const ALL_TRAFFIC = '0.0.0.0/0'
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
import { sha256 } from '../utils'
/**
* Get the URL signature for the specified path, operation and user.
*
* @param path Path to the file in the sandbox.
*
* @param operation File system operation. Can be either `read` or `write`.
*
* @param user Sandbox user.
*
* @param expirationInSeconds Optional signature expiration time in seconds.
*/
interface SignatureOpts {
path: string
operation: 'read' | 'write'
user: string | undefined
expirationInSeconds?: number
envdAccessToken?: string
}
export async function getSignature({
path,
operation,
user,
expirationInSeconds,
envdAccessToken,
}: SignatureOpts): Promise<{ signature: string; expiration: number | null }> {
if (!envdAccessToken) {
throw new Error(
'Access token is not set and signature cannot be generated!'
)
}
// expiration is unix timestamp
const signatureExpiration =
expirationInSeconds != null
? Math.floor(Date.now() / 1000) + expirationInSeconds
: null
let signatureRaw: string
// if user is undefined, set it to empty string to handle default user
if (user == undefined) {
user = ''
}
if (signatureExpiration === null) {
signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}`
} else {
signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration.toString()}`
}
const hashBase64 = await sha256(signatureRaw)
const signature = 'v1_' + hashBase64.replace(/=+$/, '')
return {
signature: signature,
expiration: signatureExpiration,
}
}
+462
View File
@@ -0,0 +1,462 @@
import type { Readable } from 'node:stream'
import { ApiClient, handleApiError, components } from '../api'
import { buildRequestSignal } from '../connectionConfig'
import { dynamicImport } from '../utils'
import { BuildError, FileUploadError, TemplateError } from '../errors'
import { FILE_UPLOAD_TIMEOUT_MS } from './consts'
import { LogEntry } from './logger'
import { getBuildStepIndex, tarFileStream } from './utils'
import {
BuildStatusReason,
TemplateBuildStatus,
TemplateBuildStatusResponse,
TemplateTag,
TemplateTagInfo,
} from './types'
type RequestBuildInput = {
name: string
tags?: string[]
cpuCount: number
memoryMB: number
}
type GetFileUploadLinkInput = {
templateID: string
filesHash: string
}
type TriggerBuildInput = {
templateID: string
buildID: string
template: TriggerBuildTemplate
}
type GetBuildStatusInput = {
templateID: string
buildID: string
logsOffset?: number
}
type CheckAliasExistsInput = {
alias: string
}
type ApiBuildStatusResponse = components['schemas']['TemplateBuildInfo']
export type TriggerBuildTemplate = components['schemas']['TemplateBuildStartV2']
export async function requestBuild(
client: ApiClient,
{ name, tags, cpuCount, memoryMB }: RequestBuildInput,
signal?: AbortSignal
) {
const requestBuildRes = await client.api.POST('/v3/templates', {
body: {
name,
tags,
cpuCount,
memoryMB,
},
signal,
})
const error = handleApiError(requestBuildRes, BuildError)
if (error) {
throw error
}
if (!requestBuildRes.data) {
throw new BuildError('Failed to request build')
}
return requestBuildRes.data
}
export async function getFileUploadLink(
client: ApiClient,
{ templateID, filesHash }: GetFileUploadLinkInput,
stackTrace?: string,
signal?: AbortSignal
) {
const fileUploadLinkRes = await client.api.GET(
'/templates/{templateID}/files/{hash}',
{
params: {
path: {
templateID,
hash: filesHash,
},
},
signal,
}
)
const error = handleApiError(fileUploadLinkRes, FileUploadError, stackTrace)
if (error) {
throw error
}
if (!fileUploadLinkRes.data) {
throw new FileUploadError('Failed to get file upload link', stackTrace)
}
return fileUploadLinkRes.data
}
export async function uploadFile(
options: {
fileName: string
fileContextPath: string
url: string
ignorePatterns: string[]
resolveSymlinks: boolean
gzip: boolean
},
stackTrace: string | undefined,
// Uploads (PUT to S3 presigned URL) can take a long time for large
// archives — the 60s API default would break them, so we use a 1-hour
// upload default (`FILE_UPLOAD_TIMEOUT_MS`) when `requestTimeoutMs` is
// not supplied. Pass `requestTimeoutMs` (or an `AbortSignal.timeout(ms)`
// via `signal`) to override.
abortOpts?: { signal?: AbortSignal; requestTimeoutMs?: number }
) {
const {
fileName,
url,
fileContextPath,
ignorePatterns,
resolveSymlinks,
gzip,
} = options
// Spool the archive to a temporary file and stream it from disk instead of
// buffering in memory. S3 presigned PUT URLs reject Transfer-Encoding:
// chunked with 501 NotImplemented (see e2b-dev/e2b#1243), so the upload
// sends an explicit Content-Length, which fetch honors for stream bodies.
// The spooled file deletes itself once the stream is consumed, so there is
// no cleanup to manage here. The Python SDK takes the same approach
// (build_api.py:upload_file).
let uploadStream: Readable | undefined
try {
// Dynamically import so the browser bundle doesn't pull in node:stream.
const { Readable } =
await dynamicImport<typeof import('node:stream')>('node:stream')
const tar = await tarFileStream(
fileName,
fileContextPath,
ignorePatterns,
resolveSymlinks,
gzip
)
uploadStream = tar.stream
const res = await fetch(url, {
method: 'PUT',
body: Readable.toWeb(uploadStream) as ReadableStream<Uint8Array>,
headers: {
'Content-Length': tar.size.toString(),
},
// Streaming request bodies require half-duplex mode.
duplex: 'half',
signal: buildRequestSignal(
abortOpts?.requestTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS,
abortOpts?.signal
),
} as RequestInit)
if (!res.ok) {
throw new FileUploadError(
`Failed to upload file: ${res.statusText}`,
stackTrace
)
}
} catch (error) {
// Ensure the spooled archive is removed even if fetch never consumed the
// stream (e.g. it threw before reading the body). Destroying the stream
// fires its `close` handler, which deletes the temp file.
uploadStream?.destroy()
if (error instanceof FileUploadError) {
throw error
}
throw new FileUploadError(`Failed to upload file: ${error}`, stackTrace)
}
}
export async function triggerBuild(
client: ApiClient,
{ templateID, buildID, template }: TriggerBuildInput,
signal?: AbortSignal
) {
const triggerBuildRes = await client.api.POST(
'/v2/templates/{templateID}/builds/{buildID}',
{
params: {
path: {
templateID,
buildID,
},
},
body: template,
signal,
}
)
const error = handleApiError(triggerBuildRes, BuildError)
if (error) {
throw error
}
}
function mapLogEntry(
entry: ApiBuildStatusResponse['logEntries'][number]
): LogEntry {
return new LogEntry(new Date(entry.timestamp), entry.level, entry.message)
}
function mapBuildStatusReason(
reason: ApiBuildStatusResponse['reason']
): BuildStatusReason | undefined {
if (!reason) {
return undefined
}
return {
message: reason.message,
step: reason.step,
logEntries: (reason.logEntries ?? []).map(mapLogEntry),
}
}
export async function getBuildStatus(
client: ApiClient,
{ templateID, buildID, logsOffset }: GetBuildStatusInput,
signal?: AbortSignal
): Promise<TemplateBuildStatusResponse> {
const buildStatusRes = await client.api.GET(
'/templates/{templateID}/builds/{buildID}/status',
{
params: {
path: {
templateID,
buildID,
},
query: {
logsOffset,
},
},
signal,
}
)
const error = handleApiError(buildStatusRes, BuildError)
if (error) {
throw error
}
if (!buildStatusRes.data) {
throw new BuildError('Failed to get build status')
}
return {
buildID: buildStatusRes.data.buildID,
templateID: buildStatusRes.data.templateID,
status: buildStatusRes.data.status,
logEntries: buildStatusRes.data.logEntries.map(mapLogEntry),
logs: buildStatusRes.data.logs,
reason: mapBuildStatusReason(buildStatusRes.data.reason),
}
}
export async function checkAliasExists(
client: ApiClient,
{ alias }: CheckAliasExistsInput,
signal?: AbortSignal
): Promise<boolean> {
const aliasRes = await client.api.GET('/templates/aliases/{alias}', {
params: {
path: {
alias,
},
},
signal,
})
// If we get a NotFound, the alias doesn't exist
if (aliasRes.response.status === 404) {
return false
}
// If we get a Forbidden, alias exists, but you are not owner
if (aliasRes.response.status === 403) {
return true
}
// Handle other errors
const error = handleApiError(aliasRes, TemplateError)
if (error) {
throw error
}
// If we get Ok with data, you are owner and the alias exists
return aliasRes.data !== undefined
}
export async function waitForBuildFinish(
client: ApiClient,
{
templateID,
buildID,
onBuildLogs,
logsRefreshFrequency,
stackTraces,
signal,
requestTimeoutMs,
}: {
templateID: string
buildID: string
onBuildLogs?: (logEntry: LogEntry) => void
logsRefreshFrequency: number
stackTraces: (string | undefined)[]
signal?: AbortSignal
requestTimeoutMs?: number
}
): Promise<void> {
let logsOffset = 0
let status: TemplateBuildStatus = 'building'
const pollStatus = async (): Promise<TemplateBuildStatusResponse> => {
const buildStatus = await getBuildStatus(
client,
{
templateID,
buildID,
logsOffset,
},
buildRequestSignal(requestTimeoutMs, signal)
)
logsOffset += buildStatus.logEntries.length
buildStatus.logEntries.forEach((logEntry) => onBuildLogs?.(logEntry))
return buildStatus
}
while (status === 'building' || status === 'waiting') {
signal?.throwIfAborted()
const buildStatus = await pollStatus()
status = buildStatus.status
switch (status) {
case 'ready':
case 'error': {
// The status endpoint returns at most 100 log entries per call, so
// the terminal response may not include the last logs — keep
// fetching until they are drained.
let tailStatus = buildStatus
while (tailStatus.logEntries.length > 0) {
signal?.throwIfAborted()
tailStatus = await pollStatus()
}
if (status === 'ready') {
return
}
let stackError: string | undefined
if (buildStatus.reason?.step !== undefined) {
const step = getBuildStepIndex(
buildStatus.reason.step,
stackTraces.length
)
stackError = stackTraces[step]
}
throw new BuildError(
buildStatus?.reason?.message ?? 'Unknown error',
stackError
)
}
case 'waiting': {
break
}
}
// Wait for a short period before checking the status again. Abort is
// observed on the next iteration via `signal?.throwIfAborted()`.
await new Promise((resolve) => setTimeout(resolve, logsRefreshFrequency))
}
throw new BuildError('Unknown build error occurred.')
}
export async function assignTags(
client: ApiClient,
{ targetName, tags }: { targetName: string; tags: string[] },
signal?: AbortSignal
): Promise<TemplateTagInfo> {
const res = await client.api.POST('/templates/tags', {
body: { target: targetName, tags },
signal,
})
const error = handleApiError(res, TemplateError)
if (error) {
throw error
}
if (!res.data) {
throw new TemplateError('Failed to assign tags')
}
return {
buildId: res.data.buildID,
tags: res.data.tags,
}
}
export async function removeTags(
client: ApiClient,
{ name, tags }: { name: string; tags: string[] },
signal?: AbortSignal
): Promise<void> {
const res = await client.api.DELETE('/templates/tags', {
body: { name, tags },
signal,
})
const error = handleApiError(res, TemplateError)
if (error) {
throw error
}
}
export async function getTemplateTags(
client: ApiClient,
{ templateID }: { templateID: string },
signal?: AbortSignal
): Promise<TemplateTag[]> {
const res = await client.api.GET('/templates/{templateID}/tags', {
params: {
path: {
templateID,
},
},
signal,
})
const error = handleApiError(res, TemplateError)
if (error) {
throw error
}
if (!res.data) {
throw new TemplateError('Failed to get template tags')
}
return res.data.map((item: components['schemas']['TemplateTag']) => ({
tag: item.tag,
buildId: item.buildID,
createdAt: new Date(item.createdAt),
}))
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Special step name for the finalization phase of template building.
* This is the last step that runs after all user-defined instructions.
* @internal
*/
export const FINALIZE_STEP_NAME = 'finalize'
/**
* Special step name for the base image phase of template building.
* This is the first step that sets up the base image.
* @internal
*/
export const BASE_STEP_NAME = 'base'
/**
* Stack trace depth for capturing caller information.
*
* Depth levels:
* 1. Template function
* 2. TemplateBase class
* 3. Caller method (e.g., copy(), fromImage(), etc.)
*
* This depth is used to determine the original caller's location
* for stack traces.
* @internal
*/
export const STACK_TRACE_DEPTH = 3
/**
* Default setting for whether to resolve symbolic links when copying files.
* When false, symlinks are copied as symlinks rather than following them.
* @internal
*/
export const RESOLVE_SYMLINKS = false
/**
* Default setting for whether to gzip files when copying them into the
* template. When true, the upload archive is gzipped before being uploaded.
* @internal
*/
export const GZIP = true
/**
* Default per-request timeout (in milliseconds) for the file-upload phase
* (PUT to S3 presigned URL) when the caller hasn't supplied
* `requestTimeoutMs`. Large archives can take well over the 60s API
* default, so we use a generous 1-hour bound here.
* @internal
*/
export const FILE_UPLOAD_TIMEOUT_MS = 3_600_000
@@ -0,0 +1,316 @@
import { CopyItem } from './types'
import {
Argument,
DockerfileParser,
Instruction as DockerfileInstruction,
ModifiableInstruction,
} from 'dockerfile-ast'
import fs from 'node:fs'
import { ReadyCmd, waitForTimeout } from './readycmd'
export interface DockerfileParseResult {
baseImage: string
}
interface DockerfileFinalParserInterface {}
export interface DockerfileParserInterface {
setWorkdir(workdir: string): DockerfileParserInterface
setUser(user: string): DockerfileParserInterface
setEnvs(envs: Record<string, string>): DockerfileParserInterface
runCmd(
commandOrCommands: string | string[],
options?: { user?: string }
): DockerfileParserInterface
copy(
src: string,
dest: string,
options?: { forceUpload?: true; user?: string; mode?: number }
): DockerfileParserInterface
copyItems(
items: CopyItem[],
options?: { forceUpload?: true; user?: string; mode?: number }
): DockerfileParserInterface
setStartCmd(
startCommand: string,
readyCommand: string | ReadyCmd
): DockerfileFinalParserInterface
}
/**
* Parse a Dockerfile and convert it to Template SDK format
*
* @param dockerfileContentOrPath Either the Dockerfile content as a string,
* or a path to a Dockerfile file
* @param templateBuilder Interface providing template builder methods
* @returns Parsed Dockerfile result with base image and instructions
*/
export function parseDockerfile(
dockerfileContentOrPath: string,
templateBuilder: DockerfileParserInterface
): DockerfileParseResult {
// Check if input is a file path that exists
let dockerfileContent: string
try {
if (
fs.existsSync(dockerfileContentOrPath) &&
fs.statSync(dockerfileContentOrPath).isFile()
) {
// Read the file content
dockerfileContent = fs.readFileSync(dockerfileContentOrPath, 'utf-8')
} else {
// Treat as content directly
dockerfileContent = dockerfileContentOrPath
}
} catch {
// If there's any error checking the file, treat as content
dockerfileContent = dockerfileContentOrPath
}
const dockerfile = DockerfileParser.parse(dockerfileContent)
const instructions = dockerfile.getInstructions()
// Check for multi-stage builds
const fromInstructions = instructions.filter(
(instruction) => instruction.getKeyword() === 'FROM'
)
if (fromInstructions.length > 1) {
throw new Error('Multi-stage Dockerfiles are not supported')
}
if (fromInstructions.length === 0) {
throw new Error('Dockerfile must contain a FROM instruction')
}
// Set the base image from the first FROM instruction
const fromInstruction = fromInstructions[0]
const argumentsData = fromInstruction.getArguments()
let baseImage = 'e2bdev/base' // default fallback
let userChanged = false
let workdirChanged = false
if (argumentsData && argumentsData.length > 0) {
baseImage = argumentsData[0].getValue()
}
// Set the user and workdir to the Docker defaults
templateBuilder.setUser('root')
templateBuilder.setWorkdir('/')
// Process all other instructions
for (const instruction of instructions) {
const keyword = instruction.getKeyword()
switch (keyword) {
case 'FROM':
// Already handled above
break
case 'RUN':
handleRunInstruction(instruction, templateBuilder)
break
case 'COPY':
case 'ADD':
handleCopyInstruction(
instruction as ModifiableInstruction,
templateBuilder
)
break
case 'WORKDIR':
handleWorkdirInstruction(instruction, templateBuilder)
workdirChanged = true
break
case 'USER':
handleUserInstruction(instruction, templateBuilder)
userChanged = true
break
case 'ENV':
case 'ARG':
handleEnvInstruction(instruction, templateBuilder)
break
case 'EXPOSE':
// EXPOSE is not directly supported in our SDK, so we'll skip it
break
case 'VOLUME':
// VOLUME is not directly supported in our SDK, so we'll skip it
break
case 'CMD':
case 'ENTRYPOINT':
handleCmdEntrypointInstruction(instruction, templateBuilder)
break
default:
console.warn(`Unsupported instruction: ${keyword}`)
break
}
}
// Set the user and workdir to the E2B defaults
if (!userChanged) {
templateBuilder.setUser('user')
}
if (!workdirChanged) {
templateBuilder.setWorkdir('/home/user')
}
return {
baseImage,
}
}
function handleRunInstruction(
instruction: DockerfileInstruction,
templateBuilder: DockerfileParserInterface
): void {
const argumentsData = instruction.getArguments()
if (argumentsData && argumentsData.length > 0) {
const command = argumentsData
.map((arg: Argument) => arg.getValue())
.join(' ')
templateBuilder.runCmd(command)
}
}
function handleCopyInstruction(
instruction: ModifiableInstruction,
templateBuilder: DockerfileParserInterface
): void {
const argumentsData = instruction.getArguments()
if (argumentsData && argumentsData.length >= 2) {
const dest = argumentsData[argumentsData.length - 1].getValue()
const sources = argumentsData
.slice(0, -1)
.map((arg: Argument) => arg.getValue())
let user: string | undefined
const flags = instruction.getFlags()
const chownFlag = flags.find((flag) => flag.getName() === 'chown')
if (chownFlag) {
user = chownFlag.getValue() ?? undefined
}
for (const src of sources) {
templateBuilder.copy(src, dest, { user })
}
}
}
function handleWorkdirInstruction(
instruction: DockerfileInstruction,
templateBuilder: DockerfileParserInterface
): void {
const argumentsData = instruction.getArguments()
if (argumentsData && argumentsData.length > 0) {
const workdir = argumentsData[0].getValue()
templateBuilder.setWorkdir(workdir)
}
}
function handleUserInstruction(
instruction: DockerfileInstruction,
templateBuilder: DockerfileParserInterface
): void {
const argumentsData = instruction.getArguments()
if (argumentsData && argumentsData.length > 0) {
const user = argumentsData[0].getValue()
templateBuilder.setUser(user)
}
}
function handleEnvInstruction(
instruction: DockerfileInstruction,
templateBuilder: DockerfileParserInterface
): void {
const argumentsData = instruction.getArguments()
const keyword = instruction.getKeyword()
if (argumentsData && argumentsData.length >= 1) {
const envVars: Record<string, string> = {}
if (argumentsData.length === 2) {
// ENV key value format OR multiple key=value pairs (from line continuation)
const firstArg = argumentsData[0].getValue()
const secondArg = argumentsData[1].getValue()
// Check if both arguments contain '=' (multiple key=value pairs)
if (firstArg.includes('=') && secondArg.includes('=')) {
// Both are key=value pairs (line continuation)
for (const arg of argumentsData) {
const envString = arg.getValue()
const equalIndex = envString.indexOf('=')
if (equalIndex > 0) {
const key = envString.substring(0, equalIndex)
const value = envString.substring(equalIndex + 1)
envVars[key] = value
}
}
} else {
// Traditional ENV key value format
envVars[firstArg] = secondArg
}
} else if (argumentsData.length === 1) {
// ENV/ARG key=value format (single argument) or ARG key (without default)
const envString = argumentsData[0].getValue()
// Check if it's a simple key=value or just a key (for ARG without default)
const equalIndex = envString.indexOf('=')
if (equalIndex > 0) {
const key = envString.substring(0, equalIndex)
const value = envString.substring(equalIndex + 1)
envVars[key] = value
} else if (keyword === 'ARG' && envString.trim()) {
// ARG without default value - set as empty ENV
const key = envString.trim()
envVars[key] = ''
}
} else {
// Multiple arguments (from line continuation with backslashes)
for (const arg of argumentsData) {
const envString = arg.getValue()
const equalIndex = envString.indexOf('=')
if (equalIndex > 0) {
const key = envString.substring(0, equalIndex)
const value = envString.substring(equalIndex + 1)
envVars[key] = value
} else if (keyword === 'ARG') {
// ARG without default value
const key = envString
envVars[key] = ''
}
}
}
// Call setEnvs once with all environment variables from this instruction
if (Object.keys(envVars).length > 0) {
templateBuilder.setEnvs(envVars)
}
}
}
function handleCmdEntrypointInstruction(
instruction: DockerfileInstruction,
templateBuilder: DockerfileParserInterface
): void {
const argumentsData = instruction.getArguments()
if (argumentsData && argumentsData.length > 0) {
let command = argumentsData.map((arg: Argument) => arg.getValue()).join(' ')
try {
const parsedCommand = JSON.parse(command)
if (Array.isArray(parsedCommand)) {
command = parsedCommand.join(' ')
}
} catch {
// Do nothing
}
templateBuilder.setStartCmd(command, waitForTimeout(20_000))
}
}
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
import chalk from 'chalk'
import { stripAnsi } from '../utils'
/**
* Log entry severity levels.
*/
export type LogEntryLevel = 'debug' | 'info' | 'warn' | 'error'
/**
* Represents a single log entry from the template build process.
*/
export class LogEntry {
public readonly timestamp: Date
public readonly level: LogEntryLevel
public readonly message: string
constructor(timestamp: Date, level: LogEntryLevel, message: string) {
this.timestamp = timestamp
this.level = level
// Strip ANSI escape codes on construction so messages are consistent
// everywhere (matches the Python SDK behavior)
this.message = stripAnsi(message)
}
toString() {
return `[${this.timestamp.toISOString()}] [${this.level}] ${this.message}`
}
}
/**
* Special log entry indicating the start of a build process.
*/
export class LogEntryStart extends LogEntry {
constructor(timestamp: Date, message: string) {
super(timestamp, 'debug', message)
}
}
/**
* Special log entry indicating the end of a build process.
*/
export class LogEntryEnd extends LogEntry {
constructor(timestamp: Date, message: string) {
super(timestamp, 'debug', message)
}
}
/**
* Interval in milliseconds for updating the build timer display.
* @internal
*/
const TIMER_UPDATE_INTERVAL_MS = 150
/**
* Default minimum log level to display.
* @internal
*/
const DEFAULT_LEVEL: LogEntryLevel = 'info'
/**
* Colored labels for each log level.
* @internal
*/
const levels: Record<LogEntryLevel, string> = {
error: chalk.red('ERROR'),
warn: chalk.hex('#FF4400')('WARN '),
info: chalk.hex('#FF8800')('INFO '),
debug: chalk.gray('DEBUG'),
}
/**
* Numeric ordering of log levels for comparison (lower = less severe).
* @internal
*/
const level_order: Record<LogEntryLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
}
interface DefaultBuildLoggerState {
startTime: number
animationFrame: number
timerInterval: NodeJS.Timeout | undefined
}
class DefaultBuildLogger {
private minLevel: LogEntryLevel
private state: DefaultBuildLoggerState
constructor(minLevel?: LogEntryLevel) {
this.minLevel = minLevel ?? DEFAULT_LEVEL
this.state = this.getInitialState()
}
logger(logEntry: LogEntry) {
if (logEntry instanceof LogEntryStart) {
this.startTimer()
return
}
if (logEntry instanceof LogEntryEnd) {
clearInterval(this.state.timerInterval)
return
}
// Filter by minimum level
if (level_order[logEntry.level] < level_order[this.minLevel]) {
return
}
const formattedLine = this.formatLogLine(logEntry)
process.stdout.write(`${formattedLine}\n`)
// Redraw the timer line
this.updateTimer()
}
private getInitialState(
timerInterval?: NodeJS.Timeout
): DefaultBuildLoggerState {
return {
startTime: Date.now(),
animationFrame: 0,
timerInterval: timerInterval,
}
}
private formatTimerLine() {
const elapsedSeconds = ((Date.now() - this.state.startTime) / 1000).toFixed(
1
)
return `${elapsedSeconds}s`
}
private animateStatus() {
const frames = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
const idx = this.state.animationFrame % frames.length
return `${frames[idx]}`
}
private formatLogLine(line: LogEntry) {
const timer = this.formatTimerLine().padEnd(5)
const timestamp = chalk.dim(
line.timestamp.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
)
const level = levels[line.level] || levels[DEFAULT_LEVEL]
const msg = line.message
return `${timer} | ${timestamp} ${level} ${msg}`
}
private startTimer() {
if (!process.stdout.isTTY) {
return
}
// Start the timer interval
const timerInterval = setInterval(
this.updateTimer.bind(this),
TIMER_UPDATE_INTERVAL_MS
)
this.state = this.getInitialState(timerInterval)
// Initial timer display
this.updateTimer()
}
private updateTimer() {
if (!process.stdout.isTTY) {
return
}
this.state.animationFrame++
const jumpingSquares = this.animateStatus()
process.stdout.write(
`${jumpingSquares} Building ${this.formatTimerLine()}\r`
)
}
}
/**
* Create a default build logger with animated timer display.
*
* @param options Logger configuration options
* @param options.minLevel Minimum log level to display (default: 'info')
* @returns Logger function that accepts LogEntry instances
*
* @example
* ```ts
* import { Template, defaultBuildLogger } from 'e2b'
*
* const template = Template().fromPythonImage()
*
* await Template.build(template, {
* alias: 'my-template',
* onBuildLogs: defaultBuildLogger({ minLevel: 'debug' })
* })
* ```
*/
export function defaultBuildLogger(options?: {
minLevel?: LogEntryLevel
}): (logEntry: LogEntry) => void {
const buildLogger = new DefaultBuildLogger(options?.minLevel)
return buildLogger.logger.bind(buildLogger)
}
+127
View File
@@ -0,0 +1,127 @@
import { shellQuote } from '../utils'
/**
* Class for ready check commands.
*/
export class ReadyCmd {
private cmd: string
constructor(cmd: string) {
this.cmd = cmd
}
getCmd(): string {
return this.cmd
}
}
/**
* Wait for a port to be listening.
* Uses `ss` command to check if a port is open and listening.
*
* @param port Port number to wait for
* @returns ReadyCmd that checks for the port
*
* @example
* ```ts
* import { Template, waitForPort } from 'e2b'
*
* const template = Template()
* .fromPythonImage()
* .setStartCmd('python -m http.server 8000', waitForPort(8000))
* ```
*/
export function waitForPort(port: number): ReadyCmd {
// Match the exact listening port via ss's source-port filter (so e.g. port
// 80 doesn't match 8080). ss exits 0 regardless of matches, so test for
// non-empty output to signal readiness.
const cmd = `[ -n "$(ss -Htuln sport = :${port})" ]`
return new ReadyCmd(cmd)
}
/**
* Wait for a URL to return a specific HTTP status code.
* Uses `curl` to make HTTP requests and check the response status.
*
* @param url URL to check (e.g., 'http://localhost:3000/health')
* @param statusCode Expected HTTP status code (default: 200)
* @returns ReadyCmd that checks the URL
*
* @example
* ```ts
* import { Template, waitForURL } from 'e2b'
*
* const template = Template()
* .fromNodeImage()
* .setStartCmd('npm start', waitForURL('http://localhost:3000/health'))
* ```
*/
export function waitForURL(url: string, statusCode: number = 200): ReadyCmd {
const cmd = `curl -s -o /dev/null -w "%{http_code}" ${shellQuote(url)} | grep -q "${statusCode}"`
return new ReadyCmd(cmd)
}
/**
* Wait for a process with a specific name to be running.
* Uses `pgrep` to check if a process exists.
*
* @param processName Name of the process to wait for
* @returns ReadyCmd that checks for the process
*
* @example
* ```ts
* import { Template, waitForProcess } from 'e2b'
*
* const template = Template()
* .fromBaseImage()
* .setStartCmd('./my-daemon', waitForProcess('my-daemon'))
* ```
*/
export function waitForProcess(processName: string): ReadyCmd {
const cmd = `pgrep ${shellQuote(processName)} > /dev/null`
return new ReadyCmd(cmd)
}
/**
* Wait for a file to exist.
* Uses shell test command to check file existence.
*
* @param filename Path to the file to wait for
* @returns ReadyCmd that checks for the file
*
* @example
* ```ts
* import { Template, waitForFile } from 'e2b'
*
* const template = Template()
* .fromBaseImage()
* .setStartCmd('./init.sh', waitForFile('/tmp/ready'))
* ```
*/
export function waitForFile(filename: string): ReadyCmd {
const cmd = `[ -f ${shellQuote(filename)} ]`
return new ReadyCmd(cmd)
}
/**
* Wait for a specified timeout before considering the sandbox ready.
* Uses `sleep` command to wait for a fixed duration.
*
* @param timeout Time to wait in milliseconds (minimum: 1000ms / 1 second)
* @returns ReadyCmd that waits for the specified duration
*
* @example
* ```ts
* import { Template, waitForTimeout } from 'e2b'
*
* const template = Template()
* .fromNodeImage()
* .setStartCmd('npm start', waitForTimeout(5000)) // Wait 5 seconds
* ```
*/
export function waitForTimeout(timeout: number): ReadyCmd {
// convert to seconds, but ensure minimum of 1 second
const seconds = Math.max(1, Math.floor(timeout / 1000))
const cmd = `sleep ${seconds}`
return new ReadyCmd(cmd)
}
+829
View File
@@ -0,0 +1,829 @@
import { ReadyCmd } from './readycmd'
import type { PathLike } from 'node:fs'
import type { LogEntry } from './logger'
import type { McpServer } from '../sandbox/mcp'
import { ConnectionOpts } from '../connectionConfig'
/**
* Options for creating a new template.
*/
export type TemplateOptions = {
/**
* Path to the directory containing files to be copied into the template.
* @default Current directory from template location
*/
fileContextPath?: PathLike
/**
* Array of glob patterns to ignore when copying files.
*/
fileIgnorePatterns?: string[]
}
/**
* Basic options for building a template.
*/
export type BasicBuildOptions = {
/**
* Alias name for the template.
* @deprecated Use the `name` parameter of `Template.build()` instead.
*/
alias: string
/**
* Tags to assign to the template build.
*/
tags?: string[]
/**
* Number of CPUs allocated to the sandbox.
* @default 2
*/
cpuCount?: number
/**
* Amount of memory in MB allocated to the sandbox.
* @default 1024
*/
memoryMB?: number
/**
* If true, skips cache and forces a complete rebuild.
* @default false
*/
skipCache?: boolean
/**
* Callback function to receive build logs during the build process.
*/
onBuildLogs?: (logEntry: LogEntry) => void
}
/**
* Options for building a template with authentication.
*/
export type BuildOptions = ConnectionOpts & BasicBuildOptions
/**
* Information about a built template.
*/
export type BuildInfo = {
/**
* First alias from the build (for backward compatibility).
* @deprecated Use `name` instead.
*/
alias: string
/**
* Name of the template.
*/
name: string
/**
* Tags assigned to this build.
*/
tags: string[]
/**
* Template identifier.
*/
templateId: string
/**
* Build identifier.
*/
buildId: string
}
/**
* Options for getting build status.
*/
export type GetBuildStatusOptions = ConnectionOpts & { logsOffset?: number }
/**
* Status of a template build.
*/
export type TemplateBuildStatus = 'building' | 'waiting' | 'ready' | 'error'
/**
* Reason for the current build status (typically for errors).
*/
export type BuildStatusReason = {
/**
* Message with the status reason.
*/
message: string
/**
* Step that failed.
*/
step?: string
/**
* Log entries related to the status reason.
*/
logEntries: LogEntry[]
}
/**
* Response from getting build status.
*/
export type TemplateBuildStatusResponse = {
/**
* Build identifier.
*/
buildID: string
/**
* Template identifier.
*/
templateID: string
/**
* Current status of the build.
*/
status: TemplateBuildStatus
/**
* Build log entries.
*/
logEntries: LogEntry[]
/**
* Build logs (raw strings).
* @deprecated Use `logEntries` instead.
*/
logs: string[]
/**
* Reason for the current status (typically for errors).
*/
reason?: BuildStatusReason
}
/**
* Information about assigned template tags.
*/
export type TemplateTagInfo = {
/**
* Build identifier associated with this tag.
*/
buildId: string
/**
* Assigned tags of the template.
*/
tags: string[]
}
/**
* Detailed information about a single template tag.
*/
export type TemplateTag = {
/**
* Name of the tag.
*/
tag: string
/**
* Build identifier associated with this tag.
*/
buildId: string
/**
* When this tag was assigned.
*/
createdAt: Date
}
/**
* Types of instructions that can be used in a template.
*/
export enum InstructionType {
COPY = 'COPY',
ENV = 'ENV',
RUN = 'RUN',
WORKDIR = 'WORKDIR',
USER = 'USER',
}
/**
* Represents a single instruction in the template build process.
*/
export type Instruction = {
type: InstructionType
args: string[]
force: boolean
forceUpload?: true
filesHash?: string
resolveSymlinks?: boolean
gzip?: boolean
}
/**
* Configuration for a single file/directory copy operation.
*/
export type CopyItem = {
src: PathLike | PathLike[]
dest: PathLike
forceUpload?: true
user?: string
mode?: number
resolveSymlinks?: boolean
gzip?: boolean
}
/**
* MCP server names that can be installed.
*/
export type McpServerName = keyof McpServer
/**
* Initial state of a template builder.
* Use one of these methods to specify the base image or template to start from.
*/
export interface TemplateFromImage {
/**
* Start from a Debian-based Docker image.
* @param variant Debian variant (default: 'stable')
*
* @example
* ```ts
* Template().fromDebianImage('bookworm')
* ```
*/
fromDebianImage(variant?: string): TemplateBuilder
/**
* Start from an Ubuntu-based Docker image.
* @param variant Ubuntu variant (default: 'latest')
*
* @example
* ```ts
* Template().fromUbuntuImage('24.04')
* ```
*/
fromUbuntuImage(variant?: string): TemplateBuilder
/**
* Start from a Python-based Docker image.
* @param version Python version (default: '3')
*
* @example
* ```ts
* Template().fromPythonImage('3')
* ```
*/
fromPythonImage(version?: string): TemplateBuilder
/**
* Start from a Node.js-based Docker image.
* @param variant Node.js variant (default: 'lts')
*
* @example
* ```ts
* Template().fromNodeImage('24')
* ```
*/
fromNodeImage(variant?: string): TemplateBuilder
/**
* Start from a Bun-based Docker image.
* @param variant Bun variant (default: 'latest')
*
* @example
* ```ts
* Template().fromBunImage('1.3')
* ```
*/
fromBunImage(variant?: string): TemplateBuilder
/**
* Start from E2B's default base image (e2bdev/base:latest).
*
* @example
* ```ts
* Template().fromBaseImage()
* ```
*/
fromBaseImage(): TemplateBuilder
/**
* Start from a custom Docker image.
* @param baseImage Docker image name
* @param credentials Optional credentials for private registries
*
* @example
* ```ts
* Template().fromImage('python:3')
*
* // With credentials (optional)
* Template().fromImage('myregistry.com/myimage:latest', {
* username: 'user',
* password: 'pass'
* })
* ```
*/
fromImage(
baseImage: string,
credentials?: { username: string; password: string }
): TemplateBuilder
/**
* Start from an existing E2B template.
* @param template E2B template ID or alias
*
* @example
* ```ts
* Template().fromTemplate('my-base-template')
* ```
*/
fromTemplate(template: string): TemplateBuilder
/**
* Parse a Dockerfile and convert it to Template SDK format.
* @param dockerfileContentOrPath Dockerfile content or path
*
* @example
* ```ts
* Template().fromDockerfile('Dockerfile')
* Template().fromDockerfile('FROM python:3\nRUN pip install numpy')
* ```
*/
fromDockerfile(dockerfileContentOrPath: string): TemplateBuilder
/**
* Start from a Docker image in AWS ECR.
* @param image Full ECR image path
* @param credentials AWS credentials
*
* @example
* ```ts
* Template().fromAWSRegistry(
* '123456789.dkr.ecr.us-west-2.amazonaws.com/myimage:latest',
* {
* accessKeyId: 'AKIA...',
* secretAccessKey: '...',
* region: 'us-west-2'
* }
* )
* ```
*/
fromAWSRegistry(
image: string,
credentials: {
accessKeyId: string
secretAccessKey: string
region: string
}
): TemplateBuilder
/**
* Start from a Docker image in Google Container Registry.
* @param image Full GCR/GAR image path
* @param credentials GCP service account credentials
*
* @example
* ```ts
* Template().fromGCPRegistry(
* 'gcr.io/myproject/myimage:latest',
* { serviceAccountJSON: 'path/to/service-account.json' }
* )
* ```
*/
fromGCPRegistry(
image: string,
credentials: {
serviceAccountJSON: object | string
}
): TemplateBuilder
/**
* Skip cache for all subsequent build instructions from this point.
*
* @example
* ```ts
* Template().skipCache().fromPythonImage('3')
* ```
*/
skipCache(): this
}
/**
* Main builder state for constructing templates.
* Provides methods for customizing the template environment.
*/
export interface TemplateBuilder {
/**
* Copy files or directories into the template.
* @param src Source path(s)
* @param dest Destination path
* @param options Copy options
*
* @example
* ```ts
* template.copy('requirements.txt', '/home/user/')
* template.copy(['app.ts', 'config.ts'], '/app/', { mode: 0o755 })
* ```
*/
copy(
src: PathLike | PathLike[],
dest: PathLike,
options?: {
forceUpload?: true
user?: string
mode?: number
resolveSymlinks?: boolean
gzip?: boolean
}
): TemplateBuilder
/**
* Copy multiple items with individual options.
* @param items Array of copy items
*
* @example
* ```ts
* template.copyItems([
* { src: 'app.ts', dest: '/app/' },
* { src: 'config.ts', dest: '/app/', mode: 0o644 }
* ])
* ```
*/
copyItems(items: CopyItem[]): TemplateBuilder
/**
* Remove files or directories.
* @param path Path(s) to remove
* @param options Remove options
*
* @example
* ```ts
* template.remove('/tmp/cache', { recursive: true, force: true })
* template.remove('/tmp/cache', { recursive: true, force: true, user: 'root' })
* ```
*/
remove(
path: PathLike | PathLike[],
options?: { force?: boolean; recursive?: boolean; user?: string }
): TemplateBuilder
/**
* Rename or move a file or directory.
* @param src Source path
* @param dest Destination path
* @param options Rename options
*
* @example
* ```ts
* template.rename('/tmp/old.txt', '/tmp/new.txt')
* template.rename('/tmp/old.txt', '/tmp/new.txt', { user: 'root' })
* ```
*/
rename(
src: PathLike,
dest: PathLike,
options?: { force?: boolean; user?: string }
): TemplateBuilder
/**
* Create directories.
* @param path Directory path(s)
* @param options Directory options
*
* @example
* ```ts
* template.makeDir('/app/data', { mode: 0o755 })
* template.makeDir(['/app/logs', '/app/cache'])
* template.makeDir('/app/data', { mode: 0o755, user: 'root' })
* ```
*/
makeDir(
path: PathLike | PathLike[],
options?: { mode?: number; user?: string }
): TemplateBuilder
/**
* Create a symbolic link.
* @param src Source path (target)
* @param dest Destination path (symlink location)
* @param options Symlink options
*
* @example
* ```ts
* template.makeSymlink('/usr/bin/python3', '/usr/bin/python')
* template.makeSymlink('/usr/bin/python3', '/usr/bin/python', { user: 'root' })
* template.makeSymlink('/usr/bin/python3', '/usr/bin/python', { force: true })
* ```
*/
makeSymlink(
src: PathLike,
dest: PathLike,
options?: { user?: string; force?: boolean }
): TemplateBuilder
/**
* Run a shell command.
* @param command Command string
* @param options Command options
*
* @example
* ```ts
* template.runCmd('apt-get update')
* template.runCmd(['pip install numpy', 'pip install pandas'])
* template.runCmd('apt-get install vim', { user: 'root' })
* ```
*/
runCmd(command: string, options?: { user?: string }): TemplateBuilder
/**
* Run multiple shell commands.
* @param commands Array of command strings
* @param options Command options
*/
runCmd(commands: string[], options?: { user?: string }): TemplateBuilder
/**
* Run command(s).
* @param commandOrCommands Command or commands
* @param options Command options
*/
runCmd(
commandOrCommands: string | string[],
options?: { user?: string }
): TemplateBuilder
/**
* Set the working directory.
* @param workdir Working directory path
*
* @example
* ```ts
* template.setWorkdir('/app')
* ```
*/
setWorkdir(workdir: PathLike): TemplateBuilder
/**
* Set the user for subsequent commands.
* @param user Username
*
* @example
* ```ts
* template.setUser('root')
* ```
*/
setUser(user: string): TemplateBuilder
/**
* Install Python packages using pip.
* @param packages Package name(s) or undefined for current directory
* @param options Install options
* @param options.g Install globally as root (default: true). Set to false for user-only installation with --user flag
*
* @example
* ```ts
* template.pipInstall('numpy') // Installs globally (default)
* template.pipInstall(['pandas', 'scikit-learn'])
* template.pipInstall('numpy', { g: false }) // Install for user only
* template.pipInstall() // Installs from current directory
* ```
*/
pipInstall(
packages?: string | string[],
options?: { g?: boolean }
): TemplateBuilder
/**
* Install Node.js packages using npm.
* @param packages Package name(s) or undefined for package.json
* @param options Install options
*
* @example
* ```ts
* template.npmInstall('express')
* template.npmInstall(['lodash', 'axios'])
* template.npmInstall('tsx', { g: true })
* template.npmInstall('typescript', { dev: true })
* template.npmInstall() // Installs from package.json
* ```
*/
npmInstall(
packages?: string | string[],
options?: { g?: boolean; dev?: boolean }
): TemplateBuilder
/**
* Install Bun packages using bun.
* @param packages Package name(s) or undefined for package.json
* @param options Install options
*
* @example
* ```ts
* template.bunInstall('express')
* template.bunInstall(['lodash', 'axios'])
* template.bunInstall('tsx', { g: true })
* template.bunInstall('typescript', { dev: true })
* template.bunInstall() // Installs from package.json
* ```
*/
bunInstall(
packages?: string | string[],
options?: { g?: boolean; dev?: boolean }
): TemplateBuilder
/**
* Install Debian/Ubuntu packages using apt-get.
* @param packages Package name(s)
*
* @example
* ```ts
* template.aptInstall('vim')
* template.aptInstall(['git', 'curl', 'wget'])
* template.aptInstall(['vim'], { noInstallRecommends: true })
* template.aptInstall(['vim'], { fixMissing: true })
* ```
*/
aptInstall(
packages: string | string[],
options?: { noInstallRecommends?: boolean; fixMissing?: boolean }
): TemplateBuilder
/**
* Install MCP servers using mcp-gateway.
* Note: Requires a base image with mcp-gateway pre-installed (e.g., mcp-gateway).
* @param servers MCP server name(s)
*
* @throws {Error} If the base template is not mcp-gateway
* @example
* ```ts
* template.addMcpServer('exa')
* template.addMcpServer(['brave', 'firecrawl', 'duckduckgo'])
* ```
*/
addMcpServer(servers: McpServerName | McpServerName[]): TemplateBuilder
/**
* Clone a Git repository.
* @param url Repository URL
* @param path Optional destination path
* @param options Clone options
*
* @example
* ```ts
* template.gitClone('https://github.com/user/repo.git', '/app/repo')
* template.gitClone('https://github.com/user/repo.git', undefined, {
* branch: 'main',
* depth: 1
* })
* template.gitClone('https://github.com/user/repo.git', '/app/repo', {
* user: 'root'
* })
* ```
*/
gitClone(
url: string,
path?: PathLike,
options?: { branch?: string; depth?: number; user?: string }
): TemplateBuilder
/**
* Set environment variables.
* Note: Environment variables defined here are available only during template build.
* @param envs Environment variables
*
* @example
* ```ts
* template.setEnvs({ NODE_ENV: 'production', PORT: '8080' })
* ```
*/
setEnvs(envs: Record<string, string>): TemplateBuilder
/**
* Skip cache for all subsequent build instructions from this point.
*
* @example
* ```ts
* template.skipCache().runCmd('apt-get update')
* ```
*/
skipCache(): this
/**
* Set the start command and ready check.
* @param startCommand Command to run on startup
* @param readyCommand Command to check readiness
*
* @example
* ```ts
* // Using a string command
* template.setStartCmd(
* 'node app.js',
* 'curl http://localhost:8000/health'
* )
*
* // Using ReadyCmd helpers
* import { waitForPort, waitForURL } from 'e2b'
*
* template.setStartCmd(
* 'python -m http.server 8000',
* waitForPort(8000)
* )
*
* template.setStartCmd(
* 'npm start',
* waitForURL('http://localhost:3000/health', 200)
* )
* ```
*/
setStartCmd(
startCommand: string,
readyCommand: string | ReadyCmd
): TemplateFinal
/**
* Set or update the ready check command.
* @param readyCommand Command to check readiness
*
* @example
* ```ts
* // Using a string command
* template.setReadyCmd('curl http://localhost:8000/health')
*
* // Using ReadyCmd helpers
* import { waitForPort, waitForFile, waitForProcess } from 'e2b'
*
* template.setReadyCmd(waitForPort(3000))
*
* template.setReadyCmd(waitForFile('/tmp/ready'))
*
* template.setReadyCmd(waitForProcess('nginx'))
* ```
*/
setReadyCmd(readyCommand: string | ReadyCmd): TemplateFinal
/**
* Prebuild a devcontainer from the specified directory.
* @param devcontainerDirectory Path to the devcontainer directory
*
* @example
* ```ts
* template
* .gitClone('https://myrepo.com/project.git', '/my-devcontainer')
* .betaDevContainerPrebuild('/my-devcontainer')
* ```
*/
betaDevContainerPrebuild(devcontainerDirectory: string): TemplateBuilder
/**
* Start a devcontainer from the specified directory.
* @param devcontainerDirectory Path to the devcontainer directory
*
* @example
* ```ts
* template
* .gitClone('https://myrepo.com/project.git', '/my-devcontainer')
* .startDevcontainer('/my-devcontainer')
*
* // Prebuild and start
* template
* .gitClone('https://myrepo.com/project.git', '/my-devcontainer')
* .betaDevContainerPrebuild('/my-devcontainer')
* // Other instructions...
* .betaSetDevContainerStart('/my-devcontainer')
* ```
*/
betaSetDevContainerStart(devcontainerDirectory: string): TemplateFinal
}
/**
* Final state of a template after start/ready commands are set.
* The template can only be built in this state.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface TemplateFinal {}
/**
* Configuration for a generic Docker registry with basic authentication.
*/
export type GenericDockerRegistry = {
/** Registry type identifier */
type: 'registry'
/** Registry username */
username: string
/** Registry password */
password: string
}
/**
* Configuration for AWS Elastic Container Registry (ECR).
*/
export type AWSRegistry = {
/** Registry type identifier */
type: 'aws'
/** AWS access key ID */
awsAccessKeyId: string
/** AWS secret access key */
awsSecretAccessKey: string
/** AWS region */
awsRegion: string
}
/**
* Configuration for Google Container Registry (GCR) or Artifact Registry.
*/
export type GCPRegistry = {
/** Registry type identifier */
type: 'gcp'
/** Service account JSON as string */
serviceAccountJson: string
}
/**
* Union type for all supported container registry configurations.
*/
export type RegistryConfig = GenericDockerRegistry | AWSRegistry | GCPRegistry
/**
* Type representing a template in any state (builder or final).
*/
export type TemplateClass = TemplateBuilder | TemplateFinal
+474
View File
@@ -0,0 +1,474 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { dynamicImport, dynamicRequire } from '../utils'
import { TemplateError } from '../errors'
import { BASE_STEP_NAME, FINALIZE_STEP_NAME } from './consts'
import type { Readable } from 'node:stream'
import type { Path } from 'glob'
import type { BuildOptions } from './types'
/**
* Validate that a source path for copy operations is a relative path that stays
* within the context directory. This prevents path traversal attacks and ensures
* files are copied from within the expected directory.
*
* @param src The source path to validate
* @param stackTrace Optional stack trace for error reporting
* @throws TemplateError if the path is absolute or escapes the context directory
*
* Invalid paths:
* - Absolute paths: /absolute/path, C:\Windows\path
* - Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar
*
* Valid paths:
* - Simple relative: foo, foo/bar
* - Current directory prefix: ./foo, ./foo/bar
* - Internal parent refs that don't escape: foo/../bar (stays within context)
*/
export function validateRelativePath(
src: string,
stackTrace: string | undefined
): void {
// Check for absolute paths using Node's cross-platform implementation
if (path.isAbsolute(src)) {
const error = new TemplateError(
`Invalid source path "${src}": absolute paths are not allowed. Use a relative path within the context directory.`,
stackTrace
)
throw error
}
// Normalize the path and check if it escapes the context directory
const normalized = path.normalize(src)
// After normalization, a path that escapes would be '..' or start with '../'
// We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig'
// Examples:
// - '../foo' -> '../foo' (escapes)
// - 'foo/../../bar' -> '../bar' (escapes)
// - './foo/../../../bar' -> '../../bar' (escapes)
// - 'foo/../bar' -> 'bar' (doesn't escape)
// - './foo/bar' -> 'foo/bar' (doesn't escape)
// - '..myconfig' -> '..myconfig' (valid filename, doesn't escape)
const escapes = normalized === '..' || normalized.startsWith('..' + path.sep)
if (escapes) {
const error = new TemplateError(
`Invalid source path "${src}": path escapes the context directory. The path must stay within the context directory.`,
stackTrace
)
throw error
}
}
/**
* Normalize build arguments from different overload signatures.
* Handles string name or legacy options object with alias.
*
* @param nameOrOptions Name or legacy options with alias
* @param options Optional build options (when first arg is name)
* @returns Object with normalized name, tags, and build options
* @throws TemplateError if no template name is provided
*/
export function normalizeBuildArguments(
nameOrOptions: string | BuildOptions,
options?: Omit<BuildOptions, 'alias'>
): {
name: string
buildOptions: Omit<BuildOptions, 'alias'>
} {
let name: string
let buildOptions: Omit<BuildOptions, 'alias'>
if (typeof nameOrOptions === 'string') {
name = nameOrOptions
buildOptions = options ?? {}
} else {
// Legacy: options object with alias
const { alias, ...restOpts } = nameOrOptions
name = alias
buildOptions = restOpts
}
if (!name || name.length === 0) {
throw new TemplateError('Name must be provided')
}
return { name, buildOptions }
}
/**
* Read and parse a .dockerignore file.
*
* @param contextPath Directory path containing the .dockerignore file
* @returns Array of ignore patterns (empty lines and comments are filtered out)
*/
export function readDockerignore(contextPath: string): string[] {
const dockerignorePath = path.join(contextPath, '.dockerignore')
if (!fs.existsSync(dockerignorePath)) {
return []
}
const content = fs.readFileSync(dockerignorePath, 'utf-8')
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
}
/**
* Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows)
* @param path - The path to normalize
* @returns The normalized path
*/
function normalizePath(path: string): string {
return path.replace(/\\/g, '/')
}
/**
* Get all files for a given path and ignore patterns.
*
* @param src Path to the source directory
* @param contextPath Base directory for resolving relative paths
* @param ignorePatterns Ignore patterns
* @returns Array of files
*/
export async function getAllFilesInPath(
src: string,
contextPath: string,
ignorePatterns: string[],
includeDirectories: boolean = true
) {
const { glob } = await dynamicImport<typeof import('glob')>('glob')
const files = new Map<string, Path>()
const globFiles = await glob(src, {
ignore: ignorePatterns,
withFileTypes: true,
dot: true,
// this is required so that the ignore pattern is relative to the file path
cwd: contextPath,
})
for (const file of globFiles) {
if (file.isDirectory()) {
// For directories, add the directory itself and all files inside it
if (includeDirectories) {
files.set(file.fullpath(), file)
}
const dirPattern = normalizePath(
// When the matched directory is '.', `file.relative()` can be an empty string.
// In that case, we want to match all files under the current directory instead of
// creating an absolute glob like '/**/*' which would traverse the entire filesystem.
path.join(file.relative() || '.', '**/*')
)
const dirFiles = await glob(dirPattern, {
ignore: ignorePatterns,
withFileTypes: true,
dot: true,
cwd: contextPath,
})
dirFiles.forEach((f) => files.set(f.fullpath(), f))
} else {
// For files, just add the file
files.set(file.fullpath(), file)
}
}
// Sort by full path for a deterministic order — the default sort() would
// stringify the Path objects to '[object Object]' and keep glob order,
// making the files hash dependent on filesystem traversal order.
return Array.from(files.values()).sort((a, b) =>
a.fullpath() < b.fullpath() ? -1 : a.fullpath() > b.fullpath() ? 1 : 0
)
}
/**
* Calculate a hash of files being copied to detect changes for cache invalidation.
* The hash includes file content, metadata (mode, size), and relative paths.
* Note: uid, gid, and mtime are excluded to ensure stable hashes across environments.
*
* @param src Source path pattern for files to copy
* @param dest Destination path where files will be copied
* @param contextPath Base directory for resolving relative paths
* @param ignorePatterns Glob patterns to ignore
* @param resolveSymlinks Whether to resolve symbolic links when hashing
* @param stackTrace Optional stack trace for error reporting
* @returns Hex string hash of all files
* @throws Error if no files match the source pattern
*/
export async function calculateFilesHash(
src: string,
dest: string,
contextPath: string,
ignorePatterns: string[],
resolveSymlinks: boolean,
stackTrace: string | undefined
): Promise<string> {
const srcPath = path.join(contextPath, src)
const hash = crypto.createHash('sha256')
const content = `COPY ${src} ${dest}`
hash.update(content)
const files = await getAllFilesInPath(src, contextPath, ignorePatterns, true)
if (files.length === 0) {
const error = new Error(`No files found in ${srcPath}`)
if (stackTrace) {
error.stack = stackTrace
}
throw error
}
// Hash stats - only include stable metadata (mode, size)
// Exclude uid, gid, and mtime to ensure consistent hashes across environments
const hashStats = (stats: fs.Stats) => {
hash.update(stats.mode.toString())
hash.update(stats.size.toString())
}
// Process files recursively
for (const file of files) {
// Add a relative path to hash calculation
const relativePath = file.relativePosix()
hash.update(relativePath)
// Add stat information to hash calculation
if (file.isSymbolicLink()) {
// If the symlink is broken, it will return undefined, otherwise it will return a stats object of the target
const stats = fs.statSync(file.fullpath(), { throwIfNoEntry: false })
const shouldFollow =
resolveSymlinks && (stats?.isFile() || stats?.isDirectory())
if (!shouldFollow) {
const stats = fs.lstatSync(file.fullpath())
hashStats(stats)
const content = fs.readlinkSync(file.fullpath())
hash.update(content)
continue
}
}
const stats = fs.statSync(file.fullpath())
hashStats(stats)
// Add file content to hash calculation
if (stats.isFile()) {
const content = fs.readFileSync(file.fullpath())
hash.update(new Uint8Array(content))
}
}
return hash.digest('hex')
}
/**
* Get the caller's stack trace frame at a specific depth.
*
* @param depth The depth of the stack trace to retrieve
* - Levels: caller (e.g., TemplateBase.fromImage) > original caller (e.g., user's template file)
* @returns The caller frame as a string, or undefined if not available
*/
export function getCallerFrame(depth: number): string | undefined {
const stackTrace = new Error().stack
if (!stackTrace) {
return
}
const lines = stackTrace.split('\n').slice(1) // Skip the this function (getCallerFrame)
if (lines.length < depth + 1) {
return
}
return lines.slice(depth).join('\n')
}
// adopted from https://github.com/sindresorhus/callsites
export function callsites(depth: number): NodeJS.CallSite[] {
const _originalPrepareStackTrace = Error.prepareStackTrace
try {
let result: NodeJS.CallSite[] = []
Error.prepareStackTrace = (_, callSites) => {
const callSitesWithoutCurrent = callSites.slice(depth)
result = callSitesWithoutCurrent
return callSitesWithoutCurrent
}
// Accessing `.stack` triggers `Error.prepareStackTrace` for its side effect.
// oxlint-disable-next-line no-unused-expressions
new Error().stack
return result
} finally {
Error.prepareStackTrace = _originalPrepareStackTrace
}
}
/**
* Get the directory of the caller at a specific stack depth.
*
* @param depth The depth of the stack trace
* @returns The caller's directory path, or undefined if not available
*/
export function getCallerDirectory(depth: number): string | undefined {
// +1 depth to skip this function (getCallerDirectory)
const callSites = callsites(depth + 1)
if (callSites.length === 0) {
return undefined
}
let fileName = callSites[0].getFileName()
if (!fileName) {
return undefined
}
// Handle file:// URLs returned by getFileName() in ESM modules
if (fileName.startsWith('file:')) {
// we use the dynamic import to avoid bundling node:url for browser compatibility
// getCallerDirectory method is not called in the browser
const { fileURLToPath } =
dynamicRequire<typeof import('node:url')>('node:url')
fileName = fileURLToPath(fileName)
}
return path.dirname(fileName)
}
/**
* Convert a numeric file mode to a zero-padded octal string.
*
* @param mode File mode as a number (e.g., 493 for 0o755)
* @returns Zero-padded 4-digit octal string (e.g., "0755")
*
* @example
* ```ts
* padOctal(0o755) // Returns "0755"
* padOctal(0o644) // Returns "0644"
* ```
*/
export function padOctal(mode: number): string {
return mode.toString(8).padStart(4, '0')
}
/**
* Create a gzipped tar archive of files matching a pattern and return a
* readable stream over it.
*
* The archive is spooled to a temporary file on disk so it can be uploaded as
* a stream with a known `Content-Length` instead of being buffered in memory.
* The temporary file is removed automatically once the returned stream is
* closed — whether it was fully read, errored, or destroyed. This mirrors the
* Python SDK's `tar_file_stream`, where closing the temp-file handle deletes
* it (Node has no portable delete-on-close, so cleanup is tied to the stream's
* `close` event instead).
*
* @param fileName Glob pattern for files to include
* @param fileContextPath Base directory for resolving file paths
* @param ignorePatterns Ignore patterns to exclude from the archive
* @param resolveSymlinks Whether to follow symbolic links
* @param gzip Whether to gzip the archive
* @returns The readable stream and the archive size in bytes
*/
export async function tarFileStream(
fileName: string,
fileContextPath: string,
ignorePatterns: string[],
resolveSymlinks: boolean,
gzip: boolean
): Promise<{ stream: Readable; size: number }> {
const { create } = await dynamicImport<typeof import('tar')>('tar')
// Dynamically import so the browser bundle doesn't pull in node:fs.
const { createReadStream } =
await dynamicImport<typeof import('node:fs')>('node:fs')
const allFiles = await getAllFilesInPath(
fileName,
fileContextPath,
ignorePatterns,
true
)
const filePaths = allFiles.map((file) => file.relativePosix())
const tmpDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'e2b-template-')
)
const tarPath = path.join(tmpDir, 'context.tar.gz')
// Best-effort removal so it can never mask the archive-creation error or the
// upload result. A leaked temp dir is non-fatal — the OS reclaims it.
const removeTmpDir = () =>
fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
try {
await create(
{
gzip,
cwd: fileContextPath,
follow: resolveSymlinks,
noDirRecurse: true,
file: tarPath,
},
filePaths
)
const { size } = await fs.promises.stat(tarPath)
const stream = createReadStream(tarPath)
// Remove the spooled archive once the stream is done — `close` fires after
// the fd is released on every path (end, error, or destroy).
stream.once('close', removeTmpDir)
return { stream, size }
} catch (err) {
await removeTmpDir()
throw err
}
}
/**
* Get the array index for a build step based on its name.
*
* Special steps:
* - BASE_STEP_NAME: Returns 0 (first step)
* - FINALIZE_STEP_NAME: Returns the last index
* - Numeric strings: Converted to number
*
* @param step Build step name or number as string
* @param stackTracesLength Total number of stack traces (used for FINALIZE_STEP_NAME)
* @returns Index for the build step
*/
export function getBuildStepIndex(
step: string,
stackTracesLength: number
): number {
if (step === BASE_STEP_NAME) {
return 0
}
if (step === FINALIZE_STEP_NAME) {
return stackTracesLength - 1
}
return Number(step)
}
/**
* Read GCP service account JSON from a file or object.
*
* @param contextPath Base directory for resolving relative file paths
* @param pathOrContent Either a path to a JSON file or a service account object
* @returns Service account JSON as a string
*/
export function readGCPServiceAccountJSON(
contextPath: string,
pathOrContent: string | object
): string {
if (typeof pathOrContent === 'string') {
return fs.readFileSync(path.join(contextPath, pathOrContent), 'utf-8')
}
return JSON.stringify(pathOrContent)
}
+64
View File
@@ -0,0 +1,64 @@
export type UndiciRequestInit = RequestInit & {
dispatcher?: unknown
duplex?: 'half'
}
export type UndiciModule = {
Agent: new (options: { allowH2: true; connections?: number }) => unknown
ProxyAgent: new (options: {
uri: string
allowH2: true
connections?: number
}) => unknown
fetch: unknown
}
export async function loadUndici(): Promise<UndiciModule | undefined> {
try {
// Keep this import opaque to bundlers. It must resolve as a package name
// from the runtime environment, not as a path relative to this file.
// eslint-disable-next-line no-new-func
const importModule = new Function(
'moduleName',
'return import(moduleName)'
) as (moduleName: string) => Promise<UndiciModule>
return await importModule('undici')
} catch {
return undefined
}
}
export function toUndiciRequestInput(
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,
}
}
+174
View File
@@ -0,0 +1,174 @@
import platform from 'platform'
declare let window: any
type Runtime =
| 'node'
| 'browser'
| 'deno'
| 'bun'
| 'vercel-edge'
| 'cloudflare-worker'
| 'unknown'
function getRuntime(): { runtime: Runtime; version: string } {
// @ts-ignore
if ((globalThis as any).Bun) {
// @ts-ignore
return { runtime: 'bun', version: globalThis.Bun.version }
}
// @ts-ignore
if ((globalThis as any).Deno) {
// @ts-ignore
return { runtime: 'deno', version: globalThis.Deno.version.deno }
}
if ((globalThis as any).process?.release?.name === 'node') {
return { runtime: 'node', version: platform.version || 'unknown' }
}
// @ts-ignore
if (typeof EdgeRuntime === 'string') {
return { runtime: 'vercel-edge', version: 'unknown' }
}
if ((globalThis as any).navigator?.userAgent === 'Cloudflare-Workers') {
return { runtime: 'cloudflare-worker', version: 'unknown' }
}
if (typeof window !== 'undefined') {
return { runtime: 'browser', version: platform.version || 'unknown' }
}
return { runtime: 'unknown', version: 'unknown' }
}
export const { runtime, version: runtimeVersion } = getRuntime()
export async function sha256(data: string): Promise<string> {
// Use WebCrypto API if available
if (typeof crypto !== 'undefined') {
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(data)
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
const hashArray = new Uint8Array(hashBuffer)
return btoa(String.fromCharCode(...hashArray))
}
// Use Node.js crypto if WebCrypto is not available
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { createHash } = require('node:crypto')
const hash = createHash('sha256').update(data, 'utf8').digest()
return hash.toString('base64')
}
export function timeoutToSeconds(timeout: number): number {
return Math.ceil(timeout / 1000)
}
export function dynamicRequire<T>(module: string): T {
if (runtime === 'browser') {
throw new Error('Browser runtime is not supported for require')
}
return require(module)
}
export async function dynamicImport<T>(module: string): Promise<T> {
if (runtime === 'browser') {
throw new Error('Browser runtime is not supported for dynamic import')
}
// @ts-ignore
return await import(module)
}
// Source: https://github.com/chalk/ansi-regex/blob/main/index.js
function ansiRegex({ onlyFirst = false } = {}) {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)'
// OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`
// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte
const csi =
'[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]'
const pattern = `${osc}|${csi}`
return new RegExp(pattern, onlyFirst ? undefined : 'g')
}
export function stripAnsi(text: string): string {
return text.replace(ansiRegex(), '')
}
/**
* Convert data to a Blob, avoiding unnecessary conversions when possible.
*/
export function toBlob(
data: string | ArrayBuffer | Blob | ReadableStream
): Blob | Promise<Blob> {
// Already a Blob - use directly
if (data instanceof Blob) {
return data
}
// String or ArrayBuffer - create Blob
if (typeof data === 'string' || data instanceof ArrayBuffer) {
return new Blob([data])
}
// ReadableStream - must consume to get Blob
return new Response(data).blob()
}
// Characters that are safe to leave unquoted in a POSIX shell, matching the
// set used by Python's shlex.quote (`[^\w@%+=:,./-]` is considered unsafe).
const UNSAFE_SHELL_CHAR = /[^\w@%+=:,./-]/
/**
* Quote a string for safe interpolation into a POSIX shell command.
*
* Faithful port of Python's `shlex.quote`: an empty string becomes `''`,
* values containing only safe characters are returned unchanged (keeping
* generated commands stable and cache-friendly), and anything else is wrapped
* in single quotes with embedded single quotes escaped as `'"'"'`.
*/
export function shellQuote(s: string): string {
if (s === '') {
return "''"
}
if (!UNSAFE_SHELL_CHAR.test(s)) {
return s
}
return "'" + s.replace(/'/g, "'\"'\"'") + "'"
}
/**
* Prepare data for upload as a BodyInit, optionally gzip-compressed.
*
* Outside the browser, streams (and gzip-compressed data) are returned as
* `ReadableStream` so they can be uploaded without buffering in memory.
* Browsers don't support streaming request bodies, so data is buffered into
* a Blob there.
*/
export async function toUploadBody(
data: string | ArrayBuffer | Blob | ReadableStream,
gzip?: boolean
): Promise<BodyInit> {
if (gzip) {
const stream =
data instanceof ReadableStream
? data
: data instanceof Blob
? data.stream()
: new Blob([data]).stream()
const compressed = stream.pipeThrough(new CompressionStream('gzip'))
return runtime === 'browser' ? new Response(compressed).blob() : compressed
}
if (data instanceof ReadableStream && runtime !== 'browser') {
return data
}
return toBlob(data)
}
+139
View File
@@ -0,0 +1,139 @@
import createClient from 'openapi-fetch'
import type { components, paths } from './schema.gen'
import { defaultHeaders, getEnvVar } from '../api/metadata'
import { createApiFetch } from '../api/http2'
import { buildRequestSignal } from '../connectionConfig'
import { createApiLogger, Logger } from '../logs'
import type { Volume } from './index'
const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds
const FILE_TIMEOUT_MS = 3_600_000 // 1 hour
export interface VolumeApiOpts {
/**
* E2B API key to use for authentication.
*
* @default E2B_API_KEY // environment variable
*/
token?: string
/**
* Domain to use for the volume API.
*
* @default E2B_DOMAIN // environment variable or `e2b.app`
*/
domain?: string
/**
* If true the SDK starts in the debug mode and connects to the local volume API server.
* @internal
* @default E2B_DEBUG // environment variable or `false`
*/
debug?: boolean
/**
* API Url to use for the API.
* @internal
* @default E2B_VOLUME_API_URL // environment variable or `https://api.${domain}`
*/
apiUrl?: string
/**
* Timeout for requests to the API in **milliseconds**.
*
* @default 60_000 // 60 seconds
*/
requestTimeoutMs?: number
/**
* Logger to use for logging messages. It can accept any object that implements `Logger` interface—for example, {@link console}.
*/
logger?: Logger
/**
* Additional headers to send with the request.
*/
headers?: Record<string, string>
/**
* Proxy URL to use for requests.
*
* @example 'http://user:pass@127.0.0.1:8080'
*/
proxy?: string
/**
* An optional `AbortSignal` that can be used to cancel the in-flight request.
* When the signal is aborted, the underlying `fetch` is aborted and the
* returned promise rejects with an `AbortError`.
*/
signal?: AbortSignal
}
export class VolumeConnectionConfig {
readonly domain: string
readonly debug: boolean
readonly apiUrl: string
readonly token?: string
readonly headers?: Record<string, string>
readonly logger?: Logger
readonly requestTimeoutMs: number
readonly signal?: AbortSignal
readonly proxy?: string
constructor(volume: Volume, opts?: VolumeApiOpts) {
this.domain = opts?.domain || volume.domain || VolumeConnectionConfig.domain
this.debug = opts?.debug ?? volume.debug ?? VolumeConnectionConfig.debug
this.apiUrl =
opts?.apiUrl ||
VolumeConnectionConfig.volumeApiUrl ||
(this.debug ? 'http://localhost:8080' : `https://api.${this.domain}`)
this.token = opts?.token || volume.token
this.headers = opts?.headers
this.logger = opts?.logger
this.requestTimeoutMs = opts?.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
this.signal = opts?.signal
this.proxy = opts?.proxy || volume.proxy
}
private static get domain() {
return getEnvVar('E2B_DOMAIN') || 'e2b.app'
}
private static get debug() {
return (getEnvVar('E2B_DEBUG') || 'false').toLowerCase() === 'true'
}
private static get volumeApiUrl() {
return getEnvVar('E2B_VOLUME_API_URL')
}
getSignal(requestTimeoutMs?: number, signal?: AbortSignal) {
return buildRequestSignal(
requestTimeoutMs ?? this.requestTimeoutMs,
signal ?? this.signal
)
}
}
/**
* Client for interacting with the E2B Volume API.
*/
class VolumeApiClient {
readonly api: ReturnType<typeof createClient<paths>>
constructor(config: VolumeConnectionConfig) {
this.api = createClient<paths>({
baseUrl: config.apiUrl,
fetch: createApiFetch(config.proxy),
headers: {
...defaultHeaders,
...(config.token && { Authorization: `Bearer ${config.token}` }),
...config.headers,
},
})
if (config.logger) {
this.api.use(createApiLogger(config.logger))
}
}
}
export type { components as VolumeApiComponents, paths as VolumeApiPaths }
export { VolumeApiClient, FILE_TIMEOUT_MS }
+762
View File
@@ -0,0 +1,762 @@
import { ApiClient, handleApiError, components as ApiComponents } from '../api'
import {
VolumeApiClient,
VolumeApiComponents,
VolumeConnectionConfig,
VolumeApiOpts,
FILE_TIMEOUT_MS,
} from './client'
import {
ConnectionConfig,
ConnectionOpts,
setupRequestController,
wrapStreamWithConnectionCleanup,
} from '../connectionConfig'
import { NotFoundError, VolumeError } from '../errors'
import { toUploadBody } from '../utils'
import { VolumeFileType } from './types'
import type {
VolumeAndToken,
VolumeEntryStat,
VolumeInfo,
VolumeMetadataOpts,
VolumeReadOpts,
VolumeWriteOpts,
} from './types'
/**
* Convert API VolumeEntryStat to SDK VolumeEntryStat.
*/
function convertVolumeEntryStat(
entry: VolumeApiComponents['schemas']['VolumeEntryStat']
): VolumeEntryStat {
return {
...entry,
type: entry.type as VolumeFileType,
atime: new Date(entry.atime),
mtime: new Date(entry.mtime),
ctime: new Date(entry.ctime),
}
}
/**
* Module for interacting with E2B volumes.
*
* Create a `Volume` instance to interact with a volume by its ID,
* or use the static methods to manage volumes.
*/
export class Volume {
/**
* Volume ID.
*/
readonly volumeId: string
/**
* Volume name.
*/
readonly name: string
/**
* Volume auth token.
*/
readonly token: string
/**
* Domain used for constructing the volume API URL.
*/
readonly domain?: string
/**
* Whether to use debug mode (connects to local volume API server).
*/
readonly debug?: boolean
/**
* Proxy URL used for requests to the volume content API.
*/
readonly proxy?: string
/**
* Create a local Volume instance with no API call.
*
* @param volumeId volume ID.
* @param name volume name.
* @param token volume auth token.
* @param domain domain for the volume API.
* @param debug whether to use debug mode.
* @param proxy proxy URL for the volume content API.
*/
constructor(
volumeId: string,
name: string,
token: string,
domain?: string,
debug?: boolean,
proxy?: string
) {
this.volumeId = volumeId
this.name = name
this.token = token
this.domain = domain
this.debug = debug
this.proxy = proxy
}
/**
* Create a new volume.
*
* @param name name of the volume.
* @param opts connection options.
*
* @returns new Volume instance.
*/
static async create(name: string, opts?: ConnectionOpts): Promise<Volume> {
const config = new ConnectionConfig(opts)
const client = new ApiClient(config)
const res = await client.api.POST('/volumes', {
body: {
name,
},
signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal),
})
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
if (!res.data) {
throw new Error('Response data is missing')
}
return new Volume(
res.data.volumeID,
res.data.name,
res.data.token,
config.domain,
config.debug,
config.proxy
)
}
/**
* Connect to an existing volume by ID.
*
* @param volumeId volume ID.
* @param opts connection options.
*
* @returns Volume instance.
*/
static async connect(
volumeId: string,
opts?: ConnectionOpts
): Promise<Volume> {
const config = new ConnectionConfig(opts)
const { name, token } = await Volume.getInfo(volumeId, opts)
return new Volume(
volumeId,
name,
token,
config.domain,
config.debug,
config.proxy
)
}
/**
* Get volume information.
*
* @param volumeId volume ID.
* @param opts connection options.
*
* @returns volume information.
*/
static async getInfo(
volumeId: string,
opts?: ConnectionOpts
): Promise<VolumeAndToken> {
const config = new ConnectionConfig(opts)
const client = new ApiClient(config)
const res = await client.api.GET('/volumes/{volumeID}', {
params: {
path: {
volumeID: volumeId,
},
},
signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal),
})
if (res.response.status === 404) {
throw new NotFoundError(`Volume ${volumeId} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
return {
volumeId: res.data!.volumeID,
name: res.data!.name,
token: res.data!.token,
}
}
/**
* List all volumes.
*
* @param opts connection options.
*
* @returns list of volume information.
*/
static async list(opts?: ConnectionOpts): Promise<VolumeInfo[]> {
const config = new ConnectionConfig(opts)
const client = new ApiClient(config)
const res = await client.api.GET('/volumes', {
signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal),
})
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
return (res.data ?? []).map((vol: ApiComponents['schemas']['Volume']) => ({
volumeId: vol.volumeID,
name: vol.name,
}))
}
/**
* Destroy a volume.
*
* @param volumeId volume ID.
* @param opts connection options.
*/
static async destroy(
volumeId: string,
opts?: ConnectionOpts
): Promise<boolean> {
const config = new ConnectionConfig(opts)
const client = new ApiClient(config)
const res = await client.api.DELETE('/volumes/{volumeID}', {
params: {
path: {
volumeID: volumeId,
},
},
signal: config.getSignal(opts?.requestTimeoutMs, opts?.signal),
})
if (res.response.status === 404) {
return false
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
return true
}
/**
* List directory contents.
*
* @param path path to the directory.
* @param opts connection options.
* @param [opts.depth] number of layers deep to recurse into the directory (default: 1).
*
* @returns list of entries in the directory.
*/
async list(
path: string,
opts?: VolumeApiOpts & { depth?: number }
): Promise<VolumeEntryStat[]> {
const config = new VolumeConnectionConfig(this, opts)
const client = new VolumeApiClient(config)
const res = await client.api.GET('/volumecontent/{volumeID}/dir', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
depth: opts?.depth,
},
},
signal: config.getSignal(),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
// VolumeDirectoryListing is an array according to the spec
const entries = Array.isArray(res.data) ? res.data : []
return entries.map(convertVolumeEntryStat)
}
/**
* Create a directory.
*
* @param path path to the directory to create.
* @param options directory creation options.
* @param opts connection options.
*/
async makeDir(
path: string,
opts?: VolumeWriteOpts & VolumeApiOpts
): Promise<VolumeEntryStat> {
const config = new VolumeConnectionConfig(this, opts)
const client = new VolumeApiClient(config)
const res = await client.api.POST('/volumecontent/{volumeID}/dir', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
uid: opts?.uid,
gid: opts?.gid,
mode: opts?.mode,
force: opts?.force,
},
},
signal: config.getSignal(),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
if (!res.data) {
throw new Error('Response data is missing')
}
return convertVolumeEntryStat(
res.data as VolumeApiComponents['schemas']['VolumeEntryStat']
)
}
/**
* Get information about a file or directory.
*
* @param path path to the file or directory.
* @param opts connection options.
*
* @returns information about the entry.
*/
async getInfo(path: string, opts?: VolumeApiOpts): Promise<VolumeEntryStat> {
const config = new VolumeConnectionConfig(this, opts)
const client = new VolumeApiClient(config)
const res = await client.api.GET('/volumecontent/{volumeID}/path', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
},
},
signal: config.getSignal(),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
if (!res.data) {
throw new Error('Response data is missing')
}
return convertVolumeEntryStat(
res.data as VolumeApiComponents['schemas']['VolumeEntryStat']
)
}
/**
* Check whether a file or directory exists.
*
* Uses {@link getInfo} under the hood. Returns `true` if the path exists,
* `false` if it does not (404). Other errors are rethrown.
*
* @param path path to the file or directory.
* @param opts connection options.
*
* @returns `true` if the path exists, `false` otherwise.
*/
async exists(path: string, opts?: VolumeApiOpts): Promise<boolean> {
try {
await this.getInfo(path, opts)
return true
} catch (err) {
if (err instanceof NotFoundError) {
return false
}
throw err
}
}
/**
* Update file or directory metadata.
*
* @param path path to the file or directory.
* @param metadata metadata to update (uid, gid, mode).
* @param opts connection options.
*
* @returns updated entry information.
*/
async updateMetadata(
path: string,
metadata: VolumeMetadataOpts,
opts?: VolumeApiOpts
): Promise<VolumeEntryStat> {
const config = new VolumeConnectionConfig(this, opts)
const client = new VolumeApiClient(config)
const res = await client.api.PATCH('/volumecontent/{volumeID}/path', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
},
},
body: {
uid: metadata.uid,
gid: metadata.gid,
mode: metadata.mode,
},
signal: config.getSignal(),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
if (!res.data) {
throw new Error('Response data is missing')
}
return convertVolumeEntryStat(
res.data as VolumeApiComponents['schemas']['VolumeEntryStat']
)
}
/**
* Read file content as a `string`.
*
* You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type.
*
* @param path path to the file.
* @param opts connection options.
* @param [opts.format] format of the file content—`text` by default.
*
* @returns file content as string
*/
async readFile(
path: string,
opts?: VolumeReadOpts & { format?: 'text' }
): Promise<string>
/**
* Read file content as a `Uint8Array`.
*
* You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type.
*
* @param path path to the file.
* @param opts connection options.
* @param [opts.format] format of the file content—`bytes`.
*
* @returns file content as `Uint8Array`
*/
async readFile(
path: string,
opts?: VolumeReadOpts & { format: 'bytes' }
): Promise<Uint8Array>
/**
* Read file content as a `Blob`.
*
* You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type.
*
* @param path path to the file.
* @param opts connection options.
* @param [opts.format] format of the file content—`blob`.
*
* @returns file content as `Blob`
*/
async readFile(
path: string,
opts?: VolumeReadOpts & { format: 'blob' }
): Promise<Blob>
/**
* Read file content as a `ReadableStream`.
*
* You can pass `text`, `bytes`, `blob`, or `stream` to `opts.format` to change the return type.
*
* @param path path to the file.
* @param opts connection options.
* @param [opts.format] format of the file content—`stream`.
*
* @returns file content as `ReadableStream`
*/
async readFile(
path: string,
opts?: VolumeReadOpts & { format: 'stream' }
): Promise<ReadableStream<Uint8Array>>
async readFile(
path: string,
opts?: VolumeReadOpts & {
format?: 'text' | 'stream' | 'bytes' | 'blob'
}
): Promise<unknown> {
const format = opts?.format ?? 'text'
const config = new VolumeConnectionConfig(this, {
...opts,
requestTimeoutMs: opts?.requestTimeoutMs ?? FILE_TIMEOUT_MS,
})
const client = new VolumeApiClient(config)
if (format === 'stream') {
// The request timeout bounds only the initial handshake; once the
// response arrives, the stream lives until it's consumed, cancelled, the
// user signal aborts, or the per-chunk idle timeout fires. Matches the
// sandbox `files.read` stream path.
const { controller, clearStartTimeout, cleanup } = setupRequestController(
config.requestTimeoutMs,
opts?.signal
)
try {
const res = await client.api.GET('/volumecontent/{volumeID}/file', {
params: {
path: { volumeID: this.volumeId },
query: { path },
},
parseAs: 'stream',
signal: controller.signal,
})
if (res.response.status === 404) {
// Cancel the unconsumed body so the pooled connection is released
// before we propagate.
if (res.response.body && !res.response.bodyUsed) {
await res.response.body.cancel().catch(() => {})
}
cleanup()
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
if (res.response.body && !res.response.bodyUsed) {
await res.response.body.cancel().catch(() => {})
}
cleanup()
throw err
}
return wrapStreamWithConnectionCleanup(
res.data as ReadableStream<Uint8Array> | null,
{
clearStartTimeout,
cleanup,
controller,
idleTimeoutMs: opts?.streamIdleTimeoutMs ?? config.requestTimeoutMs,
}
)
} catch (err) {
cleanup()
throw err
}
}
const res = await client.api.GET('/volumecontent/{volumeID}/file', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
},
},
parseAs: format === 'bytes' ? 'arrayBuffer' : format,
signal: config.getSignal(),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
// When the file is empty, `res.data` is `undefined`, so empty values are synthesized below.
if (format === 'bytes') {
return res.data instanceof ArrayBuffer
? new Uint8Array(res.data)
: new Uint8Array()
}
if (format === 'text') {
return typeof res.data === 'string' ? res.data : ''
}
// format === 'blob'
return res.data instanceof Blob ? res.data : new Blob([])
}
/**
* Write content to a file.
*
* Writing to a file that doesn't exist creates the file.
*
* Writing to a file that already exists overwrites the file.
*
* @param path path to the file.
* @param data data to write to the file. Data can be a string, `ArrayBuffer`, `Blob`, or `ReadableStream`. Outside the browser, `ReadableStream` data is streamed to the API instead of being buffered in memory.
* @param options file creation options.
* @param opts connection options.
*
* @returns information about the written file
*/
async writeFile(
path: string,
data: string | ArrayBuffer | Blob | ReadableStream<Uint8Array>,
opts?: VolumeWriteOpts & VolumeApiOpts
): Promise<VolumeEntryStat> {
const config = new VolumeConnectionConfig(this, {
...opts,
requestTimeoutMs: opts?.requestTimeoutMs ?? FILE_TIMEOUT_MS,
})
const client = new VolumeApiClient(config)
// `toUploadBody` returns a `ReadableStream` only when the body should be
// streamed (non-browser stream input); otherwise it buffers into a Blob.
const body = await toUploadBody(data)
const isStream = body instanceof ReadableStream
// A streamed upload carries no client-side timeout: the socket-write
// "wire" isn't observable through fetch, and a stalled producer is the
// caller's own code, so a stuck streamed upload is bounded server-side (or
// via `opts.signal`). Buffered uploads keep the normal request timeout.
const signal = isStream ? opts?.signal : config.getSignal()
const res = await client.api.PUT('/volumecontent/{volumeID}/file', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
uid: opts?.uid,
gid: opts?.gid,
mode: opts?.mode,
force: opts?.force,
},
},
bodySerializer: () => body,
body: {} as any,
headers: {
'Content-Type': 'application/octet-stream',
},
signal,
// Streaming request bodies require half-duplex mode.
...(isStream && { duplex: 'half' as const }),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
if (!res.data) {
throw new Error('Response data is missing')
}
return convertVolumeEntryStat(
res.data as VolumeApiComponents['schemas']['VolumeEntryStat']
)
}
/**
* Remove a file or directory.
*
* @param path path to the file or directory to remove.
* @param opts connection options.
*/
async remove(path: string, opts?: VolumeApiOpts): Promise<void> {
const config = new VolumeConnectionConfig(this, opts)
const client = new VolumeApiClient(config)
const res = await client.api.DELETE('/volumecontent/{volumeID}/path', {
params: {
path: {
volumeID: this.volumeId,
},
query: {
path,
},
},
signal: config.getSignal(),
})
if (res.response.status === 404) {
throw new NotFoundError(`Path ${path} not found`)
}
const err = handleApiError(res, VolumeError)
if (err) {
throw err
}
}
}
export type {
VolumeInfo,
VolumeAndToken,
VolumeEntryStat,
VolumeMetadataOpts,
VolumeReadOpts,
VolumeWriteOpts,
// Deprecated aliases, kept for backwards compatibility.
VolumeMetadataOptions,
VolumeWriteOptions,
} from './types'
export type { VolumeApiOpts, VolumeConnectionConfig } from './client'
export { VolumeFileType } from './types'
+414
View File
@@ -0,0 +1,414 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/volumecontent/{volumeID}/dir": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description List directory contents */
get: {
parameters: {
query: {
/** @description Number of layers deep to recurse into the directory */
depth?: number;
path: components["parameters"]["path"];
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successfully retrieved a directory listing */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VolumeDirectoryListing"];
};
};
/** @description Invalid path provided */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description path not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
500: components["responses"]["500"];
};
};
put?: never;
/** @description Create a directory */
post: {
parameters: {
query: {
/** @description Create the parents of a directory if they don't exist */
force?: boolean;
/** @description Group ID of the created directory */
gid?: number;
/** @description Mode of the created directory */
mode?: number;
path: components["parameters"]["path"];
/** @description User ID of the created directory */
uid?: number;
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successfully created a directory */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VolumeEntryStat"];
};
};
/** @description path not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
500: components["responses"]["500"];
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/volumecontent/{volumeID}/file": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Download file */
get: {
parameters: {
query: {
path: components["parameters"]["path"];
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successfully downloaded a file */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description path not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
500: components["responses"]["500"];
};
};
/** @description Upload file */
put: {
parameters: {
query: {
/** @description Force overwrite of an existing file */
force?: boolean;
/** @description Group ID of the uploaded file */
gid?: number;
/** @description Mode of the uploaded file */
mode?: number;
path: components["parameters"]["path"];
/** @description User ID of the uploaded file */
uid?: number;
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody?: {
content: {
"application/octet-stream": string;
};
};
responses: {
/** @description Successfully created a file */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VolumeEntryStat"];
};
};
/** @description path not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
500: components["responses"]["500"];
};
};
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/volumecontent/{volumeID}/path": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Get path information */
get: {
parameters: {
query: {
path: components["parameters"]["path"];
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successfully retrieved path information */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VolumeEntryStat"];
};
};
404: components["responses"]["404"];
};
};
put?: never;
post?: never;
/** @description Delete a path */
delete: {
parameters: {
query: {
path: components["parameters"]["path"];
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successfully deleted a path */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: components["responses"]["404"];
};
};
options?: never;
head?: never;
/** @description Update path metadata */
patch: {
parameters: {
query: {
path: components["parameters"]["path"];
};
header?: never;
path: {
volumeID: components["parameters"]["volumeID"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** Format: uint32 */
gid?: number;
/** Format: uint32 */
mode?: number;
/** Format: uint32 */
uid?: number;
};
};
};
responses: {
/** @description Successfully updated a file's metadata */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VolumeEntryStat"];
};
};
/** @description Invalid metadata provided */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description path not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Error: {
/** @description Error code */
code: string;
/** @description Error message */
message: string;
};
VolumeDirectoryListing: components["schemas"]["VolumeEntryStat"][];
VolumeEntryStat: {
/** Format: date-time */
atime: string;
/** Format: date-time */
ctime: string;
/** Format: uint32 */
gid: number;
/** Format: uint32 */
mode: number;
/** Format: date-time */
mtime: string;
name: string;
path: string;
/** Format: int64 */
size: number;
target?: string;
/** @enum {string} */
type: "unknown" | "file" | "directory" | "symlink";
/** Format: uint32 */
uid: number;
};
};
responses: {
/** @description Bad request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
parameters: {
path: string;
volumeID: string;
};
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
+119
View File
@@ -0,0 +1,119 @@
import { VolumeApiComponents, VolumeApiOpts } from './client'
/**
* File type enum.
*/
export enum VolumeFileType {
UNKNOWN = 'unknown',
FILE = 'file',
DIRECTORY = 'directory',
SYMLINK = 'symlink',
}
/**
* Information about a volume.
*/
export type VolumeInfo = {
/**
* Volume ID.
*/
volumeId: string
/**
* Volume name.
*/
name: string
}
/**
* Information about a volume and its auth token.
*/
export type VolumeAndToken = VolumeInfo & {
/**
* Volume auth token.
*/
token: string
}
/**
* Volume entry stat with dates converted to Date objects.
*/
export type VolumeEntryStat = Omit<
VolumeApiComponents['schemas']['VolumeEntryStat'],
'atime' | 'mtime' | 'ctime' | 'type'
> & {
/**
* Access time as a Date object.
*/
atime: Date
/**
* Modification time as a Date object.
*/
mtime: Date
/**
* Creation time as a Date object.
*/
ctime: Date
/**
* File type.
*/
type: VolumeFileType
}
/**
* Options for updating file metadata.
*/
export type VolumeMetadataOpts = {
/**
* User ID of the file or directory.
*/
uid?: number
/**
* Group ID of the file or directory.
*/
gid?: number
/**
* Mode of the file or directory.
*/
mode?: number
}
/**
* Options for reading files from a volume.
*/
export type VolumeReadOpts = VolumeApiOpts & {
/**
* Idle timeout for a streamed read (`format: 'stream'`) in **milliseconds**:
* abort if no chunk arrives from the server within this window *while
* reading*. It bounds only the wire — a slow or paused consumer never trips
* it (a consumer that holds the stream but stops reading is reclaimed
* server-side). Defaults to the request timeout; pass `0` to disable.
*/
streamIdleTimeoutMs?: number
}
/**
* Options for file and directory operations.
*/
export type VolumeWriteOpts = VolumeMetadataOpts & {
/**
* For makeDir: Create parent directories if they don't exist.
* For writeFile: Force overwrite of an existing file.
*/
force?: boolean
}
/**
* @deprecated Use {@link VolumeMetadataOpts} instead.
*/
export type VolumeMetadataOptions = VolumeMetadataOpts
/**
* @deprecated Use {@link VolumeWriteOpts} instead.
*/
export type VolumeWriteOptions = VolumeWriteOpts
@@ -0,0 +1,133 @@
import { assert, test, describe } from 'vitest'
import { handleApiError } from '../../src/api'
import {
AuthenticationError,
RateLimitError,
SandboxError,
} from '../../src/errors'
function createMockResponse(
status: number,
error: unknown,
data?: unknown
): {
response: { status: number; ok: boolean }
error: unknown
data: unknown
} {
return {
response: { status, ok: status >= 200 && status < 300 },
error,
data,
}
}
describe('handleApiError', () => {
describe('without content', () => {
// openapi-fetch leaves `error` undefined for non-2xx responses with
// Content-Length: 0
test('catches 404 with undefined error', () => {
const res = createMockResponse(404, undefined)
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '404')
})
test('catches 500 with undefined error', () => {
const res = createMockResponse(500, undefined)
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
test('returns AuthenticationError for 401 with undefined error', () => {
const res = createMockResponse(401, undefined)
const err = handleApiError(res as any)
assert.instanceOf(err, AuthenticationError)
assert.include(err?.message, 'Unauthorized')
})
})
describe('with empty error body', () => {
test('catches 404 with empty string error', () => {
const res = createMockResponse(404, '')
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '404')
})
test('catches 400 with empty string error', () => {
const res = createMockResponse(400, '')
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '400')
})
test('catches 500 with empty string error', () => {
const res = createMockResponse(500, '')
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
})
describe('with JSON error body', () => {
test('catches 404 with message', () => {
const res = createMockResponse(404, { code: 404, message: 'Not found' })
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, 'Not found')
})
test('catches 400 with message', () => {
const res = createMockResponse(400, { code: 400, message: 'Bad request' })
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, 'Bad request')
})
})
describe('special status codes', () => {
test('returns AuthenticationError for 401', () => {
const res = createMockResponse(401, { message: 'Invalid token' })
const err = handleApiError(res as any)
assert.instanceOf(err, AuthenticationError)
assert.include(err?.message, 'Unauthorized')
})
test('returns AuthenticationError for 401 with empty body', () => {
const res = createMockResponse(401, '')
const err = handleApiError(res as any)
assert.instanceOf(err, AuthenticationError)
assert.include(err?.message, 'Unauthorized')
})
test('returns RateLimitError for 429', () => {
const res = createMockResponse(429, { message: 'Too many requests' })
const err = handleApiError(res as any)
assert.instanceOf(err, RateLimitError)
assert.include(err?.message, 'Rate limit')
})
test('returns RateLimitError for 429 with empty body', () => {
const res = createMockResponse(429, '')
const err = handleApiError(res as any)
assert.instanceOf(err, RateLimitError)
assert.include(err?.message, 'Rate limit')
})
})
describe('success responses', () => {
test('returns undefined for 200 success', () => {
const res = createMockResponse(200, undefined, { id: '123' })
const err = handleApiError(res as any)
assert.isUndefined(err)
})
test('returns undefined for 201 success', () => {
const res = createMockResponse(201, undefined, { id: '123' })
const err = handleApiError(res as any)
assert.isUndefined(err)
})
})
})
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('undici')
vi.doUnmock('../../src/utils')
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 () => {
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'))
})
const loadUndici = vi.fn(() => Promise.resolve({ Agent, fetch: undiciFetch }))
const { createApiFetchForRuntime } = await import('../../src/api/http2')
const fetcher = createApiFetchForRuntime('node', {
connectionLimit: 100,
loadUndici,
})
await fetcher('https://example.com/sandboxes')
expect(loadUndici).toHaveBeenCalledOnce()
expect(agents).toEqual([{ allowH2: true, connections: 100 }])
expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent)
})
test('uses a ProxyAgent dispatcher when a proxy is configured', async () => {
const proxyAgents: Array<{
uri?: string
allowH2?: boolean
connections?: number
}> = []
const agents: Array<unknown> = []
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
class Agent {
constructor() {
agents.push(this)
}
}
class ProxyAgent {
constructor(options: {
uri?: string
allowH2?: boolean
connections?: number
}) {
proxyAgents.push(options)
}
}
const undiciFetch = vi.fn((input, init) => {
requests.push({ init })
return Promise.resolve(new Response('ok'))
})
const loadUndici = vi.fn(() =>
Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch })
)
const { createApiFetchForRuntime } = await import('../../src/api/http2')
const fetcher = createApiFetchForRuntime('node', {
connectionLimit: 100,
proxy: 'http://user:pass@127.0.0.1:8080',
loadUndici,
})
await fetcher('https://example.com/sandboxes')
expect(agents).toHaveLength(0)
expect(proxyAgents).toEqual([
{
uri: 'http://user:pass@127.0.0.1:8080',
allowH2: true,
connections: 100,
},
])
expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent)
})
test('caches API fetchers per proxy', async () => {
const { createApiFetch } = await import('../../src/api/http2')
const noProxy = createApiFetch()
const proxyA = createApiFetch('http://127.0.0.1:8080')
const proxyB = createApiFetch('http://127.0.0.1:9090')
expect(createApiFetch()).toBe(noProxy)
expect(createApiFetch('http://127.0.0.1:8080')).toBe(proxyA)
expect(proxyA).not.toBe(noProxy)
expect(proxyA).not.toBe(proxyB)
})
test('getApiConnectionLimit throws on a malformed env value', async () => {
process.env.E2B_API_CONNECTIONS = 'not-a-number'
const { getApiConnectionLimit } = await import('../../src/api/http2')
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/)
})
@@ -0,0 +1,75 @@
import { expect, test, vi } from 'vitest'
import { limitConcurrency } from '../../src/api/inflight'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<Response>()
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<Response>()
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')
})
+10
View File
@@ -0,0 +1,10 @@
import { expect } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)('get sandbox info', async ({ sandbox }) => {
const info = await Sandbox.getInfo(sandbox.sandboxId)
expect(info).toBeDefined()
expect(info.sandboxId).toBe(sandbox.sandboxId)
})
+21
View File
@@ -0,0 +1,21 @@
import { expect } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)(
'kill existing sandbox',
async ({ sandbox, sandboxTestId }) => {
await Sandbox.kill(sandbox.sandboxId)
const paginator = Sandbox.list({
query: { state: ['running'], metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId)
}
)
sandboxTest.skipIf(isDebug)('kill non-existing sandbox', async () => {
await expect(Sandbox.kill('nonexistingsandbox')).resolves.toBe(false)
})
+440
View File
@@ -0,0 +1,440 @@
import { assert } from 'vitest'
import { randomUUID } from 'crypto'
import { Sandbox, SandboxInfo } from '../../src'
import { sandboxTest, isDebug } from '../setup.js'
sandboxTest.skipIf(isDebug)(
'list sandboxes',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)
assert.isTrue(found)
}
)
sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => {
const uniqueId = randomUUID()
const extraSbx = await Sandbox.create({ metadata: { uniqueId } })
try {
const paginator = Sandbox.list({
query: { metadata: { uniqueId } },
})
const sandboxes = await paginator.nextItems()
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
} finally {
await extraSbx.kill()
}
})
sandboxTest.skipIf(isDebug)(
'list running sandboxes',
async ({ sandboxTestId }) => {
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our running sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'list paused sandboxes',
async ({ sandboxTestId }) => {
// Create and pause a sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await extraSbx.betaPause()
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our paused sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandboxes
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'running')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'running')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
await sandbox.betaPause()
// Create extra paused sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await extraSbx.betaPause()
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'paused')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'paused')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running and paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
// Pause the extra sandbox
await extraSbx.betaPause()
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: {
metadata: { sandboxTestId },
state: ['running', 'paused'],
},
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'paused')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'running')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate iterator',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes: SandboxInfo[] = []
while (paginator.hasNext) {
const sbxs = await paginator.nextItems()
sandboxes.push(...sbxs)
}
assert.isAtLeast(sandboxes.length, 1)
assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId))
}
)
sandboxTest.skipIf(isDebug)(
'list sandboxes',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)
assert.isTrue(found)
}
)
sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => {
const uniqueId = randomUUID()
const extraSbx = await Sandbox.create({ metadata: { uniqueId } })
try {
const paginator = Sandbox.list({
query: { metadata: { uniqueId } },
})
const sandboxes = await paginator.nextItems()
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
} finally {
await extraSbx.kill()
}
})
sandboxTest.skipIf(isDebug)(
'list running sandboxes',
async ({ sandboxTestId }) => {
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our running sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'list paused sandboxes',
async ({ sandboxTestId }) => {
// Create and pause a sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await Sandbox.betaPause(extraSbx.sandboxId)
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our paused sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandboxes
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'running')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'running')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
await Sandbox.betaPause(sandbox.sandboxId)
// Create extra paused sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await Sandbox.betaPause(extraSbx.sandboxId)
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'paused')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'paused')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running and paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
// Pause the extra sandbox
await Sandbox.betaPause(sandbox.sandboxId)
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: {
metadata: { sandboxTestId },
state: ['running', 'paused'],
},
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'running')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'paused')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate iterator',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes: SandboxInfo[] = []
while (paginator.hasNext) {
const sbxs = await paginator.nextItems()
sandboxes.push(...sbxs)
}
assert.isAtLeast(sandboxes.length, 1)
assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId))
}
)
@@ -0,0 +1,26 @@
import { assert } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)('pause sandbox', async ({ sandbox }) => {
await Sandbox.pause(sandbox.sandboxId)
assert.isFalse(
await sandbox.isRunning(),
'Sandbox should not be running after pause'
)
})
sandboxTest.skipIf(isDebug)('resume sandbox', async ({ sandbox }) => {
await Sandbox.pause(sandbox.sandboxId)
assert.isFalse(
await sandbox.isRunning(),
'Sandbox should not be running after pause'
)
await Sandbox.connect(sandbox.sandboxId)
assert.isTrue(
await sandbox.isRunning(),
'Sandbox should be running after resume'
)
})
@@ -0,0 +1,91 @@
import { assert, test, describe, vi, afterEach } from 'vitest'
import { ApiClient, validateApiKey } from '../../src/api'
import { ConnectionConfig } from '../../src/connectionConfig'
import { AuthenticationError } from '../../src/errors'
describe('validateApiKey', () => {
const validKey = 'e2b_' + '0123456789abcdef'.repeat(2) + '01234567'
test('accepts a well-formed key', () => {
assert.doesNotThrow(() => validateApiKey(validKey))
})
test('rejects a key without the e2b_ prefix', () => {
assert.throws(
() => validateApiKey('sk_' + '0'.repeat(40)),
AuthenticationError,
/Invalid API key format/
)
})
test('accepts a key with a non-default body length', () => {
assert.doesNotThrow(() => validateApiKey('e2b_' + '0'.repeat(20)))
})
test('rejects an empty body after the prefix', () => {
assert.throws(
() => validateApiKey('e2b_'),
AuthenticationError,
/Invalid API key format/
)
})
test('rejects a key with non-hex characters in the body', () => {
assert.throws(
() => validateApiKey('e2b_' + 'z'.repeat(40)),
AuthenticationError,
/Invalid API key format/
)
})
test('error message includes an example token', () => {
try {
validateApiKey('nope')
assert.fail('expected validateApiKey to throw')
} catch (err) {
assert.instanceOf(err, AuthenticationError)
assert.match(
(err as Error).message,
/e2b_0{40}/,
'expected example token in error message'
)
}
})
})
describe('ApiClient API key validation', () => {
test('throws on a malformed key by default', () => {
const config = new ConnectionConfig({ apiKey: 'not-a-valid-key' })
assert.throws(() => new ApiClient(config), AuthenticationError)
})
test('skips validation when validateApiKey is false', () => {
const config = new ConnectionConfig({
apiKey: 'not-a-valid-key',
validateApiKey: false,
})
assert.doesNotThrow(() => new ApiClient(config))
})
})
describe('ApiClient API key requirement', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
test('throws when no API key is supplied', () => {
vi.stubEnv('E2B_API_KEY', '')
const config = new ConnectionConfig({})
assert.throws(
() => new ApiClient(config),
AuthenticationError,
/API key is required/
)
})
test('does not require an API key when requireApiKey is false', () => {
vi.stubEnv('E2B_API_KEY', '')
const config = new ConnectionConfig({})
assert.doesNotThrow(() => new ApiClient(config, { requireApiKey: false }))
})
})
+20
View File
@@ -0,0 +1,20 @@
import { CommandHandle, CommandExitError } from '../src/index.js'
import { assert } from 'vitest'
export function catchCmdExitErrorInBackground(cmd: CommandHandle) {
let disabled = false
cmd.wait().catch((res: CommandExitError) => {
if (!disabled) {
assert.equal(
res.exitCode,
0,
`command failed with exit code ${res.exitCode}: ${res.stderr}`
)
}
})
return () => {
disabled = true
}
}
@@ -0,0 +1,24 @@
import { afterEach, assert, test, vi } from 'vitest'
afterEach(() => {
vi.resetModules()
vi.doUnmock('../src/utils')
})
test('sandbox_url keeps per-sandbox host in browser runtime', async () => {
vi.doMock('../src/utils', () => ({
runtime: 'browser',
runtimeVersion: 'test',
}))
const { ConnectionConfig } = await import('../src/connectionConfig')
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://49983-sbx-test.e2b.app'
)
})
@@ -0,0 +1,568 @@
import { assert, test, beforeEach, afterEach } from 'vitest'
import {
ConnectionConfig,
setupRequestController,
wrapStreamWithConnectionCleanup,
} from '../src/connectionConfig'
// Store original env vars to restore after tests
let originalEnv: { [key: string]: string | undefined }
beforeEach(() => {
originalEnv = {
E2B_API_URL: process.env.E2B_API_URL,
E2B_DOMAIN: process.env.E2B_DOMAIN,
E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL,
E2B_DEBUG: process.env.E2B_DEBUG,
E2B_VALIDATE_API_KEY: process.env.E2B_VALIDATE_API_KEY,
}
})
afterEach(() => {
// Restore original env vars
Object.keys(originalEnv).forEach((key) => {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
})
// Clear the process-wide integration attribution
ConnectionConfig.setIntegration(undefined)
})
test('api_url defaults correctly', () => {
// Ensure no env vars interfere
delete process.env.E2B_API_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG
const config = new ConnectionConfig()
assert.equal(config.apiUrl, 'https://api.e2b.app')
})
test('api_url in args', () => {
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
assert.equal(config.apiUrl, 'http://localhost:8080')
})
test('api_url in env var', () => {
process.env.E2B_API_URL = 'http://localhost:8080'
const config = new ConnectionConfig()
assert.equal(config.apiUrl, 'http://localhost:8080')
})
test('api_url has correct priority', () => {
process.env.E2B_API_URL = 'http://localhost:1111'
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
assert.equal(config.apiUrl, 'http://localhost:8080')
})
test('sandbox_url defaults to stable sandbox host in production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.e2b.app'
)
})
test('sandbox_direct_url keeps per-sandbox host in production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG
const config = new ConnectionConfig()
assert.equal(
config.getSandboxDirectUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://49983-sbx-test.e2b.app'
)
})
test('sandbox_url keeps per-sandbox host outside production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DEBUG
const config = new ConnectionConfig({ domain: 'e2b.dev' })
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'sandbox.e2b.dev',
envdPort: 49983,
}),
'https://49983-sbx-test.sandbox.e2b.dev'
)
})
test('sandbox_url in args has priority', () => {
process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'
const config = new ConnectionConfig({ sandboxUrl: 'https://sandbox.custom' })
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.custom'
)
})
test('sandbox_url in env var overrides default', () => {
process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.from-env'
)
})
test('sandbox_url stays localhost in debug mode', () => {
delete process.env.E2B_SANDBOX_URL
process.env.E2B_DEBUG = 'true'
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'http://localhost:49983'
)
})
test('validateApiKey defaults to true', () => {
delete process.env.E2B_VALIDATE_API_KEY
const config = new ConnectionConfig()
assert.equal(config.validateApiKey, true)
})
test('validateApiKey disabled via env var', () => {
process.env.E2B_VALIDATE_API_KEY = 'false'
const config = new ConnectionConfig()
assert.equal(config.validateApiKey, false)
})
test('validateApiKey in args has priority over env var', () => {
process.env.E2B_VALIDATE_API_KEY = 'true'
const config = new ConnectionConfig({ validateApiKey: false })
assert.equal(config.validateApiKey, false)
})
test('debug false in args overrides E2B_DEBUG env var', () => {
process.env.E2B_DEBUG = 'true'
const config = new ConnectionConfig({ debug: false })
assert.equal(config.debug, false)
})
test('debug defaults to E2B_DEBUG env var', () => {
process.env.E2B_DEBUG = 'true'
const config = new ConnectionConfig()
assert.equal(config.debug, true)
})
test('setIntegration appends the integration to the user agent', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig()
assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
assert.equal(
config.headers?.['User-Agent']?.endsWith(' testing/version'),
true
)
})
test('integration survives config rebuilds', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig()
const rebuiltConfig = new ConnectionConfig({ ...config })
assert.equal(
rebuiltConfig.headers?.['User-Agent']?.endsWith(' testing/version'),
true
)
})
test('setIntegration does not retro-tag configs built earlier', () => {
const before = new ConnectionConfig()
ConnectionConfig.setIntegration('testing/version')
const after = new ConnectionConfig()
assert.equal(before.headers?.['User-Agent']?.includes('testing'), false)
assert.equal(
after.headers?.['User-Agent']?.endsWith(' testing/version'),
true
)
})
test('clearing the integration restores the plain user agent', () => {
ConnectionConfig.setIntegration('testing/version')
ConnectionConfig.setIntegration(undefined)
const config = new ConnectionConfig()
assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
assert.equal(config.headers?.['User-Agent']?.includes('testing'), false)
})
test('custom user agent is preserved without integration', () => {
const config = new ConnectionConfig({
apiHeaders: { 'User-Agent': 'my-app/1.0' },
})
assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
})
test('custom user agent wins over integration', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig({
headers: { 'User-Agent': 'my-app/1.0' },
})
assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
})
test('custom user agent survives config rebuilds', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig({
apiHeaders: { 'User-Agent': 'my-app/1.0' },
})
const rebuiltConfig = new ConnectionConfig({ ...config })
assert.equal(rebuiltConfig.headers?.['User-Agent'], 'my-app/1.0')
})
test('clearing the integration propagates to config rebuilds', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig()
ConnectionConfig.setIntegration(undefined)
const rebuiltConfig = new ConnectionConfig({ ...config })
assert.equal(
rebuiltConfig.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'),
true
)
assert.equal(
rebuiltConfig.headers?.['User-Agent']?.includes('testing'),
false
)
})
test('getSignal returns user signal when no timeout is set', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const controller = new AbortController()
const signal = config.getSignal(0, controller.signal)
assert.strictEqual(signal, controller.signal)
})
test('getSignal aborts when user signal is aborted', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
const controller = new AbortController()
const signal = config.getSignal(undefined, controller.signal)
assert.ok(signal)
assert.equal(signal!.aborted, false)
controller.abort()
assert.equal(signal!.aborted, true)
})
test('getSignal returns timeout signal when no user signal is provided', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
const signal = config.getSignal()
assert.ok(signal)
assert.equal(signal!.aborted, false)
})
test('getSignal returns undefined when no timeout and no signal', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const signal = config.getSignal(0)
assert.equal(signal, undefined)
})
test('requestTimeoutMs 0 from the config disables the timeout', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
// The stored value is kept as 0 (not replaced by the default).
assert.equal(config.requestTimeoutMs, 0)
// getSignal() with no per-call arg falls back to the stored 0, which must
// NOT produce a timeout signal.
assert.equal(config.getSignal(), undefined)
// With only a user signal, no timeout signal is layered on top.
const controller = new AbortController()
assert.strictEqual(
config.getSignal(undefined, controller.signal),
controller.signal
)
})
test('setupRequestController with config timeout 0 never auto-aborts', async () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const { controller } = setupRequestController(
config.requestTimeoutMs,
undefined
)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, false)
})
test('setupRequestController aborts when user signal aborts', () => {
const userController = new AbortController()
const { controller } = setupRequestController(0, userController.signal)
assert.equal(controller.signal.aborted, false)
userController.abort()
assert.equal(controller.signal.aborted, true)
})
test('setupRequestController is already aborted when user signal was pre-aborted', () => {
const userController = new AbortController()
userController.abort()
const { controller } = setupRequestController(0, userController.signal)
assert.equal(controller.signal.aborted, true)
})
test('setupRequestController cleanup removes the user-signal listener', () => {
const userController = new AbortController()
const { controller, cleanup } = setupRequestController(
0,
userController.signal
)
cleanup()
// Internal controller is aborted by cleanup, so it stays aborted.
assert.equal(controller.signal.aborted, true)
// After cleanup the listener is detached — a subsequent abort on the
// user signal must not propagate (verified indirectly: cleanup is
// idempotent and no error is thrown).
userController.abort()
cleanup()
})
test('setupRequestController clearStartTimeout stops the handshake timer', async () => {
const { controller, clearStartTimeout } = setupRequestController(
20,
undefined
)
clearStartTimeout()
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, false)
})
test('setupRequestController handshake timer aborts when not cleared', async () => {
const { controller } = setupRequestController(20, undefined)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, true)
})
test('setupRequestController handshake timeout aborts with TimeoutError reason', async () => {
const { controller } = setupRequestController(20, undefined)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, true)
assert.ok(controller.signal.reason instanceof DOMException)
assert.equal((controller.signal.reason as DOMException).name, 'TimeoutError')
})
test('setupRequestController user signal still cancels after clearStartTimeout', () => {
const userController = new AbortController()
const { controller, clearStartTimeout } = setupRequestController(
60_000,
userController.signal
)
clearStartTimeout()
assert.equal(controller.signal.aborted, false)
userController.abort()
assert.equal(controller.signal.aborted, true)
})
// Builds a source ReadableStream that records whether its underlying reader was
// cancelled, standing in for a fetch response body backed by a pooled
// connection. `cancel` being invoked is what releases that connection.
function trackedSource() {
const state: { cancelled: boolean; cancelReason: unknown } = {
cancelled: false,
cancelReason: undefined,
}
const chunks = ['a', 'b'].map((s) => new TextEncoder().encode(s))
let i = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++])
} else {
controller.close()
}
},
cancel(reason) {
state.cancelled = true
state.cancelReason = reason
},
})
return { body, state }
}
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader()
let out = ''
const decoder = new TextDecoder()
for (;;) {
const { done, value } = await reader.read()
if (done) break
out += decoder.decode(value, { stream: true })
}
return out
}
// Builds a source ReadableStream that never produces a chunk and errors its
// pending read when `signal` aborts, standing in for a stalled fetch response
// body whose connection is torn down by aborting the request controller.
function stallingSource(signal: AbortSignal) {
const state: { cancelled: boolean } = { cancelled: false }
const body = new ReadableStream<Uint8Array>({
start(controller) {
signal.addEventListener('abort', () => controller.error(signal.reason), {
once: true,
})
},
cancel() {
state.cancelled = true
},
})
return { body, state }
}
test('wrapStreamWithConnectionCleanup releases once on full read', async () => {
const { body } = trackedSource()
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
})
assert.equal(await readAll(stream), 'ab')
assert.equal(cleanups, 1)
})
test('wrapStreamWithConnectionCleanup cancel cancels the underlying reader', async () => {
const { body, state } = trackedSource()
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
})
await stream.cancel('done')
assert.equal(state.cancelled, true)
assert.equal(state.cancelReason, 'done')
assert.equal(cleanups, 1)
})
test('wrapStreamWithConnectionCleanup handles a null body', async () => {
let cleanups = 0
let cleared = 0
const stream = wrapStreamWithConnectionCleanup(null, {
clearStartTimeout: () => {
cleared++
},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
})
assert.equal(cleared, 1)
assert.equal(cleanups, 1)
assert.equal(await readAll(stream), '')
})
test('wrapStreamWithConnectionCleanup aborts and releases an idle stream', async () => {
const controller = new AbortController()
const { body } = stallingSource(controller.signal)
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller,
idleTimeoutMs: 20,
})
// No chunk ever arrives, so the idle timer fires, aborts the controller,
// and the read rejects with the TimeoutError reason.
let error: unknown
try {
await readAll(stream)
} catch (err) {
error = err
}
assert.equal((error as DOMException)?.name, 'TimeoutError')
assert.equal(cleanups, 1)
assert.equal(controller.signal.aborted, true)
})
test('wrapStreamWithConnectionCleanup with idle timeout 0 never auto-aborts', async () => {
const { body } = trackedSource()
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
idleTimeoutMs: 0,
})
assert.equal(await readAll(stream), 'ab')
assert.equal(cleanups, 1)
})
test('wrapStreamWithConnectionCleanup does not abort a slow consumer (wire-only)', async () => {
// Source has both chunks ready immediately; the consumer pauses far longer
// than the idle timeout between reads. Because the timer is armed only around
// the network read and cleared as soon as a chunk arrives, the consumer's
// pace must not trip it.
const controller = new AbortController()
const { body } = trackedSource()
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {},
controller,
idleTimeoutMs: 20,
})
const reader = stream.getReader()
const decoder = new TextDecoder()
const first = await reader.read()
assert.equal(decoder.decode(first.value), 'a')
await new Promise((resolve) => setTimeout(resolve, 50))
const second = await reader.read()
assert.equal(decoder.decode(second.value), 'b')
assert.equal(controller.signal.aborted, false)
})
@@ -0,0 +1,152 @@
import { assert, test, describe } from 'vitest'
import { handleEnvdApiError, handleEnvdApiFetchError } from '../../src/envd/api'
import {
AuthenticationError,
InvalidArgumentError,
NotEnoughSpaceError,
NotFoundError,
RateLimitError,
SandboxError,
TimeoutError,
} from '../../src/errors'
function createMockResponse(
status: number,
error?: { message?: string } | string
): {
error?: { message?: string } | string
response: Response
} {
return {
error,
response: {
status,
ok: status >= 200 && status < 300,
statusText: '',
// openapi-fetch consumes the body whenever it produces an error value
bodyUsed: error !== undefined,
text: async () => (typeof error === 'string' ? error : ''),
} as unknown as Response,
}
}
describe('handleEnvdApiError', () => {
test('returns undefined for a successful response', async () => {
const err = await handleEnvdApiError(createMockResponse(200))
assert.isUndefined(err)
})
test('returns an error for non-2xx response without content', async () => {
// openapi-fetch leaves `error` undefined for responses with
// Content-Length: 0
const res = createMockResponse(500)
const err = await handleEnvdApiError(res)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
test('returns an error for non-2xx response with empty string error', async () => {
const res = createMockResponse(500, '')
const err = await handleEnvdApiError(res)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
test('returns a mapped error for non-2xx response without content', async () => {
const res = createMockResponse(404)
const err = await handleEnvdApiError(res)
assert.instanceOf(err, NotFoundError)
})
test('returns InvalidArgumentError for 400', async () => {
const res = createMockResponse(400, { message: 'Bad request' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, InvalidArgumentError)
})
test('returns AuthenticationError for 401', async () => {
const res = createMockResponse(401, { message: 'Invalid token' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, AuthenticationError)
})
test('returns NotFoundError for 404', async () => {
const res = createMockResponse(404, { message: 'Not found' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, NotFoundError)
})
test('returns RateLimitError for 429', async () => {
const res = createMockResponse(429, { message: 'Too many requests' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, RateLimitError)
assert.include(err?.message, 'rate limited')
})
test('returns TimeoutError for 502', async () => {
const res = createMockResponse(502, { message: 'Bad gateway' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, TimeoutError)
})
test('returns NotEnoughSpaceError for 507', async () => {
const res = createMockResponse(507, { message: 'No space left' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, NotEnoughSpaceError)
})
test('falls back to SandboxError for unmapped status', async () => {
const res = createMockResponse(500, { message: 'Internal error' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
})
describe('handleEnvdApiFetchError', () => {
test('returns the original error for terminated fetch without a health check', async () => {
const original = new TypeError('terminated')
const err = await handleEnvdApiFetchError(original)
assert.strictEqual(err, original)
})
test('returns a TimeoutError when the health check says the sandbox is not running', async () => {
const err = await handleEnvdApiFetchError(
new TypeError('terminated'),
async () => false
)
assert.instanceOf(err, TimeoutError)
assert.include(err.message, 'sandbox was killed or reached its end of life')
})
// Each JS runtime surfaces a dropped connection with different wording, and not
// always as a TypeError (Bun raises a plain Error), so match by message
const runtimeTerminatedErrors = {
Node: new TypeError('terminated'),
Bun: new Error('The socket connection was closed unexpectedly'),
Deno: new TypeError('error reading a body from connection'),
}
for (const [runtime, error] of Object.entries(runtimeTerminatedErrors)) {
test(`treats the ${runtime} dropped-connection error as terminated`, async () => {
const err = await handleEnvdApiFetchError(error, async () => false)
assert.instanceOf(err, TimeoutError)
assert.include(
err.message,
'sandbox was killed or reached its end of life'
)
})
}
test('returns the original error when the health check says the sandbox is running', async () => {
const original = new TypeError('terminated')
const err = await handleEnvdApiFetchError(original, async () => true)
assert.strictEqual(err, original)
})
test('returns the original error for other fetch failures', async () => {
const original = new TypeError('fetch failed')
const err = await handleEnvdApiFetchError(original)
assert.strictEqual(err, original)
})
})
@@ -0,0 +1,138 @@
import { assert, test, describe } from 'vitest'
import { Code, ConnectError } from '@connectrpc/connect'
import {
handleRpcError,
handleRpcErrorWithHealthCheck,
} from '../../src/envd/rpc'
import {
AuthenticationError,
InvalidArgumentError,
NotFoundError,
RateLimitError,
SandboxError,
TimeoutError,
} from '../../src/errors'
describe('handleRpcError', () => {
test('returns InvalidArgumentError for InvalidArgument', () => {
const err = handleRpcError(new ConnectError('bad', Code.InvalidArgument))
assert.instanceOf(err, InvalidArgumentError)
})
test('returns AuthenticationError for Unauthenticated', () => {
const err = handleRpcError(new ConnectError('nope', Code.Unauthenticated))
assert.instanceOf(err, AuthenticationError)
})
test('returns NotFoundError for NotFound', () => {
const err = handleRpcError(new ConnectError('missing', Code.NotFound))
assert.instanceOf(err, NotFoundError)
})
test('returns RateLimitError for ResourceExhausted', () => {
const err = handleRpcError(
new ConnectError('too many', Code.ResourceExhausted)
)
assert.instanceOf(err, RateLimitError)
assert.include(err.message, 'Rate limit')
})
test('returns TimeoutError for Unavailable', () => {
const err = handleRpcError(new ConnectError('gone', Code.Unavailable))
assert.instanceOf(err, TimeoutError)
})
test('falls back to SandboxError for unmapped code', () => {
const err = handleRpcError(new ConnectError('boom', Code.Internal))
assert.instanceOf(err, SandboxError)
})
test('falls back to generic SandboxError for Unknown "terminated"', () => {
const err = handleRpcError(new ConnectError('terminated', Code.Unknown))
assert.instanceOf(err, SandboxError)
assert.include(err.message, 'terminated')
assert.notInclude(err.message, 'killed')
})
test('returns the original error when not a ConnectError', () => {
const original = new Error('not connect')
const err = handleRpcError(original)
assert.strictEqual(err, original)
})
})
describe('handleRpcErrorWithHealthCheck', () => {
const terminated = () => new ConnectError('terminated', Code.Unknown)
// Each JS runtime surfaces a dropped connection with different wording
const runtimeTerminatedMessages = {
Node: 'terminated',
Bun: 'The socket connection was closed unexpectedly',
Deno: 'error reading a body from connection',
}
test('returns a TimeoutError when the health check says the sandbox is not running', async () => {
const err = await handleRpcErrorWithHealthCheck(
terminated(),
async () => false
)
assert.instanceOf(err, TimeoutError)
assert.include(err.message, 'sandbox was killed or reached its end of life')
})
for (const [runtime, message] of Object.entries(runtimeTerminatedMessages)) {
test(`treats the ${runtime} dropped-connection message as terminated`, async () => {
const err = await handleRpcErrorWithHealthCheck(
new ConnectError(message, Code.Unknown),
async () => false
)
assert.instanceOf(err, TimeoutError)
assert.include(
err.message,
'sandbox was killed or reached its end of life'
)
})
}
test('falls back to the generic mapping when the health check says the sandbox is running', async () => {
const err = await handleRpcErrorWithHealthCheck(
terminated(),
async () => true
)
assert.instanceOf(err, SandboxError)
assert.notInstanceOf(err, TimeoutError)
assert.notInclude(err.message, 'killed')
})
test('falls back to the generic mapping when the sandbox state is unknown', async () => {
const err = await handleRpcErrorWithHealthCheck(
terminated(),
async () => undefined
)
assert.instanceOf(err, SandboxError)
assert.notInstanceOf(err, TimeoutError)
assert.notInclude(err.message, 'killed')
})
test('falls back to the generic mapping when the health check itself fails', async () => {
const err = await handleRpcErrorWithHealthCheck(terminated(), async () => {
throw new Error('health check failed')
})
assert.instanceOf(err, SandboxError)
assert.notInstanceOf(err, TimeoutError)
assert.notInclude(err.message, 'killed')
})
test('does not run the health check for other errors', async () => {
let called = false
const err = await handleRpcErrorWithHealthCheck(
new ConnectError('missing', Code.NotFound),
async () => {
called = true
return false
}
)
assert.instanceOf(err, NotFoundError)
assert.isFalse(called)
})
})
+276
View File
@@ -0,0 +1,276 @@
import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('undici')
vi.doUnmock('../../src/utils')
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 () => {
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'))
})
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }),
})
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('uses a ProxyAgent dispatcher when a proxy is configured', async () => {
const proxyAgents: Array<{
uri?: string
allowH2?: boolean
connections?: number
}> = []
const agents: Array<unknown> = []
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
class Agent {
constructor() {
agents.push(this)
}
}
class ProxyAgent {
constructor(options: {
uri?: string
allowH2?: boolean
connections?: number
}) {
proxyAgents.push(options)
}
}
const undiciFetch = vi.fn((input, init) => {
requests.push({ init })
return Promise.resolve(new Response('ok'))
})
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
proxy: 'http://127.0.0.1:8080',
loadUndici: () =>
Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch }),
})
await fetcher('https://example.com/status')
expect(agents).toHaveLength(0)
expect(proxyAgents).toEqual([
{ uri: 'http://127.0.0.1:8080', allowH2: true, connections: 1 },
])
expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent)
})
test('caches envd fetchers per proxy', async () => {
const { createEnvdFetch, createEnvdRpcFetch } = await import(
'../../src/envd/http2'
)
const noProxy = createEnvdFetch()
const proxyA = createEnvdFetch('http://127.0.0.1:8080')
expect(createEnvdFetch()).toBe(noProxy)
expect(createEnvdFetch('http://127.0.0.1:8080')).toBe(proxyA)
expect(proxyA).not.toBe(noProxy)
const rpcNoProxy = createEnvdRpcFetch()
const rpcProxyA = createEnvdRpcFetch('http://127.0.0.1:8080')
expect(createEnvdRpcFetch()).toBe(rpcNoProxy)
expect(createEnvdRpcFetch('http://127.0.0.1:8080')).toBe(rpcProxyA)
expect(rpcProxyA).not.toBe(rpcNoProxy)
})
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'))
})
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }),
})
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 a bounded 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')))
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 100,
loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }),
})
await fetcher('https://example.com/rpc')
expect(agents).toEqual([{ allowH2: true, connections: 100 }])
})
test('reads RPC stream dispatcher connection limit from env', async () => {
process.env.E2B_ENVD_RPC_CONNECTIONS = '200'
const { getEnvdRpcConnectionLimit } = await import('../../src/envd/http2')
expect(getEnvdRpcConnectionLimit()).toBe(200)
})
test('getEnvdRpcConnectionLimit throws on malformed env value', async () => {
process.env.E2B_ENVD_RPC_CONNECTIONS = 'bogus'
const { getEnvdRpcConnectionLimit } = await import('../../src/envd/http2')
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')))
const loadUndici = vi.fn(() => Promise.resolve({ Agent, fetch: undiciFetch }))
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
loadUndici,
})
expect(loadUndici).not.toHaveBeenCalled()
expect(Agent).not.toHaveBeenCalled()
expect(undiciFetch).not.toHaveBeenCalled()
await fetcher('https://example.com/status')
expect(loadUndici).toHaveBeenCalledOnce()
expect(Agent).toHaveBeenCalledOnce()
expect(undiciFetch).toHaveBeenCalledOnce()
})
test('falls back to global fetch when undici cannot be loaded', async () => {
const fallbackFetch = vi.fn(() =>
Promise.resolve(new Response('fallback ok'))
) as unknown as typeof fetch
vi.stubGlobal('fetch', fallbackFetch)
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
loadUndici: () => Promise.resolve(undefined),
})
const res = await fetcher('https://example.com/status')
expect(await res.text()).toBe('fallback ok')
expect(fallbackFetch).toHaveBeenCalledWith(
'https://example.com/status',
undefined
)
})
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)
})
@@ -0,0 +1,53 @@
import { test, assert } from 'vitest'
import Sandbox from '../../src/index.js'
import { isIntegrationTest } from '../setup.js'
const integrationTestTemplate = 'en716jw99aj63v1k8ugh'
test.skipIf(!isIntegrationTest)(
'test python random number generation in same sandbox',
async () => {
const sbx = await Sandbox.create(integrationTestTemplate, {
timeoutMs: 120,
})
console.log('sandboxId', sbx.sandboxId)
const cmd1 = await sbx.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd1 stdout', cmd1.stdout)
const cmd2 = await sbx.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd2 stdout', cmd2.stdout)
assert.notEqual(cmd1.stdout, cmd2.stdout)
}
)
test.skipIf(!isIntegrationTest)(
'test python random number generation with same template',
async () => {
const sbx = await Sandbox.create(integrationTestTemplate, {
timeoutMs: 120,
})
console.log('sandboxId 1', sbx.sandboxId)
const cmd1 = await sbx.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd1 stdout', cmd1.stdout)
await sbx.kill()
const sbx2 = await Sandbox.create(integrationTestTemplate, {
timeoutMs: 120,
})
console.log('sandboxId 2', sbx2.sandboxId)
const cmd2 = await sbx2.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd2 stdout', cmd2.stdout)
assert.notEqual(cmd1.stdout, cmd2.stdout)
}
)
@@ -0,0 +1,77 @@
import { test } from 'vitest'
import Sandbox from '../../src/index.js'
import { isIntegrationTest, wait } from '../setup.js'
const heavyArray = new ArrayBuffer(256 * 1024 * 1024) // 256 MiB = 256 * 1024 * 1024 bytes
const view = new Uint8Array(heavyArray)
for (let i = 0; i < view.length; i++) {
view[i] = Math.floor(Math.random() * 256)
}
const integrationTestTemplate = 'integration-test-v1'
const sandboxCount = 10
test.skipIf(!isIntegrationTest)(
'stress test heavy file writes and reads',
async () => {
const promises: Array<Promise<string | void>> = []
for (let i = 0; i < sandboxCount; i++) {
promises.push(
Sandbox.create(integrationTestTemplate, { timeoutMs: 60 })
.then((sbx) => {
console.log(sbx.sandboxId)
return sbx.files
.write('heavy-file', heavyArray)
.then(() => sbx.files.read('heavy-file'))
})
.catch(console.error)
)
}
await wait(10_000)
await Promise.all(promises)
}
)
test.skipIf(!isIntegrationTest)('stress requests to nextjs app', async () => {
const hostPromises: Array<Promise<string | void>> = []
for (let i = 0; i < sandboxCount; i++) {
hostPromises.push(
Sandbox.create(integrationTestTemplate, { timeoutMs: 60_000 }).then(
(sbx) => {
console.log('created sandbox', sbx.sandboxId)
return new Promise((resolve, reject) => {
try {
resolve(sbx.getHost(3000))
} catch (e) {
console.error('error getting sbx host', e)
reject(e)
}
})
}
)
)
}
await wait(10_000)
const hosts = await Promise.all(hostPromises)
const fetchPromises: Array<Promise<string | void>> = []
for (let i = 0; i < 100; i++) {
for (const host of hosts) {
fetchPromises.push(
new Promise((resolve) => {
fetch('https://' + host)
.then((res) => {
console.log(`response for ${host}: ${res.status}`)
})
.then(resolve)
})
)
}
}
await Promise.all(fetchPromises)
})
@@ -0,0 +1,5 @@
# Integration test template
# Build the template
`$ e2b template build -c "cd /basic-nextjs-app/ && sudo npm run dev"`
@@ -0,0 +1,11 @@
FROM e2bdev/code-interpreter:latest
# Install stress-ng, a tool to load and stress computer systems
RUN apt update
RUN apt install -y stress-ng
# Create a basic Next.js app
RUN npx -y create-next-app@latest basic-nextjs-app --yes --ts --use-npm
# Install dependencies
RUN cd basic-nextjs-app && npm install
@@ -0,0 +1,18 @@
# This is a config for E2B sandbox template.
# You can use template ID (2e2z80zhv34yumbrybvn) or template name (integration-test-v1) to create a sandbox:
# Python SDK
# from e2b import Sandbox, AsyncSandbox
# sandbox = Sandbox("integration-test-v1") # Sync sandbox
# sandbox = await AsyncSandbox.create("integration-test-v1") # Async sandbox
# JS SDK
# import { Sandbox } from 'e2b'
# const sandbox = await Sandbox.create('integration-test-v1')
team_id = "460355b3-4f64-48f9-9a16-4442817f79f5"
memory_mb = 512
start_cmd = "npm run dev"
dockerfile = "e2b.Dockerfile"
template_name = "integration-test-v1"
template_id = "2e2z80zhv34yumbrybvn"
+39
View File
@@ -0,0 +1,39 @@
import { assert, describe, test } from 'vitest'
import { createRpcLogger } from '../src/logs'
const req = { url: 'http://localhost:49983/process.Process/Start' } as any
describe('createRpcLogger', () => {
test('logs unary responses containing bigint fields without throwing', async () => {
const logs: any[][] = []
const interceptor = createRpcLogger({ info: (...args) => logs.push(args) })
const res = { stream: false, message: { size: 42n, name: 'file' } } as any
const result = await interceptor(async () => res)(req)
assert.equal(result, res)
assert.deepEqual(logs[1], ['Response:', { size: '42', name: 'file' }])
})
test('logs streamed messages containing bigint fields without throwing', async () => {
const logs: any[][] = []
const interceptor = createRpcLogger({ debug: (...args) => logs.push(args) })
async function* stream() {
yield { offset: 9007199254740993n }
}
const res = { stream: true, message: stream() } as any
const result = (await interceptor(async () => res)(req)) as any
const received = []
for await (const m of result.message) {
received.push(m)
}
assert.deepEqual(received, [{ offset: 9007199254740993n }])
assert.deepEqual(logs[0], [
'Response stream:',
{ offset: '9007199254740993' },
])
})
})
+62
View File
@@ -0,0 +1,62 @@
import { assert, expect, test } from 'vitest'
import { Paginator } from '../src/paginator'
// Build a fake HTTP Response carrying only the `x-next-token` header.
function fakeResponse(nextToken?: string): Response {
const headers = new Headers()
if (nextToken !== undefined) {
headers.set('x-next-token', nextToken)
}
return new Response(null, { headers })
}
// Minimal concrete paginator that returns canned pages and drives the shared
// base state machine via `updatePagination`.
class FakePaginator extends Paginator<string> {
private call = 0
constructor(
private readonly pages: Array<{ items: string[]; nextToken?: string }>
) {
super()
}
async nextItems(): Promise<string[]> {
if (!this.hasNext) {
throw new Error('No more items to fetch')
}
const page = this.pages[this.call++]
this.updatePagination(fakeResponse(page.nextToken))
return page.items
}
}
test('paginator exposes pagination state and advances across pages', async () => {
const paginator = new FakePaginator([
{ items: ['a', 'b'], nextToken: 'tok-2' },
{ items: ['c'], nextToken: undefined },
])
assert.isTrue(paginator.hasNext)
assert.isUndefined(paginator.nextToken)
const first = await paginator.nextItems()
assert.deepEqual(first, ['a', 'b'])
assert.isTrue(paginator.hasNext)
assert.equal(paginator.nextToken, 'tok-2')
const second = await paginator.nextItems()
assert.deepEqual(second, ['c'])
assert.isFalse(paginator.hasNext)
assert.isUndefined(paginator.nextToken)
})
test('paginator throws once exhausted', async () => {
const paginator = new FakePaginator([{ items: [], nextToken: undefined }])
await paginator.nextItems()
assert.isFalse(paginator.hasNext)
await expect(paginator.nextItems()).rejects.toThrow('No more items to fetch')
})
@@ -0,0 +1,38 @@
import { expect, inject, test } from 'vitest'
import { render } from 'vitest-browser-react'
import React from 'react'
import { useEffect, useState } from 'react'
import { Sandbox } from '../../../src'
import { template } from '../../template'
function E2BTest() {
const [text, setText] = useState<string>()
useEffect(() => {
const getText = async () => {
const sandbox = await Sandbox.create(template, {
apiKey: inject('E2B_API_KEY'),
domain: inject('E2B_DOMAIN'),
})
try {
await sandbox.commands.run('echo "Hello World" > hello.txt')
const content = await sandbox.files.read('hello.txt')
setText(content)
} finally {
await sandbox.kill()
}
}
getText()
}, [])
return <div>{text}</div>
}
test('browser test', async () => {
const screen = await render(<E2BTest />)
await expect
.element(screen.getByText('Hello World'), { timeout: 30_000 })
.toBeInTheDocument()
}, 40_000)
@@ -0,0 +1,30 @@
import { expect, test } from 'bun:test'
import { Sandbox } from '../../../src'
import { template } from '../../template'
test(
'Bun test',
async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 5_000 })
try {
const isRunning = await sbx.isRunning()
expect(isRunning).toBeTruthy()
const text = 'Hello, World!'
const cmd = await sbx.commands.run(`echo "${text}"`)
expect(cmd.exitCode).toBe(0)
expect(cmd.stdout).toEqual(`${text}\n`)
await sbx.files.write('test.txt', text)
const test = await sbx.files.read('test.txt')
expect(test).toEqual(text)
} finally {
await sbx.kill()
}
},
{ timeout: 20_000 }
)
@@ -0,0 +1,27 @@
import {
assert,
assertEquals,
} from 'https://deno.land/std@0.224.0/assert/mod.ts'
import { load } from 'https://deno.land/std@0.224.0/dotenv/mod.ts'
await load({ envPath: '.env', export: true })
import { Sandbox } from '../../../dist/index.mjs'
import { template } from '../../template'
Deno.test('Deno test', async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 5_000 })
try {
const isRunning = await sbx.isRunning()
assert(isRunning)
const text = 'Hello, World!'
const cmd = await sbx.commands.run(`echo "${text}"`)
assertEquals(cmd.exitCode, 0)
assertEquals(cmd.stdout, `${text}\n`)
} finally {
await sbx.kill()
}
})
@@ -0,0 +1,116 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { Sandbox } from '../../src'
import { TEST_API_KEY, apiUrl } from '../setup'
// Hold the request open until the caller aborts. If the signal is already
// aborted by the time the handler runs, `addEventListener('abort', …)` would
// never fire — so check `aborted` first to avoid hanging.
function holdUntilAborted(signal: AbortSignal): Promise<never> {
return new Promise<never>((_, reject) => {
const abort = () => reject(new DOMException('aborted', 'AbortError'))
if (signal.aborted) {
abort()
return
}
signal.addEventListener('abort', abort, { once: true })
})
}
const restHandlers = [
http.post(apiUrl('/sandboxes'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.delete(apiUrl('/sandboxes/:sandboxID'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.get(apiUrl('/v2/sandboxes'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json([])
}),
]
const server = setupServer(...restHandlers)
beforeAll(() =>
server.listen({
onUnhandledRequest: 'bypass',
})
)
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
// Resolves once MSW has dispatched the next request, so tests can abort
// deterministically instead of guessing with `setTimeout`.
function nextRequestStart(): Promise<void> {
return new Promise<void>((resolve) => {
const listener = () => {
server.events.removeListener('request:start', listener)
resolve()
}
server.events.on('request:start', listener)
})
}
test('Sandbox.create rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Sandbox.create('base', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Sandbox.create rejects immediately when signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
Sandbox.create('base', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
).rejects.toThrow()
})
test('Sandbox.kill rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Sandbox.kill('some-sandbox', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('SandboxPaginator.nextItems rejects when per-call AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const paginator = Sandbox.list({
apiKey: TEST_API_KEY,
})
const promise = paginator.nextItems({ signal: controller.signal })
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
@@ -0,0 +1,477 @@
import { describe, expect, it, vi } from 'vitest'
import { CommandHandle } from '../../../src/sandbox/commands/commandHandle'
type EventKind = 'stdout' | 'stderr' | 'pty'
function createEvents(kind: EventKind): AsyncIterable<any> {
async function* events() {
if (kind === 'pty') {
yield {
event: {
event: {
case: 'data',
value: {
output: {
case: 'pty',
value: new Uint8Array([1, 2, 3]),
},
},
},
},
}
} else {
yield {
event: {
event: {
case: 'data',
value: {
output: {
case: kind,
value: new TextEncoder().encode(kind),
},
},
},
},
}
}
yield {
event: {
event: {
case: 'end',
value: {
exitCode: 0,
error: undefined,
},
},
},
}
}
return events()
}
function dataEvent(kind: 'stdout' | 'stderr', value: Uint8Array) {
return {
event: {
event: {
case: 'data',
value: {
output: {
case: kind,
value,
},
},
},
},
}
}
function endEvent(exitCode = 0) {
return {
event: {
event: {
case: 'end',
value: {
exitCode,
error: undefined,
},
},
},
}
}
// An async iterable whose events are delivered on demand. Lets a test hold the
// handle's event loop blocked on `next()` (idle between bursts) and then push a
// late event to simulate stdout arriving after `disconnect()` — the transport
// condition that triggers the production leak. `return()` (called when the
// handle's `for await` breaks) unblocks any pending read, mirroring the stream
// being torn down from the client side.
function createControllableEvents() {
const queue: any[] = []
let pending: ((result: IteratorResult<any>) => void) | undefined
let closed = false
const iterator: AsyncIterator<any> = {
next() {
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift(), done: false })
}
if (closed) {
return Promise.resolve({ value: undefined, done: true })
}
return new Promise((resolve) => {
pending = resolve
})
},
return() {
closed = true
if (pending) {
const resolve = pending
pending = undefined
resolve({ value: undefined, done: true })
}
return Promise.resolve({ value: undefined, done: true })
},
}
return {
events: { [Symbol.asyncIterator]: () => iterator } as AsyncIterable<any>,
push(event: any) {
if (pending) {
const resolve = pending
pending = undefined
resolve({ value: event, done: false })
} else {
queue.push(event)
}
},
}
}
describe('CommandHandle', () => {
it.each<EventKind>(['stdout', 'stderr', 'pty'])(
'wait awaits async %s callbacks',
async (kind) => {
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const callback = async () => {
callbackStarted = true
await callbackBlocked
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
createEvents(kind),
kind === 'stdout' ? callback : undefined,
kind === 'stderr' ? callback : undefined,
kind === 'pty' ? callback : undefined
)
let waitResolved = false
const waitPromise = handle.wait().then(() => {
waitResolved = true
})
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
expect(waitResolved).toBe(false)
releaseCallback?.()
await waitPromise
expect(waitResolved).toBe(true)
}
)
it('decodes multibyte characters split across chunks', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
yield dataEvent(
'stdout',
new Uint8Array([
...emojiBytes.slice(2),
...new TextEncoder().encode('b'),
])
)
yield dataEvent('stderr', emojiBytes.slice(0, 3))
yield dataEvent('stderr', emojiBytes.slice(3))
yield endEvent()
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
const result = await handle.wait()
expect(result.stdout).toBe('a😀b')
expect(result.stderr).toBe('😀')
expect(result.stdout).not.toContain('')
expect(result.stderr).not.toContain('')
expect(stdoutChunks.join('')).toBe('a😀b')
})
it('replaces incomplete trailing utf-8 sequences at the end of the stream', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
yield endEvent()
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
events()
)
const result = await handle.wait()
expect(result.stdout).toBe('a')
})
it('flushes incomplete trailing utf-8 sequences when the stream closes without an end event', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
// No end event arrives, so wait() rejects, but the buffered bytes must
// still be flushed to the stdout callback as a replacement character.
await expect(handle.wait()).rejects.toThrow()
expect(stdoutChunks.join('')).toBe('a')
})
it('flushes incomplete trailing utf-8 sequences when the stream errors', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
throw new Error('stream died')
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
// The stream errors before an end event arrives, so wait() rejects, but the
// buffered bytes must still be flushed to the stdout callback as a
// replacement character.
await expect(handle.wait()).rejects.toThrow()
expect(stdoutChunks.join('')).toBe('a')
})
it('onStdout stops firing after await disconnect()', async () => {
const controllable = createControllableEvents()
const chunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
(out) => {
chunks.push(out)
}
)
// First burst is delivered to the live subscriber.
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(chunks).toEqual(['a'])
})
// Disconnect while the event loop is idle (blocked on the next read), then
// push a late event — as if stdout arrived after the abort but before the
// stream was torn down. The late event must never reach the callback.
await handle.disconnect()
controllable.push(dataEvent('stdout', new TextEncoder().encode('b')))
await new Promise((r) => setTimeout(r, 0))
expect(chunks).toEqual(['a'])
// Any further output is ignored too.
controllable.push(dataEvent('stdout', new TextEncoder().encode('c')))
await new Promise((r) => setTimeout(r, 0))
expect(chunks).toEqual(['a'])
})
it('disconnect() resolves promptly when a stream is idle', async () => {
// An idle long-running command (e.g. `sleep`) produces no further output,
// so the event handler stays blocked on the next read. disconnect() must
// not wait for that read and must resolve right away.
const controllable = createControllableEvents()
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
() => {}
)
// No event is ever pushed; this would hang if disconnect() awaited the
// event handler.
await handle.disconnect()
})
it('disconnect() does not block on an in-flight callback', async () => {
const controllable = createControllableEvents()
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
async () => {
callbackStarted = true
await callbackBlocked
}
)
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
// The callback is still in flight; disconnect() must resolve without
// waiting for it to settle.
await handle.disconnect()
// Releasing the callback afterwards is harmless.
releaseCallback?.()
})
it('disconnect() resolves when called from inside a callback', async () => {
const controllable = createControllableEvents()
let disconnectReturned = false
const handle: CommandHandle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
async () => {
await handle.disconnect()
disconnectReturned = true
}
)
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(disconnectReturned).toBe(true)
})
})
it('records the exit code when disconnected before an end event with trailing bytes', async () => {
const controllable = createControllableEvents()
const emojiBytes = new TextEncoder().encode('😀')
const chunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
(out) => {
chunks.push(out)
}
)
// First chunk leaves an incomplete multibyte sequence buffered in the
// decoder, so the end event will flush a trailing replacement character.
controllable.push(
dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
)
await vi.waitFor(() => {
expect(chunks).toEqual(['a'])
})
// Disconnect, then the process exits. The end event carries a flushed
// chunk; wait() must still surface the exit code instead of failing as if
// the process never produced a result.
await handle.disconnect()
controllable.push(endEvent(0))
const result = await handle.wait()
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('a')
// The flushed chunk was produced after disconnect, so it must not reach
// the live callback.
expect(chunks).toEqual(['a'])
})
it('surfaces an async callback error through wait()', async () => {
async function* events() {
yield dataEvent('stdout', new TextEncoder().encode('a'))
yield endEvent()
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
async () => {
throw new Error('callback failed')
}
)
await expect(handle.wait()).rejects.toThrow('callback failed')
})
})
@@ -0,0 +1,18 @@
import { assert, expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('connect to process', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 10', { background: true })
const pid = cmd.pid
const processInfo = await sandbox.commands.connect(pid)
assert.isObject(processInfo)
assert.equal(processInfo.pid, pid)
})
sandboxTest('connect to non-existing process', async ({ sandbox }) => {
const nonExistingPid = 999999
await expect(sandbox.commands.connect(nonExistingPid)).rejects.toThrowError()
})
@@ -0,0 +1,45 @@
import { assert, describe } from 'vitest'
import { sandboxTest, isDebug } from '../../setup.js'
describe('sandbox global env vars', () => {
sandboxTest.scoped({
sandboxOpts: {
envs: { FOO: 'bar' },
},
})
sandboxTest.skipIf(isDebug)(
'sandbox global env vars',
async ({ sandbox }) => {
const cmd = await sandbox.commands.run('echo $FOO')
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'bar')
}
)
})
sandboxTest('bash command scoped env vars', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('echo $FOO', {
envs: { FOO: 'bar' },
})
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'bar')
// test that it is secure and not accessible to subsequent commands
const cmd2 = await sandbox.commands.run('sudo echo "$FOO"')
assert.equal(cmd2.exitCode, 0)
assert.equal(cmd2.stdout.trim(), '')
})
sandboxTest('python command scoped env vars', async ({ sandbox }) => {
const cmd = await sandbox.commands.run(
'python3 -c "import os; print(os.environ[\'FOO\'])"',
{ envs: { FOO: 'bar' } }
)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'bar')
})
@@ -0,0 +1,21 @@
import { expect } from 'vitest'
import { ProcessExitError } from '../../../src/index.js'
import { sandboxTest } from '../../setup.js'
sandboxTest('kill process', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 10', { background: true })
const pid = cmd.pid
await sandbox.commands.kill(pid)
await expect(sandbox.commands.run(`kill -0 ${pid}`)).rejects.toThrowError(
ProcessExitError
)
})
sandboxTest('kill non-existing process', async ({ sandbox }) => {
const nonExistingPid = 999999
await expect(sandbox.commands.kill(nonExistingPid)).resolves.toBe(false)
})
@@ -0,0 +1,17 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('list processes', async ({ sandbox }) => {
// Start them firsts
await sandbox.commands.run('sleep 10', { background: true })
await sandbox.commands.run('sleep 10', { background: true })
const processes = await sandbox.commands.list()
assert.isArray(processes)
assert.isAtLeast(processes.length, 2)
processes.forEach((process) => {
assert.containsAllKeys(process, ['pid'])
})
})
@@ -0,0 +1,54 @@
import { expect, assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
import { TimeoutError } from '../../../src/index.js'
sandboxTest('run', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run(`echo "${text}"`)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, `${text}\n`)
})
sandboxTest('run with special characters', async ({ sandbox }) => {
const text = '!@#$%^&*()_+'
const cmd = await sandbox.commands.run(`echo "${text}"`)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, `${text}\n`)
})
sandboxTest('run with multiline string', async ({ sandbox }) => {
const text = 'Hello,\nWorld!'
const cmd = await sandbox.commands.run(`echo "${text}"`)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, `${text}\n`)
})
sandboxTest('run with timeout', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('echo "Hello, World!"', {
timeoutMs: 4000,
})
assert.equal(cmd.exitCode, 0)
})
sandboxTest('run with too short timeout', async ({ sandbox }) => {
await expect(
sandbox.commands.run('sleep 10', { timeoutMs: 1000 })
).rejects.toThrow()
})
sandboxTest('run with too short timeout iterating', async ({ sandbox }) => {
const handle = await sandbox.commands.run('sleep 10', {
timeoutMs: 2000,
background: true,
})
await expect(handle.wait()).rejects.toThrowError(TimeoutError)
})
@@ -0,0 +1,20 @@
import { expect } from 'vitest'
import { TimeoutError } from '../../../src/index.js'
import { sandboxTest, isDebug } from '../../setup.js'
sandboxTest.skipIf(isDebug)(
'killing the sandbox while a command is running throws an actionable error',
async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 60', { background: true })
await sandbox.kill()
const err = await cmd.wait().catch((e) => e)
expect(err).toBeInstanceOf(TimeoutError)
// The health check confirms the sandbox is gone, so the error states it outright
expect(err.message).toContain(
'sandbox was killed or reached its end of life'
)
}
)
@@ -0,0 +1,138 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('send stdin to process', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send Uint8Array stdin to process', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, new TextEncoder().encode(text))
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send stdin via command handle', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await cmd.sendStdin(text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('close stdin via command handle', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await cmd.sendStdin(text)
await cmd.closeStdin()
// `cat` exits once stdin is closed (EOF).
const result = await cmd.wait()
assert.equal(result.exitCode, 0)
assert.equal(result.stdout, text)
})
sandboxTest('send empty stdin to process', async ({ sandbox }) => {
const text = ''
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send special characters to stdin', async ({ sandbox }) => {
const text = '!@#$%^&*()_+'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send multiline string to stdin', async ({ sandbox }) => {
const text = 'Hello,\nWorld!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
@@ -0,0 +1,109 @@
import { afterEach, assert, beforeEach, describe, test, vi } from 'vitest'
import { Sandbox } from '../../src'
import { SandboxApi } from '../../src/sandbox/sandboxApi'
import { TEST_API_KEY } from '../setup'
let originalSandboxUrl: string | undefined
const baseConfig = {
apiKey: TEST_API_KEY,
domain: 'base.e2b.dev',
requestTimeoutMs: 1111,
debug: false,
apiHeaders: { 'X-Test': 'base' },
}
function createSandbox() {
return new Sandbox({
sandboxId: 'sbx-test',
sandboxDomain: 'sandbox.e2b.dev',
envdVersion: '0.2.4',
envdAccessToken: 'tok',
trafficAccessToken: 'tok',
...baseConfig,
})
}
describe('Sandbox API config propagation', () => {
beforeEach(() => {
originalSandboxUrl = process.env.E2B_SANDBOX_URL
})
afterEach(() => {
vi.restoreAllMocks()
if (originalSandboxUrl === undefined) {
delete process.env.E2B_SANDBOX_URL
} else {
process.env.E2B_SANDBOX_URL = originalSandboxUrl
}
})
test('passes connectionConfig to public API methods when called without overrides', async () => {
const pauseSpy = vi.spyOn(SandboxApi, 'pause').mockResolvedValue(true)
const sandbox = createSandbox()
await sandbox.pause()
const opts = pauseSpy.mock.calls[0][1]
assert.equal(opts?.apiKey, baseConfig.apiKey)
assert.equal(opts?.domain, baseConfig.domain)
assert.equal(opts?.requestTimeoutMs, baseConfig.requestTimeoutMs)
assert.equal(opts?.debug, baseConfig.debug)
assert.equal(opts?.headers?.['X-Test'], baseConfig.apiHeaders['X-Test'])
})
test('accepts apiHeaders in static Sandbox API options', () => {
const opts = { apiHeaders: baseConfig.apiHeaders } satisfies Parameters<
typeof Sandbox.kill
>[1]
assert.equal(opts.apiHeaders['X-Test'], baseConfig.apiHeaders['X-Test'])
})
test('lets public method call overrides win over connectionConfig', async () => {
const pauseSpy = vi.spyOn(SandboxApi, 'pause').mockResolvedValue(true)
const sandbox = createSandbox()
await sandbox.pause({
domain: 'override.e2b.dev',
requestTimeoutMs: 9999,
})
const opts = pauseSpy.mock.calls[0][1]
assert.equal(opts?.apiKey, baseConfig.apiKey)
assert.equal(opts?.domain, 'override.e2b.dev')
assert.equal(opts?.requestTimeoutMs, 9999)
assert.equal(opts?.debug, baseConfig.debug)
})
test('updateNetwork forwards per-call signal', async () => {
const updateNetworkSpy = vi
.spyOn(SandboxApi, 'updateNetwork')
.mockResolvedValue()
const sandbox = createSandbox()
const controller = new AbortController()
await sandbox.updateNetwork({}, { signal: controller.signal })
const opts = updateNetworkSpy.mock.calls[0][2]
assert.equal(opts?.signal, controller.signal)
})
test('downloadUrl keeps sandbox identity in production direct URLs', async () => {
delete process.env.E2B_SANDBOX_URL
const sandbox = new Sandbox({
sandboxId: 'sbx-test',
sandboxDomain: 'e2b.app',
envdVersion: '0.2.4',
domain: 'e2b.app',
debug: false,
})
assert.equal(
await sandbox.downloadUrl('/hello.txt'),
'https://49983-sbx-test.e2b.app/files?username=user&path=%2Fhello.txt'
)
})
})
@@ -0,0 +1,121 @@
import { assert, test, expect, vi } from 'vitest'
import { Sandbox } from '../../src'
import { isDebug, sandboxTest, template } from '../setup.js'
test('connect in debug mode does not call the API', async () => {
const fetchSpy = vi.fn(() => {
throw new Error('unexpected request in debug mode')
})
vi.stubGlobal('fetch', fetchSpy)
try {
const sbx = await Sandbox.connect('debug-sandbox-id', {
debug: true,
apiKey: 'test-api-key',
})
assert.equal(sbx.sandboxId, 'debug-sandbox-id')
const sameSbx = await sbx.connect()
assert.strictEqual(sameSbx, sbx)
expect(fetchSpy).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
}
})
test.skipIf(isDebug)('connect', async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 10_000 })
try {
const isRunning = await sbx.isRunning()
assert.isTrue(isRunning)
const sbxConnection = await Sandbox.connect(sbx.sandboxId)
const isRunning2 = await sbxConnection.isRunning()
assert.isTrue(isRunning2)
} finally {
if (!isDebug) {
await sbx.kill()
}
}
})
sandboxTest.skipIf(isDebug)(
'connect resumes paused sandbox',
async ({ sandbox }) => {
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
const resumed = await Sandbox.connect(sandbox.sandboxId)
assert.isTrue(await resumed.isRunning())
}
)
sandboxTest.skipIf(isDebug)(
'connect to non-running sandbox',
async ({ sandbox }) => {
const isRunning = await sandbox.isRunning()
assert.isTrue(isRunning)
await sandbox.kill()
const connectPromise = Sandbox.connect(sandbox.sandboxId)
await expect(connectPromise).rejects.toThrowError(
expect.objectContaining({
name: 'SandboxNotFoundError',
})
)
}
)
test.skipIf(isDebug)(
'connect does not shorten timeout on running sandbox',
async () => {
// Create sandbox with a 300 second timeout
const sbx = await Sandbox.create(template, { timeoutMs: 300_000 })
try {
const isRunning = await sbx.isRunning()
assert.isTrue(isRunning)
// Get initial info to check endAt
const infoBefore = await Sandbox.getInfo(sbx.sandboxId)
// Connect with a shorter timeout (10 seconds)
await Sandbox.connect(sbx.sandboxId, { timeoutMs: 10_000 })
// Get info after connection
const infoAfter = await sbx.getInfo()
// The endAt time should not have been shortened. It should be the same
assert.equal(
infoAfter.endAt.getTime(),
infoBefore.endAt.getTime(),
`Timeout was shortened: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}`
)
} finally {
await sbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'connect extends timeout on running sandbox',
async ({ sandbox }) => {
// Get initial info to check endAt
const infoBefore = await sandbox.getInfo()
// Connect with a longer timeout
await sandbox.connect({ timeoutMs: 600_000 })
// Get info after connection
const infoAfter = await sandbox.getInfo()
// The endAt time should have been extended
assert.isTrue(
infoAfter.endAt.getTime() > infoBefore.endAt.getTime(),
`Timeout was not extended: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}`
)
}
)
@@ -0,0 +1,34 @@
import { assert, test } from 'vitest'
import { Sandbox } from '../../src'
import { template, isDebug } from '../setup.js'
test.skipIf(isDebug)('create', async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 5_000 })
try {
const isRunning = await sbx.isRunning()
// @ts-ignore It's only for testing
assert.isDefined(sbx.envdApi.version)
assert.isTrue(isRunning)
} finally {
await sbx.kill()
}
})
test.skipIf(isDebug)('metadata', async () => {
const metadata = {
'test-key': 'test-value',
}
const sbx = await Sandbox.create(template, { timeoutMs: 5_000, metadata })
try {
const paginator = Sandbox.list()
const sbxs = await paginator.nextItems()
const sbxInfo = sbxs.find((s) => s.sandboxId === sbx.sandboxId)
assert.deepEqual(sbxInfo?.metadata, metadata)
} finally {
await sbx.kill()
}
})
@@ -0,0 +1,94 @@
import { assert } from 'vitest'
import { WriteEntry } from '../../../src/sandbox/filesystem'
import { isDebug, sandboxTest } from '../../setup.js'
sandboxTest(
'write and read file with gzip content encoding',
async ({ sandbox }) => {
const filename = 'test_gzip_write.txt'
const content = 'This is a test file with gzip encoding.'
const info = await sandbox.files.write(filename, content, {
gzip: true,
})
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${filename}`)
const readContent = await sandbox.files.read(filename, {
gzip: true,
})
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest(
'write with gzip and read without encoding',
async ({ sandbox }) => {
const filename = 'test_gzip_write_plain_read.txt'
const content = 'Written with gzip, read without.'
await sandbox.files.write(filename, content, {
gzip: true,
})
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest('writeFiles with gzip content encoding', async ({ sandbox }) => {
const files: WriteEntry[] = [
{ path: 'gzip_multi_1.txt', data: 'File 1 content' },
{ path: 'gzip_multi_2.txt', data: 'File 2 content' },
{ path: 'gzip_multi_3.txt', data: 'File 3 content' },
]
const infos = await sandbox.files.writeFiles(files, {
gzip: true,
})
assert.equal(infos.length, files.length)
for (let i = 0; i < files.length; i++) {
const readContent = await sandbox.files.read(files[i].path)
assert.equal(readContent, files[i].data)
}
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
})
sandboxTest(
'read file as bytes with gzip content encoding',
async ({ sandbox }) => {
const filename = 'test_gzip_bytes.txt'
const content = 'Binary content with gzip.'
await sandbox.files.write(filename, content)
const readBytes = await sandbox.files.read(filename, {
format: 'bytes',
gzip: true,
})
assert.instanceOf(readBytes, Uint8Array)
const decoded = new TextDecoder().decode(readBytes)
assert.equal(decoded, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
@@ -0,0 +1,18 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('file exists', async ({ sandbox }) => {
const filename = 'test_exists.txt'
await sandbox.files.write(filename, 'test')
const exists = await sandbox.files.exists(filename)
assert.isTrue(exists)
})
sandboxTest('file does not exist', async ({ sandbox }) => {
const filename = 'test_does_not_exist.txt'
const exists = await sandbox.files.exists(filename)
assert.isFalse(exists)
})
@@ -0,0 +1,72 @@
import { assert } from 'vitest'
import { expect } from 'vitest'
import { FileNotFoundError } from '../../../src/errors.js'
import { sandboxTest } from '../../setup.js'
sandboxTest('get info of a file', async ({ sandbox }) => {
const filename = 'test_file.txt'
await sandbox.files.write(filename, 'test')
const info = await sandbox.files.getInfo(filename)
const { stdout: currentPath } = await sandbox.commands.run('pwd')
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, currentPath.trim() + '/' + filename)
assert.equal(info.size, 4)
assert.equal(info.mode, 0o644)
assert.equal(info.permissions, '-rw-r--r--')
assert.equal(info.owner, 'user')
assert.equal(info.group, 'user')
assert.property(info, 'modifiedTime')
})
sandboxTest('get info of a file that does not exist', async ({ sandbox }) => {
const filename = 'test_does_not_exist.txt'
await expect(sandbox.files.getInfo(filename)).rejects.toThrow(
FileNotFoundError
)
})
sandboxTest('get info of a directory', async ({ sandbox }) => {
const dirname = 'test_dir'
await sandbox.files.makeDir(dirname)
const info = await sandbox.files.getInfo(dirname)
const { stdout: currentPath } = await sandbox.commands.run('pwd')
assert.equal(info.name, dirname)
assert.equal(info.type, 'dir')
assert.equal(info.path, currentPath.trim() + '/' + dirname)
assert.isAbove(info.size, 0)
assert.equal(info.mode, 0o755)
assert.equal(info.permissions, 'drwxr-xr-x')
assert.equal(info.owner, 'user')
assert.equal(info.group, 'user')
assert.property(info, 'modifiedTime')
})
sandboxTest(
'get info of a directory that does not exist',
async ({ sandbox }) => {
const dirname = 'test_does_not_exist_dir'
await expect(sandbox.files.getInfo(dirname)).rejects.toThrow(
FileNotFoundError
)
}
)
sandboxTest('get info of a symlink', async ({ sandbox }) => {
const filename = 'test_file.txt'
await sandbox.files.write(filename, 'test')
const symlinkName = 'test_symlink.txt'
await sandbox.commands.run(`ln -s ${filename} ${symlinkName}`)
const info = await sandbox.files.getInfo(symlinkName)
const { stdout: currentPath } = await sandbox.commands.run('pwd')
assert.equal(info.name, symlinkName)
assert.equal(info.symlinkTarget, currentPath.trim() + '/' + filename)
})
@@ -0,0 +1,206 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
const parentDirName = 'test_directory'
sandboxTest('list directory', async ({ sandbox }) => {
const homeDirName = '/home/user'
await sandbox.files.makeDir(parentDirName)
await sandbox.files.makeDir(`${parentDirName}/subdir1`)
await sandbox.files.makeDir(`${parentDirName}/subdir2`)
await sandbox.files.makeDir(`${parentDirName}/subdir1/subdir1_1`)
await sandbox.files.makeDir(`${parentDirName}/subdir1/subdir1_2`)
await sandbox.files.makeDir(`${parentDirName}/subdir2/subdir2_1`)
await sandbox.files.makeDir(`${parentDirName}/subdir2/subdir2_2`)
await sandbox.files.write(`${parentDirName}/file1.txt`, 'Hello, world!')
const testCases = [
{
test_name: 'default depth (1)',
depth: undefined,
expectedLen: 3,
expectedFileNames: ['file1.txt', 'subdir1', 'subdir2'],
expectedFileTypes: ['file', 'dir', 'dir'],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir2`,
],
},
{
test_name: 'explicit depth 1',
depth: 1,
expectedLen: 3,
expectedFileNames: ['file1.txt', 'subdir1', 'subdir2'],
expectedFileTypes: ['file', 'dir', 'dir'],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir2`,
],
},
{
test_name: 'explicit depth 2',
depth: 2,
expectedLen: 7,
expectedFileTypes: ['file', 'dir', 'dir', 'dir', 'dir', 'dir', 'dir'],
expectedFileNames: [
'file1.txt',
'subdir1',
'subdir1_1',
'subdir1_2',
'subdir2',
'subdir2_1',
'subdir2_2',
],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_2`,
`${homeDirName}/${parentDirName}/subdir2`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_1`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_2`,
],
},
{
test_name: 'explicit depth 3 (should be the same as depth 2)',
depth: 3,
expectedLen: 7,
expectedFileTypes: ['file', 'dir', 'dir', 'dir', 'dir', 'dir', 'dir'],
expectedFileNames: [
'file1.txt',
'subdir1',
'subdir1_1',
'subdir1_2',
'subdir2',
'subdir2_1',
'subdir2_2',
],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_2`,
`${homeDirName}/${parentDirName}/subdir2`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_1`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_2`,
],
},
]
for (const testCase of testCases) {
const files = await sandbox.files.list(
parentDirName,
testCase.depth !== undefined ? { depth: testCase.depth } : undefined
)
assert.equal(files.length, testCase.expectedLen)
for (let i = 0; i < testCase.expectedFilePaths.length; i++) {
assert.equal(files[i].type, testCase.expectedFileTypes[i])
assert.equal(files[i].name, testCase.expectedFileNames[i])
assert.equal(files[i].path, testCase.expectedFilePaths[i])
}
}
})
sandboxTest('list directory with invalid depth', async ({ sandbox }) => {
await sandbox.files.makeDir(parentDirName)
try {
await sandbox.files.list(parentDirName, { depth: -1 })
assert.fail('Expected error but none was thrown')
} catch (err) {
const expectedErrorMessage = 'depth should be at least one'
assert.ok(
err.message.includes(expectedErrorMessage),
`expected error message to include "${expectedErrorMessage}"`
)
}
})
sandboxTest('file entry details', async ({ sandbox }) => {
const testDir = 'test-file-entry'
const filePath = `${testDir}/test.txt`
const content = 'Hello, World!'
await sandbox.files.makeDir(testDir)
await sandbox.files.write(filePath, content)
const files = await sandbox.files.list(testDir, { depth: 1 })
assert.equal(files.length, 1)
const fileEntry = files[0]
assert.equal(fileEntry.name, 'test.txt')
assert.equal(fileEntry.path, `/home/user/${filePath}`)
assert.equal(fileEntry.type, 'file')
assert.equal(fileEntry.mode, 0o644)
assert.equal(fileEntry.permissions, '-rw-r--r--')
assert.equal(fileEntry.owner, 'user')
assert.equal(fileEntry.group, 'user')
assert.equal(fileEntry.size, content.length)
assert.ok(fileEntry.modifiedTime)
assert.isUndefined(fileEntry.symlinkTarget)
})
sandboxTest('directory entry details', async ({ sandbox }) => {
const testDir = 'test-entry-info'
const subDir = `${testDir}/subdir`
await sandbox.files.makeDir(testDir)
await sandbox.files.makeDir(subDir)
const files = await sandbox.files.list(testDir, { depth: 1 })
assert.equal(files.length, 1)
const dirEntry = files[0]
assert.equal(dirEntry.name, 'subdir')
assert.equal(dirEntry.path, `/home/user/${subDir}`)
assert.equal(dirEntry.type, 'dir')
assert.equal(dirEntry.mode, 0o755)
assert.equal(dirEntry.permissions, 'drwxr-xr-x')
assert.equal(dirEntry.owner, 'user')
assert.equal(dirEntry.group, 'user')
assert.ok(dirEntry.modifiedTime)
})
sandboxTest('mixed entries (files and directories)', async ({ sandbox }) => {
const testDir = 'test-mixed-entries'
const subDir = `${testDir}/subdir`
const filePath = `${testDir}/test.txt`
const content = 'Hello, World!'
await sandbox.files.makeDir(testDir)
await sandbox.files.makeDir(subDir)
await sandbox.files.write(filePath, content)
const files = await sandbox.files.list(testDir, { depth: 1 })
assert.equal(files.length, 2)
// Create a map of entries by name for easier verification
const entries = new Map(files.map((entry) => [entry.name, entry]))
// Verify directory entry
const dirEntry = entries.get('subdir')
assert.ok(dirEntry)
assert.equal(dirEntry!.path, `/home/user/${subDir}`)
assert.equal(dirEntry!.type, 'dir')
assert.equal(dirEntry!.mode, 0o755)
assert.equal(dirEntry!.permissions, 'drwxr-xr-x')
assert.equal(dirEntry!.owner, 'user')
assert.equal(dirEntry!.group, 'user')
assert.ok(dirEntry!.modifiedTime)
// Verify file entry
const fileEntry = entries.get('test.txt')
assert.ok(fileEntry)
assert.equal(fileEntry!.path, `/home/user/${filePath}`)
assert.equal(fileEntry!.type, 'file')
assert.equal(fileEntry!.mode, 0o644)
assert.equal(fileEntry!.permissions, '-rw-r--r--')
assert.equal(fileEntry!.owner, 'user')
assert.equal(fileEntry!.group, 'user')
assert.equal(fileEntry!.size, content.length)
assert.ok(fileEntry!.modifiedTime)
})
@@ -0,0 +1,30 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('make directory', async ({ sandbox }) => {
const dirName = 'test_directory1'
await sandbox.files.makeDir(dirName)
const exists = await sandbox.files.exists(dirName)
assert.isTrue(exists)
})
sandboxTest('make existing directory', async ({ sandbox }) => {
const dirName = 'test_directory2'
await sandbox.files.makeDir(dirName)
const exists = await sandbox.files.exists(dirName)
assert.isTrue(exists)
const exists2 = await sandbox.files.makeDir(dirName)
assert.isFalse(exists2)
})
sandboxTest('make nested directory', async ({ sandbox }) => {
const nestedDirName = 'test_directory3/nested_directory'
await sandbox.files.makeDir(nestedDirName)
const exists = await sandbox.files.exists(nestedDirName)
assert.isTrue(exists)
})
@@ -0,0 +1,194 @@
import { assert, expect } from 'vitest'
import { WriteEntry } from '../../../src/sandbox/filesystem'
import { InvalidArgumentError } from '../../../src/errors.js'
import { isDebug, sandboxTest } from '../../setup.js'
sandboxTest('write file with metadata', async ({ sandbox }) => {
const filename = 'test_metadata.txt'
const content = 'This is a test file with metadata.'
const metadata = { author: 'mish', purpose: 'upload' }
const info = await sandbox.files.write(filename, content, { metadata })
assert.isFalse(Array.isArray(info))
assert.deepEqual(info.metadata, metadata)
// Metadata is persisted and surfaced on subsequent reads.
const stat = await sandbox.files.getInfo(filename)
assert.deepEqual(stat.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'write file with metadata using octet-stream',
async ({ sandbox }) => {
const filename = 'test_metadata_octet.txt'
const content = 'This is a test file with metadata.'
const metadata = { author: 'mish', purpose: 'upload' }
const info = await sandbox.files.write(filename, content, {
metadata,
useOctetStream: true,
})
assert.deepEqual(info.metadata, metadata)
const stat = await sandbox.files.getInfo(filename)
assert.deepEqual(stat.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest('write file without metadata', async ({ sandbox }) => {
const filename = 'test_no_metadata.txt'
const info = await sandbox.files.write(filename, 'no metadata here')
assert.isUndefined(info.metadata)
const stat = await sandbox.files.getInfo(filename)
assert.isUndefined(stat.metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'writeFiles applies metadata to every file',
async ({ sandbox }) => {
// The same metadata is applied to every file in the upload.
const metadata = { source: 'test-suite' }
const files: WriteEntry[] = [
{ path: 'metadata_multi_1.txt', data: 'File 1' },
{ path: 'metadata_multi_2.txt', data: 'File 2' },
]
const infos = await sandbox.files.writeFiles(files, { metadata })
assert.equal(infos.length, files.length)
for (const info of infos) {
assert.deepEqual(info.metadata, metadata)
const stat = await sandbox.files.getInfo(info.path)
assert.deepEqual(stat.metadata, metadata)
}
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
}
)
sandboxTest('metadata is surfaced when listing', async ({ sandbox }) => {
const dirname = 'metadata_list_dir'
const filename = 'listed.txt'
const metadata = { tag: 'listed' }
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, 'content', { metadata })
const entries = await sandbox.files.list(dirname)
const entry = entries.find((e) => e.name === filename)
assert.isDefined(entry)
assert.deepEqual(entry?.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(dirname)
}
})
sandboxTest('metadata is surfaced after rename', async ({ sandbox }) => {
const oldPath = 'metadata_rename_old.txt'
const newPath = 'metadata_rename_new.txt'
const metadata = { stage: 'renamed' }
await sandbox.files.write(oldPath, 'content', { metadata })
const info = await sandbox.files.rename(oldPath, newPath)
assert.deepEqual(info.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(newPath)
}
})
sandboxTest('overwriting a file clears stale metadata', async ({ sandbox }) => {
const filename = 'metadata_overwrite.txt'
await sandbox.files.write(filename, 'first', {
metadata: { author: 'mish' },
})
// Overwriting without metadata removes the previously stored metadata.
const info = await sandbox.files.write(filename, 'second')
assert.isUndefined(info.metadata)
const stat = await sandbox.files.getInfo(filename)
assert.isUndefined(stat.metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'metadata set via xattrs is surfaced in getInfo',
async ({ sandbox }) => {
const filename = 'metadata_xattr.txt'
await sandbox.files.write(filename, 'content')
const { stdout: filePath } = await sandbox.commands.run(
`realpath ${filename}`
)
// Set an xattr directly in the `user.e2b.` namespace (out-of-band, not via
// the SDK upload); it should surface as metadata (with the namespace prefix
// stripped) when reading the file info.
await sandbox.commands.run(
`python3 -c "import os; os.setxattr('${filePath.trim()}', 'user.e2b.author', b'mish')"`
)
const info = await sandbox.files.getInfo(filename)
assert.deepEqual(info.metadata, { author: 'mish' })
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest('rejects invalid metadata', async ({ sandbox }) => {
const filename = 'invalid_metadata.txt'
// Key with a space is not a valid HTTP header token.
await expect(
sandbox.files.write(filename, 'x', { metadata: { 'bad key': 'value' } })
).rejects.toThrow(InvalidArgumentError)
// Empty key.
await expect(
sandbox.files.write(filename, 'x', { metadata: { '': 'value' } })
).rejects.toThrow(InvalidArgumentError)
// Value with a non-printable / non-ASCII character.
await expect(
sandbox.files.write(filename, 'x', { metadata: { good: 'bad\nvalue' } })
).rejects.toThrow(InvalidArgumentError)
// Trailing newline in value or key.
await expect(
sandbox.files.write(filename, 'x', { metadata: { good: 'value\n' } })
).rejects.toThrow(InvalidArgumentError)
await expect(
sandbox.files.write(filename, 'x', { metadata: { 'key\n': 'value' } })
).rejects.toThrow(InvalidArgumentError)
// The file must not have been created by a rejected write.
assert.isFalse(await sandbox.files.exists(filename))
})
@@ -0,0 +1,126 @@
import { expect, assert } from 'vitest'
import { FileNotFoundError, NotFoundError } from '../../../src'
import { sandboxTest } from '../../setup.js'
sandboxTest('read file', async ({ sandbox }) => {
const filename = 'test_read.txt'
const content = 'Hello, world!'
await sandbox.files.write(filename, content)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
})
sandboxTest('read non-existing file', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(sandbox.files.read(filename)).rejects.toThrowError(
FileNotFoundError
)
})
sandboxTest(
'read non-existing file catches with deprecated NotFoundError',
async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(sandbox.files.read(filename)).rejects.toThrowError(
NotFoundError
)
}
)
sandboxTest('read file as stream', async ({ sandbox }) => {
const filename = 'test_read_stream.txt'
const content = 'Streamed read content. '.repeat(10_000)
await sandbox.files.write(filename, content)
const stream = await sandbox.files.read(filename, { format: 'stream' })
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
const readContent = Buffer.concat(chunks).toString('utf-8')
assert.equal(readContent, content)
})
sandboxTest('read non-existing file as stream', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(
sandbox.files.read(filename, { format: 'stream' })
).rejects.toThrowError(FileNotFoundError)
})
sandboxTest('read empty file in all formats', async ({ sandbox }) => {
const filename = 'empty-file-formats.txt'
await sandbox.commands.run(`touch ${filename}`)
const text = await sandbox.files.read(filename, { format: 'text' })
expect(text).toBe('')
const bytes = await sandbox.files.read(filename, { format: 'bytes' })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(bytes.length).toBe(0)
const blob = await sandbox.files.read(filename, { format: 'blob' })
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe(0)
const stream = await sandbox.files.read(filename, { format: 'stream' })
expect(stream).toBeInstanceOf(ReadableStream)
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
expect(Buffer.concat(chunks).length).toBe(0)
})
sandboxTest('read file as stream', async ({ sandbox }) => {
const filename = 'test_read_stream.txt'
const content = 'Streamed read content. '.repeat(10_000)
await sandbox.files.write(filename, content)
const stream = await sandbox.files.read(filename, { format: 'stream' })
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
const readContent = Buffer.concat(chunks).toString('utf-8')
assert.equal(readContent, content)
})
sandboxTest('read non-existing file as stream', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(
sandbox.files.read(filename, { format: 'stream' })
).rejects.toThrowError(FileNotFoundError)
})
sandboxTest('read empty file in all formats', async ({ sandbox }) => {
const filename = 'empty-file-formats.txt'
await sandbox.commands.run(`touch ${filename}`)
const text = await sandbox.files.read(filename, { format: 'text' })
expect(text).toBe('')
const bytes = await sandbox.files.read(filename, { format: 'bytes' })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(bytes.length).toBe(0)
const blob = await sandbox.files.read(filename, { format: 'blob' })
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe(0)
const stream = await sandbox.files.read(filename, { format: 'stream' })
expect(stream).toBeInstanceOf(ReadableStream)
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
expect(Buffer.concat(chunks).length).toBe(0)
})
@@ -0,0 +1,20 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('remove file', async ({ sandbox }) => {
const filename = 'test_remove.txt'
const content = 'This file will be removed.'
await sandbox.files.write(filename, content)
await sandbox.files.remove(filename)
const exists = await sandbox.files.exists(filename)
assert.isFalse(exists)
})
sandboxTest('remove non-existing file', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await sandbox.files.remove(filename)
})
@@ -0,0 +1,32 @@
import { assert, expect } from 'vitest'
import { FileNotFoundError } from '../../../src'
import { sandboxTest } from '../../setup.js'
sandboxTest('rename file', async ({ sandbox }) => {
const oldFilename = 'test_rename_old.txt'
const newFilename = 'test_rename_new.txt'
const content = 'This file will be renamed.'
await sandbox.files.write(oldFilename, content)
const info = await sandbox.files.rename(oldFilename, newFilename)
assert.equal(info.name, newFilename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${newFilename}`)
const existsOld = await sandbox.files.exists(oldFilename)
const existsNew = await sandbox.files.exists(newFilename)
assert.isFalse(existsOld)
assert.isTrue(existsNew)
const readContent = await sandbox.files.read(newFilename)
assert.equal(readContent, content)
})
sandboxTest('rename non-existing file', async ({ sandbox }) => {
const oldFilename = 'non_existing_file.txt'
const newFilename = 'new_non_existing_file.txt'
await expect(
sandbox.files.rename(oldFilename, newFilename)
).rejects.toThrowError(FileNotFoundError)
})
@@ -0,0 +1,152 @@
import { assert, describe } from 'vitest'
import { sandboxTest, isDebug } from '../../setup'
describe('file signing', () => {
sandboxTest.scoped({
sandboxOpts: {
secure: true,
},
})
sandboxTest.skipIf(isDebug)(
'test access file with expired signing',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world')
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', {
useSignatureExpiration: -10_000,
})
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 401)
assert.deepEqual(JSON.parse(resBody), {
code: 401,
message: 'signature is already expired',
})
}
)
sandboxTest.skipIf(isDebug)(
'test access file with valid signing',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world')
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', {
useSignatureExpiration: 10_000,
})
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.equal(resBody, 'hello world')
}
)
sandboxTest.skipIf(isDebug)(
'test access file with valid signing as root',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world', { user: 'root' })
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', {
user: 'root',
useSignatureExpiration: 10_000,
})
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.equal(resBody, 'hello world')
}
)
sandboxTest.skipIf(isDebug)(
'test upload file with valid signing',
async ({ sandbox }) => {
const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', {
useSignatureExpiration: 10_000,
})
const form = new FormData()
form.append('file', 'file content')
const res = await fetch(fileUrlWithSigning, {
method: 'POST',
body: form,
})
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.deepEqual(JSON.parse(resBody), [
{ name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' },
])
}
)
sandboxTest.skipIf(isDebug)(
'test upload file with valid signing as root user',
async ({ sandbox }) => {
const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', {
user: 'root',
useSignatureExpiration: 10_000,
})
const form = new FormData()
form.append('file', 'file content')
const res = await fetch(fileUrlWithSigning, {
method: 'POST',
body: form,
})
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.deepEqual(JSON.parse(resBody), [
{ name: 'hello.txt', path: '/root/hello.txt', type: 'file' },
])
}
)
sandboxTest.skipIf(isDebug)(
'test upload file with invalid signing',
async ({ sandbox }) => {
const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', {
useSignatureExpiration: -100_000,
})
const form = new FormData()
form.append('file', 'file content')
const res = await fetch(fileUrlWithSigning, {
method: 'POST',
body: form,
})
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 401)
assert.deepEqual(JSON.parse(resBody), {
code: 401,
message: 'signature is already expired',
})
}
)
sandboxTest.skipIf(isDebug)(
'test command run with secured sbx',
async ({ sandbox }) => {
const response = await sandbox.commands.run('echo Hello World!')
assert.equal(response.stdout, 'Hello World!\n')
}
)
})
@@ -0,0 +1,236 @@
import { expect, onTestFinished } from 'vitest'
import { isDebug, sandboxTest } from '../../setup.js'
import {
FileNotFoundError,
FilesystemEvent,
FilesystemEventType,
FileType,
SandboxError,
} from '../../../src'
sandboxTest('watch directory changes', async ({ sandbox }) => {
const dirname = 'test_watch_dir'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, content)
let trigger: () => void
const eventPromise = new Promise<void>((resolve) => {
trigger = resolve
})
const handle = await sandbox.files.watchDir(dirname, async (event) => {
if (event.type === FilesystemEventType.WRITE && event.name === filename) {
trigger()
}
})
await sandbox.files.write(`${dirname}/${filename}`, newContent)
await eventPromise
await handle.stop()
})
sandboxTest('watch recursive directory changes', async ({ sandbox }) => {
const dirname = 'test_recursive_watch_dir'
const nestedDirname = 'test_nested_watch_dir'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(`${dirname}/${nestedDirname}`)
if (isDebug) {
onTestFinished(() => sandbox.files.remove(dirname))
}
await sandbox.files.write(`${dirname}/${nestedDirname}/${filename}`, content)
let trigger: () => void
const eventPromise = new Promise<void>((resolve) => {
trigger = resolve
})
const expectedFileName = `${nestedDirname}/${filename}`
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (
event.type === FilesystemEventType.WRITE &&
event.name === expectedFileName
) {
trigger()
}
},
{
recursive: true,
}
)
await sandbox.files.write(
`${dirname}/${nestedDirname}/${filename}`,
newContent
)
await eventPromise
await handle.stop()
})
sandboxTest(
'watch recursive directory after nested folder addition',
async ({ sandbox }) => {
const dirname = 'test_recursive_watch_dir_add'
const nestedDirname = 'test_nested_watch_dir'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
await sandbox.files.makeDir(dirname)
if (isDebug) {
onTestFinished(() => sandbox.files.remove(dirname))
}
let triggerFile: () => void
let triggerFolder: () => void
const eventFilePromise = new Promise<void>((resolve) => {
triggerFile = resolve
})
const eventFolderPromise = new Promise<void>((resolve) => {
triggerFolder = resolve
})
const expectedFileName = `${nestedDirname}/${filename}`
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (
event.type === FilesystemEventType.WRITE &&
event.name === expectedFileName
) {
triggerFile()
} else if (
event.type === FilesystemEventType.CREATE &&
event.name === nestedDirname
) {
triggerFolder()
}
},
{
recursive: true,
}
)
await sandbox.files.makeDir(`${dirname}/${nestedDirname}`)
await eventFolderPromise
await sandbox.files.write(
`${dirname}/${nestedDirname}/${filename}`,
content
)
await eventFilePromise
await handle.stop()
}
)
sandboxTest('watch directory changes with entry info', async ({ sandbox }) => {
const dirname = 'test_watch_dir_entry'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, content)
let resolveEvent: (event: FilesystemEvent) => void
const eventPromise = new Promise<FilesystemEvent>((resolve) => {
resolveEvent = resolve
})
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (event.type === FilesystemEventType.WRITE && event.name === filename) {
resolveEvent(event)
}
},
{ includeEntry: true }
)
await sandbox.files.write(`${dirname}/${filename}`, newContent)
const event = await eventPromise
// The entry is populated best-effort for events where the path still exists.
expect(event.entry).toBeDefined()
expect(event.entry?.name).toBe(filename)
expect(event.entry?.path).toBe(`/home/user/${dirname}/${filename}`)
expect(event.entry?.type).toBe(FileType.FILE)
await handle.stop()
})
sandboxTest(
'watch directory changes with network mounts allowed',
async ({ sandbox }) => {
const dirname = 'test_watch_dir_network_mounts'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, content)
let trigger: () => void
const eventPromise = new Promise<void>((resolve) => {
trigger = resolve
})
// The flag only lifts the network-mount restriction — watching a regular
// directory must work the same with it enabled.
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (
event.type === FilesystemEventType.WRITE &&
event.name === filename
) {
trigger()
}
},
{ allowNetworkMounts: true }
)
await sandbox.files.write(`${dirname}/${filename}`, newContent)
await eventPromise
await handle.stop()
}
)
sandboxTest('watch non-existing directory', async ({ sandbox }) => {
const dirname = 'non_existing_watch_dir'
await expect(sandbox.files.watchDir(dirname, () => {})).rejects.toThrowError(
FileNotFoundError
)
})
sandboxTest('watch file', async ({ sandbox }) => {
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
await sandbox.files.write(filename, content)
await expect(sandbox.files.watchDir(filename, () => {})).rejects.toThrowError(
SandboxError
)
})
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { EventType } from '../../../src/envd/filesystem/filesystem_pb'
import {
FilesystemEventType,
WatchHandle,
} from '../../../src/sandbox/filesystem/watchHandle'
function filesystemEvent(name: string, type: EventType = EventType.WRITE) {
return {
event: {
case: 'filesystem' as const,
value: { name, type, entry: undefined },
},
}
}
function events(items: ReturnType<typeof filesystemEvent>[]) {
async function* gen() {
for (const item of items) {
yield item as any
}
}
return gen()
}
describe('WatchHandle', () => {
it('awaits an async onEvent before finishing and firing onExit', async () => {
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const onEvent = async () => {
callbackStarted = true
await callbackBlocked
}
let exitCalled = false
const onExit = () => {
exitCalled = true
}
new WatchHandle(
() => {},
events([filesystemEvent('a.txt')]),
onEvent,
onExit
)
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
// onExit must not fire while onEvent is still pending.
expect(exitCalled).toBe(false)
releaseCallback?.()
await vi.waitFor(() => {
expect(exitCalled).toBe(true)
})
})
it('routes a rejecting async onEvent to onExit and stops the watch', async () => {
const error = new Error('callback failed')
let stopped = false
let exitErr: Error | undefined | 'unset' = 'unset'
new WatchHandle(
() => {
stopped = true
},
events([filesystemEvent('a.txt')]),
async () => {
throw error
},
(err) => {
exitErr = err ?? undefined
}
)
await vi.waitFor(() => {
expect(exitErr).not.toBe('unset')
})
expect(exitErr).toBe(error)
expect(stopped).toBe(true)
})
it('calls onExit with no argument when the stream ends cleanly', async () => {
const received: FilesystemEventType[] = []
let exitArgs: unknown[] | undefined
new WatchHandle(
() => {},
events([filesystemEvent('a.txt')]),
(event) => {
received.push(event.type)
},
(...args: unknown[]) => {
exitArgs = args
}
)
await vi.waitFor(() => {
expect(exitArgs).toBeDefined()
})
// No error is passed on a clean exit — onExit is called with zero args.
expect(exitArgs).toEqual([])
expect(received).toEqual([FilesystemEventType.WRITE])
})
it('does not let a throwing onExit become an unhandled rejection', async () => {
let stopped = false
new WatchHandle(
() => {
stopped = true
},
events([filesystemEvent('a.txt')]),
() => {},
() => {
throw new Error('onExit failed')
}
)
// handleStop runs after onExit, so observing the stop confirms the loop
// ran the throwing onExit to completion. A leaked rejection would fail the
// test run instead.
await vi.waitFor(() => {
expect(stopped).toBe(true)
})
})
})

Some files were not shown because too many files have changed in this diff Show More