@adobe/aio-lib-sandbox 0.1.0-alpha.7 → 0.1.0-alpha.9

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 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aio-lib-sandbox",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "JavaScript SDK for Adobe Runtime Sandboxes",
5
5
  "main": "src/index.js",
6
6
  "license": "Apache-2.0",
package/src/Sandbox.js CHANGED
@@ -23,7 +23,7 @@ const {
23
23
  normalizeSize,
24
24
  apiRequest
25
25
  } = require('./utils')
26
- const { SANDBOX_SIZES } = require('./constants')
26
+ const { SANDBOX_SIZES, PROTOCOL_VERSION, API_PREFIX } = require('./constants')
27
27
  const { SandboxSocket } = require('./ws')
28
28
 
29
29
  /**
@@ -42,7 +42,9 @@ class Sandbox {
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
47
+ this.protocolVersion = options.protocolVersion || PROTOCOL_VERSION
46
48
 
47
49
  this.namespace = options.namespace
48
50
  this.apiHost = options.apiHost
@@ -70,7 +72,10 @@ class Sandbox {
70
72
  * @param {string} [options.name] sandbox display name
71
73
  * @param {string} [options.type] sandbox type (default: `'cpu:default'`)
72
74
  * @param {string|object} [options.size] sandbox size tier (name or spec object)
73
- * @param {number} [options.maxLifetime] maximum lifetime in seconds
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)
74
79
  * @param {number[]} [options.ports] TCP ports to expose via preview URLs (default: `[]`)
75
80
  * @param {object} [options.envs] environment variables to inject into the sandbox
76
81
  * @param {object} [options.policy] network policy (e.g. egress allowlist)
@@ -84,6 +89,7 @@ class Sandbox {
84
89
  name: options.name,
85
90
  size: normalizeSize(options.size),
86
91
  type: options.type || 'cpu:default',
92
+ idleTimeout: options.idleTimeout || 900,
87
93
  maxLifetime: options.maxLifetime || 3600
88
94
  }
89
95
 
@@ -93,7 +99,7 @@ class Sandbox {
93
99
  if (options.policy !== undefined) body.policy = options.policy
94
100
  if (options.ports !== undefined) body.ports = options.ports
95
101
 
96
- const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes`
102
+ const url = `${creds.apiHost}${API_PREFIX}/namespaces/${creds.namespace}/sandboxes`
97
103
  const payload = await apiRequest('POST', url, creds.apiKey, body)
98
104
 
99
105
  const sandboxId = payload.sandboxId
@@ -105,7 +111,9 @@ class Sandbox {
105
111
  status: payload.status,
106
112
  cluster: payload.cluster,
107
113
  region: payload.region,
114
+ idleTimeout: payload.idleTimeout,
108
115
  maxLifetime: payload.maxLifetime,
116
+ protocolVersion: payload.protocolVersion || PROTOCOL_VERSION,
109
117
  previewUrls: parsePreviewUrls(payload.previewUrls),
110
118
  managementEndpoint: payload.managementEndpoint || null,
111
119
  namespace: creds.namespace,
@@ -124,17 +132,23 @@ class Sandbox {
124
132
  * Credentials are read from the environment automatically.
125
133
  * Any value passed explicitly in `options` overrides the environment.
126
134
  *
135
+ * Pass the management endpoint so the request is sent to the correct host;
136
+ * falls back to `options.apiHost` when omitted.
137
+ *
127
138
  * @param {string} sandboxId the sandbox ID to look up
128
139
  * @param {object} [options] credential overrides
129
140
  * @param {string} [options.apiHost] Runtime API host
130
141
  * @param {string} [options.namespace] Runtime namespace
131
142
  * @param {string} [options.auth] Runtime API key
143
+ * @param {string} [options.managementEndpoint] per-sandbox management endpoint returned by
144
+ * `Sandbox.create()`. Falls back to `apiHost` otherwise.
132
145
  * @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
133
146
  */
