@adobe/aio-lib-sandbox 0.1.0-alpha.3 → 0.1.0-alpha.5
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 +42 -4
- package/RELEASING.md +36 -0
- package/package.json +1 -1
- package/src/Sandbox.js +178 -70
- package/src/errors.js +7 -1
- package/src/index.js +8 -2
- package/src/ws.js +310 -10
- package/test/Sandbox.test.js +552 -17
package/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# App Builder Sandbox SDK
|
|
2
2
|
|
|
3
3
|
[](https://npmjs.org/package/@adobe/aio-lib-sandbox)
|
|
4
|
+
[](https://npmjs.org/package/@adobe/aio-lib-sandbox)
|
|
5
|
+

|
|
4
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
|
+
[](https://codecov.io/gh/adobe/aio-lib-sandbox/)
|
|
5
8
|

|
|
6
9
|
|
|
7
10
|
JavaScript SDK for Adobe Runtime Sandboxes.
|
|
@@ -23,7 +26,7 @@ npm install @adobe/aio-lib-sandbox@alpha
|
|
|
23
26
|
|
|
24
27
|
## Quickstart
|
|
25
28
|
|
|
26
|
-
Inside a Runtime action, credentials are read automatically from the environment.
|
|
29
|
+
Inside a Runtime action, no configuration is needed to use the SDK as credentials are read automatically from the environment.
|
|
27
30
|
|
|
28
31
|
```js
|
|
29
32
|
const { Sandbox } = require('@adobe/aio-lib-sandbox')
|
|
@@ -72,6 +75,7 @@ const sandbox = await Sandbox.create({
|
|
|
72
75
|
name: 'my-sandbox',
|
|
73
76
|
type: 'cpu:default',
|
|
74
77
|
maxLifetime: 3600,
|
|
78
|
+
ports: [3000, 8080],
|
|
75
79
|
envs: { API_KEY: 'your-api-key' }
|
|
76
80
|
})
|
|
77
81
|
```
|
|
@@ -93,6 +97,31 @@ console.log('exit code:', result.exitCode)
|
|
|
93
97
|
|
|
94
98
|
> Note: Commands run in the `/workspace` directory by default, this is not configurable
|
|
95
99
|
|
|
100
|
+
### Detached Commands
|
|
101
|
+
|
|
102
|
+
Pass `detached: true` to run a long-lived background process.
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
// Start a background server
|
|
106
|
+
const command = await sandbox.exec('node server.js', { detached: true })
|
|
107
|
+
|
|
108
|
+
// Wait for it to exit (e.g. after you stop it)
|
|
109
|
+
const result = await command.wait()
|
|
110
|
+
console.log('exit code:', result.exitCode)
|
|
111
|
+
|
|
112
|
+
// Send a signal to stop it
|
|
113
|
+
await command.kill()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If the process is still running and you need a handle to it from a different context, use `getCommand()` to re-attach by `execId`:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
const command = await sandbox.getCommand(execId, { onOutput: (data, stream) => process.stdout.write(data) })
|
|
120
|
+
await command.wait()
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
> Note: Only 5 background processes are allowed to run at once currently.
|
|
124
|
+
|
|
96
125
|
### File Management
|
|
97
126
|
|
|
98
127
|
```js
|
|
@@ -146,11 +175,20 @@ await sandbox.destroy()
|
|
|
146
175
|
|
|
147
176
|
### Preview URLs
|
|
148
177
|
|
|
149
|
-
|
|
178
|
+
Ports that should be publicly accessible must be declared at creation time via the `ports` array.
|
|
150
179
|
|
|
151
180
|
```js
|
|
152
|
-
const
|
|
153
|
-
|
|
181
|
+
const sandbox = await Sandbox.create({
|
|
182
|
+
name: 'web-sandbox',
|
|
183
|
+
ports: [3000, 8080]
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Start a server inside the sandbox on the declared port
|
|
187
|
+
await sandbox.exec('node server.js &', { timeout: 50_000 })
|
|
188
|
+
|
|
189
|
+
// Retrieve the pre-provisioned preview URL — synchronous, no network call
|
|
190
|
+
const url = sandbox.getUrl(3000)
|
|
191
|
+
console.log('preview:', url)
|
|
154
192
|
// https://sb-abc123-va6-0-xK3mPq2nAeB-3000.sandbox-adobeioruntime.net
|
|
155
193
|
```
|
|
156
194
|
|
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.5 --no-publish
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Replace `0.1.0-alpha.5` 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.
|
|
36
|
+
- If `np` complains about a dirty working tree or unpushed commits, resolve those before proceeding.
|
package/package.json
CHANGED
package/src/Sandbox.js
CHANGED
|
@@ -13,7 +13,9 @@ const crypto = require('node:crypto')
|
|
|
13
13
|
const {
|
|
14
14
|
SandboxClientError,
|
|
15
15
|
SandboxTimeoutError,
|
|
16
|
-
SandboxWebSocketError
|
|
16
|
+
SandboxWebSocketError,
|
|
17
|
+
SandboxPortNotProvisionedError,
|
|
18
|
+
SandboxInvalidPortError
|
|
17
19
|
} = require('./errors')
|
|
18
20
|
const {
|
|
19
21
|
buildWebSocketEndpoint,
|
|
@@ -34,7 +36,7 @@ class Sandbox {
|
|
|
34
36
|
* @param {object} options sandbox options
|
|
35
37
|
* @private
|
|
36
38
|
*/
|
|
37
|
-
constructor
|
|
39
|
+
constructor(options) {
|
|
38
40
|
this.id = options.id
|
|
39
41
|
this.endpoint = options.endpoint
|
|
40
42
|
this.status = options.status
|
|
@@ -46,7 +48,8 @@ class Sandbox {
|
|
|
46
48
|
this.apiHost = options.apiHost
|
|
47
49
|
this.apiKey = options.apiKey
|
|
48
50
|
this.token = options.token
|
|
49
|
-
|
|
51
|
+
// previewUrls is a Map<number, string> of (port → URL) returned by the server.
|
|
52
|
+
this.previewUrls = options.previewUrls || new Map()
|
|
50
53
|
this.managementEndpoint = options.managementEndpoint || null
|
|
51
54
|
this.ws = null
|
|
52
55
|
}
|
|
@@ -68,11 +71,12 @@ class Sandbox {
|
|
|
68
71
|
* @param {string} [options.type] sandbox type (default: `'cpu:default'`)
|
|
69
72
|
* @param {string|object} [options.size] sandbox size tier (name or spec object)
|
|
70
73
|
* @param {number} [options.maxLifetime] maximum lifetime in seconds
|
|
74
|
+
* @param {number[]} [options.ports] TCP ports to expose via preview URLs (default: `[]`)
|
|
71
75
|
* @param {object} [options.envs] environment variables to inject into the sandbox
|
|
72
76
|
* @param {object} [options.policy] network policy (e.g. egress allowlist)
|
|
73
77
|
* @returns {Promise<Sandbox>} connected sandbox instance
|
|
74
78
|
*/
|
|
75
|
-
static async create
|
|
79
|
+
static async create(options = {}) {
|
|
76
80
|
console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
|
|
77
81
|
const creds = resolveCredentials(options)
|
|
78
82
|
|
|
@@ -87,6 +91,7 @@ class Sandbox {
|
|
|
87
91
|
if (options.region !== undefined) body.region = options.region
|
|
88
92
|
if (options.envs !== undefined) body.envs = options.envs
|
|
89
93
|
if (options.policy !== undefined) body.policy = options.policy
|
|
94
|
+
if (options.ports !== undefined) body.ports = options.ports
|
|
90
95
|
|
|
91
96
|
const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox`
|
|
92
97
|
const payload = await apiRequest('POST', url, creds.apiKey, body)
|
|
@@ -101,7 +106,7 @@ class Sandbox {
|
|
|
101
106
|
cluster: payload.cluster,
|
|
102
107
|
region: payload.region,
|
|
103
108
|
maxLifetime: payload.maxLifetime,
|
|
104
|
-
|
|
109
|
+
previewUrls: parsePreviewUrls(payload.previewUrls),
|
|
105
110
|
managementEndpoint: payload.managementEndpoint || null,
|
|
106
111
|
namespace: creds.namespace,
|
|
107
112
|
apiHost: creds.apiHost,
|
|
@@ -126,7 +131,7 @@ class Sandbox {
|
|
|
126
131
|
* @param {string} [options.auth] Runtime API key
|
|
127
132
|
* @returns {Promise<Sandbox>} sandbox instance with `status` populated (not WebSocket-connected)
|
|
128
133
|
*/
|
|
129
|
-
static async get
|
|
134
|
+
static async get(sandboxId, options = {}) {
|
|
130
135
|
console.warn('[aio-lib-sandbox] alpha — APIs may change without notice')
|
|
131
136
|
const creds = resolveCredentials(options)
|
|
132
137
|
const url = `${creds.apiHost}/api/v1/namespaces/${creds.namespace}/sandbox/${sandboxId}`
|
|
@@ -139,6 +144,7 @@ class Sandbox {
|
|
|
139
144
|
cluster: payload.cluster,
|
|
140
145
|
region: payload.region,
|
|
141
146
|
maxLifetime: payload.maxLifetime,
|
|
147
|
+
previewUrls: parsePreviewUrls(payload.previewUrls),
|
|
142
148
|
namespace: creds.namespace,
|
|
143
149
|
apiHost: creds.apiHost,
|
|
144
150
|
apiKey: creds.apiKey,
|
|
@@ -151,7 +157,7 @@ class Sandbox {
|
|
|
151
157
|
*
|
|
152
158
|
* @type {object}
|
|
153
159
|
*/
|
|
154
|
-
static get sizes
|
|
160
|
+
static get sizes() {
|
|
155
161
|
return SANDBOX_SIZES
|
|
156
162
|
}
|
|
157
163
|
|
|
@@ -161,7 +167,7 @@ class Sandbox {
|
|
|
161
167
|
* @param {object} overrides credential overrides
|
|
162
168
|
* @returns {{ apiHost: string, namespace: string, apiKey: string }}
|
|
163
169
|
*/
|
|
164
|
-
static resolveCredentials
|
|
170
|
+
static resolveCredentials(overrides = {}) {
|
|
165
171
|
return resolveCredentials(overrides)
|
|
166
172
|
}
|
|
167
173
|
|
|
@@ -171,7 +177,7 @@ class Sandbox {
|
|
|
171
177
|
* @param {string|object|undefined} size
|
|
172
178
|
* @returns {string}
|
|
173
179
|
*/
|
|
174
|
-
static normalizeSize
|
|
180
|
+
static normalizeSize(size) {
|
|
175
181
|
return normalizeSize(size)
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -184,7 +190,7 @@ class Sandbox {
|
|
|
184
190
|
*
|
|
185
191
|
* @returns {Promise<void>}
|
|
186
192
|
*/
|
|
187
|
-
connect
|
|
193
|
+
connect() {
|
|
188
194
|
if (!this.ws) {
|
|
189
195
|
this.ws = new SandboxSocket({
|
|
190
196
|
id: this.id,
|
|
@@ -202,62 +208,110 @@ class Sandbox {
|
|
|
202
208
|
/**
|
|
203
209
|
* Executes a command inside the sandbox.
|
|
204
210
|
*
|
|
205
|
-
*
|
|
206
|
-
* `{ execId, stdout, stderr, exitCode }` when the command completes.
|
|
211
|
+
* `options.timeout` is not supported with `options.detached: true`.
|
|
207
212
|
*
|
|
208
213
|
* @param {string} command command to run
|
|
209
214
|
* @param {object} [options] execution options
|
|
210
|
-
* @param {number} [options.timeout] timeout in milliseconds
|
|
215
|
+
* @param {number} [options.timeout] timeout in milliseconds (foreground only)
|
|
216
|
+
* @param {boolean} [options.detached] when true, run as a detached background process
|
|
211
217
|
* @param {string|Buffer} [options.stdin] data to send to stdin at startup
|
|
212
218
|
* @param {function} [options.onOutput] callback called with `(data, stream)` for each output chunk
|
|
213
|
-
* @returns {Promise
|
|
219
|
+
* @returns {Promise}
|
|
214
220
|
*/
|
|
215
|
-
exec
|
|
221
|
+
exec(command, options = {}) {
|
|
216
222
|
try {
|
|
217
223
|
this.ensureOpen()
|
|
218
224
|
} catch (error) {
|
|
219
225
|
return Promise.reject(error)
|
|
220
226
|
}
|
|
221
227
|
|
|
228
|
+
if (options.detached && options.timeout) {
|
|
229
|
+
return Promise.reject(new SandboxClientError(
|
|
230
|
+
'cannot set a timeout for a detached command'
|
|
231
|
+
))
|
|
232
|
+
}
|
|
233
|
+
|
|
222
234
|
const execId = `exec-${crypto.randomBytes(12).toString('hex')}`
|
|
223
|
-
|
|
235
|
+
const promise = this.sendExecFrameAndAwaitResponse(execId, command, options)
|
|
236
|
+
promise.execId = execId
|
|
237
|
+
return promise
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {string} execId
|
|
242
|
+
* @param {string} command
|
|
243
|
+
* @param {object} options
|
|
244
|
+
* @private
|
|
245
|
+
*/
|
|
246
|
+
async sendExecFrameAndAwaitResponse(execId, command, options) {
|
|
247
|
+
const detached = !!options.detached
|
|
248
|
+
const frame = { type: 'exec.run', execId, command, ...(detached && { detached: true }) }
|
|
249
|
+
|
|
250
|
+
const { ackPromise, waitPromise } = this.ws.sendExec(execId, frame, {
|
|
251
|
+
detached,
|
|
252
|
+
onOutput: options.onOutput
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
if (options.timeout) {
|
|
256
|
+
this.ws.setExecTimeout(execId, this.scheduleTimeout(execId, command, options.timeout))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (options.stdin !== undefined) {
|
|
260
|
+
this.writeStdin(execId, options.stdin)
|
|
261
|
+
this.closeStdin(execId)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = await ackPromise
|
|
224
265
|
|
|
225
|
-
|
|
226
|
-
|
|
266
|
+
if (!detached) return result
|
|
267
|
+
|
|
268
|
+
const { pid, startedAt } = result
|
|
269
|
+
return {
|
|
270
|
+
execId,
|
|
271
|
+
pid,
|
|
272
|
+
startedAt,
|
|
273
|
+
detached: true,
|
|
274
|
+
wait: () => waitPromise,
|
|
275
|
+
writeStdin: (data) => this.writeStdin(execId, data),
|
|
276
|
+
closeStdin: () => this.closeStdin(execId),
|
|
277
|
+
kill: (signal) => this.kill(execId, signal)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Re-attaches to a detached command that is still running in the sandbox.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} execId the execId returned by the original `exec()` call
|
|
285
|
+
* @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}>}
|
|
288
|
+
*/
|
|
289
|
+
getCommand(execId, options = {}) {
|
|
290
|
+
try {
|
|
291
|
+
this.ensureOpen()
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return Promise.reject(error)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const getPromise = new Promise((resolve, reject) => {
|
|
297
|
+
this.ws.pendingGetOps.set(execId, {
|
|
227
298
|
resolve,
|
|
228
299
|
reject,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
onOutput: options.onOutput,
|
|
232
|
-
timeout: undefined
|
|
300
|
+
onOutput: options.onOutput || null,
|
|
301
|
+
sandbox: this
|
|
233
302
|
})
|
|
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
303
|
})
|
|
245
304
|
|
|
246
|
-
execPromise.execId = execId
|
|
247
|
-
|
|
248
305
|
try {
|
|
249
|
-
this.sendFrame({ type: 'exec.
|
|
250
|
-
if (options.stdin !== undefined) {
|
|
251
|
-
this.writeStdin(execId, options.stdin)
|
|
252
|
-
this.closeStdin(execId)
|
|
253
|
-
}
|
|
306
|
+
this.sendFrame({ type: 'exec.get', execId })
|
|
254
307
|
} catch (error) {
|
|
255
|
-
this.ws.
|
|
256
|
-
|
|
308
|
+
this.ws.pendingGetOps.delete(execId)
|
|
309
|
+
return Promise.reject(new SandboxWebSocketError(
|
|
310
|
+
`Could not send exec.get frame: ${error.message}`
|
|
257
311
|
))
|
|
258
312
|
}
|
|
259
313
|
|
|
260
|
-
return
|
|
314
|
+
return getPromise
|
|
261
315
|
}
|
|
262
316
|
|
|
263
317
|
/**
|
|
@@ -266,7 +320,7 @@ class Sandbox {
|
|
|
266
320
|
* @param {string} execId execution id
|
|
267
321
|
* @param {string} [signal] signal to deliver (default: `'SIGTERM'`)
|
|
268
322
|
*/
|
|
269
|
-
kill
|
|
323
|
+
kill(execId, signal = 'SIGTERM') {
|
|
270
324
|
this.ensureOpen()
|
|
271
325
|
this.sendFrame({ type: 'exec.kill', execId, signal })
|
|
272
326
|
}
|
|
@@ -278,7 +332,7 @@ class Sandbox {
|
|
|
278
332
|
* @param {string} execId execution id from `exec()`
|
|
279
333
|
* @param {string|Buffer} data data to write
|
|
280
334
|
*/
|
|
281
|
-
writeStdin
|
|
335
|
+
writeStdin(execId, data) {
|
|
282
336
|
this.ensureOpen()
|
|
283
337
|
const frame = { type: 'exec.input', execId }
|
|
284
338
|
if (Buffer.isBuffer(data)) {
|
|
@@ -296,7 +350,7 @@ class Sandbox {
|
|
|
296
350
|
*
|
|
297
351
|
* @param {string} execId execution id from `exec()`
|
|
298
352
|
*/
|
|
299
|
-
closeStdin
|
|
353
|
+
closeStdin(execId) {
|
|
300
354
|
this.ensureOpen()
|
|
301
355
|
this.sendFrame({ type: 'exec.endInput', execId })
|
|
302
356
|
}
|
|
@@ -311,7 +365,7 @@ class Sandbox {
|
|
|
311
365
|
* @param {string} path path inside the sandbox
|
|
312
366
|
* @returns {Promise<string>} file contents as a UTF-8 string
|
|
313
367
|
*/
|
|
314
|
-
readFile
|
|
368
|
+
readFile(path) {
|
|
315
369
|
try {
|
|
316
370
|
this.ensureOpen()
|
|
317
371
|
} catch (error) {
|
|
@@ -341,7 +395,7 @@ class Sandbox {
|
|
|
341
395
|
* @param {string|Buffer} content file contents
|
|
342
396
|
* @returns {Promise<{path: string, size: number, ok: boolean}>} write confirmation
|
|
343
397
|
*/
|
|
344
|
-
writeFile
|
|
398
|
+
writeFile(path, content) {
|
|
345
399
|
try {
|
|
346
400
|
this.ensureOpen()
|
|
347
401
|
} catch (error) {
|
|
@@ -374,7 +428,7 @@ class Sandbox {
|
|
|
374
428
|
* @param {string} path directory path inside the sandbox
|
|
375
429
|
* @returns {Promise<Array<{name: string, type: string, size?: number}>>} directory entries
|
|
376
430
|
*/
|
|
377
|
-
listFiles
|
|
431
|
+
listFiles(path) {
|
|
378
432
|
try {
|
|
379
433
|
this.ensureOpen()
|
|
380
434
|
} catch (error) {
|
|
@@ -404,30 +458,29 @@ class Sandbox {
|
|
|
404
458
|
/**
|
|
405
459
|
* Returns the public preview URL for a given port on this sandbox.
|
|
406
460
|
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* @
|
|
461
|
+
* This is a synchronous local lookup against the `previewUrls` map returned
|
|
462
|
+
* by the server at create time. The URL is opaque — do not parse or reconstruct it.
|
|
463
|
+
*
|
|
464
|
+
* @param {number} port port number (1–65535)
|
|
465
|
+
* @returns {string} public preview URL
|
|
466
|
+
* @throws {SandboxInvalidPortError} when `port` is not an integer in the
|
|
467
|
+
* range 1–65535
|
|
468
|
+
* @throws {SandboxPortNotProvisionedError} when `port` is valid but was not
|
|
469
|
+
* declared in `create({ ports })`
|
|
411
470
|
*/
|
|
412
|
-
|
|
413
|
-
if (!this.publicUrlTemplate) {
|
|
414
|
-
throw new SandboxClientError(
|
|
415
|
-
`Cannot get URL for sandbox '${this.id}': publicUrlTemplate is not available`
|
|
416
|
-
)
|
|
417
|
-
}
|
|
418
|
-
|
|
471
|
+
getUrl (port) {
|
|
419
472
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
420
|
-
throw new
|
|
473
|
+
throw new SandboxInvalidPortError(
|
|
421
474
|
`Invalid port '${port}': must be an integer between 1 and 65535`
|
|
422
475
|
)
|
|
423
476
|
}
|
|
424
477
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
478
|
+
const url = this.previewUrls.get(port)
|
|
479
|
+
if (url === undefined) {
|
|
480
|
+
throw new SandboxPortNotProvisionedError(
|
|
481
|
+
`Port ${port} was not provisioned for sandbox '${this.id}'. ` +
|
|
482
|
+
"Declare it in create({ ports: [...] }) to get a preview URL."
|
|
483
|
+
)
|
|
431
484
|
}
|
|
432
485
|
|
|
433
486
|
return url
|
|
@@ -438,10 +491,18 @@ class Sandbox {
|
|
|
438
491
|
*
|
|
439
492
|
* @returns {Promise<object>} destroy response payload
|
|
440
493
|
*/
|
|
441
|
-
async destroy
|
|
494
|
+
async destroy() {
|
|
442
495
|
const base = this.managementEndpoint || this.apiHost
|
|
443
496
|
const url = `${base}/api/v1/namespaces/${this.namespace}/sandbox/${this.id}`
|
|
444
|
-
|
|
497
|
+
this.ws?.beginIntentionalClose()
|
|
498
|
+
|
|
499
|
+
let payload
|
|
500
|
+
try {
|
|
501
|
+
payload = await apiRequest('DELETE', url, this.apiKey)
|
|
502
|
+
} catch (error) {
|
|
503
|
+
this.ws?.cancelIntentionalClose()
|
|
504
|
+
throw error
|
|
505
|
+
}
|
|
445
506
|
|
|
446
507
|
this.status = payload.status || this.status
|
|
447
508
|
this.ws?.close()
|
|
@@ -452,16 +513,63 @@ class Sandbox {
|
|
|
452
513
|
// Private helpers
|
|
453
514
|
// ------------------------------------------------------------------
|
|
454
515
|
|
|
455
|
-
|
|
516
|
+
/**
|
|
517
|
+
* Schedules a timeout that kills `execId` and rejects its pending entry.
|
|
518
|
+
*
|
|
519
|
+
* @param {string} execId
|
|
520
|
+
* @param {string} command human-readable command string (for the error message)
|
|
521
|
+
* @param {number} ms timeout in milliseconds
|
|
522
|
+
* @returns {ReturnType<setTimeout>} the timer handle (stored on the entry for cancellation)
|
|
523
|
+
*/
|
|
524
|
+
scheduleTimeout(execId, command, ms) {
|
|
525
|
+
return setTimeout(() => {
|
|
526
|
+
try {
|
|
527
|
+
this.kill(execId)
|
|
528
|
+
} catch (_) {
|
|
529
|
+
// ignore errors
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
this.ws.rejectExec(execId, new SandboxTimeoutError(
|
|
533
|
+
`Command '${command}' exceeded timeout of ${ms}ms`
|
|
534
|
+
))
|
|
535
|
+
}, ms)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
ensureOpen() {
|
|
456
539
|
if (!this.ws) {
|
|
457
540
|
throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
|
|
458
541
|
}
|
|
459
542
|
this.ws.ensureOpen()
|
|
460
543
|
}
|
|
461
544
|
|
|
462
|
-
sendFrame
|
|
545
|
+
sendFrame(frame) {
|
|
463
546
|
this.ws.send(frame)
|
|
464
547
|
}
|
|
465
548
|
}
|
|
466
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Parses the `previewUrls` JSON object returned by the server into a
|
|
552
|
+
* `Map<number, string>`. String keys (port numbers) are converted to integers.
|
|
553
|
+
* The URL values are treated as opaque — not parsed or reconstructed.
|
|
554
|
+
*
|
|
555
|
+
* Returns an empty Map when the server response omits `previewUrls` (fail-closed:
|
|
556
|
+
* every `getUrl()` call will throw `SandboxPortNotProvisionedError`).
|
|
557
|
+
*
|
|
558
|
+
* @param {object|null|undefined} raw the `previewUrls` field from the API response
|
|
559
|
+
* @returns {Map<number, string>}
|
|
560
|
+
*/
|
|
561
|
+
function parsePreviewUrls (raw) {
|
|
562
|
+
if (!raw || typeof raw !== 'object') {
|
|
563
|
+
return new Map()
|
|
564
|
+
}
|
|
565
|
+
const map = new Map()
|
|
566
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
567
|
+
const port = Number(key)
|
|
568
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535 && typeof value === 'string') {
|
|
569
|
+
map.set(port, value)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return map
|
|
573
|
+
}
|
|
574
|
+
|
|
467
575
|
module.exports = Sandbox
|
package/src/errors.js
CHANGED
|
@@ -25,6 +25,9 @@ class SandboxNotFoundError extends SandboxSDKError {}
|
|
|
25
25
|
class SandboxUnauthorizedError extends SandboxSDKError {}
|
|
26
26
|
class SandboxTimeoutError extends SandboxSDKError {}
|
|
27
27
|
class SandboxWebSocketError extends SandboxSDKError {}
|
|
28
|
+
class SandboxCommandNotFoundError extends SandboxSDKError {}
|
|
29
|
+
class SandboxPortNotProvisionedError extends SandboxSDKError {}
|
|
30
|
+
class SandboxInvalidPortError extends SandboxClientError {}
|
|
28
31
|
|
|
29
32
|
module.exports = {
|
|
30
33
|
SandboxSDKError,
|
|
@@ -33,5 +36,8 @@ module.exports = {
|
|
|
33
36
|
SandboxNotFoundError,
|
|
34
37
|
SandboxUnauthorizedError,
|
|
35
38
|
SandboxTimeoutError,
|
|
36
|
-
SandboxWebSocketError
|
|
39
|
+
SandboxWebSocketError,
|
|
40
|
+
SandboxCommandNotFoundError,
|
|
41
|
+
SandboxPortNotProvisionedError,
|
|
42
|
+
SandboxInvalidPortError
|
|
37
43
|
}
|
package/src/index.js
CHANGED
|
@@ -17,7 +17,10 @@ const {
|
|
|
17
17
|
SandboxNotFoundError,
|
|
18
18
|
SandboxUnauthorizedError,
|
|
19
19
|
SandboxTimeoutError,
|
|
20
|
-
SandboxWebSocketError
|
|
20
|
+
SandboxWebSocketError,
|
|
21
|
+
SandboxCommandNotFoundError,
|
|
22
|
+
SandboxPortNotProvisionedError,
|
|
23
|
+
SandboxInvalidPortError
|
|
21
24
|
} = require('./errors')
|
|
22
25
|
|
|
23
26
|
module.exports = {
|
|
@@ -28,5 +31,8 @@ module.exports = {
|
|
|
28
31
|
SandboxNotFoundError,
|
|
29
32
|
SandboxUnauthorizedError,
|
|
30
33
|
SandboxTimeoutError,
|
|
31
|
-
SandboxWebSocketError
|
|
34
|
+
SandboxWebSocketError,
|
|
35
|
+
SandboxCommandNotFoundError,
|
|
36
|
+
SandboxPortNotProvisionedError,
|
|
37
|
+
SandboxInvalidPortError
|
|
32
38
|
}
|