@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/ws.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
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 WebSocket = require('ws')
|
|
13
|
+
const {
|
|
14
|
+
SandboxClientError,
|
|
15
|
+
SandboxUnauthorizedError,
|
|
16
|
+
SandboxWebSocketError
|
|
17
|
+
} = require('./errors')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Manages the WebSocket connection, authentication, and frame routing for a sandbox.
|
|
21
|
+
*
|
|
22
|
+
* Holds the raw socket, all pending exec and file-op promises, and handles every
|
|
23
|
+
* incoming message. `Sandbox` creates one instance and delegates all WS work here.
|
|
24
|
+
*/
|
|
25
|
+
class SandboxSocket {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {string} options.id sandbox id
|
|
29
|
+
* @param {string} options.endpoint WebSocket endpoint URL
|
|
30
|
+
* @param {string} options.token authentication token
|
|
31
|
+
*/
|
|
32
|
+
constructor ({ id, endpoint, token }) {
|
|
33
|
+
this.id = id
|
|
34
|
+
this.endpoint = endpoint
|
|
35
|
+
this.token = token
|
|
36
|
+
|
|
37
|
+
this.socket = null
|
|
38
|
+
this.connectPromise = null
|
|
39
|
+
|
|
40
|
+
/** @type {Map<string, {resolve: Function, reject: Function, stdout: string, stderr: string, onOutput: Function|undefined, timeout: any}>} */
|
|
41
|
+
this.pendingExecs = new Map()
|
|
42
|
+
/** @type {Map<string, {resolve: Function, reject: Function}>} */
|
|
43
|
+
this.pendingFileOps = new Map()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Opens the WebSocket, authenticates, and starts routing messages
|
|
48
|
+
*
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
connect () {
|
|
52
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
53
|
+
return Promise.resolve()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.connectPromise) {
|
|
57
|
+
return this.connectPromise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.socket = new WebSocket(this.endpoint)
|
|
61
|
+
const socket = this.socket
|
|
62
|
+
|
|
63
|
+
socket.on('message', message => this.handleMessage(message))
|
|
64
|
+
socket.on('close', code => this.handleClose(code))
|
|
65
|
+
socket.on('error', () => {})
|
|
66
|
+
|
|
67
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
68
|
+
const onOpen = () => {
|
|
69
|
+
try {
|
|
70
|
+
this.send({ type: 'auth', token: this.token })
|
|
71
|
+
} catch (error) {
|
|
72
|
+
onError(error)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const onMessage = (message) => {
|
|
77
|
+
const frame = this.parseFrame(message)
|
|
78
|
+
if (!frame || !this.isAuthAckFrame(frame)) return
|
|
79
|
+
cleanup()
|
|
80
|
+
this.connectPromise = null
|
|
81
|
+
resolve()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const onClose = (code) => {
|
|
85
|
+
cleanup()
|
|
86
|
+
this.connectPromise = null
|
|
87
|
+
reject(this.createCloseError(code))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const onError = (error) => {
|
|
91
|
+
cleanup()
|
|
92
|
+
this.connectPromise = null
|
|
93
|
+
reject(new SandboxWebSocketError(
|
|
94
|
+
`Could not connect sandbox '${this.id}': ${error.message}`
|
|
95
|
+
))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const cleanup = () => {
|
|
99
|
+
socket.off('open', onOpen)
|
|
100
|
+
socket.off('message', onMessage)
|
|
101
|
+
socket.off('close', onClose)
|
|
102
|
+
socket.off('error', onError)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
socket.once('open', onOpen)
|
|
106
|
+
socket.on('message', onMessage)
|
|
107
|
+
socket.once('close', onClose)
|
|
108
|
+
socket.once('error', onError)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
return this.connectPromise
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Throws `SandboxWebSocketError` if the socket is not currently open.
|
|
116
|
+
*/
|
|
117
|
+
ensureOpen () {
|
|
118
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
119
|
+
throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Serialises `frame` and sends it over the socket.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} frame
|
|
127
|
+
*/
|
|
128
|
+
send (frame) {
|
|
129
|
+
this.socket.send(JSON.stringify(frame))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Closes the underlying socket.
|
|
134
|
+
*/
|
|
135
|
+
close () {
|
|
136
|
+
this.socket?.close()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ------------------------------------------------------------------
|
|
140
|
+
// Pending operation helpers
|
|
141
|
+
// ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Rejects and removes a pending exec, clearing its timeout.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} execId
|
|
147
|
+
* @param {Error} error
|
|
148
|
+
*/
|
|
149
|
+
rejectExec (execId, error) {
|
|
150
|
+
const pending = this.pendingExecs.get(execId)
|
|
151
|
+
if (!pending) return
|
|
152
|
+
this.pendingExecs.delete(execId)
|
|
153
|
+
clearTimeout(pending.timeout)
|
|
154
|
+
pending.reject(error)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Rejects and removes a pending file operation.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} execId
|
|
161
|
+
* @param {Error} error
|
|
162
|
+
*/
|
|
163
|
+
rejectFileOp (execId, error) {
|
|
164
|
+
const pending = this.pendingFileOps.get(execId)
|
|
165
|
+
if (!pending) return
|
|
166
|
+
this.pendingFileOps.delete(execId)
|
|
167
|
+
pending.reject(error)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
handleMessage (message) {
|
|
171
|
+
const frame = this.parseFrame(message)
|
|
172
|
+
if (!frame || this.isAuthAckFrame(frame)) return
|
|
173
|
+
|
|
174
|
+
if (this.pendingFileOps.has(frame.execId)) {
|
|
175
|
+
this.handleFileFrame(frame)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (this.pendingExecs.has(frame.execId)) {
|
|
180
|
+
this.handleExecFrame(frame)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
handleClose (code) {
|
|
185
|
+
const error = this.createCloseError(code)
|
|
186
|
+
for (const execId of [...this.pendingExecs.keys()]) {
|
|
187
|
+
this.rejectExec(execId, error)
|
|
188
|
+
}
|
|
189
|
+
for (const execId of [...this.pendingFileOps.keys()]) {
|
|
190
|
+
this.rejectFileOp(execId, error)
|
|
191
|
+
}
|
|
192
|
+
this.connectPromise = null
|
|
193
|
+
this.socket = null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
createCloseError (code) {
|
|
197
|
+
if (code === 4001) {
|
|
198
|
+
return new SandboxUnauthorizedError(
|
|
199
|
+
`Sandbox '${this.id}' rejected the WebSocket authentication token`
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
return new SandboxWebSocketError(
|
|
203
|
+
`Sandbox '${this.id}' WebSocket closed with code ${code}`
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
parseFrame (message) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(message.toString())
|
|
210
|
+
} catch (_) {
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
isAuthAckFrame (frame) {
|
|
216
|
+
return frame?.type === 'auth.ok' && (!frame.sandboxId || frame.sandboxId === this.id)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ------------------------------------------------------------------
|
|
220
|
+
// Frame routing
|
|
221
|
+
// ------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
handleExecFrame (frame) {
|
|
224
|
+
const pending = this.pendingExecs.get(frame.execId)
|
|
225
|
+
if (!pending) return
|
|
226
|
+
|
|
227
|
+
if (frame.type === 'exec.output') {
|
|
228
|
+
if (frame.stream === 'stderr') {
|
|
229
|
+
pending.stderr += frame.data || ''
|
|
230
|
+
} else {
|
|
231
|
+
pending.stdout += frame.data || ''
|
|
232
|
+
}
|
|
233
|
+
if (pending.onOutput) {
|
|
234
|
+
pending.onOutput(frame.data || '', frame.stream || 'stdout')
|
|
235
|
+
}
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (frame.type === 'exec.exit') {
|
|
240
|
+
this.pendingExecs.delete(frame.execId)
|
|
241
|
+
clearTimeout(pending.timeout)
|
|
242
|
+
pending.resolve({
|
|
243
|
+
execId: frame.execId,
|
|
244
|
+
stdout: pending.stdout,
|
|
245
|
+
stderr: pending.stderr,
|
|
246
|
+
exitCode: frame.exitCode
|
|
247
|
+
})
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (frame.type === 'error') {
|
|
252
|
+
this.rejectExec(frame.execId, new SandboxClientError(
|
|
253
|
+
frame.message || `Command '${frame.execId}' failed`
|
|
254
|
+
))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
handleFileFrame (frame) {
|
|
259
|
+
const pending = this.pendingFileOps.get(frame.execId)
|
|
260
|
+
if (!pending) return
|
|
261
|
+
|
|
262
|
+
if (frame.type === 'file.content') {
|
|
263
|
+
this.pendingFileOps.delete(frame.execId)
|
|
264
|
+
const content = frame.encoding === 'base64'
|
|
265
|
+
? Buffer.from(frame.content, 'base64').toString('utf8')
|
|
266
|
+
: (frame.content || '')
|
|
267
|
+
pending.resolve(content)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (frame.type === 'file.writeResult') {
|
|
272
|
+
this.pendingFileOps.delete(frame.execId)
|
|
273
|
+
if (!frame.ok) {
|
|
274
|
+
pending.reject(new SandboxClientError(
|
|
275
|
+
`file.write failed for path '${frame.path}'`
|
|
276
|
+
))
|
|
277
|
+
} else {
|
|
278
|
+
pending.resolve({ path: frame.path, size: frame.size, ok: frame.ok })
|
|
279
|
+
}
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (frame.type === 'file.entries') {
|
|
284
|
+
this.pendingFileOps.delete(frame.execId)
|
|
285
|
+
pending.resolve(frame.entries || [])
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (frame.type === 'error') {
|
|
290
|
+
this.rejectFileOp(frame.execId, new SandboxClientError(
|
|
291
|
+
frame.message || `File operation '${frame.execId}' failed`
|
|
292
|
+
))
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { SandboxSocket }
|