@adobe/aio-lib-sandbox 0.1.0-alpha.6 → 0.1.0-alpha.8

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/.eslintignore ADDED
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ coverage/
package/.eslintrc.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": ["@adobe/eslint-config-aio-lib-config"],
3
+ "settings": {
4
+ "jsdoc": {
5
+ "ignorePrivate": true
6
+ }
7
+ },
8
+ "rules": {
9
+ "no-param-reassign": ["warn", { "props": true }],
10
+ "jsdoc/tag-lines": [
11
+ "error",
12
+ "never",
13
+ {
14
+ "startLines": null
15
+ }
16
+ ]
17
+ },
18
+ "parserOptions": {
19
+ "ecmaVersion": 2020
20
+ }
21
+ }
package/README.md CHANGED
@@ -74,12 +74,19 @@ const { Sandbox } = require('@adobe/aio-lib-sandbox')
74
74
  const sandbox = await Sandbox.create({
75
75
  name: 'my-sandbox',
76
76
  type: 'cpu:default',
77
+ idleTimeout: 900,
77
78
  maxLifetime: 3600,
78
79
  ports: [3000, 8080],
79
80
  envs: { API_KEY: 'your-api-key' }
80
81
  })
81
82
  ```
82
83
 
84
+ #### Sandbox lifetime model
85
+
86
+ A sandbox is always deleted when `maxLifetime` has elapsed. It will also be deleted after the `idleTimeout` has elapsed, if there has been no activity.
87
+
88
+ To keep a sandbox alive, send at least one command or check the status every `idleTimeout` seconds.
89
+
83
90
  ### Get Status
84
91
 
85
92
  ```js
@@ -218,3 +225,24 @@ const sandbox = await Sandbox.create({
218
225
  }
219
226
  })
220
227
  ```
228
+
229
+ ## Development
230
+
231
+ Install development dependencies:
232
+
233
+ ```bash
234
+ npm install
235
+ ```
236
+
237
+ To run the same checks used by CI:
238
+
239
+ ```bash
240
+ npm test
241
+ ```
242
+
243
+ Linting is powered by ESLint:
244
+
245
+ ```bash
246
+ npm run lint
247
+ npm run lint-fix
248
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aio-lib-sandbox",
3
- "version": "0.1.0-alpha.6",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "JavaScript SDK for Adobe Runtime Sandboxes",
5
5
  "main": "src/index.js",
6
6
  "license": "Apache-2.0",
@@ -19,11 +19,22 @@
19
19
  "ws": "^8.19.0"
20
20
  },
21
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",
22
31
  "jest": "^29",
23
32
  "jest-junit": "^16.0.0"
24
33
  },
25
34
  "scripts": {
26
- "test": "jest --ci",
27
- "lint": "node --check src/**/*.js"
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"
28
39
  }
29
40
  }
