Skip to content

Commit 2a5fa86

Browse files
committed
feat: add ExecStream class, dispose guard, and tsup npm packaging
1 parent 82ca87d commit 2a5fa86

8 files changed

Lines changed: 434 additions & 51 deletions

File tree

bun.lock

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

packages/sdk-ts/package.json

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
11
{
22
"name": "@sandchest/sdk",
3-
"version": "0.0.1",
3+
"version": "0.1.0",
4+
"description": "Sandchest SDK — isolated Linux sandbox environments for AI agent code execution",
45
"type": "module",
5-
"main": "./dist/index.js",
6+
"main": "./dist/index.cjs",
7+
"module": "./dist/index.js",
68
"types": "./dist/index.d.ts",
79
"exports": {
810
".": {
9-
"import": "./dist/index.js",
10-
"types": "./dist/index.d.ts"
11+
"import": {
12+
"types": "./dist/index.d.ts",
13+
"default": "./dist/index.js"
14+
},
15+
"require": {
16+
"types": "./dist/index.d.cts",
17+
"default": "./dist/index.cjs"
18+
}
1119
}
1220
},
21+
"files": ["dist"],
22+
"keywords": ["sandbox", "firecracker", "microvm", "code-execution", "mcp", "ai-agent"],
23+
"license": "MIT",
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/sandchest/sandchest",
27+
"directory": "packages/sdk-ts"
28+
},
29+
"engines": {
30+
"node": ">=20"
31+
},
1332
"scripts": {
14-
"build": "tsc",
33+
"build": "tsup",
1534
"typecheck": "tsc --noEmit",
1635
"lint": "eslint src/",
1736
"test": "bun test"
@@ -21,6 +40,7 @@
2140
},
2241
"devDependencies": {
2342
"@types/bun": "^1.3.9",
24-
"@types/node": "^25.3.0"
43+
"@types/node": "^25.3.0",
44+
"tsup": "^8.5.1"
2545
}
2646
}

