@sandbank.dev/boxlite 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @sandbank.dev/boxlite
2
+
3
+ > BoxLite bare-metal micro-VM sandbox adapter for [Sandbank](../../README.md).
4
+
5
+ BoxLite provides lightweight micro-VMs using libkrun (Hypervisor.framework on macOS, KVM on Linux). This adapter supports two modes of operation:
6
+
7
+ - **Remote mode** — Connect to a [BoxRun](https://github.com/nicholasgasior/boxlite) REST API server
8
+ - **Local mode** — Run VMs directly on the local machine via the boxlite Python SDK
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add @sandbank.dev/core @sandbank.dev/boxlite
14
+ ```
15
+
16
+ For local mode, you also need the boxlite Python package:
17
+
18
+ ```bash
19
+ pip install boxlite
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### Remote mode (BoxRun REST API)
25
+
26
+ ```typescript
27
+ import { createProvider } from '@sandbank.dev/core'
28
+ import { BoxLiteAdapter } from '@sandbank.dev/boxlite'
29
+
30
+ const provider = createProvider(
31
+ new BoxLiteAdapter({
32
+ apiUrl: 'http://localhost:9090',
33
+ apiToken: process.env.BOXLITE_API_TOKEN,
34
+ prefix: 'default', // multi-tenant prefix (optional)
35
+ })
36
+ )
37
+
38
+ const sandbox = await provider.create({
39
+ image: 'ubuntu:24.04',
40
+ resources: { cpu: 2, memory: 1024 },
41
+ })
42
+
43
+ const { stdout } = await sandbox.exec('uname -a')
44
+ await provider.destroy(sandbox.id)
45
+ ```
46
+
47
+ ### Local mode (Python SDK)
48
+
49
+ ```typescript
50
+ import { createProvider } from '@sandbank.dev/core'
51
+ import { BoxLiteAdapter } from '@sandbank.dev/boxlite'
52
+
53
+ const provider = createProvider(
54
+ new BoxLiteAdapter({
55
+ mode: 'local',
56
+ pythonPath: '/usr/bin/python3', // optional, defaults to 'python3'
57
+ boxliteHome: '~/.boxlite', // optional
58
+ })
59
+ )
60
+
61
+ const sandbox = await provider.create({ image: 'ubuntu:24.04' })
62
+ const { stdout } = await sandbox.exec('echo hello')
63
+ await provider.destroy(sandbox.id)
64
+ ```
65
+
66
+ ### OAuth2 authentication (remote mode)
67
+
68
+ ```typescript
69
+ new BoxLiteAdapter({
70
+ apiUrl: 'http://boxrun.example.com:9090',
71
+ clientId: process.env.BOXLITE_CLIENT_ID,
72
+ clientSecret: process.env.BOXLITE_CLIENT_SECRET,
73
+ })
74
+ ```
75
+
76
+ ## Capabilities
77
+
78
+ | Capability | Remote | Local |
79
+ |------------|:------:|:-----:|
80
+ | `exec.stream` | ✅ | ✅ |
81
+ | `terminal` | ✅ | ✅ |
82
+ | `sleep` | ✅ | ✅ |
83
+ | `port.expose` | ✅ | ✅ |
84
+ | `snapshot` | ✅ | — |
85
+
86
+ ## Characteristics
87
+
88
+ - **Runtime:** Micro-VM (libkrun)
89
+ - **Cold start:** ~3-5s
90
+ - **File I/O:** tar archive upload/download
91
+ - **Hypervisor:** Hypervisor.framework (macOS) / KVM (Linux)
92
+ - **Local dependency:** `boxlite` Python package (local mode only)
93
+
94
+ ## Architecture
95
+
96
+ ```
97
+ ┌─────────────────────────────────────┐
98
+ │ BoxLiteAdapter │
99
+ │ mode: 'remote' | 'local' │
100
+ ├──────────────┬──────────────────────┤
101
+ │ REST Client │ Local Client │
102
+ │ (fetch) │ (Python subprocess) │
103
+ ├──────────────┼──────────────────────┤
104
+ │ BoxRun API │ boxlite Python SDK │
105
+ │ (HTTP/JSON) │ (JSON-line bridge) │
106
+ └──────────────┴──────────────────────┘
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT
package/dist/adapter.d.ts CHANGED
@@ -5,12 +5,14 @@ export declare class BoxLiteAdapter implements SandboxAdapter {
5
5
  readonly capabilities: ReadonlySet<Capability>;
6
6
  private readonly client;
7
7
  private readonly config;
8
- /** Track port mappings per box: boxId → Map<guestPort, hostPort> */
8
+ private readonly host;
9
9
  private readonly portMaps;
10
10
  constructor(config: BoxLiteAdapterConfig);
11
11
  createSandbox(config: CreateConfig): Promise<AdapterSandbox>;
12
12
  getSandbox(id: string): Promise<AdapterSandbox>;
13
13
  listSandboxes(filter?: ListFilter): Promise<SandboxInfo[]>;
14
14
  destroySandbox(id: string): Promise<void>;
15
+ /** Dispose the adapter and clean up resources (e.g. Python bridge process) */
16
+ dispose(): Promise<void>;
15
17
  }
16
18
  //# sourceMappingURL=adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EACV,YAAY,EAGZ,UAAU,EACV,cAAc,EACd,WAAW,EAIZ,MAAM,oBAAoB,CAAA;AAG3B,OAAO,KAAK,EAAE,oBAAoB,EAAc,MAAM,YAAY,CAAA;AAwKlE,qBAAa,cAAe,YAAW,cAAc;IACnD,QAAQ,CAAC,IAAI,aAAY;IACzB,QAAQ,CAAC,YAAY,EAAE,WAAW,CAAC,UAAU,CAAC,CAM5C;IAEF,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;IAC7C,oEAAoE;IACpE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyC;gBAEtD,MAAM,EAAE,oBAAoB;IAKlC,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC;IA0C5D,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAW/C,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IA0B1D,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAShD"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EACV,YAAY,EAGZ,UAAU,EACV,cAAc,EACd,WAAW,EAIZ,MAAM,oBAAoB,CAAA;AAI3B,OAAO,KAAK,EAAE,oBAAoB,EAA6B,MAAM,YAAY,CAAA;AAoKjF,qBAAa,cAAe,YAAW,cAAc;IACnD,QAAQ,CAAC,IAAI,aAAY;IACzB,QAAQ,CAAC,YAAY,EAAE,WAAW,CAAC,UAAU,CAAC,CAAA;IAE9C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyC;gBAEtD,MAAM,EAAE,oBAAoB;IAalC,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC;IAuC5D,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAW/C,aAAa,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IA0B1D,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,8EAA8E;IACxE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAG/B"}
package/dist/adapter.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { SandboxNotFoundError, ProviderError } from '@sandbank.dev/core';
2
- import { createBoxLiteClient } from './client.js';
2
+ import { createBoxLiteRestClient } from './client.js';
3
+ import { createBoxLiteLocalClient } from './local-client.js';
3
4
  /** Map BoxLite box status to Sandbank SandboxState */
4
5
  function mapState(status) {
5
6
  switch (status) {
@@ -15,26 +16,26 @@ function mapState(status) {
15
16
  return 'error';
16
17
  }
17
18
  }
18
- /** Extract host from API URL for port exposure */
19
- function getApiHost(apiUrl) {
19
+ /** Resolve the host used for port exposure and terminal URLs */
20
+ function resolveHost(config) {
21
+ if (config.mode === 'local')
22
+ return '127.0.0.1';
20
23
  try {
21
- const url = new URL(apiUrl);
22
- return url.hostname;
24
+ return new URL(config.apiUrl).hostname;
23
25
  }
24
26
  catch {
25
- return apiUrl;
27
+ return config.apiUrl;
26
28
  }
27
29
  }
28
30
  /** Wrap a BoxLite box into an AdapterSandbox */
29
- function wrapBox(box, client, config, portMappings) {
31
+ function wrapBox(box, client, host, portMappings) {
30
32
  return {
31
- get id() { return box.box_id; },
33
+ get id() { return box.id; },
32
34
  get state() { return mapState(box.status); },
33
35
  get createdAt() { return box.created_at; },
34
36
  async exec(command, options) {
35
- const result = await client.exec(box.box_id, {
36
- command: 'bash',
37
- args: ['-c', command],
37
+ const result = await client.exec(box.id, {
38
+ cmd: ['bash', '-c', command],
38
39
  working_dir: options?.cwd,
39
40
  timeout_seconds: options?.timeout ? Math.ceil(options.timeout / 1000) : undefined,
40
41
  });
@@ -45,9 +46,8 @@ function wrapBox(box, client, config, portMappings) {
45
46
  };
46
47
  },
47
48
  async execStream(command, options) {
48
- return client.execStream(box.box_id, {
49
- command: 'bash',
50
- args: ['-c', command],
49
+ return client.execStream(box.id, {
50
+ cmd: ['bash', '-c', command],
51
51
  working_dir: options?.cwd,
52
52
  timeout_seconds: options?.timeout ? Math.ceil(options.timeout / 1000) : undefined,
53
53
  });
@@ -58,7 +58,6 @@ function wrapBox(box, client, config, portMappings) {
58
58
  data = archive;
59
59
  }
60
60
  else {
61
- // Collect ReadableStream into Uint8Array
62
61
  const reader = archive.getReader();
63
62
  const chunks = [];
64
63
  while (true) {
@@ -75,40 +74,37 @@ function wrapBox(box, client, config, portMappings) {
75
74
  offset += chunk.length;
76
75
  }
77
76
  }
78
- await client.uploadFiles(box.box_id, destDir ?? '/', data);
77
+ await client.uploadFiles(box.id, destDir ?? '/', data);
79
78
  },
80
79
  async downloadArchive(srcDir) {
81
- return client.downloadFiles(box.box_id, srcDir ?? '/');
80
+ return client.downloadFiles(box.id, srcDir ?? '/');
82
81
  },
83
82
  async sleep() {
84
- await client.stopBox(box.box_id);
83
+ await client.stopBox(box.id);
85
84
  },
86
85
  async wake() {
87
- await client.startBox(box.box_id);
86
+ await client.startBox(box.id);
88
87
  },
89
88
  async createSnapshot(name) {
90
89
  const snapshotName = name ?? `snap-${Date.now()}`;
91
- await client.createSnapshot(box.box_id, snapshotName);
90
+ await client.createSnapshot(box.id, snapshotName);
92
91
  return { snapshotId: snapshotName };
93
92
  },
94
93
  async restoreSnapshot(snapshotId) {
95
- await client.restoreSnapshot(box.box_id, snapshotId);
94
+ await client.restoreSnapshot(box.id, snapshotId);
96
95
  },
97
96
  async exposePort(port) {
98
97
  const hostPort = portMappings.get(port) ?? port;
99
- const host = getApiHost(config.apiUrl);
100
98
  return { url: `http://${host}:${hostPort}` };
101
99
  },