package/src/Sandbox.js CHANGED
@@ -36,12 +36,13 @@ class Sandbox {
36
36
  * @param {object} options sandbox options
37
37
  * @private
38
38
  */
39
- constructor(options) {
39
+ constructor (options) {
40
40
  this.id = options.id
41
41
  this.endpoint = options.endpoint
42
42
  this.status = options.status
43
43
  this.cluster = options.cluster
44
44
  this.region = options.region
45
+ this.idleTimeout = options.idleTimeout
45
46
  this.maxLifetime = options.maxLifetime
46
47
 
47
48
  this.namespace = options.namespace
@@ -70,13 +71,16 @@ class Sandbox {
70
71
  * @param {string} [options.name] sandbox display name
71
72
  * @param {string} [options.type] sandbox type (default: `'cpu:default'`)
72
73
  * @param {string|object} [options.size] sandbox size tier (name or spec object)
73
- * @param {number} [options.maxLifetime] maximum lifetime in seconds
74
+ * @param {number} [options.idleTimeout] seconds of inactivity before the sandbox is terminated
75
+ * (default: 900, max: 10800). The idle timer resets on every WebSocket message or status-check
76
+ * request.
77
+ * @param {number} [options.maxLifetime] maximum lifetime in seconds (default: 3600, max: 10800)
74
78
  * @param {number[]} [options.ports] TCP ports to expose via preview URLs (default: `[]`)
75
79
  * @param {object} [options.envs] environment variables to inject into the sandbox
76
80
  * @param {object} [options.policy] network policy (e.g. egress allowlist)
77
81
  * @returns {Promise<Sandbox>} connected sandbox instance
78
82
  */
79
- static async create(options = {}) {
83
+ static async create (options = {}) {
80
84
  console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
81
85
  const creds = resolveCredentials(options)
82
86
 
@@ -84,6 +88,7 @@ class Sandbox {
84
88
  name: options.name,
85
89
  size: normalizeSize(options.size),
86
90
  type: options.type || 'cpu:default',
91
+ idleTimeout: options.idleTimeout || 900,
87
92
  maxLifetime: options.maxLifetime || 3600
88
93
  }
89
94
 
@@ -93,7 +98,7 @@ class Sandbox {
93
98
  if (options.policy !== undefined) body.policy = options.policy
94
99
  if (options.ports !== undefined) body.ports = options.ports
95
100
 
96
- const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox`
101
+ const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes`
97
102
  const payload = await apiRequest('POST', url, creds.apiKey, body)
98
103
 
99
104
  const sandboxId = payload.sandboxId
@@ -105,6 +110,7 @@ class Sandbox {
105
110
  status: payload.status,
106
111
  cluster: payload.cluster,
107
112
  region: payload.region,
113
+ idleTimeout: payload.idleTimeout,
108
114
  maxLifetime: payload.maxLifetime,
109
115
  previewUrls: parsePreviewUrls(payload.previewUrls),
110
116
  managementEndpoint: payload.managementEndpoint || null,
@@ -124,17 +130,23 @@ class Sandbox {
124
130
  * Credentials are read from the environment automatically.
125
131
  * Any value passed explicitly in `options` overrides the environment.
126
132
  *
133
+ * Pass the management endpoint so the request is sent to the correct host;
134
+ * falls back to `options.apiHost` when omitted.
135
+ *
127
136
  * @param {string} sandboxId the sandbox ID to look up
128
137
  * @param {object} [options] credential overrides
129
138
  * @param {string} [options.apiHost] Runtime API host
130
139
  * @param {string} [options.namespace] Runtime namespace
131
140
  * @param {string} [options.auth] Runtime API key
141
+ * @param {string} [options.managementEndpoint] per-sandbox management endpoint returned by
142
+ * `Sandbox.create()`. Falls back to `apiHost` otherwise.
132
143
  * @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
133
144
  */
134
- static async get(sandboxId, options = {}) {
145
+ static async get (sandboxId, options = {}) {
135
146
  console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
136
147
  const creds = resolveCredentials(options)
137
- const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox/${sandboxId}`
148
+ const base = options.managementEndpoint || creds.apiHost
149
+ const url = `${base}/api/v1/namespaces/${creds.namespace}/sandboxes/${sandboxId}`
138
150
  const payload = await apiRequest('GET', url, creds.apiKey)
139
151
 
140
152
  return new Sandbox({
@@ -143,7 +155,9 @@ class Sandbox {
143
155
  status: payload.status,
144
156
  cluster: payload.cluster,
145
157
  region: payload.region,
158
+ idleTimeout: payload.idleTimeout,
146
159
  maxLifetime: payload.maxLifetime,
160
+ managementEndpoint: payload.managementEndpoint || options.managementEndpoint || null,
147
161
  previewUrls: parsePreviewUrls(payload.previewUrls),
148
162
  namespace: creds.namespace,
149
163
  apiHost: creds.apiHost,
@@ -157,7 +171,7 @@ class Sandbox {
157
171
  *
158
172
  * @type {object}
159
173
  */
160
- static get sizes() {
174
+ static get sizes () {
161
175
  return SANDBOX_SIZES
162
176
  }
163
177
 
@@ -165,19 +179,19 @@ class Sandbox {
165
179
  * Exposes `resolveCredentials` as a static helper (useful for testing).
166
180
  *
167
181
  * @param {object} overrides credential overrides
168
- * @returns {{ apiHost: string, namespace: string, apiKey: string }}
182
+ * @returns {{ apiHost: string, namespace: string, apiKey: string }} resolved Runtime credentials
169
183
  */
170
- static resolveCredentials(overrides = {}) {
184
+ static resolveCredentials (overrides = {}) {
171
185
  return resolveCredentials(overrides)
172
186
  }
173
187
 
174
188
  /**
175
189
  * Exposes `normalizeSize` as a static helper (useful for testing).
176
190
  *
177
- * @param {string|object|undefined} size
178
- * @returns {string}
191
+ * @param {string|object|undefined} size sandbox size name or resource spec
192
+ * @returns {string} normalized sandbox size name
179
193
  */
180
- static normalizeSize(size) {
194
+ static normalizeSize (size) {
181
195
  return normalizeSize(size)
182
196
  }
183
197
 
@@ -190,7 +204,7 @@ class Sandbox {
190
204
  *
191
205
  * @returns {Promise<void>}
192
206
  */
193
- connect() {
207
+ connect () {
194
208
  if (!this.ws) {
195
209
  this.ws = new SandboxSocket({
196
210
  id: this.id,
@@ -215,10 +229,10 @@ class Sandbox {
215
229
  * @param {number} [options.timeout] timeout in milliseconds (foreground only)
216
230
  * @param {boolean} [options.detached] when true, run as a detached background process
217
231
  * @param {string|Buffer} [options.stdin] data to send to stdin at startup
218
- * @param {function} [options.onOutput] callback called with `(data, stream)` for each output chunk
219
- * @returns {Promise}
232
+ * @param {Function} [options.onOutput] callback called with `(data, stream)` for each output chunk
233
+ * @returns {Promise} command result, or a detached command handle when `options.detached` is true
220
234
  */
221
- exec(command, options = {}) {
235
+ exec (command, options = {}) {
222
236
  try {
223
237
  this.ensureOpen()
224
238
  } catch (error) {
@@ -238,12 +252,12 @@ class Sandbox {
238
252
  }
239
253
 
240
254
  /**
241
- * @param {string} execId
255
+ * @param {string} execId execution id to run inside the sandbox
242
256
  * @param {string} command
243
257
  * @param {object} options
244
258
  * @private
245
259
  */
246
- async sendExecFrameAndAwaitResponse(execId, command, options) {
260
+ async sendExecFrameAndAwaitResponse (execId, command, options) {
247
261
  const detached = !!options.detached
248
262
  const frame = { type: 'exec.run', execId, command, ...(detached && { detached: true }) }
249
263
 
@@ -283,10 +297,10 @@ class Sandbox {
283
297
  *
284
298
  * @param {string} execId the execId returned by the original `exec()` call
285
299
  * @param {object} [options] re-attach options
286
- * @param {function} [options.onOutput] callback called with `(data, stream)` for live output
287
- * @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>}
300
+ * @param {Function} [options.onOutput] callback called with `(data, stream)` for live output
301
+ * @returns {Promise<{execId, command, pid, startedAt, detached, wait, kill, writeStdin, closeStdin}>} command handle
288
302
  */
289
- getCommand(execId, options = {}) {
303
+ getCommand (execId, options = {}) {
290
304
  try {
291
305
  this.ensureOpen()
292
306
  } catch (error) {
@@ -320,7 +334,7 @@ class Sandbox {
320
334
  * @param {string} execId execution id
321
335
  * @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
322
336
  */
323
- kill(execId, signal = 'SIGTERM') {
337
+ kill (execId, signal = 'SIGTERM') {
324
338
  this.ensureOpen()
325
339
  this.sendFrame({ type: 'exec.kill', execId, signal })
326
340
  }
@@ -332,7 +346,7 @@ class Sandbox {
332
346
  * @param {string} execId execution id from `exec()`
333
347
  * @param {string|Buffer} data data to write
334
348
  */
335
- writeStdin(execId, data) {
349
+ writeStdin (execId, data) {
336
350
  this.ensureOpen()
337
351
  const frame = { type: 'exec.input', execId }
338
352
  if (Buffer.isBuffer(data)) {
@@ -350,7 +364,7 @@ class Sandbox {
350
364
  *
351
365
  * @param {string} execId execution id from `exec()`
352
366
  */
353
- closeStdin(execId) {
367
+ closeStdin (execId) {
354
368
  this.ensureOpen()
355
369
  this.sendFrame({ type: 'exec.endInput', execId })
356
370
  }
@@ -365,7 +379,7 @@ class Sandbox {
365
379
  * @param {string} path path inside the sandbox
366
380
  * @returns {Promise<string>} file contents as a UTF-8 string
367
381
  */
368
- readFile(path) {
382
+ readFile (path) {
369
383
  try {
370
384
  this.ensureOpen()
371
385
  } catch (error) {
@@ -395,7 +409,7 @@ class Sandbox {
395
409
  * @param {string|Buffer} content file contents
396
410
  * @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
397
411
  */
398
- writeFile(path, content) {
412
+ writeFile (path, content) {
399
413
  try {
400
414
  this.ensureOpen()
401
415
  } catch (error) {
@@ -428,7 +442,7 @@ class Sandbox {
428
442
  * @param {string} path directory path inside the sandbox
429
443
  * @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
430
444
  */
431
- listFiles(path) {
445
+ listFiles (path) {
432
446
  try {
433
447
  this.ensureOpen()
434
448
  } catch (error) {
@@ -479,7 +493,7 @@ class Sandbox {
479
493
  if (url === undefined) {
480
494
  throw new SandboxPortNotProvisionedError(
481
495
  `Port ${port} was not provisioned for sandbox '${this.id}'. ` +
482
- "Declare it in create({ ports: [...] }) to get a preview URL."
496
+ 'Declare it in create({ ports: [...] }) to get a preview URL.'
483
497
  )
484
498
  }
485
499
 
@@ -491,9 +505,9 @@ class Sandbox {
491
505
  *
492
506
  * @returns {Promise<object>} destroy response payload
493
507
  */
494
- async destroy() {
508
+ async destroy () {
495
509
  const base = this.managementEndpoint || this.apiHost
496
- const url = `${base}/api/v1/namespaces/${this.namespace}/sandbox/${this.id}`
510
+ const url = `${base}/api/v1/namespaces/${this.namespace}/sandboxes/${this.id}`
497
511
  this.ws?.beginIntentionalClose()
498
512
 
499
513
  let payload
@@ -516,33 +530,33 @@ class Sandbox {
516
530
  /**
517
531
  * Schedules a timeout that kills `execId` and rejects its pending entry.
518
532
  *
519
- * @param {string} execId
533
+ * @param {string} execId exec identifier to reject when the timeout fires
520
534
  * @param {string} command human-readable command string (for the error message)
521
535
  * @param {number} ms timeout in milliseconds
522
536
  * @returns {ReturnType<setTimeout>} the timer handle (stored on the entry for cancellation)
523
537
  */
524
- scheduleTimeout(execId, command, ms) {
538
+ scheduleTimeout (execId, command, ms) {
525
539
  return setTimeout(() => {
526
540
  try {
527
541
  this.kill(execId)
528
542
  } catch (_) {
529
543
  // ignore errors
530
544
  }
531
-
545
+
532
546
  this.ws.rejectExec(execId, new SandboxTimeoutError(
533
547
  `Command '${command}' exceeded timeout of ${ms}ms`
534
548
  ))
535
549
  }, ms)
536
550
  }
537
551
 
538
- ensureOpen() {
552
+ ensureOpen () {
539
553
  if (!this.ws) {
540
554
  throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
541
555
  }
542
556
  this.ws.ensureOpen()
543
557
  }
544
558
 
545
- sendFrame(frame) {
559
+ sendFrame (frame) {
546
560
  this.ws.send(frame)
547
561
  }
548
562
  }
@@ -556,7 +570,7 @@ class Sandbox {
556
570
  * every `getUrl()` call will throw `SandboxPortNotProvisionedError`).
557
571
  *
558
572
  * @param {object|null|undefined} raw the `previewUrls` field from the API response
559
- * @returns {Map<number, string>}
573
+ * @returns {Map<number, string>} preview URLs keyed by port number
560
574
  */
561
575
  function parsePreviewUrls (raw) {
562
576
  if (!raw || typeof raw !== 'object') {
package/src/utils.js CHANGED
@@ -18,10 +18,23 @@ const {
18
18
  } = require('./errors')
19
19
  const { SANDBOX_SIZES } = require('./constants')
20
20
 
21
+ /**
22
+ * Builds a Basic authorization header from a Runtime API key.
23
+ *
24
+ * @param {string} apiKey Runtime API key
25
+ * @returns {string} Basic authorization header value
26
+ */
21
27
  function buildAuthorizationHeader (apiKey) {
22
28
  return `Basic ${Buffer.from(apiKey).toString('base64')}`
23
29
  }
24
30
 
31
+ /**
32
+ * Maps sandbox management API status codes to SDK error classes.
33
+ *
34
+ * @param {number} status HTTP response status
35
+ * @param {string} message error message
36
+ * @returns {SandboxClientError} SDK error matching the response status
37
+ */
25
38
  function createSandboxHttpError (status, message) {
26
39
  if (status === 401 || status === 403) {
27
40
  return new SandboxUnauthorizedError(message)
@@ -35,6 +48,12 @@ function createSandboxHttpError (status, message) {
35
48
  return new SandboxClientError(message)
36
49
  }
37
50
 
51
+ /**
52
+ * Ensures the Runtime API host has a URL scheme.
53
+ *
54
+ * @param {string} host Runtime API host
55
+ * @returns {string} API host with a URL scheme
56
+ */
38
57
  function normalizeApiHost (host) {
39
58
  if (!host.match(/^https?:\/\//)) {
40
59
  return `https://${host}`
@@ -42,10 +61,18 @@ function normalizeApiHost (host) {
42
61
  return host
43
62
  }
44
63
 
64
+ /**
65
+ * Builds the sandbox execution WebSocket endpoint from Runtime API details.
66
+ *
67
+ * @param {string} apiHost Runtime API host
68
+ * @param {string} namespace Runtime namespace
69
+ * @param {string} sandboxId sandbox id
70
+ * @returns {string} sandbox WebSocket endpoint
71
+ */
45
72
  function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
46
73
  const url = new URL(apiHost)
47
74
  url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
48
- url.pathname = `/ws/v1/namespaces/${namespace}/sandbox/${sandboxId}/exec`
75
+ url.pathname = `/api/v1/namespaces/${namespace}/sandboxes/${sandboxId}/exec`
49
76
  url.search = ''
50
77
  return url.toString()
51
78
  }
@@ -55,7 +82,7 @@ function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
55
82
  * explicit overrides. Throws `SandboxInitializationError` for missing values.
56
83
  *
57
84
  * @param {object} overrides explicit credential overrides
58
- * @returns {{ apiHost: string, namespace: string, apiKey: string }}
85
+ * @returns {{ apiHost: string, namespace: string, apiKey: string }} resolved Runtime credentials
59
86
  */
60
87
  function resolveCredentials (overrides = {}) {
61
88
  const apiHost = overrides.apiHost || process.env.__OW_API_HOST
package/src/ws.js CHANGED
@@ -25,7 +25,7 @@ const {
25
25
  */
26
26
  class SandboxSocket {
27
27
  /**
28
- * @param {object} options
28
+ * @param {object} options socket options
29
29
  * @param {string} options.id sandbox id
30
30
  * @param {string} options.endpoint WebSocket endpoint URL
31
31
  * @param {string} options.token authentication token
@@ -144,7 +144,7 @@ class SandboxSocket {
144
144
  /**
145
145
  * Serialises `frame` and sends it over the socket.
146
146
  *
147
- * @param {object} frame
147
+ * @param {object} frame WebSocket frame to send
148
148
  */
149
149
  send (frame) {
150
150
  this.socket.send(JSON.stringify(frame))
@@ -167,7 +167,7 @@ class SandboxSocket {
167
167
  /**
168
168
  * Closes the underlying socket.
169
169
  *
170
- * @param {object} [options]
170
+ * @param {object} [options] close options
171
171
  * @param {boolean} [options.intentional] whether pending work should be drained without error
172
172
  */
173
173
  close ({ intentional = false } = {}) {
@@ -180,22 +180,22 @@ class SandboxSocket {
180
180
  // ------------------------------------------------------------------
181
181
 
182
182
  /**
183
- * Registers a pending exec, sends the frame, and returns the promises
183
+ * Registers a pending exec, sends the frame, and returns the promises
184
184
  * that will be settled by subsequent result or ack frames (for detached)
185
185
  *
186
- * @param {string} execId
186
+ * @param {string} execId execution id for the pending command
187
187
  * @param {object} frame the frame to send (must include `type` and `execId`)
188
- * @param {{ detached: boolean, onOutput?: Function }} options
189
- * @returns {{ ackPromise: Promise, waitPromise: Promise|null }}
188
+ * @param {{ detached: boolean, onOutput?: Function }} options pending exec options
189
+ * @returns {{ ackPromise: Promise, waitPromise: Promise|null }} promises for ack and optional completion
190
190
  */
191
191
  sendExec (execId, frame, { detached, onOutput }) {
192
192
  let waitResolve, waitReject, waitPromise
193
193
  if (detached) {
194
- waitPromise = new Promise((res, rej) => { waitResolve = res; waitReject = rej })
194
+ waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
195
195
  }
196
196
 
197
197
  let resolve, reject
198
- const ackPromise = new Promise((res, rej) => { resolve = res; reject = rej })
198
+ const ackPromise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject })
199
199
 
200
200
  this.pendingExecs.set(execId, {
201
201
  resolve,
@@ -225,8 +225,8 @@ class SandboxSocket {
225
225
  /**
226
226
  * Stores a timer handle on a pending exec entry so it can be cleared on completion.
227
227
  *
228
- * @param {string} execId
229
- * @param {ReturnType<setTimeout>} handle
228
+ * @param {string} execId execution id for the pending command
229
+ * @param {ReturnType<setTimeout>} handle timeout handle to store
230
230
  */
231
231
  setExecTimeout (execId, handle) {
232
232
  const entry = this.pendingExecs.get(execId)
@@ -236,8 +236,8 @@ class SandboxSocket {
236
236
  /**
237
237
  * Rejects and removes a pending exec, clearing its timeout.
238
238
  *
239
- * @param {string} execId
240
- * @param {Error} error
239
+ * @param {string} execId execution id for the pending command
240
+ * @param {Error} error error used to reject the command
241
241
  */
242
242
  rejectExec (execId, error) {
243
243
  const pending = this.pendingExecs.get(execId)
@@ -256,8 +256,8 @@ class SandboxSocket {
256
256
  /**
257
257
  * Rejects and removes a pending file operation.
258
258
  *
259
- * @param {string} execId
260
- * @param {Error} error
259
+ * @param {string} execId file operation id
260
+ * @param {Error} error error used to reject the file operation
261
261
  */
262
262
  rejectFileOp (execId, error) {
263
263
  const pending = this.pendingFileOps.get(execId)
@@ -269,7 +269,7 @@ class SandboxSocket {
269
269
  /**
270
270
  * Resolves and removes a pending exec during an intentional sandbox shutdown.
271
271
  *
272
- * @param {string} execId
272
+ * @param {string} execId execution id for the pending command
273
273
  */
274
274
  resolveExecOnIntentionalClose (execId) {
275
275
  const pending = this.pendingExecs.get(execId)
@@ -469,7 +469,7 @@ class SandboxSocket {
469
469
  /**
470
470
  * Handles exec.info (response to exec.get) and error frames routed from handleMessage.
471
471
  *
472
- * @param {object} frame
472
+ * @param {object} frame exec.get response or error frame
473
473
  */
474
474
  handleGetFrame (frame) {
475
475
  const pending = this.pendingGetOps.get(frame.execId)
@@ -517,12 +517,12 @@ class SandboxSocket {
517
517
  *
518
518
  * @param {object} frame exec.info frame
519
519
  * @param {object} pending entry from pendingGetOps
520
- * @returns {Promise}
520
+ * @returns {Promise} wait promise for the running command
521
521
  */
522
522
  resolveExecEntry (frame, pending) {
523
523
  const existingExec = this.pendingExecs.get(frame.execId)
524
524
  if (existingExec) {
525
- this.mergeOnOutputCallback(existingExec, pending.onOutput)
525
+ existingExec.onOutput = this.mergeOnOutputCallback(existingExec.onOutput, pending.onOutput)
526
526
  return existingExec._waitPromise
527
527
  }
528
528
  return this.registerReattachedExec(frame, pending.onOutput)
@@ -531,13 +531,13 @@ class SandboxSocket {
531
531
  /**
532
532
  * Appends `onOutput` to an existing exec entry's callback chain, preserving the previous handler.
533
533
  *
534
- * @param {object} existingExec entry from pendingExecs
534
+ * @param {Function|null|undefined} prev existing callback
535
535
  * @param {Function|undefined} onOutput new callback to add
536
+ * @returns {Function|null|undefined} merged callback
536
537
  */
537
- mergeOnOutputCallback (existingExec, onOutput) {
538
- if (!onOutput) return
539
- const prev = existingExec.onOutput
540
- existingExec.onOutput = (data, stream) => {
538
+ mergeOnOutputCallback (prev, onOutput) {
539
+ if (!onOutput) return prev
540
+ return (data, stream) => {
541
541
  if (prev) prev(data, stream)
542
542
  onOutput(data, stream)
543
543
  }
@@ -549,11 +549,11 @@ class SandboxSocket {
549
549
  *
550
550
  * @param {object} frame exec.info frame
551
551
  * @param {Function|undefined} onOutput output callback
552
- * @returns {Promise}
552
+ * @returns {Promise} wait promise for the reattached command
553
553
  */
554
554
  registerReattachedExec (frame, onOutput) {
555
555
  let waitResolve, waitReject
556
- const waitPromise = new Promise((res, rej) => { waitResolve = res; waitReject = rej })
556
+ const waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
557
557
  this.pendingExecs.set(frame.execId, {
558
558
  resolve: () => {},
559
559
  reject: () => {},
@@ -576,7 +576,7 @@ class SandboxSocket {
576
576
  * @param {object} frame exec.info frame
577
577
  * @param {Promise} waitPromise resolves when the process exits
578
578
  * @param {object} sandbox Sandbox instance for delegating control operations
579
- * @returns {object}
579
+ * @returns {object} command handle with wait and control helpers
580
580
  */
581
581
  buildCommandObject (frame, waitPromise, sandbox) {
582
582
  const { execId } = frame
@@ -67,7 +67,7 @@ class FakeWebSocket extends EventEmitter {
67
67
 
68
68
  const BASE_OPTIONS = {
69
69
  id: 'sb-test',
70
- endpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-test/exec',
70
+ endpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-test/exec',
71
71
  status: 'ready',
72
72
  namespace: 'ns',
73
73
  apiHost: 'https://runtime.example.net',
@@ -80,6 +80,9 @@ const BASE_OPTIONS = {
80
80
 
81
81
  let sockets
82
82
 
83
+ /**
84
+ *
85
+ */
83
86
  function setupWebSocket () {
84
87
  sockets = []
85
88
  WebSocket.OPEN = 1
@@ -90,6 +93,12 @@ function setupWebSocket () {
90
93
  })
91
94
  }
92
95
 
96
+ /**
97
+ * Builds a connected sandbox backed by the fake WebSocket.
98
+ *
99
+ * @param {object} opts sandbox option overrides
100
+ * @returns {Promise<Sandbox>} connected sandbox instance
101
+ */
93
102
  async function buildConnectedSandbox (opts = {}) {
94
103
  const sandbox = new Sandbox({ ...BASE_OPTIONS, ...opts })
95
104
  const connectPromise = sandbox.connect()
@@ -200,7 +209,7 @@ describe('Sandbox', () => {
200
209
  ok: true,
201
210
  json: () => Promise.resolve({
202
211
  sandboxId: 'sb-new',
203
- wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-new/exec',
212
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-new/exec',
204
213
  status: 'ready',
205
214
  token: 'tok-new',
206
215
  maxLifetime: 3600,
@@ -231,7 +240,7 @@ describe('Sandbox', () => {
231
240
  [3000, 'https://sb-new-3000.preview.example.net']
232
241
  ]))
233
242
  expect(mockFetch).toHaveBeenCalledWith(
234
- 'https://runtime.example.net/api/v1/namespaces/ns/sandbox',
243
+ 'https://runtime.example.net/api/v1/namespaces/ns/sandboxes',
235
244
  expect.objectContaining({ method: 'POST' })
236
245
  )
237
246
  })
@@ -241,7 +250,7 @@ describe('Sandbox', () => {
241
250
  ok: true,
242
251
  json: () => Promise.resolve({
243
252
  sandboxId: 'sb-pol',
244
- wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-pol/exec',
253
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-pol/exec',
245
254
  status: 'ready',
246
255
  token: 'tok-pol',
247
256
  maxLifetime: 3600
@@ -272,7 +281,7 @@ describe('Sandbox', () => {
272
281
  ok: true,
273
282
  json: () => Promise.resolve({
274
283
  sandboxId: 'sb-ports',
275
- wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-ports/exec',
284
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-ports/exec',
276
285
  status: 'ready',
277
286
  token: 'tok-ports',
278
287
  maxLifetime: 3600,
@@ -312,7 +321,7 @@ describe('Sandbox', () => {
312
321
  ok: true,
313
322
  json: () => Promise.resolve({
314
323
  sandboxId: 'sb-env',
315
- wsEndpoint: 'wss://runtime.example.net/ws/v1/namespaces/ns/sandbox/sb-env/exec',
324
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-env/exec',
316
325
  status: 'ready',
317
326
  token: 'tok-env',
318
327
  maxLifetime: 3600
@@ -333,6 +342,98 @@ describe('Sandbox', () => {
333
342
  await expect(Sandbox.create({ name: 'no-creds' })).rejects.toThrow(SandboxInitializationError)
334
343
  })
335
344
 
345
+ test('sends default idleTimeout 900 and maxLifetime 3600 when not specified', async () => {
346
+ const mockFetch = jest.fn().mockResolvedValue({
347
+ ok: true,
348
+ json: () => Promise.resolve({
349
+ sandboxId: 'sb-defaults',
350
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-defaults/exec',
351
+ status: 'ready',
352
+ token: 'tok-defaults',
353
+ idleTimeout: 900,
354
+ maxLifetime: 3600
355
+ })
356
+ })
357
+ global.fetch = mockFetch
358
+
359
+ const createPromise = Sandbox.create({
360
+ name: 'defaults-sandbox',
361
+ apiHost: 'https://runtime.example.net',
362
+ namespace: 'ns',
363
+ auth: 'uuid:key'
364
+ })
365
+
366
+ await new Promise(resolve => setImmediate(resolve))
367
+ sockets[0].open()
368
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-defaults' })
369
+ await createPromise
370
+
371
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
372
+ expect(body.idleTimeout).toBe(900)
373
+ expect(body.maxLifetime).toBe(3600)
374
+ })
375
+
376
+ test('forwards explicit idleTimeout in the request body', async () => {
377
+ const mockFetch = jest.fn().mockResolvedValue({
378
+ ok: true,
379
+ json: () => Promise.resolve({
380
+ sandboxId: 'sb-idle',
381
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-idle/exec',
382
+ status: 'ready',
383
+ token: 'tok-idle',
384
+ idleTimeout: 1800,
385
+ maxLifetime: 3600
386
+ })
387
+ })
388
+ global.fetch = mockFetch
389
+
390
+ const createPromise = Sandbox.create({
391
+ name: 'idle-sandbox',
392
+ apiHost: 'https://runtime.example.net',
393
+ namespace: 'ns',
394
+ auth: 'uuid:key',
395
+ idleTimeout: 1800
396
+ })
397
+
398
+ await new Promise(resolve => setImmediate(resolve))
399
+ sockets[0].open()
400
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-idle' })
401
+ await createPromise
402
+
403
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
404
+ expect(body.idleTimeout).toBe(1800)
405
+ })
406
+
407
+ test('stores idleTimeout from the create response on the instance', async () => {
408
+ const mockFetch = jest.fn().mockResolvedValue({
409
+ ok: true,
410
+ json: () => Promise.resolve({
411
+ sandboxId: 'sb-store',
412
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-store/exec',
413
+ status: 'ready',
414
+ token: 'tok-store',
415
+ idleTimeout: 1800,
416
+ maxLifetime: 3600
417
+ })
418
+ })
419
+ global.fetch = mockFetch
420
+
421
+ const createPromise = Sandbox.create({
422
+ name: 'store-sandbox',
423
+ apiHost: 'https://runtime.example.net',
424
+ namespace: 'ns',
425
+ auth: 'uuid:key',
426
+ idleTimeout: 1800
427
+ })
428
+
429
+ await new Promise(resolve => setImmediate(resolve))
430
+ sockets[0].open()
431
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-store' })
432
+ const sandbox = await createPromise
433
+
434
+ expect(sandbox.idleTimeout).toBe(1800)
435
+ })
436
+
336
437
  test('falls back to buildWebSocketEndpoint when wsEndpoint absent', async () => {
337
438
  const mockFetch = jest.fn().mockResolvedValue({
338
439
  ok: true,
@@ -355,7 +456,7 @@ describe('Sandbox', () => {
355
456
  await new Promise(resolve => setImmediate(resolve))
356
457
  sockets[0].open()
357
458
  sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-noep' })
358
- const sandbox = await createPromise
459
+ await createPromise
359
460
 
360
461
  expect(sockets[0].url).toContain('wss://')
361
462
  expect(sockets[0].url).toContain('sb-noep')
@@ -385,6 +486,26 @@ describe('Sandbox', () => {
385
486
  expect(sandbox.cluster).toBe('cluster-b')
386
487
  })
387
488
 
489
+ test('stores idleTimeout from the get response on the instance', async () => {
490
+ global.fetch = jest.fn().mockResolvedValue({
491
+ ok: true,
492
+ json: () => Promise.resolve({
493
+ sandboxId: 'sb-get-idle',
494
+ status: 'running',
495
+ idleTimeout: 1200,
496
+ maxLifetime: 3600
497
+ })
498
+ })
499
+
500
+ const sandbox = await Sandbox.get('sb-get-idle', {
501
+ apiHost: 'https://runtime.example.net',
502
+ namespace: 'ns',
503
+ auth: 'uuid:key'
504
+ })
505
+
506
+ expect(sandbox.idleTimeout).toBe(1200)
507
+ })
508
+
388
509
  test('throws SandboxNotFoundError on 404', async () => {
389
510
  global.fetch = jest.fn().mockResolvedValue({
390
511
  ok: false,
@@ -408,6 +529,38 @@ describe('Sandbox', () => {
408
529
  Sandbox.get('sb-x', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'bad' })
409
530
  ).rejects.toThrow(SandboxUnauthorizedError)
410
531
  })
532
+
533
+ test('throws SandboxTimeoutError on 504', async () => {
534
+ global.fetch = jest.fn().mockResolvedValue({
535
+ ok: false,
536
+ status: 504,
537
+ text: () => Promise.resolve('gateway timeout')
538
+ })
539
+
540
+ await expect(
541
+ Sandbox.get('sb-slow', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
542
+ ).rejects.toThrow(SandboxTimeoutError)
543
+ })
544
+
545
+ test('throws SandboxClientError on unexpected API status', async () => {
546
+ global.fetch = jest.fn().mockResolvedValue({
547
+ ok: false,
548
+ status: 500,
549
+ text: () => Promise.resolve('server error')
550
+ })
551
+
552
+ await expect(
553
+ Sandbox.get('sb-error', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
554
+ ).rejects.toThrow(SandboxClientError)
555
+ })
556
+
557
+ test('wraps fetch failures in SandboxClientError', async () => {
558
+ global.fetch = jest.fn().mockRejectedValue(new Error('network unavailable'))
559
+
560
+ await expect(
561
+ Sandbox.get('sb-network', { apiHost: 'https://runtime.example.net', namespace: 'ns', auth: 'key' })
562
+ ).rejects.toThrow(SandboxClientError)
563
+ })
411
564
  })
412
565
 
413
566
  // -------------------------------------------------------------------------
@@ -844,7 +997,7 @@ describe('Sandbox', () => {
844
997
  expect(result.status).toBe('destroyed')
845
998
  expect(sandbox.status).toBe('destroyed')
846
999
  expect(mockFetch).toHaveBeenCalledWith(
847
- expect.stringContaining('/sandbox/sb-test'),
1000
+ expect.stringContaining('/sandboxes/sb-test'),
848
1001
  expect.objectContaining({ method: 'DELETE' })
849
1002
  )
850
1003
  })