@adobe/aio-lib-sandbox 0.1.0-alpha.3
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/.github/CONTRIBUTING.md +44 -0
- package/.github/workflows/daily.yml +11 -0
- package/.github/workflows/node.js.yml +15 -0
- package/.github/workflows/on-push-publish-to-npm.yml +25 -0
- package/CODE_OF_CONDUCT.md +79 -0
- package/COPYRIGHT +5 -0
- package/LICENSE +201 -0
- package/README.md +182 -0
- package/jest.config.js +19 -0
- package/package.json +29 -0
- package/src/Sandbox.js +467 -0
- package/src/constants.js +19 -0
- package/src/errors.js +37 -0
- package/src/index.js +32 -0
- package/src/utils.js +143 -0
- package/src/ws.js +297 -0
- package/test/Sandbox.test.js +748 -0
package/src/Sandbox.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
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
|
+
} = require('./errors')
|
|
18
|
+
const {
|
|
19
|
+
buildWebSocketEndpoint,
|
|
20
|
+
resolveCredentials,
|
|
21
|
+
normalizeSize,
|
|
22
|
+
apiRequest
|
|
23
|
+
} = require('./utils')
|
|
24
|
+
const { SANDBOX_SIZES } = require('./constants')
|
|
25
|
+
const { SandboxSocket } = require('./ws')
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Connected compute sandbox session.
|
|
29
|
+
*
|
|
30
|
+
* Use `Sandbox.create()` or `Sandbox.get()`
|
|
31
|
+
*/
|
|
32
|
+
class Sandbox {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} options sandbox options
|
|
35
|
+
* @private
|
|
36
|
+
*/
|
|
37
|
+
constructor (options) {
|
|
38
|
+
this.id = options.id
|
|
39
|
+
this.endpoint = options.endpoint
|
|
40
|
+
this.status = options.status
|
|
41
|
+
this.cluster = options.cluster
|
|
42
|
+
this.region = options.region
|
|
43
|
+
this.maxLifetime = options.maxLifetime
|
|
44
|
+
|
|
45
|
+
this.namespace = options.namespace
|
|
46
|
+
this.apiHost = options.apiHost
|
|
47
|
+
this.apiKey = options.apiKey
|
|
48
|
+
this.token = options.token
|
|
49
|
+
this.publicUrlTemplate = options.publicUrlTemplate || null
|
|
50
|
+
this.managementEndpoint = options.managementEndpoint || null
|
|
51
|
+
this.ws = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates a new compute sandbox and opens its WebSocket session.
|
|
56
|
+
*
|
|
57
|
+
* Credentials are read from the environment automatically when running inside
|
|
58
|
+
* a Runtime action (`__OW_API_HOST`, `__OW_NAMESPACE`, `__OW_API_KEY`).
|
|
59
|
+
* Any value passed explicitly in `options` overrides the environment.
|
|
60
|
+
*
|
|
61
|
+
* Commands run inside the sandbox start in the `/workspace` directory by default.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [options] creation options
|
|
64
|
+
* @param {string} [options.apiHost] Runtime API host (overrides `__OW_API_HOST`)
|
|
65
|
+
* @param {string} [options.namespace] Runtime namespace (overrides `__OW_NAMESPACE`)
|
|
66
|
+
* @param {string} [options.auth] Runtime API key (overrides `__OW_API_KEY`)
|
|
67
|
+
* @param {string} [options.name] sandbox display name
|
|
68
|
+
* @param {string} [options.type] sandbox type (default: `'cpu:default'`)
|
|
69
|
+
* @param {string|object} [options.size] sandbox size tier (name or spec object)
|
|
70
|
+
* @param {number} [options.maxLifetime] maximum lifetime in seconds
|
|
71
|
+
* @param {object} [options.envs] environment variables to inject into the sandbox
|
|
72
|
+
* @param {object} [options.policy] network policy (e.g. egress allowlist)
|
|
73
|
+
* @returns {Promise<Sandbox>} connected sandbox instance
|
|
74
|
+
*/
|
|
75
|
+
static async create (options = {}) {
|
|
76
|
+
console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
|
|
77
|
+
const creds = resolveCredentials(options)
|
|
78
|
+
|
|
79
|
+
const body = {
|
|
80
|
+
name: options.name,
|
|
81
|
+
size: normalizeSize(options.size),
|
|
82
|
+
type: options.type || 'cpu:default',
|
|
83
|
+
maxLifetime: options.maxLifetime || 3600
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.cluster !== undefined) body.cluster = options.cluster
|
|
87
|
+
if (options.region !== undefined) body.region = options.region
|
|
88
|
+
if (options.envs !== undefined) body.envs = options.envs
|
|
89
|
+
if (options.policy !== undefined) body.policy = options.policy
|
|
90
|
+
|
|
91
|
+
const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox`
|
|
92
|
+
const payload = await apiRequest('POST', url, creds.apiKey, body)
|
|
93
|
+
|
|
94
|
+
const sandboxId = payload.sandboxId
|
|
95
|
+
const endpoint = payload.wsEndpoint || buildWebSocketEndpoint(creds.apiHost, creds.namespace, sandboxId)
|
|
96
|
+
|
|
97
|
+
const sandbox = new Sandbox({
|
|
98
|
+
id: sandboxId,
|
|
99
|
+
endpoint,
|
|
100
|
+
status: payload.status,
|
|
101
|
+
cluster: payload.cluster,
|
|
102
|
+
region: payload.region,
|
|
103
|
+
maxLifetime: payload.maxLifetime,
|
|
104
|
+
publicUrlTemplate: payload.publicUrlTemplate || null,
|
|
105
|
+
managementEndpoint: payload.managementEndpoint || null,
|
|
106
|
+
namespace: creds.namespace,
|
|
107
|
+
apiHost: creds.apiHost,
|
|
108
|
+
apiKey: creds.apiKey,
|
|
109
|
+
token: payload.token
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
await sandbox.connect()
|
|
113
|
+
return sandbox
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fetches an existing sandbox.
|
|
118
|
+
*
|
|
119
|
+
* Credentials are read from the environment automatically.
|
|
120
|
+
* Any value passed explicitly in `options` overrides the environment.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} sandboxId the sandbox ID to look up
|
|
123
|
+
* @param {object} [options] credential overrides
|
|
124
|
+
* @param {string} [options.apiHost] Runtime API host
|
|
125
|
+
* @param {string} [options.namespace] Runtime namespace
|
|
126
|
+
* @param {string} [options.auth] Runtime API key
|
|
127
|
+
* @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
|
|
128
|
+
*/
|
|
129
|
+
static async get (sandboxId, options = {}) {
|
|
130
|
+
console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
|
|
131
|
+
const creds = resolveCredentials(options)
|
|
132
|
+
const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox/${sandboxId}`
|
|
133
|
+
const payload = await apiRequest('GET', url, creds.apiKey)
|
|
134
|
+
|
|
135
|
+
return new Sandbox({
|
|
136
|
+
id: payload.sandboxId || sandboxId,
|
|
137
|
+
endpoint: null,
|
|
138
|
+
status: payload.status,
|
|
139
|
+
cluster: payload.cluster,
|
|
140
|
+
region: payload.region,
|
|
141
|
+
maxLifetime: payload.maxLifetime,
|
|
142
|
+
namespace: creds.namespace,
|
|
143
|
+
apiHost: creds.apiHost,
|
|
144
|
+
apiKey: creds.apiKey,
|
|
145
|
+
token: null
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Named sandbox size tiers.
|
|
151
|
+
*
|
|
152
|
+
* @type {object}
|
|
153
|
+
*/
|
|
154
|
+
static get sizes () {
|
|
155
|
+
return SANDBOX_SIZES
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Exposes `resolveCredentials` as a static helper (useful for testing).
|
|
160
|
+
*
|
|
161
|
+
* @param {object} overrides credential overrides
|
|
162
|
+
* @returns {{ apiHost: string, namespace: string, apiKey: string }}
|
|
163
|
+
*/
|
|
164
|
+
static resolveCredentials (overrides = {}) {
|
|
165
|
+
return resolveCredentials(overrides)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Exposes `normalizeSize` as a static helper (useful for testing).
|
|
170
|
+
*
|
|
171
|
+
* @param {string|object|undefined} size
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
static normalizeSize (size) {
|
|
175
|
+
return normalizeSize(size)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ------------------------------------------------------------------
|
|
179
|
+
// WebSocket connection
|
|
180
|
+
// ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Opens the sandbox WebSocket connection (called automatically by `create()`).
|
|
184
|
+
*
|
|
185
|
+
* @returns {Promise<void>}
|
|
186
|
+
*/
|
|
187
|
+
connect () {
|
|
188
|
+
if (!this.ws) {
|
|
189
|
+
this.ws = new SandboxSocket({
|
|
190
|
+
id: this.id,
|
|
191
|
+
endpoint: this.endpoint,
|
|
192
|
+
token: this.token
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
return this.ws.connect()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ------------------------------------------------------------------
|
|
199
|
+
// Exec
|
|
200
|
+
// ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Executes a command inside the sandbox.
|
|
204
|
+
*
|
|
205
|
+
* Returns a Promise (with an `execId` property) that resolves to
|
|
206
|
+
* `{ execId, stdout, stderr, exitCode }` when the command completes.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} command command to run
|
|
209
|
+
* @param {object} [options] execution options
|
|
210
|
+
* @param {number} [options.timeout] timeout in milliseconds
|
|
211
|
+
* @param {string|Buffer} [options.stdin] data to send to stdin at startup
|
|
212
|
+
* @param {function} [options.onOutput] callback called with `(data, stream)` for each output chunk
|
|
213
|
+
* @returns {Promise<{execId: string, stdout: string, stderr: string, exitCode: number}>}
|
|
214
|
+
*/
|
|
215
|
+
exec (command, options = {}) {
|
|
216
|
+
try {
|
|
217
|
+
this.ensureOpen()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return Promise.reject(error)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const execId = `exec-${crypto.randomBytes(12).toString('hex')}`
|
|
223
|
+
let timeoutHandle
|
|
224
|
+
|
|
225
|
+
const execPromise = new Promise((resolve, reject) => {
|
|
226
|
+
this.ws.pendingExecs.set(execId, {
|
|
227
|
+
resolve,
|
|
228
|
+
reject,
|
|
229
|
+
stdout: '',
|
|
230
|
+
stderr: '',
|
|
231
|
+
onOutput: options.onOutput,
|
|
232
|
+
timeout: undefined
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
if (options.timeout) {
|
|
236
|
+
timeoutHandle = setTimeout(() => {
|
|
237
|
+
try { this.kill(execId) } catch (_) {}
|
|
238
|
+
this.ws.rejectExec(execId, new SandboxTimeoutError(
|
|
239
|
+
`Command '${command}' exceeded timeout of ${options.timeout}ms`
|
|
240
|
+
))
|
|
241
|
+
}, options.timeout)
|
|
242
|
+
this.ws.pendingExecs.get(execId).timeout = timeoutHandle
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
execPromise.execId = execId
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
this.sendFrame({ type: 'exec.run', execId, command })
|
|
250
|
+
if (options.stdin !== undefined) {
|
|
251
|
+
this.writeStdin(execId, options.stdin)
|
|
252
|
+
this.closeStdin(execId)
|
|
253
|
+
}
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this.ws.rejectExec(execId, new SandboxWebSocketError(
|
|
256
|
+
`Could not send exec frame: ${error.message}`
|
|
257
|
+
))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return execPromise
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Sends a signal to a running command.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} execId execution id
|
|
267
|
+
* @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
|
|
268
|
+
*/
|
|
269
|
+
kill (execId, signal = 'SIGTERM') {
|
|
270
|
+
this.ensureOpen()
|
|
271
|
+
this.sendFrame({ type: 'exec.kill', execId, signal })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Writes data to the stdin of a running command.
|
|
276
|
+
* Fire-and-forget — there is no response on success.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} execId execution id from `exec()`
|
|
279
|
+
* @param {string|Buffer} data data to write
|
|
280
|
+
*/
|
|
281
|
+
writeStdin (execId, data) {
|
|
282
|
+
this.ensureOpen()
|
|
283
|
+
const frame = { type: 'exec.input', execId }
|
|
284
|
+
if (Buffer.isBuffer(data)) {
|
|
285
|
+
frame.data = data.toString('base64')
|
|
286
|
+
frame.encoding = 'base64'
|
|
287
|
+
} else {
|
|
288
|
+
frame.data = data
|
|
289
|
+
}
|
|
290
|
+
this.sendFrame(frame)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Closes stdin for a running command, signalling EOF.
|
|
295
|
+
* Fire-and-forget — there is no response on success.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} execId execution id from `exec()`
|
|
298
|
+
*/
|
|
299
|
+
closeStdin (execId) {
|
|
300
|
+
this.ensureOpen()
|
|
301
|
+
this.sendFrame({ type: 'exec.endInput', execId })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------------
|
|
305
|
+
// File operations
|
|
306
|
+
// ------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Reads a file from the sandbox filesystem.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} path path inside the sandbox
|
|
312
|
+
* @returns {Promise<string>} file contents as a UTF-8 string
|
|
313
|
+
*/
|
|
314
|
+
readFile (path) {
|
|
315
|
+
try {
|
|
316
|
+
this.ensureOpen()
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return Promise.reject(error)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const execId = `file-${crypto.randomBytes(12).toString('hex')}`
|
|
322
|
+
const opPromise = new Promise((resolve, reject) => {
|
|
323
|
+
this.ws.pendingFileOps.set(execId, { resolve, reject })
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
this.sendFrame({ type: 'file.read', execId, path })
|
|
328
|
+
} catch (error) {
|
|
329
|
+
this.ws.rejectFileOp(execId, new SandboxWebSocketError(
|
|
330
|
+
`Could not send file.read frame: ${error.message}`
|
|
331
|
+
))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return opPromise
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Writes a file to the sandbox filesystem. Parent directories are created automatically.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} path path inside the sandbox
|
|
341
|
+
* @param {string|Buffer} content file contents
|
|
342
|
+
* @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
|
|
343
|
+
*/
|
|
344
|
+
writeFile (path, content) {
|
|
345
|
+
try {
|
|
346
|
+
this.ensureOpen()
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return Promise.reject(error)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const execId = `file-${crypto.randomBytes(12).toString('hex')}`
|
|
352
|
+
const encoded = Buffer.isBuffer(content)
|
|
353
|
+
? content.toString('base64')
|
|
354
|
+
: Buffer.from(content).toString('base64')
|
|
355
|
+
|
|
356
|
+
const opPromise = new Promise((resolve, reject) => {
|
|
357
|
+
this.ws.pendingFileOps.set(execId, { resolve, reject })
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
this.sendFrame({ type: 'file.write', execId, path, content: encoded, encoding: 'base64' })
|
|
362
|
+
} catch (error) {
|
|
363
|
+
this.ws.rejectFileOp(execId, new SandboxWebSocketError(
|
|
364
|
+
`Could not send file.write frame: ${error.message}`
|
|
365
|
+
))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return opPromise
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Lists the contents of a directory inside the sandbox.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} path directory path inside the sandbox
|
|
375
|
+
* @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
|
|
376
|
+
*/
|
|
377
|
+
listFiles (path) {
|
|
378
|
+
try {
|
|
379
|
+
this.ensureOpen()
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return Promise.reject(error)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const execId = `file-${crypto.randomBytes(12).toString('hex')}`
|
|
385
|
+
const opPromise = new Promise((resolve, reject) => {
|
|
386
|
+
this.ws.pendingFileOps.set(execId, { resolve, reject })
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
this.sendFrame({ type: 'file.list', execId, path })
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.ws.rejectFileOp(execId, new SandboxWebSocketError(
|
|
393
|
+
`Could not send file.list frame: ${error.message}`
|
|
394
|
+
))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return opPromise
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ------------------------------------------------------------------
|
|
401
|
+
// Lifecycle
|
|
402
|
+
// ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Returns the public preview URL for a given port on this sandbox.
|
|
406
|
+
*
|
|
407
|
+
* @param {object} options URL options
|
|
408
|
+
* @param {number} options.port port number (1–65535)
|
|
409
|
+
* @param {string} [options.protocol] override the URL scheme (e.g. `'wss'`)
|
|
410
|
+
* @returns {Promise<string>} public preview URL
|
|
411
|
+
*/
|
|
412
|
+
async getUrl ({ port, protocol } = {}) {
|
|
413
|
+
if (!this.publicUrlTemplate) {
|
|
414
|
+
throw new SandboxClientError(
|
|
415
|
+
`Cannot get URL for sandbox '${this.id}': publicUrlTemplate is not available`
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
420
|
+
throw new SandboxClientError(
|
|
421
|
+
`Invalid port '${port}': must be an integer between 1 and 65535`
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let url = this.publicUrlTemplate
|
|
426
|
+
.replace('{sandboxId}', this.id)
|
|
427
|
+
.replace('{port}', String(port))
|
|
428
|
+
|
|
429
|
+
if (protocol) {
|
|
430
|
+
url = url.replace(/^https?:\/\//, `${protocol}://`)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return url
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Destroys the sandbox and closes its WebSocket connection.
|
|
438
|
+
*
|
|
439
|
+
* @returns {Promise<object>} destroy response payload
|
|
440
|
+
*/
|
|
441
|
+
async destroy () {
|
|
442
|
+
const base = this.managementEndpoint || this.apiHost
|
|
443
|
+
const url = `${base}/api/v1/namespaces/${this.namespace}/sandbox/${this.id}`
|
|
444
|
+
const payload = await apiRequest('DELETE', url, this.apiKey)
|
|
445
|
+
|
|
446
|
+
this.status = payload.status || this.status
|
|
447
|
+
this.ws?.close()
|
|
448
|
+
return payload
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ------------------------------------------------------------------
|
|
452
|
+
// Private helpers
|
|
453
|
+
// ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
ensureOpen () {
|
|
456
|
+
if (!this.ws) {
|
|
457
|
+
throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
|
|
458
|
+
}
|
|
459
|
+
this.ws.ensureOpen()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
sendFrame (frame) {
|
|
463
|
+
this.ws.send(frame)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
module.exports = Sandbox
|
package/src/constants.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
|
+
const SANDBOX_SIZES = Object.freeze({
|
|
13
|
+
SMALL: { cpu: '500m', memory: '512Mi', gpu: 0 },
|
|
14
|
+
MEDIUM: { cpu: '2000m', memory: '4Gi', gpu: 0 },
|
|
15
|
+
LARGE: { cpu: '4000m', memory: '16Gi', gpu: 0 },
|
|
16
|
+
XLARGE: { cpu: '8000m', memory: '32Gi', gpu: 1 }
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
module.exports = { SANDBOX_SIZES }
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
class SandboxSDKError extends Error {
|
|
13
|
+
constructor (message) {
|
|
14
|
+
super(message)
|
|
15
|
+
this.name = this.constructor.name
|
|
16
|
+
if (Error.captureStackTrace) {
|
|
17
|
+
Error.captureStackTrace(this, this.constructor)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class SandboxInitializationError extends SandboxSDKError {}
|
|
23
|
+
class SandboxClientError extends SandboxSDKError {}
|
|
24
|
+
class SandboxNotFoundError extends SandboxSDKError {}
|
|
25
|
+
class SandboxUnauthorizedError extends SandboxSDKError {}
|
|
26
|
+
class SandboxTimeoutError extends SandboxSDKError {}
|
|
27
|
+
class SandboxWebSocketError extends SandboxSDKError {}
|
|
28
|
+
|
|
29
|
+
module.exports = {
|
|
30
|
+
SandboxSDKError,
|
|
31
|
+
SandboxInitializationError,
|
|
32
|
+
SandboxClientError,
|
|
33
|
+
SandboxNotFoundError,
|
|
34
|
+
SandboxUnauthorizedError,
|
|
35
|
+
SandboxTimeoutError,
|
|
36
|
+
SandboxWebSocketError
|
|
37
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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 Sandbox = require('./Sandbox')
|
|
13
|
+
const {
|
|
14
|
+
SandboxSDKError,
|
|
15
|
+
SandboxInitializationError,
|
|
16
|
+
SandboxClientError,
|
|
17
|
+
SandboxNotFoundError,
|
|
18
|
+
SandboxUnauthorizedError,
|
|
19
|
+
SandboxTimeoutError,
|
|
20
|
+
SandboxWebSocketError
|
|
21
|
+
} = require('./errors')
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
Sandbox,
|
|
25
|
+
SandboxSDKError,
|
|
26
|
+
SandboxInitializationError,
|
|
27
|
+
SandboxClientError,
|
|
28
|
+
SandboxNotFoundError,
|
|
29
|
+
SandboxUnauthorizedError,
|
|
30
|
+
SandboxTimeoutError,
|
|
31
|
+
SandboxWebSocketError
|
|
32
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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 {
|
|
13
|
+
SandboxClientError,
|
|
14
|
+
SandboxInitializationError,
|
|
15
|
+
SandboxNotFoundError,
|
|
16
|
+
SandboxUnauthorizedError,
|
|
17
|
+
SandboxTimeoutError
|
|
18
|
+
} = require('./errors')
|
|
19
|
+
const { SANDBOX_SIZES } = require('./constants')
|
|
20
|
+
|
|
21
|
+
function buildAuthorizationHeader (apiKey) {
|
|
22
|
+
return `Basic ${Buffer.from(apiKey).toString('base64')}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createSandboxHttpError (status, message) {
|
|
26
|
+
if (status === 401 || status === 403) {
|
|
27
|
+
return new SandboxUnauthorizedError(message)
|
|
28
|
+
}
|
|
29
|
+
if (status === 404) {
|
|
30
|
+
return new SandboxNotFoundError(message)
|
|
31
|
+
}
|
|
32
|
+
if (status === 504) {
|
|
33
|
+
return new SandboxTimeoutError(message)
|
|
34
|
+
}
|
|
35
|
+
return new SandboxClientError(message)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeApiHost (host) {
|
|
39
|
+
if (!host.match(/^https?:\/\//)) {
|
|
40
|
+
return `https://${host}`
|
|
41
|
+
}
|
|
42
|
+
return host
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildWebSocketEndpoint (apiHost, namespace, sandboxId) {
|
|
46
|
+
const url = new URL(apiHost)
|
|
47
|
+
url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
|
|
48
|
+
url.pathname = `/ws/v1/namespaces/${namespace}/sandbox/${sandboxId}/exec`
|
|
49
|
+
url.search = ''
|
|
50
|
+
return url.toString()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reads Runtime credentials from environment variables, merged with any
|
|
55
|
+
* explicit overrides. Throws `SandboxInitializationError` for missing values.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} overrides explicit credential overrides
|
|
58
|
+
* @returns {{ apiHost: string, namespace: string, apiKey: string }}
|
|
59
|
+
*/
|
|
60
|
+
function resolveCredentials (overrides = {}) {
|
|
61
|
+
const apiHost = overrides.apiHost || process.env.__OW_API_HOST
|
|
62
|
+
const namespace = overrides.namespace || process.env.__OW_NAMESPACE
|
|
63
|
+
const apiKey = overrides.auth || process.env.__OW_API_KEY
|
|
64
|
+
|
|
65
|
+
const missing = []
|
|
66
|
+
if (!apiHost) missing.push('apiHost')
|
|
67
|
+
if (!namespace) missing.push('namespace')
|
|
68
|
+
if (!apiKey) missing.push('auth')
|
|
69
|
+
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
throw new SandboxInitializationError(
|
|
72
|
+
`Missing required credentials: ${missing.join(', ')}. ` +
|
|
73
|
+
'Pass them explicitly or set __OW_API_HOST, __OW_NAMESPACE, __OW_API_KEY in the environment.'
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { apiHost: normalizeApiHost(apiHost), namespace, apiKey }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string|object|undefined} size size name or spec object
|
|
82
|
+
* @returns {string} normalised size name
|
|
83
|
+
*/
|
|
84
|
+
function normalizeSize (size) {
|
|
85
|
+
if (!size) return 'MEDIUM'
|
|
86
|
+
|
|
87
|
+
if (typeof size === 'string' && SANDBOX_SIZES[size]) return size
|
|
88
|
+
|
|
89
|
+
if (typeof size === 'object') {
|
|
90
|
+
const entry = Object.entries(SANDBOX_SIZES).find(
|
|
91
|
+
([, v]) => v.cpu === size.cpu && v.memory === size.memory && v.gpu === size.gpu
|
|
92
|
+
)
|
|
93
|
+
if (entry) return entry[0]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new SandboxClientError('Invalid sandbox size provided')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Thin fetch wrapper around the management REST API.
|
|
101
|
+
*
|
|
102
|
+
* Uses the Node.js global `fetch` (available since Node 18).
|
|
103
|
+
*
|
|
104
|
+
* @param {string} method HTTP method
|
|
105
|
+
* @param {string} url full request URL
|
|
106
|
+
* @param {string} apiKey API key for Basic auth
|
|
107
|
+
* @param {object|undefined} body request body (JSON-encoded when present)
|
|
108
|
+
* @returns {Promise<object>} parsed response JSON
|
|
109
|
+
*/
|
|
110
|
+
async function apiRequest (method, url, apiKey, body) {
|
|
111
|
+
const headers = { Authorization: buildAuthorizationHeader(apiKey) }
|
|
112
|
+
const init = { method, headers }
|
|
113
|
+
|
|
114
|
+
if (body !== undefined) {
|
|
115
|
+
headers['Content-Type'] = 'application/json'
|
|
116
|
+
init.body = JSON.stringify(body)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let response
|
|
120
|
+
try {
|
|
121
|
+
response = await fetch(url, init)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new SandboxClientError(`Sandbox API request failed: ${error.message}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
const text = await response.text()
|
|
128
|
+
const detail = `${response.status}${text ? ` ${text}` : ''}`
|
|
129
|
+
throw createSandboxHttpError(response.status, detail)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return response.json()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
buildAuthorizationHeader,
|
|
137
|
+
createSandboxHttpError,
|
|
138
|
+
normalizeApiHost,
|
|
139
|
+
buildWebSocketEndpoint,
|
|
140
|
+
resolveCredentials,
|
|
141
|
+
normalizeSize,
|
|
142
|
+
apiRequest
|
|
143
|
+
}
|