102
100
  async startTerminal(options) {
103
101
  const port = 7681;
104
102
  const shell = options?.shell ?? '/bin/bash';
105
103
  const ttydBase = 'https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd';
106
- // 1. Ensure ttyd is available
107
- const check = await client.exec(box.box_id, { command: 'which', args: ['ttyd'] });
104
+ const check = await client.exec(box.id, { cmd: ['which', 'ttyd'] });
108
105
  if (check.exitCode !== 0) {
109
- await client.exec(box.box_id, {
110
- command: 'bash',
111
- args: ['-c',
106
+ await client.exec(box.id, {
107
+ cmd: ['bash', '-c',
112
108
  `ARCH=$(uname -m); case "$ARCH" in aarch64|arm64) ARCH=aarch64;; x86_64) ARCH=x86_64;; *) echo "Unsupported arch: $ARCH" >&2; exit 1;; esac; `
113
109
  + `TTYD_URL="${ttydBase}.$ARCH"; `
114
110
  + `command -v curl > /dev/null && curl -sL "$TTYD_URL" -o /usr/local/bin/ttyd`
@@ -116,23 +112,17 @@ function wrapBox(box, client, config, portMappings) {
116
112
  + ` || { apt-get update -qq && apt-get install -y -qq wget > /dev/null && wget -qO /usr/local/bin/ttyd "$TTYD_URL"; }`,
117
113
  ],
118
114
  });
119
- await client.exec(box.box_id, {
120
- command: 'chmod',
121
- args: ['+x', '/usr/local/bin/ttyd'],
115
+ await client.exec(box.id, {
116
+ cmd: ['chmod', '+x', '/usr/local/bin/ttyd'],
122
117
  });
123
118
  }
124
- // 2. Start ttyd in background (-W enables write)
125
- await client.exec(box.box_id, {
126
- command: 'bash',
127
- args: ['-c', `nohup ttyd -W -p ${port} '${shell.replace(/'/g, "'\\''")}' > /dev/null 2>&1 &`],
119
+ await client.exec(box.id, {
120
+ cmd: ['bash', '-c', `nohup ttyd -W -p ${port} '${shell.replace(/'/g, "'\\''")}' > /dev/null 2>&1 &`],
128
121
  });
129
- // 3. Wait for ttyd to be ready
130
- await client.exec(box.box_id, {
131
- command: 'bash',
132
- args: ['-c', `for i in $(seq 1 20); do pgrep -x ttyd > /dev/null && break || sleep 0.5; done`],
122
+ await client.exec(box.id, {
123
+ cmd: ['bash', '-c', `for i in $(seq 1 20); do pgrep -x ttyd > /dev/null && break || sleep 0.5; done`],
133
124
  });
134
125
  const hostPort = portMappings.get(port) ?? port;
135
- const host = getApiHost(config.apiUrl);
136
126
  return {
137
127
  url: `ws://${host}:${hostPort}/ws`,
138
128
  port,
@@ -145,55 +135,60 @@ function isNotFound(err) {
145
135
  const msg = err instanceof Error ? err.message : String(err);
146
136
  return msg.includes('404') || msg.includes('not found') || msg.includes('Not Found');
147
137
  }
138
+ /** Create the appropriate client based on config mode */
139
+ function createClient(config) {
140
+ if (config.mode === 'local') {
141
+ return createBoxLiteLocalClient(config);
142
+ }
143
+ return createBoxLiteRestClient(config);
144
+ }
148
145
  export class BoxLiteAdapter {
149
146
  name = 'boxlite';
150
- capabilities = new Set([
151
- 'exec.stream',
152
- 'terminal',
153
- 'sleep',
154
- 'snapshot',
155
- 'port.expose',
156
- ]);
147
+ capabilities;
157
148
  client;
158
149
  config;
159
- /** Track port mappings per box: boxId → Map<guestPort, hostPort> */
150
+ host;
160
151
  portMaps = new Map();
161
152
  constructor(config) {
162
153
  this.config = config;
163
- this.client = createBoxLiteClient(config);
154
+ this.host = resolveHost(config);
155
+ this.client = createClient(config);
156
+ // Local mode: snapshots not supported yet
157
+ const caps = ['exec.stream', 'terminal', 'sleep', 'port.expose'];
158
+ if (config.mode !== 'local') {
159
+ caps.push('snapshot');
160
+ }
161
+ this.capabilities = new Set(caps);
164
162
  }
165
163
  async createSandbox(config) {
166
164
  try {
167
165
  const box = await this.client.createBox({
168
166
  image: config.image,
169
- cpus: config.resources?.cpu,
170
- memory_mib: config.resources?.memory,
167
+ cpu: config.resources?.cpu,
168
+ memory_mb: config.resources?.memory,
171
169
  env: config.env,
172
170
  auto_remove: false,
173
171
  });
174
- // Store port mappings if they were specified at creation
175
172
  const portMap = new Map();
176
- this.portMaps.set(box.box_id, portMap);
177
- // Start the box if it was created in configured state
173
+ this.portMaps.set(box.id, portMap);
178
174
  if (box.status === 'configured' || box.status === 'stopped') {
179
- await this.client.startBox(box.box_id);
175
+ await this.client.startBox(box.id);
180
176
  }
181
- // Wait for box to be running (timeout is in seconds per CreateConfig docs)
182
177
  const timeoutSec = config.timeout ?? 30;
183
178
  const maxAttempts = Math.max(1, timeoutSec);
184
179
  let current = box;
185
180
  for (let i = 0; i < maxAttempts; i++) {
186
- current = await this.client.getBox(box.box_id);
181
+ current = await this.client.getBox(box.id);
187
182
  if (current.status === 'running')
188
183
  break;
189
184
  await new Promise(r => setTimeout(r, 1000));
190
185
  }
191
186
  if (current.status !== 'running') {
192
- await this.client.deleteBox(box.box_id, true).catch(() => { });
193
- this.portMaps.delete(box.box_id);
187
+ await this.client.deleteBox(box.id, true).catch(() => { });
188
+ this.portMaps.delete(box.id);
194
189
  throw new ProviderError('boxlite', new Error(`Sandbox failed to start within ${timeoutSec}s (status: ${current.status})`));
195
190
  }
196
- return wrapBox(current, this.client, this.config, portMap);
191
+ return wrapBox(current, this.client, this.host, portMap);
197
192
  }
198
193
  catch (err) {
199
194
  if (err instanceof ProviderError)
@@ -205,7 +200,7 @@ export class BoxLiteAdapter {
205
200
  try {
206
201
  const box = await this.client.getBox(id);
207
202
  const portMap = this.portMaps.get(id) ?? new Map();
208
- return wrapBox(box, this.client, this.config, portMap);
203
+ return wrapBox(box, this.client, this.host, portMap);
209
204
  }
210
205
  catch (err) {
211
206
  if (isNotFound(err))
@@ -217,7 +212,7 @@ export class BoxLiteAdapter {
217
212
  try {
218
213
  const boxes = await this.client.listBoxes();
219
214
  let infos = boxes.map((b) => ({
220
- id: b.box_id,
215
+ id: b.id,
221
216
  state: mapState(b.status),
222
217
  createdAt: b.created_at,
223
218
  image: b.image,
@@ -246,4 +241,8 @@ export class BoxLiteAdapter {
246
241
  throw new ProviderError('boxlite', err, id);
247
242
  }
248
243
  }
244
+ /** Dispose the adapter and clean up resources (e.g. Python bridge process) */
245
+ async dispose() {
246
+ await this.client.dispose?.();
247
+ }
249
248
  }
package/dist/client.d.ts CHANGED
@@ -1,23 +1,7 @@
1
- import type { BoxLiteAdapterConfig, BoxLiteBox, BoxLiteCreateParams, BoxLiteExecRequest, BoxLiteSnapshot } from './types.js';
2
- export declare function createBoxLiteClient(config: BoxLiteAdapterConfig): {
3
- createBox(params: BoxLiteCreateParams): Promise<BoxLiteBox>;
4
- getBox(boxId: string): Promise<BoxLiteBox>;
5
- listBoxes(status?: string, pageSize?: number): Promise<BoxLiteBox[]>;
6
- deleteBox(boxId: string, force?: boolean): Promise<void>;
7
- startBox(boxId: string): Promise<void>;
8
- stopBox(boxId: string): Promise<void>;
9
- exec(boxId: string, req: BoxLiteExecRequest): Promise<{
10
- stdout: string;
11
- stderr: string;
12
- exitCode: number;
13
- }>;
14
- execStream(boxId: string, req: BoxLiteExecRequest): Promise<ReadableStream<Uint8Array>>;
15
- uploadFiles(boxId: string, path: string, tarData: Uint8Array): Promise<void>;
16
- downloadFiles(boxId: string, path: string): Promise<ReadableStream<Uint8Array>>;
17
- createSnapshot(boxId: string, name: string): Promise<BoxLiteSnapshot>;
18
- restoreSnapshot(boxId: string, name: string): Promise<void>;
19
- listSnapshots(boxId: string): Promise<BoxLiteSnapshot[]>;
20
- deleteSnapshot(boxId: string, name: string): Promise<void>;
21
- };
22
- export type BoxLiteClient = ReturnType<typeof createBoxLiteClient>;
1
+ import type { BoxLiteClient, BoxLiteRemoteConfig } from './types.js';
2
+ /**
3
+ * Create a BoxLite REST client for communicating with a BoxRun REST API.
4
+ * Used in remote mode.
5
+ */
6
+ export declare function createBoxLiteRestClient(config: BoxLiteRemoteConfig): BoxLiteClient;
23
7
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,UAAU,EACV,mBAAmB,EACnB,kBAAkB,EAElB,eAAe,EAEhB,MAAM,YAAY,CAAA;AAEnB,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,oBAAoB;sBA0HpC,mBAAmB,GAAG,OAAO,CAAC,UAAU,CAAC;kBAO7C,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;uBAIvB,MAAM,aAAa,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;qBASnD,MAAM,oBAAkB,OAAO,CAAC,IAAI,CAAC;oBAMtC,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;mBAIvB,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;gBAOlC,MAAM,OACR,kBAAkB,GACtB,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;sBAwBvD,MAAM,OACR,kBAAkB,GACtB,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;uBAuEb,MAAM,QAAQ,MAAM,WAAW,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;yBAkBvD,MAAM,QAAQ,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;0BAwBzD,MAAM,QAAQ,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;2BAO9C,MAAM,QAAQ,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;yBAMtC,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;0BAIlC,MAAM,QAAQ,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;EAMnE;AAED,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAA"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,aAAa,EAIb,mBAAmB,EAGpB,MAAM,YAAY,CAAA;AAEnB;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,mBAAmB,GAAG,aAAa,CAgQlF"}
package/dist/client.js CHANGED
@@ -1,4 +1,8 @@
1
- export function createBoxLiteClient(config) {
1
+ /**
2
+ * Create a BoxLite REST client for communicating with a BoxRun REST API.
3
+ * Used in remote mode.
4
+ */
5
+ export function createBoxLiteRestClient(config) {
2
6
  const { apiUrl } = config;
3
7
  const prefix = config.prefix ?? '';
4
8
  const baseUrl = apiUrl.replace(/\/$/, '') + '/v1';
@@ -6,13 +10,10 @@ export function createBoxLiteClient(config) {
6
10
  let token = config.apiToken ?? '';
7
11
  let tokenExpiresAt = 0;
8
12
  async function ensureToken() {
9
- // If a static token was provided, always use it
10
13
  if (config.apiToken)
11
14
  return config.apiToken;
12
- // No auth configured — run without authentication
13
15
  if (!config.clientId || !config.clientSecret)
14
16
  return '';
15
- // If we have a valid cached token, use it
16
17
  if (token && Date.now() < tokenExpiresAt)
17
18
  return token;
18
19
  const response = await fetch(`${baseUrl}/oauth/tokens`, {
@@ -30,7 +31,6 @@ export function createBoxLiteClient(config) {
30
31
  }
31
32
  const data = await response.json();
32
33
  token = data.access_token;
33
- // Refresh 60s before expiry
34
34
  tokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000;
35
35
  return token;
36
36
  }
@@ -56,57 +56,7 @@ export function createBoxLiteClient(config) {
56
56
  return {};
57
57
  return JSON.parse(text);
58
58
  }
59
- /**
60
- * Parse SSE data field — may be JSON `{"data":"<base64>"}` or raw base64.
61
- */
62
- function decodeSSEData(raw) {
63
- try {
64
- const parsed = JSON.parse(raw);
65
- if (parsed.data)
66
- return atob(parsed.data);
67
- }
68
- catch {
69
- // Fall through to raw base64
70
- }
71
- return atob(raw);
72
- }
73
- /**
74
- * Consume an SSE stream from BoxLite exec output.
75
- * SSE events: stdout/stderr data is base64-encoded, exit event has exit_code.
76
- */
77
- function parseSSE(text) {
78
- let stdout = '';
79
- let stderr = '';
80
- let exitCode = 0;
81
- const lines = text.split('\n');
82
- let currentEvent = '';
83
- for (const line of lines) {
84
- if (line.startsWith('event:')) {
85
- currentEvent = line.slice(6).trim();
86
- }
87
- else if (line.startsWith('data:')) {
88
- const data = line.slice(5).trim();
89
- if (currentEvent === 'stdout') {
90
- stdout += decodeSSEData(data);
91
- }
92
- else if (currentEvent === 'stderr') {
93
- stderr += decodeSSEData(data);
94
- }
95
- else if (currentEvent === 'exit') {
96
- try {
97
- const parsed = JSON.parse(data);
98
- exitCode = parsed.exit_code;
99
- }
100
- catch {
101
- exitCode = parseInt(data, 10) || 0;
102
- }
103
- }
104
- }
105
- }
106
- return { stdout, stderr, exitCode };
107
- }
108
59
  return {
109
- // --- Box lifecycle ---
110
60
  async createBox(params) {
111
61
  return request('/boxes', {
112
62
  method: 'POST',
@@ -124,6 +74,8 @@ export function createBoxLiteClient(config) {
124
74
  params.set('page_size', String(pageSize));
125
75
  const qs = params.toString();
126
76
  const data = await request(`/boxes${qs ? `?${qs}` : ''}`);
77
+ if (Array.isArray(data))
78
+ return data;
127
79
  return data.boxes ?? [];
128
80
  },
129
81
  async deleteBox(boxId, force = false) {
@@ -137,81 +89,78 @@ export function createBoxLiteClient(config) {
137
89
  async stopBox(boxId) {
138
90
  await request(`/boxes/${boxId}/stop`, { method: 'POST' });
139
91
  },
140
- // --- Exec ---
141
92
  async exec(boxId, req) {
142
- // 1. POST /exec to start execution
143
93
  const execution = await request(`/boxes/${boxId}/exec`, {
144
94
  method: 'POST',
145
95
  body: JSON.stringify(req),
146
96
  });
147
- // 2. GET /executions/{id}/output SSE stream
148
- const response = await request(`/boxes/${boxId}/executions/${execution.execution_id}/output`, { headers: { 'Accept': 'text/event-stream' } }, true);
149
- if (!response.ok) {
150
- const body = await response.text();
151
- throw new Error(`BoxLite API error ${response.status}: ${body}`);
97
+ if (execution.exit_code !== null && execution.exit_code !== undefined) {
98
+ return {
99
+ stdout: execution.stdout ?? '',
100
+ stderr: execution.stderr ?? '',
101
+ exitCode: execution.exit_code,
102
+ };
152
103
  }
153
- const sseText = await response.text();
154
- return parseSSE(sseText);
104
+ const timeoutMs = (req.timeout_seconds ?? 300) * 1000;
105
+ const startTime = Date.now();
106
+ let pollInterval = 100;
107
+ while (Date.now() - startTime < timeoutMs) {
108
+ await new Promise(r => setTimeout(r, pollInterval));
109
+ pollInterval = Math.min(pollInterval * 2, 2000);
110
+ const result = await request(`/boxes/${boxId}/exec/${execution.id}`);
111
+ if (result.exit_code !== null && result.exit_code !== undefined) {
112
+ return {
113
+ stdout: result.stdout ?? '',
114
+ stderr: result.stderr ?? '',
115
+ exitCode: result.exit_code,
116
+ };
117
+ }
118
+ }
119
+ throw new Error('BoxLite exec timed out waiting for completion');
155
120
  },
156
121
  async execStream(boxId, req) {
157
- // 1. POST /exec to start execution
158
122
  const execution = await request(`/boxes/${boxId}/exec`, {
159
123
  method: 'POST',
160
124
  body: JSON.stringify(req),
161
125
  });
162
- // 2. GET /executions/{id}/output — return raw SSE stream
163
- const response = await request(`/boxes/${boxId}/executions/${execution.execution_id}/output`, { headers: { 'Accept': 'text/event-stream' } }, true);
164
- if (!response.ok) {
165
- const body = await response.text();
166
- throw new Error(`BoxLite API error ${response.status}: ${body}`);
167
- }
168
- if (!response.body) {
169
- throw new Error('BoxLite exec stream: no response body');
170
- }
171
- // Transform SSE events into decoded data chunks
172
- const decoder = new TextDecoder();
173
- let buffer = '';
174
- return response.body.pipeThrough(new TransformStream({
175
- transform(chunk, controller) {
176
- buffer += decoder.decode(chunk, { stream: true });
177
- const lines = buffer.split('\n');
178
- buffer = lines.pop() ?? '';
179
- let currentEvent = '';
180
- for (const line of lines) {
181
- if (line.startsWith('event:')) {
182
- currentEvent = line.slice(6).trim();
183
- }
184
- else if (line.startsWith('data:')) {
185
- const data = line.slice(5).trim();
186
- if (currentEvent === 'stdout' || currentEvent === 'stderr') {
187
- const decoded = decodeSSEData(data);
188
- controller.enqueue(new TextEncoder().encode(decoded));
189
- }
190
- }
126
+ const encoder = new TextEncoder();
127
+ const self = { request };
128
+ return new ReadableStream({
129
+ async start(controller) {
130
+ if (execution.exit_code !== null && execution.exit_code !== undefined) {
131
+ if (execution.stdout)
132
+ controller.enqueue(encoder.encode(execution.stdout));
133
+ if (execution.stderr)
134
+ controller.enqueue(encoder.encode(execution.stderr));
135
+ controller.close();
136
+ return;
191
137
  }
192
- },
193
- flush(controller) {
194
- if (buffer) {
195
- const lines = buffer.split('\n');
196
- let currentEvent = '';
197
- for (const line of lines) {
198
- if (line.startsWith('event:')) {
199
- currentEvent = line.slice(6).trim();
200
- }
201
- else if (line.startsWith('data:')) {
202
- const data = line.slice(5).trim();
203
- if (currentEvent === 'stdout' || currentEvent === 'stderr') {
204
- const decoded = decodeSSEData(data);
205
- controller.enqueue(new TextEncoder().encode(decoded));
206
- }
138
+ const timeoutMs = (req.timeout_seconds ?? 300) * 1000;
139
+ const startTime = Date.now();
140
+ let pollInterval = 100;
141
+ while (Date.now() - startTime < timeoutMs) {
142
+ await new Promise(r => setTimeout(r, pollInterval));
143
+ pollInterval = Math.min(pollInterval * 2, 2000);
144
+ try {
145
+ const result = await self.request(`/boxes/${boxId}/exec/${execution.id}`);
146
+ if (result.exit_code !== null && result.exit_code !== undefined) {
147
+ if (result.stdout)
148
+ controller.enqueue(encoder.encode(result.stdout));
149
+ if (result.stderr)
150
+ controller.enqueue(encoder.encode(result.stderr));
151
+ controller.close();
152
+ return;
207
153
  }
208
154
  }
155
+ catch (err) {
156
+ controller.error(err);
157
+ return;
158
+ }
209
159
  }
210
- controller.terminate();
160
+ controller.error(new Error('BoxLite exec stream timed out'));
211
161
  },
212
- }));
162
+ });
213
163
  },
214
- // --- Files (native tar API) ---
215
164
  async uploadFiles(boxId, path, tarData) {
216
165
  const bearerToken = await ensureToken();
217
166
  const url = `${baseUrl}${prefix ? `/${prefix}` : ''}/boxes/${boxId}/files?path=${encodeURIComponent(path)}`;
@@ -246,7 +195,6 @@ export function createBoxLiteClient(config) {
246
195
  }
247
196
  return response.body;
248
197
  },
249
- // --- Snapshots ---
250
198
  async createSnapshot(boxId, name) {
251
199
  return request(`/boxes/${boxId}/snapshots`, {
252
200
  method: 'POST',