@adobe/aio-lib-sandbox 0.1.0-alpha.10

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/RELEASING.md ADDED
@@ -0,0 +1,36 @@
1
+ # Releasing
2
+
3
+ Releases are managed with [`np`](https://github.com/sindresorhus/np)
4
+
5
+ ## Prerequisites
6
+
7
+ Install `np`:
8
+
9
+ ```bash
10
+ npm install --global np
11
+ ```
12
+
13
+ Ensure your working tree is clean and you are on the `main` branch with the latest changes pulled:
14
+
15
+ ```bash
16
+ git checkout main && git pull
17
+ ```
18
+
19
+ ## Steps
20
+
21
+ 1. **Run `np` with `--no-publish`** and the target version to bump the version, generate the changelog, create the git tag, and push without publishing to npm:
22
+
23
+ ```bash
24
+ np 0.1.0-alpha.10 --no-publish
25
+ ```
26
+
27
+ Replace `0.1.0-alpha.10` with the appropriate next version.
28
+
29
+ 2. In the popup GitHub browser window, add the release version as the title and create the new release.
30
+
31
+ 3. Actual publishing should be handled by the `on-push-publish-to-npm.yml` workflow automatically.
32
+
33
+ ## Notes
34
+
35
+ - This package is in **alpha**. Prefer pre-release version increments (`alpha.x`) until the API is stable. It publishes to the `latest` npm dist-tag, so a bare `npm install` resolves to the current alpha.
36
+ - If `np` complains about a dirty working tree or unpushed commits, resolve those before proceeding.
package/jest.config.js ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ module.exports = {
13
+ testEnvironment: 'node',
14
+ testMatch: ['**/test/**/*.test.js'],
15
+ collectCoverage: true,
16
+ coverageDirectory: 'coverage',
17
+ coverageReporters: ['text', 'lcov'],
18
+ reporters: ['default', 'jest-junit']
19
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@adobe/aio-lib-sandbox",
3
+ "version": "0.1.0-alpha.10",
4
+ "description": "JavaScript SDK for Adobe Runtime Sandboxes",
5
+ "main": "src/index.js",
6
+ "license": "Apache-2.0",
7
+ "bugs": {
8
+ "url": "https://github.com/adobe/aio-lib-sandbox/issues"
9
+ },
10
+ "homepage": "https://github.com/adobe/aio-lib-sandbox",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/adobe/aio-lib-sandbox.git"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dependencies": {
19
+ "ws": "^8.19.0"
20
+ },
21
+ "devDependencies": {
22
+ "@adobe/eslint-config-aio-lib-config": "^4.0.0",
23
+ "eslint": "^8.57.1",
24
+ "eslint-config-standard": "^17.1.0",
25
+ "eslint-plugin-import": "^2.31.0",
26
+ "eslint-plugin-jest": "^27.9.0",
27
+ "eslint-plugin-jsdoc": "^48.11.0",
28
+ "eslint-plugin-n": "^15.7.0",
29
+ "eslint-plugin-node": "^11.1.0",
30
+ "eslint-plugin-promise": "^6.6.0",
31
+ "jest": "^29",
32
+ "jest-junit": "^16.0.0"
33
+ },
34
+ "scripts": {
35
+ "lint": "eslint src test",
36
+ "lint-fix": "eslint src test --fix",
37
+ "test": "npm run unit-tests && npm run lint",
38
+ "unit-tests": "jest --ci"
39
+ }
40
+ }
package/src/Sandbox.js ADDED
@@ -0,0 +1,599 @@
1
+ /*
2
+ Copyright 2026 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ Unless required by applicable law or agreed to in writing, software distributed under
7
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
8
+ OF ANY KIND, either express or implied. See the License for the specific language
9
+ governing permissions and limitations under the License.
10
+ */
11
+
12
+ const crypto = require('node:crypto')
13
+ const {
14
+ SandboxClientError,
15
+ SandboxTimeoutError,
16
+ SandboxWebSocketError,
17
+ SandboxPortNotProvisionedError,
18
+ SandboxInvalidPortError
19
+ } = require('./errors')
20
+ const {
21
+ buildWebSocketEndpoint,
22
+ resolveCredentials,
23
+ normalizeSize,
24
+ apiRequest
25
+ } = require('./utils')
26
+ const { SANDBOX_SIZES, PROTOCOL_VERSION, API_PREFIX } = require('./constants')
27
+ const { SandboxSocket } = require('./ws')
28
+
29
+ /**
30
+ * Connected compute sandbox session.
31
+ *
32
+ * Use `Sandbox.create()` or `Sandbox.get()`
33
+ */
34
+ class Sandbox {
35
+ /**
36
+ * @param {object} options sandbox options
37
+ * @private
38
+ */
39
+ constructor (options) {
40
+ this.id = options.id
41
+ this.endpoint = options.endpoint
42
+ this.status = options.status
43
+ this.cluster = options.cluster
44
+ this.region = options.region
45
+ this.idleTimeout = options.idleTimeout
46
+ this.maxLifetime = options.maxLifetime
47
+ this.protocolVersion = options.protocolVersion || PROTOCOL_VERSION
48
+
49
+ this.namespace = options.namespace
50
+ this.apiHost = options.apiHost
51
+ this.apiKey = options.apiKey
52
+ this.token = options.token
53
+ // previewUrls is a Map<number, string> of (port → URL) returned by the server.
54
+ this.previewUrls = options.previewUrls || new Map()
55
+ this.managementEndpoint = options.managementEndpoint || null
56
+ this.ws = null
57
+ }
58
+
59
+ /**
60
+ * Creates a new compute sandbox and opens its WebSocket session.
61
+ *
62
+ * Credentials are read from the environment automatically when running inside
63
+ * a Runtime action (`__OW_API_HOST`, `__OW_NAMESPACE`, `__OW_API_KEY`).
64
+ * Any value passed explicitly in `options` overrides the environment.
65
+ *
66
+ * Commands run inside the sandbox start in the `/workspace` directory by default.
67
+ *
68
+ * @param {object} [options] creation options
69
+ * @param {string} [options.apiHost] Runtime API host (overrides `__OW_API_HOST`)
70
+ * @param {string} [options.namespace] Runtime namespace (overrides `__OW_NAMESPACE`)
71
+ * @param {string} [options.auth] Runtime API key (overrides `__OW_API_KEY`)
72
+ * @param {string} [options.name] sandbox display name
73
+ * @param {string} [options.type] sandbox type (default: `'cpu:default'`)
74
+ * @param {string|object} [options.size] sandbox size tier (name or spec object)
75
+ * @param {number} [options.idleTimeout] seconds of inactivity before the sandbox is terminated
76
+ * (default: 900, max: 10800). The idle timer resets on every WebSocket message or status-check
77
+ * request.
78
+ * @param {number} [options.maxLifetime] maximum lifetime in seconds (default: 3600, max: 10800)
79
+ * @param {number[]} [options.ports] TCP ports to expose via preview URLs (default: `[]`)
80
+ * @param {object} [options.envs] environment variables to inject into the sandbox
81
+ * @param {object} [options.policy] network policy (e.g. egress allowlist)
82
+ * @returns {Promise<Sandbox>} connected sandbox instance
83
+ */
84
+ static async create (options = {}) {
85
+ const creds = resolveCredentials(options)
86
+
87
+ const body = {
88
+ name: options.name,
89
+ size: normalizeSize(options.size),
90
+ type: options.type || 'cpu:default',
91
+ idleTimeout: options.idleTimeout || 900,
92
+ maxLifetime: options.maxLifetime || 3600
93
+ }
94
+
95
+ if (options.cluster !== undefined) body.cluster = options.cluster
96
+ if (options.region !== undefined) body.region = options.region
97
+ if (options.envs !== undefined) body.envs = options.envs
98
+ if (options.policy !== undefined) body.policy = options.policy
99
+ if (options.ports !== undefined) body.ports = options.ports
100
+
101
+ const url = `${creds.apiHost}${API_PREFIX}/namespaces/${creds.namespace}/sandboxes`
102
+ const payload = await apiRequest('POST', url, creds.apiKey, body)
103
+
104
+ const sandboxId = payload.sandboxId
105
+ const endpoint = payload.wsEndpoint || buildWebSocketEndpoint(creds.apiHost, creds.namespace, sandboxId)
106
+
107
+ const sandbox = new Sandbox({
108
+ id: sandboxId,
109
+ endpoint,
110
+ status: payload.status,
111
+ cluster: payload.cluster,
112
+ region: payload.region,
113
+ idleTimeout: payload.idleTimeout,
114
+ maxLifetime: payload.maxLifetime,
115
+ protocolVersion: payload.protocolVersion || PROTOCOL_VERSION,
116
+ previewUrls: parsePreviewUrls(payload.previewUrls),
117
+ managementEndpoint: payload.managementEndpoint || null,
118
+ namespace: creds.namespace,
119
+ apiHost: creds.apiHost,
120
+ apiKey: creds.apiKey,
121
+ token: payload.token
122
+ })
123
+
124
+ await sandbox.connect()
125
+ return sandbox
126
+ }
127
+
128
+ /**
129
+ * Fetches an existing sandbox.
130
+ *
131
+ * Credentials are read from the environment automatically.
132
+ * Any value passed explicitly in `options` overrides the environment.
133
+ *
134
+ * Pass the management endpoint so the request is sent to the correct host;
135
+ * falls back to `options.apiHost` when omitted.
136
+ *
137
+ * @param {string} sandboxId the sandbox ID to look up
138
+ * @param {object} [options] credential overrides
139
+ * @param {string} [options.apiHost] Runtime API host
140
+ * @param {string} [options.namespace] Runtime namespace
141
+ * @param {string} [options.auth] Runtime API key
142
+ * @param {string} [options.managementEndpoint] per-sandbox management endpoint returned by
143
+ * `Sandbox.create()`. Falls back to `apiHost` otherwise.
144
+ * @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
145
+ */
146
+ static async get (sandboxId, options = {}) {
147
+ const creds = resolveCredentials(options)
148
+ const base = options.managementEndpoint || creds.apiHost
149
+ const url = `${base}${API_PREFIX}/namespaces/${creds.namespace}/sandboxes/${sandboxId}`
150
+ const payload = await apiRequest('GET', url, creds.apiKey)
151
+
152
+ return new Sandbox({
153
+ id: payload.sandboxId || sandboxId,
154
+ endpoint: null,
155
+ status: payload.status,
156
+ cluster: payload.cluster,
157
+ region: payload.region,
158
+ idleTimeout: payload.idleTimeout,
159
+ maxLifetime: payload.maxLifetime,
160
+ protocolVersion: payload.protocolVersion || PROTOCOL_VERSION,
161
+ managementEndpoint: payload.managementEndpoint || options.managementEndpoint || null,
162
+ previewUrls: parsePreviewUrls(payload.previewUrls),
163
+ namespace: creds.namespace,
164
+ apiHost: creds.apiHost,
165
+ apiKey: creds.apiKey,
166
+ token: null
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Named sandbox size tiers.
172
+ *
173
+ * @type {object}
174
+ */
175
+ static get sizes () {
176
+ return SANDBOX_SIZES
177
+ }
178
+
179
+ /**
180
+ * Sandbox wire protocol major bundled with this SDK.
181
+ *
182
+ * @type {string}
183
+ */
184
+ static get protocolVersion () {
185
+ return PROTOCOL_VERSION
186
+ }
187
+
188
+ /**
189
+ * Exposes `resolveCredentials` as a static helper (useful for testing).
190
+ *
191
+ * @param {object} overrides credential overrides
192
+ * @returns {{ apiHost: string, namespace: string, apiKey: string }} resolved Runtime credentials
193
+ */
194
+ static resolveCredentials (overrides = {}) {
195
+ return resolveCredentials(overrides)
196
+ }
197
+
198
+ /**
199
+ * Exposes `normalizeSize` as a static helper (useful for testing).
200
+ *
201
+ * @param {string|object|undefined} size sandbox size name or resource spec
202
+ * @returns {string} normalized sandbox size name
203
+ */
204
+ static normalizeSize (size) {
205
+ return normalizeSize(size)
206
+ }
207
+
208
+ // ------------------------------------------------------------------
209
+ // WebSocket connection
210
+ // ------------------------------------------------------------------
211
+
212
+ /**
213
+ * Opens the sandbox WebSocket connection (called automatically by `create()`).
214
+ *
215
+ * @returns {Promise<void>}
216
+ */
217
+ connect () {
218
+ if (!this.ws) {
219
+ this.ws = new SandboxSocket({
220
+ id: this.id,
221
+ endpoint: this.endpoint,
222
+ token: this.token
223
+ })
224
+ }
225
+ return this.ws.connect()
226
+ }
227
+
228
+ // ------------------------------------------------------------------
229
+ // Exec
230
+ // ------------------------------------------------------------------
231
+
232
+ /**
233
+ * Executes a command inside the sandbox.
234
+ *
235
+ * `options.timeout` is not supported with `options.detached: true`.
236
+ *
237
+ * @param {string} command command to run
238
+ * @param {object} [options] execution options
239
+ * @param {number} [options.timeout] timeout in milliseconds (foreground only)
240
+ * @param {boolean} [options.detached] when true, run as a detached background process
241
+ * @param {string|Buffer} [options.stdin] data to send to stdin at startup
242
+ * @param {Function} [options.onOutput] callback called with `(data, stream)` for each output chunk
243
+ * @returns {Promise} command result, or a detached command handle when `options.detached` is true
244
+ */
245
+ exec (command, options = {}) {
246
+ try {
247
+ this.ensureOpen()
248
+ } catch (error) {
249
+ return Promise.reject(error)
250
+ }
251
+
252
+ if (options.detached && options.timeout) {
253
+ return Promise.reject(new SandboxClientError(
254
+ 'cannot set a timeout for a detached command'
255
+ ))
256
+ }
257
+
258
+ const execId = `exec-${crypto.randomBytes(12).toString('hex')}`
259
+ const promise = this.sendExecFrameAndAwaitResponse(execId, command, options)
260
+ promise.execId = execId
261
+ return promise
262
+ }
263
+
264
+ /**
265
+ * @param {string} execId execution id to run inside the sandbox
266
+ * @param {string} command
267
+ * @param {object} options
268
+ * @private
269
+ */
270
+ async sendExecFrameAndAwaitResponse (execId, command, options) {
271
+ const detached = !!options.detached
272
+ const frame = { type: 'exec.run', execId, command, ...(detached && { detached: true }) }
273
+
274
+ const { ackPromise, waitPromise } = this.ws.sendExec(execId, frame, {
275
+ detached,
276
+ onOutput: options.onOutput
277
+ })
278
+
279
+ if (options.timeout) {
280
+ this.ws.setExecTimeout(execId, this.scheduleTimeout(execId, command, options.timeout))
281
+ }
282
+
283
+ if (options.stdin !== undefined) {
284
+ this.writeStdin(execId, options.stdin)
285
+ this.closeStdin(execId)
286
+ }
287
+
288
+ const result = await ackPromise
289
+
290
+ if (!detached) return result
291
+
292
+ const { pid, startedAt } = result
293
+ return {
294
+ execId,
295
+ pid,
296
+ startedAt,
297
+ detached: true,
298
+ wait: () => waitPromise,
299
+ writeStdin: (data) => this.writeStdin(execId, data),
300
+ closeStdin: () => this.closeStdin(execId),
301
+ kill: (signal) => this.kill(execId, signal)
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Re-attaches to a detached command that is still running in the sandbox.
307
+ *
308
+ * @param {string} execId the execId returned by the original `exec()` call
309
+ * @param {object} [options] re-attach options
310
+ * @param {Function} [options.onOutput] callback called with `(data, stream)` for live output
311
+ * @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>} command handle
312
+ */
313
+ getCommand (execId, options = {}) {
314
+ try {
315
+ this.ensureOpen()
316
+ } catch (error) {
317
+ return Promise.reject(error)
318
+ }
319
+
320
+ const getPromise = new Promise((resolve, reject) => {
321
+ this.ws.pendingGetOps.set(execId, {
322
+ resolve,
323
+ reject,
324
+ onOutput: options.onOutput || null,
325
+ sandbox: this
326
+ })
327
+ })
328
+
329
+ try {
330
+ this.sendFrame({ type: 'exec.get', execId })
331
+ } catch (error) {
332
+ this.ws.pendingGetOps.delete(execId)
333
+ return Promise.reject(new SandboxWebSocketError(
334
+ `Could not send exec.get frame: ${error.message}`
335
+ ))
336
+ }
337
+
338
+ return getPromise
339
+ }
340
+
341
+ /**
342
+ * Sends a signal to a running command.
343
+ *
344
+ * @param {string} execId execution id
345
+ * @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
346
+ */
347
+ kill (execId, signal = 'SIGTERM') {
348
+ this.ensureOpen()
349
+ this.sendFrame({ type: 'exec.kill', execId, signal })
350
+ }
351
+
352
+ /**
353
+ * Writes data to the stdin of a running command.
354
+ * Fire-and-forget — there is no response on success.
355
+ *
356
+ * @param {string} execId execution id from `exec()`
357
+ * @param {string|Buffer} data data to write
358
+ */
359
+ writeStdin (execId, data) {
360
+ this.ensureOpen()
361
+ const frame = { type: 'exec.input', execId }
362
+ if (Buffer.isBuffer(data)) {
363
+ frame.data = data.toString('base64')
364
+ frame.encoding = 'base64'
365
+ } else {
366
+ frame.data = data
367
+ }
368
+ this.sendFrame(frame)
369
+ }
370
+
371
+ /**
372
+ * Closes stdin for a running command, signalling EOF.
373
+ * Fire-and-forget — there is no response on success.
374
+ *
375
+ * @param {string} execId execution id from `exec()`
376
+ */
377
+ closeStdin (execId) {
378
+ this.ensureOpen()
379
+ this.sendFrame({ type: 'exec.endInput', execId })
380
+ }
381
+
382
+ // ------------------------------------------------------------------
383
+ // File operations
384
+ // ------------------------------------------------------------------
385
+
386
+ /**
387
+ * Reads a file from the sandbox filesystem.
388
+ *
389
+ * @param {string} path path inside the sandbox
390
+ * @returns {Promise<string>} file contents as a UTF-8 string
391
+ */
392
+ readFile (path) {
393
+ try {
394
+ this.ensureOpen()
395
+ } catch (error) {
396
+ return Promise.reject(error)
397
+ }
398
+
399
+ const execId = `file-${crypto.randomBytes(12).toString('hex')}`
400
+ const opPromise = new Promise((resolve, reject) => {
401
+ this.ws.pendingFileOps.set(execId, { resolve, reject })
402
+ })
403
+
404
+ try {
405
+ this.sendFrame({ type: 'file.read', execId, path })
406
+ } catch (error) {
407
+ this.ws.rejectFileOp(execId, new SandboxWebSocketError(
408
+ `Could not send file.read frame: ${error.message}`
409
+ ))
410
+ }
411
+
412
+ return opPromise
413
+ }
414
+
415
+ /**
416
+ * Writes a file to the sandbox filesystem. Parent directories are created automatically.
417
+ *
418
+ * @param {string} path path inside the sandbox
419
+ * @param {string|Buffer} content file contents
420
+ * @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
421
+ */
422
+ writeFile (path, content) {
423
+ try {
424
+ this.ensureOpen()
425
+ } catch (error) {
426
+ return Promise.reject(error)
427
+ }
428
+
429
+ const execId = `file-${crypto.randomBytes(12).toString('hex')}`
430
+ const encoded = Buffer.isBuffer(content)
431
+ ? content.toString('base64')
432
+ : Buffer.from(content).toString('base64')
433
+
434
+ const opPromise = new Promise((resolve, reject) => {
435
+ this.ws.pendingFileOps.set(execId, { resolve, reject })
436
+ })
437
+
438
+ try {
439
+ this.sendFrame({ type: 'file.write', execId, path, content: encoded, encoding: 'base64' })
440
+ } catch (error) {
441
+ this.ws.rejectFileOp(execId, new SandboxWebSocketError(
442
+ `Could not send file.write frame: ${error.message}`
443
+ ))
444
+ }
445
+
446
+ return opPromise
447
+ }
448
+
449
+ /**
450
+ * Lists the contents of a directory inside the sandbox.
451
+ *
452
+ * @param {string} path directory path inside the sandbox
453
+ * @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
454
+ */
455
+ listFiles (path) {
456
+ try {
457
+ this.ensureOpen()
458
+ } catch (error) {
459
+ return Promise.reject(error)
460
+ }
461
+
462
+ const execId = `file-${crypto.randomBytes(12).toString('hex')}`
463
+ const opPromise = new Promise((resolve, reject) => {
464
+ this.ws.pendingFileOps.set(execId, { resolve, reject })
465
+ })
466
+
467
+ try {
468
+ this.sendFrame({ type: 'file.list', execId, path })
469
+ } catch (error) {
470
+ this.ws.rejectFileOp(execId, new SandboxWebSocketError(
471
+ `Could not send file.list frame: ${error.message}`
472
+ ))
473
+ }
474
+
475
+ return opPromise
476
+ }
477
+
478
+ // ------------------------------------------------------------------
479
+ // Lifecycle
480
+ // ------------------------------------------------------------------
481
+
482
+ /**
483
+ * Returns the public preview URL for a given port on this sandbox.
484
+ *
485
+ * This is a synchronous local lookup against the `previewUrls` map returned
486
+ * by the server at create time. The URL is opaque — do not parse or reconstruct it.
487
+ *
488
+ * @param {number} port port number (1–65535)
489
+ * @returns {string} public preview URL
490
+ * @throws {SandboxInvalidPortError} when `port` is not an integer in the
491
+ * range 1–65535
492
+ * @throws {SandboxPortNotProvisionedError} when `port` is valid but was not
493
+ * declared in `create({ ports })`
494
+ */
495
+ getUrl (port) {
496
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
497
+ throw new SandboxInvalidPortError(
498
+ `Invalid port '${port}': must be an integer between 1 and 65535`
499
+ )
500
+ }
501
+
502
+ const url = this.previewUrls.get(port)
503
+ if (url === undefined) {
504
+ throw new SandboxPortNotProvisionedError(
505
+ `Port ${port} was not provisioned for sandbox '${this.id}'. ` +
506
+ 'Declare it in create({ ports: [...] }) to get a preview URL.'
507
+ )
508
+ }
509
+
510
+ return url
511
+ }
512
+
513
+ /**
514
+ * Destroys the sandbox and closes its WebSocket connection.
515
+ *
516
+ * @returns {Promise<object>} destroy response payload
517
+ */
518
+ async destroy () {
519
+ const base = this.managementEndpoint || this.apiHost
520
+ const url = `${base}${API_PREFIX}/namespaces/${this.namespace}/sandboxes/${this.id}`
521
+ this.ws?.beginIntentionalClose()
522
+
523
+ let payload
524
+ try {
525
+ payload = await apiRequest('DELETE', url, this.apiKey)
526
+ } catch (error) {
527
+ this.ws?.cancelIntentionalClose()
528
+ throw error
529
+ }
530
+
531
+ this.status = payload.status || this.status
532
+ this.ws?.close()
533
+ return payload
534
+ }
535
+
536
+ // ------------------------------------------------------------------
537
+ // Private helpers
538
+ // ------------------------------------------------------------------
539
+
540
+ /**
541
+ * Schedules a timeout that kills `execId` and rejects its pending entry.
542
+ *
543
+ * @param {string} execId exec identifier to reject when the timeout fires
544
+ * @param {string} command human-readable command string (for the error message)
545
+ * @param {number} ms timeout in milliseconds
546
+ * @returns {ReturnType<setTimeout>} the timer handle (stored on the entry for cancellation)
547
+ */
548
+ scheduleTimeout (execId, command, ms) {
549
+ return setTimeout(() => {
550
+ try {
551
+ this.kill(execId)
552
+ } catch (_) {
553
+ // ignore errors
554
+ }
555
+
556
+ this.ws.rejectExec(execId, new SandboxTimeoutError(
557
+ `Command '${command}' exceeded timeout of ${ms}ms`
558
+ ))
559
+ }, ms)
560
+ }
561
+
562
+ ensureOpen () {
563
+ if (!this.ws) {
564
+ throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
565
+ }
566
+ this.ws.ensureOpen()
567
+ }
568
+
569
+ sendFrame (frame) {
570
+ this.ws.send(frame)
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Parses the `previewUrls` JSON object returned by the server into a
576
+ * `Map<number, string>`. String keys (port numbers) are converted to integers.
577
+ * The URL values are treated as opaque — not parsed or reconstructed.
578
+ *
579
+ * Returns an empty Map when the server response omits `previewUrls` (fail-closed:
580
+ * every `getUrl()` call will throw `SandboxPortNotProvisionedError`).
581
+ *
582
+ * @param {object|null|undefined} raw the `previewUrls` field from the API response
583
+ * @returns {Map<number, string>} preview URLs keyed by port number
584
+ */
585
+ function parsePreviewUrls (raw) {
586
+ if (!raw || typeof raw !== 'object') {
587
+ return new Map()
588
+ }
589
+ const map = new Map()
590
+ for (const [key, value] of Object.entries(raw)) {
591
+ const port = Number(key)
592
+ if (Number.isInteger(port) && port >= 1 && port <= 65535 && typeof value === 'string') {
593
+ map.set(port, value)
594
+ }
595
+ }
596
+ return map
597
+ }
598
+
599
+ module.exports = Sandbox