134
147
  static async get (sandboxId, options = {}) {
135
148
  console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
136
149
  const creds = resolveCredentials(options)
137
- const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandboxes/${sandboxId}`
150
+ const base = options.managementEndpoint || creds.apiHost
151
+ const url = `${base}${API_PREFIX}/namespaces/${creds.namespace}/sandboxes/${sandboxId}`
138
152
  const payload = await apiRequest('GET', url, creds.apiKey)
139
153
 
140
154
  return new Sandbox({
@@ -143,7 +157,10 @@ class Sandbox {
143
157
  status: payload.status,
144
158
  cluster: payload.cluster,
145
159
  region: payload.region,
160
+ idleTimeout: payload.idleTimeout,
146
161
  maxLifetime: payload.maxLifetime,
162
+ protocolVersion: payload.protocolVersion || PROTOCOL_VERSION,
163
+ managementEndpoint: payload.managementEndpoint || options.managementEndpoint || null,
147
164
  previewUrls: parsePreviewUrls(payload.previewUrls),
148
165
  namespace: creds.namespace,
149
166
  apiHost: creds.apiHost,
@@ -161,6 +178,15 @@ class Sandbox {
161
178
  return SANDBOX_SIZES
162
179
  }
163
180
 
181
+ /**
182
+ * Sandbox wire protocol major bundled with this SDK.
183
+ *
184
+ * @type {string}
185
+ */
186
+ static get protocolVersion () {
187
+ return PROTOCOL_VERSION
188
+ }
189
+
164
190
  /**
165
191
  * Exposes `resolveCredentials` as a static helper (useful for testing).
166
192
  *
@@ -493,7 +519,7 @@ class Sandbox {
493
519
  */
494
520
  async destroy () {
495
521
  const base = this.managementEndpoint || this.apiHost
496
- const url = `${base}/api/v1/namespaces/${this.namespace}/sandboxes/${this.id}`
522
+ const url = `${base}${API_PREFIX}/namespaces/${this.namespace}/sandboxes/${this.id}`
497
523
  this.ws?.beginIntentionalClose()
498
524
 
499
525
  let payload
package/src/constants.js CHANGED
@@ -16,4 +16,7 @@ const SANDBOX_SIZES = Object.freeze({
16
16
  XLARGE: { cpu: '8000m', memory: '32Gi', gpu: 1 }
17
17
  })
18
18
 
19
- module.exports = { SANDBOX_SIZES }
19
+ const PROTOCOL_VERSION = '1'
20
+ const API_PREFIX = `/api/v${PROTOCOL_VERSION}`
21
+
22
+ module.exports = { SANDBOX_SIZES, PROTOCOL_VERSION, API_PREFIX }
package/src/errors.js CHANGED
@@ -28,6 +28,8 @@ class SandboxWebSocketError extends SandboxSDKError {}
28
28
  class SandboxCommandNotFoundError extends SandboxSDKError {}
29
29
  class SandboxPortNotProvisionedError extends SandboxSDKError {}
30
30
  class SandboxInvalidPortError extends SandboxClientError {}
31
+ class ProtocolVersionMismatchError extends SandboxClientError {}
32
+ class SandboxMalformedFrameError extends SandboxClientError {}
31
33
 
32
34
  module.exports = {
33
35
  SandboxSDKError,
@@ -39,5 +41,7 @@ module.exports = {
39
41
  SandboxWebSocketError,
40
42
  SandboxCommandNotFoundError,
41
43
  SandboxPortNotProvisionedError,
42
- SandboxInvalidPortError
44
+ SandboxInvalidPortError,
45
+ ProtocolVersionMismatchError,
46
+ SandboxMalformedFrameError
43
47
  }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ governing permissions and limitations under the License.
10
10
  */
11
11
 
12
12
  const Sandbox = require('./Sandbox')
13
+ const { PROTOCOL_VERSION } = require('./constants')
13
14
  const {
14
15
  SandboxSDKError,
15
16
  SandboxInitializationError,
@@ -20,11 +21,14 @@ const {
20
21
  SandboxWebSocketError,
21
22
  SandboxCommandNotFoundError,
22
23
  SandboxPortNotProvisionedError,
23
- SandboxInvalidPortError
24
+ SandboxInvalidPortError,
25
+ ProtocolVersionMismatchError,
26
+ SandboxMalformedFrameError
24
27
  } = require('./errors')
25
28
 
26
29
  module.exports = {
27
30
  Sandbox,
31
+ SANDBOX_PROTOCOL_VERSION: PROTOCOL_VERSION,
28
32
  SandboxSDKError,
29
33
  SandboxInitializationError,
30
34
  SandboxClientError,
@@ -34,5 +38,7 @@ module.exports = {
34
38
  SandboxWebSocketError,
35
39
  SandboxCommandNotFoundError,
36
40
  SandboxPortNotProvisionedError,
37
- SandboxInvalidPortError
41
+ SandboxInvalidPortError,
42
+ ProtocolVersionMismatchError,
43
+ SandboxMalformedFrameError
38
44
  }
package/src/utils.js CHANGED
@@ -16,7 +16,7 @@ const {
16
16
  SandboxUnauthorizedError,
17
17
  SandboxTimeoutError
18
18
  } = require('./errors')
19
- const { SANDBOX_SIZES } = require('./constants')
19
+ const { SANDBOX_SIZES, API_PREFIX } = require('./constants')
20
20
 
21
21
  /**
22
22
  * Builds a Basic authorization header from a Runtime API key.
@@ -72,7 +72,7 @@ function normalizeApiHost (host) {
72
72
  function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
73
73
  const url = new URL(apiHost)
74
74
  url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
75
- url.pathname = `/api/v1/namespaces/${namespace}/sandboxes/${sandboxId}/exec`
75
+ url.pathname = `${API_PREFIX}/namespaces/${namespace}/sandboxes/${sandboxId}/exec`
76
76
  url.search = ''
77
77
  return url.toString()
78
78
  }
package/src/ws.js CHANGED
@@ -13,6 +13,8 @@ const WebSocket = require('ws')
13
13
  const {
14
14
  SandboxClientError,
15
15
  SandboxCommandNotFoundError,
16
+ ProtocolVersionMismatchError,
17
+ SandboxMalformedFrameError,
16
18
  SandboxUnauthorizedError,
17
19
  SandboxWebSocketError
18
20
  } = require('./errors')
@@ -355,6 +357,16 @@ class SandboxSocket {
355
357
  `Sandbox '${this.id}' rejected the WebSocket authentication token`
356
358
  )
357
359
  }
360
+ if (code === 4003) {
361
+ return new ProtocolVersionMismatchError(
362
+ `Sandbox '${this.id}' WebSocket protocol version does not match this SDK`
363
+ )
364
+ }
365
+ if (code === 4004) {
366
+ return new SandboxMalformedFrameError(
367
+ `Sandbox '${this.id}' rejected a malformed WebSocket frame`
368
+ )
369
+ }
358
370
  return new SandboxWebSocketError(
359
371
  `Sandbox '${this.id}' WebSocket closed with code ${code}`
360
372
  )
@@ -17,11 +17,13 @@ const {
17
17
  SandboxCommandNotFoundError,
18
18
  SandboxInitializationError,
19
19
  SandboxNotFoundError,
20
+ ProtocolVersionMismatchError,
20
21
  SandboxPortNotProvisionedError,
21
22
  SandboxInvalidPortError,
22
23
  SandboxTimeoutError,
23
24
  SandboxUnauthorizedError,
24
- SandboxWebSocketError
25
+ SandboxWebSocketError,
26
+ SandboxMalformedFrameError
25
27
  } = require('../src/errors')
26
28
 
27
29
  jest.mock('ws')
@@ -199,6 +201,12 @@ describe('Sandbox', () => {
199
201
  })
200
202
  })
201
203
 
204
+ describe('protocolVersion', () => {
205
+ test('exposes the bundled sandbox protocol major', () => {
206
+ expect(Sandbox.protocolVersion).toBe('1')
207
+ })
208
+ })
209
+
202
210
  // -------------------------------------------------------------------------
203
211
  // Static factories
204
212
  // -------------------------------------------------------------------------
@@ -213,6 +221,7 @@ describe('Sandbox', () => {
213
221
  status: 'ready',
214
222
  token: 'tok-new',
215
223
  maxLifetime: 3600,
224
+ protocolVersion: '1',
216
225
  previewUrls: {
217
226
  3000: 'https://sb-new-3000.preview.example.net'
218
227
  }
@@ -236,6 +245,7 @@ describe('Sandbox', () => {
236
245
 
237
246
  expect(sandbox.id).toBe('sb-new')
238
247
  expect(sandbox.status).toBe('ready')
248
+ expect(sandbox.protocolVersion).toBe('1')
239
249
  expect(sandbox.previewUrls).toEqual(new Map([
240
250
  [3000, 'https://sb-new-3000.preview.example.net']
241
251
  ]))
@@ -342,6 +352,98 @@ describe('Sandbox', () => {
342
352
  await expect(Sandbox.create({ name: 'no-creds' })).rejects.toThrow(SandboxInitializationError)
343
353
  })
344
354
 
355
+ test('sends default idleTimeout 900 and maxLifetime 3600 when not specified', async () => {
356
+ const mockFetch = jest.fn().mockResolvedValue({
357
+ ok: true,
358
+ json: () => Promise.resolve({
359
+ sandboxId: 'sb-defaults',
360
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-defaults/exec',
361
+ status: 'ready',
362
+ token: 'tok-defaults',
363
+ idleTimeout: 900,
364
+ maxLifetime: 3600
365
+ })
366
+ })
367
+ global.fetch = mockFetch
368
+
369
+ const createPromise = Sandbox.create({
370
+ name: 'defaults-sandbox',
371
+ apiHost: 'https://runtime.example.net',
372
+ namespace: 'ns',
373
+ auth: 'uuid:key'
374
+ })
375
+
376
+ await new Promise(resolve => setImmediate(resolve))
377
+ sockets[0].open()
378
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-defaults' })
379
+ await createPromise
380
+
381
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
382
+ expect(body.idleTimeout).toBe(900)
383
+ expect(body.maxLifetime).toBe(3600)
384
+ })
385
+
386
+ test('forwards explicit idleTimeout in the request body', async () => {
387
+ const mockFetch = jest.fn().mockResolvedValue({
388
+ ok: true,
389
+ json: () => Promise.resolve({
390
+ sandboxId: 'sb-idle',
391
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-idle/exec',
392
+ status: 'ready',
393
+ token: 'tok-idle',
394
+ idleTimeout: 1800,
395
+ maxLifetime: 3600
396
+ })
397
+ })
398
+ global.fetch = mockFetch
399
+
400
+ const createPromise = Sandbox.create({
401
+ name: 'idle-sandbox',
402
+ apiHost: 'https://runtime.example.net',
403
+ namespace: 'ns',
404
+ auth: 'uuid:key',
405
+ idleTimeout: 1800
406
+ })
407
+
408
+ await new Promise(resolve => setImmediate(resolve))
409
+ sockets[0].open()
410
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-idle' })
411
+ await createPromise
412
+
413
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
414
+ expect(body.idleTimeout).toBe(1800)
415
+ })
416
+
417
+ test('stores idleTimeout from the create response on the instance', async () => {
418
+ const mockFetch = jest.fn().mockResolvedValue({
419
+ ok: true,
420
+ json: () => Promise.resolve({
421
+ sandboxId: 'sb-store',
422
+ wsEndpoint: 'wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-store/exec',
423
+ status: 'ready',
424
+ token: 'tok-store',
425
+ idleTimeout: 1800,
426
+ maxLifetime: 3600
427
+ })
428
+ })
429
+ global.fetch = mockFetch
430
+
431
+ const createPromise = Sandbox.create({
432
+ name: 'store-sandbox',
433
+ apiHost: 'https://runtime.example.net',
434
+ namespace: 'ns',
435
+ auth: 'uuid:key',
436
+ idleTimeout: 1800
437
+ })
438
+
439
+ await new Promise(resolve => setImmediate(resolve))
440
+ sockets[0].open()
441
+ sockets[0].message({ type: 'auth.ok', sandboxId: 'sb-store' })
442
+ const sandbox = await createPromise
443
+
444
+ expect(sandbox.idleTimeout).toBe(1800)
445
+ })
446
+
345
447
  test('falls back to buildWebSocketEndpoint when wsEndpoint absent', async () => {
346
448
  const mockFetch = jest.fn().mockResolvedValue({
347
449
  ok: true,
@@ -379,7 +481,8 @@ describe('Sandbox', () => {
379
481
  sandboxId: 'sb-get',
380
482
  status: 'running',
381
483
  cluster: 'cluster-b',
382
- region: 'va6'
484
+ region: 'va6',
485
+ protocolVersion: '1'
383
486
  })
384
487
  })
385
488
 
@@ -392,6 +495,27 @@ describe('Sandbox', () => {
392
495
  expect(sandbox.id).toBe('sb-get')
393
496
  expect(sandbox.status).toBe('running')
394
497
  expect(sandbox.cluster).toBe('cluster-b')
498
+ expect(sandbox.protocolVersion).toBe('1')
499
+ })
500
+
501
+ test('stores idleTimeout from the get response on the instance', async () => {
502
+ global.fetch = jest.fn().mockResolvedValue({
503
+ ok: true,
504
+ json: () => Promise.resolve({
505
+ sandboxId: 'sb-get-idle',
506
+ status: 'running',
507
+ idleTimeout: 1200,
508
+ maxLifetime: 3600
509
+ })
510
+ })
511
+
512
+ const sandbox = await Sandbox.get('sb-get-idle', {
513
+ apiHost: 'https://runtime.example.net',
514
+ namespace: 'ns',
515
+ auth: 'uuid:key'
516
+ })
517
+
518
+ expect(sandbox.idleTimeout).toBe(1200)
395
519
  })
396
520
 
397
521
  test('throws SandboxNotFoundError on 404', async () => {
@@ -490,6 +614,22 @@ describe('Sandbox', () => {
490
614
  await expect(p).rejects.toThrow(SandboxUnauthorizedError)
491
615
  })
492
616
 
617
+ test('rejects on protocol mismatch close code 4003 with ProtocolVersionMismatchError', async () => {
618
+ const sandbox = new Sandbox(BASE_OPTIONS)
619
+ const p = sandbox.connect()
620
+ sockets[0].open()
621
+ sockets[0].closeWith(4003)
622
+ await expect(p).rejects.toThrow(ProtocolVersionMismatchError)
623
+ })
624
+
625
+ test('rejects on malformed frame close code 4004 with SandboxMalformedFrameError', async () => {
626
+ const sandbox = new Sandbox(BASE_OPTIONS)
627
+ const p = sandbox.connect()
628
+ sockets[0].open()
629
+ sockets[0].closeWith(4004)
630
+ await expect(p).rejects.toThrow(SandboxMalformedFrameError)
631
+ })
632
+
493
633
  test('rejects on unexpected socket close', async () => {
494
634
  const sandbox = new Sandbox(BASE_OPTIONS)
495
635
  const p = sandbox.connect()