@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/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 }