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