packages/sdk-ts/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { Sandchest } from './client.js'
22
export { Sandbox } from './sandbox.js'
33
export { Session } from './session.js'
4+
export { ExecStream } from './stream.js'
45
export {
56
SandchestError,
67
NotFoundError,
@@ -27,3 +28,4 @@ export type {
2728
ArtifactOperations,
2829
SessionManager,
2930
} from './types.js'
31+
export type { ExecStreamEvent } from '@sandchest/contract'

packages/sdk-ts/src/sandbox.test.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
22
import { Sandbox } from './sandbox.js'
33
import { Session } from './session.js'
4+
import { ExecStream } from './stream.js'
45
import { HttpClient } from './http.js'
56
import { TimeoutError } from './errors.js'
67

@@ -120,7 +121,7 @@ describe('Sandbox', () => {
120121
})
121122

122123
describe('exec (streaming)', () => {
123-
test('returns async iterable of SSE events', async () => {
124+
test('returns ExecStream that yields SSE events', async () => {
124125
let callCount = 0
125126
globalThis.fetch = mock(async () => {
126127
callCount++
@@ -135,8 +136,13 @@ describe('Sandbox', () => {
135136
}) as unknown as typeof fetch
136137

137138
const sandbox = new Sandbox('sb_x', 'running', 'https://replay.sandchest.com/sb_x', createMockHttp())
139+
const stream = await sandbox.exec('echo hello', { stream: true })
140+
141+
expect(stream).toBeInstanceOf(ExecStream)
142+
expect(stream.execId).toBe('ex_s1')
143+
138144
const events = []
139-
for await (const event of sandbox.exec('echo hello', { stream: true })) {
145+
for await (const event of stream) {
140146
events.push(event)
141147
}
142148

@@ -145,6 +151,31 @@ describe('Sandbox', () => {
145151
expect(events[1]).toEqual({ seq: 2, t: 'stderr', data: 'warn\n' })
146152
expect(events[2]!.t).toBe('exit')
147153
})
154+
155+
test('ExecStream.collect() returns aggregated result', async () => {
156+
let callCount = 0
157+
globalThis.fetch = mock(async () => {
158+
callCount++
159+
if (callCount === 1) {
160+
return jsonResponse({ exec_id: 'ex_c1', status: 'queued' }, 202)
161+
}
162+
return sseResponse([
163+
{ data: '{"seq":1,"t":"stdout","data":"out\\n"}' },
164+
{ data: '{"seq":2,"t":"stderr","data":"err\\n"}' },
165+
{ data: '{"seq":3,"t":"exit","code":0,"duration_ms":30,"resource_usage":{"cpu_ms":5,"peak_memory_bytes":512}}' },
166+
])
167+
}) as unknown as typeof fetch
168+
169+
const sandbox = new Sandbox('sb_x', 'running', 'https://replay.sandchest.com/sb_x', createMockHttp())
170+
const stream = await sandbox.exec('echo hello', { stream: true })
171+
const result = await stream.collect()
172+
173+
expect(result.execId).toBe('ex_c1')
174+
expect(result.stdout).toBe('out\n')
175+
expect(result.stderr).toBe('err\n')
176+
expect(result.exitCode).toBe(0)
177+
expect(result.durationMs).toBe(30)
178+
})
148179
})
149180

150181
describe('exec (with callbacks)', () => {
@@ -356,7 +387,7 @@ describe('Sandbox', () => {
356387
})
357388

358389
describe('Symbol.asyncDispose', () => {
359-
test('calls stop()', async () => {
390+
test('calls stop() when status is running', async () => {
360391
globalThis.fetch = mock(async () =>
361392
jsonResponse({ sandbox_id: 'sb_x', status: 'stopping' }, 202),
362393
) as unknown as typeof fetch
@@ -365,6 +396,33 @@ describe('Sandbox', () => {
365396
await sandbox[Symbol.asyncDispose]()
366397
expect(sandbox.status).toBe('stopping')
367398
})
399+
400+
test('skips stop() when status is stopped', async () => {
401+
let fetchCalled = false
402+
globalThis.fetch = mock(async () => {
403+
fetchCalled = true
404+
return jsonResponse({ sandbox_id: 'sb_x', status: 'stopped' })
405+
}) as unknown as typeof fetch
406+
407+
const sandbox = new Sandbox('sb_x', 'stopped', 'https://replay.sandchest.com/sb_x', createMockHttp())
408+
await sandbox[Symbol.asyncDispose]()
409+
410+
expect(fetchCalled).toBe(false)
411+
expect(sandbox.status).toBe('stopped')
412+
})
413+
414+
test('skips stop() when status is deleted', async () => {
415+
let fetchCalled = false
416+
globalThis.fetch = mock(async () => {
417+
fetchCalled = true
418+
return jsonResponse({ sandbox_id: 'sb_x', status: 'deleted' })
419+
}) as unknown as typeof fetch
420+
421+
const sandbox = new Sandbox('sb_x', 'deleted', 'https://replay.sandchest.com/sb_x', createMockHttp())
422+
await sandbox[Symbol.asyncDispose]()
423+
424+
expect(fetchCalled).toBe(false)
425+
})
368426
})
369427

370428
describe('fs operations', () => {

packages/sdk-ts/src/sandbox.ts

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -28,41 +28,11 @@ import type {
2828
} from './types.js'
2929
import { Session } from './session.js'
3030
import { TimeoutError } from './errors.js'
31+
import { parseSSE, ExecStream } from './stream.js'
3132

3233
const WAIT_READY_DEFAULT_TIMEOUT = 120_000
3334
const WAIT_READY_POLL_INTERVAL = 1_000
3435

35-
/** Parse SSE events from a streaming Response. */
36-
async function* parseSSE<T>(response: Response): AsyncGenerator<T> {
37-
const reader = response.body!.getReader()
38-
const decoder = new TextDecoder()
39-
let buffer = ''
40-
41-
try {
42-
while (true) {
43-
const { done, value } = await reader.read()
44-
if (done) break
45-
46-
buffer += decoder.decode(value, { stream: true })
47-
const parts = buffer.split('\n\n')
48-
buffer = parts.pop()!
49-
50-
for (const part of parts) {
51-
for (const line of part.split('\n')) {
52-
if (line.startsWith('data: ')) {
53-
const data = line.slice(6)
54-
if (data) {
55-
yield JSON.parse(data) as T
56-
}
57-
}
58-
}
59-
}
60-
}
61-
} finally {
62-
reader.releaseLock()
63-
}
64-
}
65-
6636
/**
6737
* A Sandchest sandbox — an isolated Firecracker microVM.
6838
* All operations hang off this instance. No ID passing needed.
@@ -169,12 +139,12 @@ export class Sandbox {
169139

170140
/** Execute a command (blocking, returns result). */
171141
exec(cmd: string | string[], options?: ExecOptions): Promise<ExecResult>
172-
/** Execute a command (streaming, returns async iterable of events). */
173-
exec(cmd: string | string[], options: StreamExecOptions): AsyncIterable<ExecStreamEvent>
142+
/** Execute a command (streaming, returns ExecStream). */
143+
exec(cmd: string | string[], options: StreamExecOptions): Promise<ExecStream>
174144
exec(
175145
cmd: string | string[],
176146
options?: ExecOptions | StreamExecOptions,
177-
): Promise<ExecResult> | AsyncIterable<ExecStreamEvent> {
147+
): Promise<ExecResult> | Promise<ExecStream> {
178148
if (options && 'stream' in options && options.stream === true) {
179149
return this._execStream(cmd, options)
180150
}
@@ -251,9 +221,11 @@ export class Sandbox {
251221
}
252222
}
253223

254-
/** Auto-cleanup via Explicit Resource Management. Calls stop(). */
224+
/** Auto-cleanup via Explicit Resource Management. Calls stop() if running. */
255225
async [Symbol.asyncDispose](): Promise<void> {
256-
await this.stop()
226+
if (this.status === 'running') {
227+
await this.stop()
228+
}
257229
}
258230

259231
private async _execBlocking(
@@ -332,10 +304,10 @@ export class Sandbox {
332304
return { execId: asyncRes.exec_id, exitCode, stdout, stderr, durationMs }
333305
}
334306

335-
private async *_execStream(
307+
private async _execStream(
336308
cmd: string | string[],
337309
options: StreamExecOptions,
338-
): AsyncIterable<ExecStreamEvent> {
310+
): Promise<ExecStream> {
339311
const asyncRes = await this._http.request<ExecAsyncResponse>({
340312
method: 'POST',
341313
path: `/v1/sandboxes/${this.id}/exec`,
@@ -354,6 +326,6 @@ export class Sandbox {
354326
headers: { Accept: 'text/event-stream' },
355327
})
356328

357-
yield* parseSSE<ExecStreamEvent>(response)
329+
return new ExecStream(asyncRes.exec_id, parseSSE<ExecStreamEvent>(response))
358330
}
359331
}

packages/sdk-ts/src/stream.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { ExecStream, parseSSE } from './stream.js'
3+
import type { ExecStreamEvent } from '@sandchest/contract'
4+
5+
function sseResponse(events: Array<{ data: string }>): Response {
6+
const text = events.map((e) => `data: ${e.data}\n\n`).join('')
7+
return new Response(text, {
8+
status: 200,
9+
headers: { 'Content-Type': 'text/event-stream' },
10+
})
11+
}
12+
13+
describe('parseSSE', () => {
14+
test('parses SSE data lines into typed objects', async () => {
15+
const response = sseResponse([
16+
{ data: '{"seq":1,"t":"stdout","data":"hello\\n"}' },
17+
{ data: '{"seq":2,"t":"exit","code":0,"duration_ms":10,"resource_usage":{"cpu_ms":5,"peak_memory_bytes":256}}' },
18+
])
19+
20+
const events: ExecStreamEvent[] = []
21+
for await (const event of parseSSE<ExecStreamEvent>(response)) {
22+
events.push(event)
23+
}
24+
25+
expect(events).toHaveLength(2)
26+
expect(events[0]!.t).toBe('stdout')
27+
expect(events[1]!.t).toBe('exit')
28+
})
29+
30+
test('skips empty data lines', async () => {
31+
const text = 'data: \n\ndata: {"seq":1,"t":"stdout","data":"ok"}\n\n'
32+
const response = new Response(text, { status: 200 })
33+
34+
const events: unknown[] = []
35+
for await (const event of parseSSE(response)) {
36+
events.push(event)
37+
}
38+
39+
expect(events).toHaveLength(1)
40+
})
41+
42+
test('handles chunked delivery across event boundaries', async () => {
43+
const fullText = 'data: {"seq":1,"t":"stdout","data":"a"}\n\ndata: {"seq":2,"t":"stdout","data":"b"}\n\n'
44+
const encoder = new TextEncoder()
45+
const bytes = encoder.encode(fullText)
46+
const chunks: Uint8Array[] = []
47+
for (let i = 0; i < bytes.length; i += 5) {
48+
chunks.push(bytes.slice(i, i + 5))
49+
}
50+
51+
const stream = new ReadableStream<Uint8Array>({
52+
start(controller) {
53+
for (const chunk of chunks) {
54+
controller.enqueue(chunk)
55+
}
56+
controller.close()
57+
},
58+
})
59+
60+
const response = new Response(stream, { status: 200 })
61+
const events: ExecStreamEvent[] = []
62+
for await (const event of parseSSE<ExecStreamEvent>(response)) {
63+
events.push(event)
64+
}
65+
66+
expect(events).toHaveLength(2)
67+
})
68+
})
69+
70+
describe('ExecStream', () => {
71+
function makeGenerator(events: ExecStreamEvent[]): AsyncGenerator<ExecStreamEvent> {
72+
return (async function* () {
73+
for (const event of events) {
74+
yield event
75+
}
76+
})()
77+
}
78+
79+
test('exposes execId', () => {
80+
const stream = new ExecStream('ex_123', makeGenerator([]))
81+
expect(stream.execId).toBe('ex_123')
82+
})
83+
84+
test('is async iterable', async () => {
85+
const events: ExecStreamEvent[] = [
86+
{ seq: 1, t: 'stdout', data: 'hello\n' },
87+
{ seq: 2, t: 'exit', code: 0, duration_ms: 10, resource_usage: { cpu_ms: 5, peak_memory_bytes: 256 } },
88+
]
89+
90+
const stream = new ExecStream('ex_123', makeGenerator(events))
91+
const collected: ExecStreamEvent[] = []
92+
for await (const event of stream) {
93+
collected.push(event)
94+
}
95+
96+
expect(collected).toHaveLength(2)
97+
expect(collected[0]!.t).toBe('stdout')
98+
expect(collected[1]!.t).toBe('exit')
99+
})
100+
101+
test('collect() returns aggregated ExecResult', async () => {
102+
const events: ExecStreamEvent[] = [
103+
{ seq: 1, t: 'stdout', data: 'line1\n' },
104+
{ seq: 2, t: 'stderr', data: 'warn\n' },
105+
{ seq: 3, t: 'stdout', data: 'line2\n' },
106+
{ seq: 4, t: 'exit', code: 0, duration_ms: 50, resource_usage: { cpu_ms: 10, peak_memory_bytes: 1024 } },
107+
]
108+
109+
const stream = new ExecStream('ex_456', makeGenerator(events))
110+
const result = await stream.collect()
111+
112+
expect(result.execId).toBe('ex_456')
113+
expect(result.exitCode).toBe(0)
114+
expect(result.stdout).toBe('line1\nline2\n')
115+
expect(result.stderr).toBe('warn\n')
116+
expect(result.durationMs).toBe(50)
117+
})
118+
119+
test('collect() handles stream with no output', async () => {
120+
const events: ExecStreamEvent[] = [
121+
{ seq: 1, t: 'exit', code: 1, duration_ms: 5, resource_usage: { cpu_ms: 1, peak_memory_bytes: 128 } },
122+
]
123+
124+
const stream = new ExecStream('ex_789', makeGenerator(events))
125+
const result = await stream.collect()
126+
127+
expect(result.execId).toBe('ex_789')
128+
expect(result.exitCode).toBe(1)
129+
expect(result.stdout).toBe('')
130+
expect(result.stderr).toBe('')
131+
expect(result.durationMs).toBe(5)
132+
})
133+
})

0 commit comments

Comments
 (0)