@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/src/ws.js ADDED
@@ -0,0 +1,609 @@
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
+ SandboxCommandNotFoundError,
16
+ ProtocolVersionMismatchError,
17
+ SandboxMalformedFrameError,
18
+ SandboxUnauthorizedError,
19
+ SandboxWebSocketError
20
+ } = require('./errors')
21
+
22
+ /**
23
+ * Manages the WebSocket connection, authentication, and frame routing for a sandbox.
24
+ *
25
+ * Holds the raw socket, all pending exec and file-op promises, and handles every
26
+ * incoming message. `Sandbox` creates one instance and delegates all WS work here.
27
+ */
28
+ class SandboxSocket {
29
+ /**
30
+ * @param {object} options socket options
31
+ * @param {string} options.id sandbox id
32
+ * @param {string} options.endpoint WebSocket endpoint URL
33
+ * @param {string} options.token authentication token
34
+ */
35
+ constructor ({ id, endpoint, token }) {
36
+ this.id = id
37
+ this.endpoint = endpoint
38
+ this.token = token
39
+
40
+ this.socket = null
41
+ this.connectPromise = null
42
+ this.intentionalClose = false
43
+
44
+ /**
45
+ * Pending exec entries
46
+ *
47
+ * @type {Map<string, {
48
+ * resolve: Function, reject: Function,
49
+ * waitResolve: Function|null, waitReject: Function|null,
50
+ * stdout: string, stderr: string,
51
+ * onOutput: Function|undefined, timeout: any,
52
+ * detached: boolean, resolved: boolean
53
+ * }>}
54
+ */
55
+ this.pendingExecs = new Map()
56
+ /** @type {Map<string, {resolve: Function, reject: Function}>} */
57
+ this.pendingFileOps = new Map()
58
+ /**
59
+ * Pending exec.get operations keyed by execId.
60
+ * @type {Map<string, {resolve: Function, reject: Function, onOutput: Function|undefined, sandbox: object}>}
61
+ */
62
+ this.pendingGetOps = new Map()
63
+ }
64
+
65
+ /**
66
+ * Opens the WebSocket, authenticates, and starts routing messages
67
+ *
68
+ * @returns {Promise<void>}
69
+ */
70
+ connect () {
71
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
72
+ return Promise.resolve()
73
+ }
74
+
75
+ if (this.connectPromise) {
76
+ return this.connectPromise
77
+ }
78
+
79
+ this.socket = new WebSocket(this.endpoint)
80
+ const socket = this.socket
81
+
82
+ socket.on('message', message => this.handleMessage(message))
83
+ socket.on('close', code => this.handleClose(code))
84
+ socket.on('error', () => {})
85
+
86
+ this.connectPromise = new Promise((resolve, reject) => {
87
+ const onOpen = () => {
88
+ try {
89
+ this.send({ type: 'auth', token: this.token })
90
+ } catch (error) {
91
+ onError(error)
92
+ }
93
+ }
94
+
95
+ const onMessage = (message) => {
96
+ const frame = this.parseFrame(message)
97
+ if (!frame || !this.isAuthAckFrame(frame)) return
98
+ cleanup()
99
+ this.connectPromise = null
100
+ resolve()
101
+ }
102
+
103
+ const onClose = (code) => {
104
+ cleanup()
105
+ this.connectPromise = null
106
+ if (this.intentionalClose) {
107
+ resolve()
108
+ return
109
+ }
110
+ reject(this.createCloseError(code))
111
+ }
112
+
113
+ const onError = (error) => {
114
+ cleanup()
115
+ this.connectPromise = null
116
+ reject(new SandboxWebSocketError(
117
+ `Could not connect sandbox '${this.id}': ${error.message}`
118
+ ))
119
+ }
120
+
121
+ const cleanup = () => {
122
+ socket.off('open', onOpen)
123
+ socket.off('message', onMessage)
124
+ socket.off('close', onClose)
125
+ socket.off('error', onError)
126
+ }
127
+
128
+ socket.once('open', onOpen)
129
+ socket.on('message', onMessage)
130
+ socket.once('close', onClose)
131
+ socket.once('error', onError)
132
+ })
133
+
134
+ return this.connectPromise
135
+ }
136
+
137
+ /**
138
+ * Throws `SandboxWebSocketError` if the socket is not currently open.
139
+ */
140
+ ensureOpen () {
141
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
142
+ throw new SandboxWebSocketError(`Sandbox '${this.id}' is not connected`)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Serialises `frame` and sends it over the socket.
148
+ *
149
+ * @param {object} frame WebSocket frame to send
150
+ */
151
+ send (frame) {
152
+ this.socket.send(JSON.stringify(frame))
153
+ }
154
+
155
+ /**
156
+ * Marks the next socket close as expected by the caller.
157
+ */
158
+ beginIntentionalClose () {
159
+ this.intentionalClose = true
160
+ }
161
+
162
+ /**
163
+ * Clears a previously requested intentional close.
164
+ */
165
+ cancelIntentionalClose () {
166
+ this.intentionalClose = false
167
+ }
168
+
169
+ /**
170
+ * Closes the underlying socket.
171
+ *
172
+ * @param {object} [options] close options
173
+ * @param {boolean} [options.intentional] whether pending work should be drained without error
174
+ */
175
+ close ({ intentional = false } = {}) {
176
+ if (intentional) this.beginIntentionalClose()
177
+ this.socket?.close()
178
+ }
179
+
180
+ // ------------------------------------------------------------------
181
+ // Pending operation helpers
182
+ // ------------------------------------------------------------------
183
+
184
+ /**
185
+ * Registers a pending exec, sends the frame, and returns the promises
186
+ * that will be settled by subsequent result or ack frames (for detached)
187
+ *
188
+ * @param {string} execId execution id for the pending command
189
+ * @param {object} frame the frame to send (must include `type` and `execId`)
190
+ * @param {{ detached: boolean, onOutput?: Function }} options pending exec options
191
+ * @returns {{ ackPromise: Promise, waitPromise: Promise|null }} promises for ack and optional completion
192
+ */
193
+ sendExec (execId, frame, { detached, onOutput }) {
194
+ let waitResolve, waitReject, waitPromise
195
+ if (detached) {
196
+ waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
197
+ }
198
+
199
+ let resolve, reject
200
+ const ackPromise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject })
201
+
202
+ this.pendingExecs.set(execId, {
203
+ resolve,
204
+ reject,
205
+ waitResolve: waitResolve || null,
206
+ waitReject: waitReject || null,
207
+ _waitPromise: waitPromise || null,
208
+ stdout: '',
209
+ stderr: '',
210
+ onOutput: onOutput || null,
211
+ timeout: undefined,
212
+ detached,
213
+ resolved: false
214
+ })
215
+
216
+ try {
217
+ this.send(frame)
218
+ } catch (error) {
219
+ this.rejectExec(execId, new SandboxWebSocketError(
220
+ `Could not send exec frame: ${error.message}`
221
+ ))
222
+ }
223
+
224
+ return { ackPromise, waitPromise: waitPromise || null }
225
+ }
226
+
227
+ /**
228
+ * Stores a timer handle on a pending exec entry so it can be cleared on completion.
229
+ *
230
+ * @param {string} execId execution id for the pending command
231
+ * @param {ReturnType<setTimeout>} handle timeout handle to store
232
+ */
233
+ setExecTimeout (execId, handle) {
234
+ const entry = this.pendingExecs.get(execId)
235
+ if (entry) entry.timeout = handle
236
+ }
237
+
238
+ /**
239
+ * Rejects and removes a pending exec, clearing its timeout.
240
+ *
241
+ * @param {string} execId execution id for the pending command
242
+ * @param {Error} error error used to reject the command
243
+ */
244
+ rejectExec (execId, error) {
245
+ const pending = this.pendingExecs.get(execId)
246
+ if (!pending) return
247
+ this.pendingExecs.delete(execId)
248
+ clearTimeout(pending.timeout)
249
+
250
+ // For detached execs, the first promise is always resolved, so reject the wait promise instead
251
+ if (pending.detached && pending.resolved) {
252
+ if (pending.waitReject) pending.waitReject(error)
253
+ } else {
254
+ pending.reject(error)
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Rejects and removes a pending file operation.
260
+ *
261
+ * @param {string} execId file operation id
262
+ * @param {Error} error error used to reject the file operation
263
+ */
264
+ rejectFileOp (execId, error) {
265
+ const pending = this.pendingFileOps.get(execId)
266
+ if (!pending) return
267
+ this.pendingFileOps.delete(execId)
268
+ pending.reject(error)
269
+ }
270
+
271
+ /**
272
+ * Resolves and removes a pending exec during an intentional sandbox shutdown.
273
+ *
274
+ * @param {string} execId execution id for the pending command
275
+ */
276
+ resolveExecOnIntentionalClose (execId) {
277
+ const pending = this.pendingExecs.get(execId)
278
+ if (!pending) return
279
+ this.pendingExecs.delete(execId)
280
+ clearTimeout(pending.timeout)
281
+
282
+ const result = { exitCode: null, destroyed: true }
283
+ if (pending.detached) {
284
+ if (!pending.resolved) {
285
+ pending.resolved = true
286
+ pending.resolve({ pid: undefined, startedAt: undefined, destroyed: true })
287
+ }
288
+ if (pending.waitResolve) pending.waitResolve(result)
289
+ return
290
+ }
291
+
292
+ pending.resolve({
293
+ execId,
294
+ stdout: pending.stdout,
295
+ stderr: pending.stderr,
296
+ ...result
297
+ })
298
+ }
299
+
300
+ handleMessage (message) {
301
+ const frame = this.parseFrame(message)
302
+ if (!frame || this.isAuthAckFrame(frame)) return
303
+
304
+ if (frame.type === 'exec.info' ||
305
+ (frame.type === 'error' && this.pendingGetOps.has(frame.execId))) {
306
+ this.handleGetFrame(frame)
307
+ return
308
+ }
309
+
310
+ if (this.pendingFileOps.has(frame.execId)) {
311
+ this.handleFileFrame(frame)
312
+ return
313
+ }
314
+
315
+ if (this.pendingExecs.has(frame.execId)) {
316
+ this.handleExecFrame(frame)
317
+ }
318
+ }
319
+
320
+ handleClose (code) {
321
+ if (this.intentionalClose) {
322
+ for (const execId of [...this.pendingExecs.keys()]) {
323
+ this.resolveExecOnIntentionalClose(execId)
324
+ }
325
+ for (const [, pending] of [...this.pendingFileOps.entries()]) {
326
+ pending.resolve(undefined)
327
+ }
328
+ for (const [, pending] of [...this.pendingGetOps.entries()]) {
329
+ pending.resolve(null)
330
+ }
331
+ this.pendingFileOps.clear()
332
+ this.pendingGetOps.clear()
333
+ this.connectPromise = null
334
+ this.socket = null
335
+ this.intentionalClose = false
336
+ return
337
+ }
338
+
339
+ const error = this.createCloseError(code)
340
+ for (const execId of [...this.pendingExecs.keys()]) {
341
+ this.rejectExec(execId, error)
342
+ }
343
+ for (const execId of [...this.pendingFileOps.keys()]) {
344
+ this.rejectFileOp(execId, error)
345
+ }
346
+ for (const [, pending] of [...this.pendingGetOps.entries()]) {
347
+ pending.reject(error)
348
+ }
349
+ this.pendingGetOps.clear()
350
+ this.connectPromise = null
351
+ this.socket = null
352
+ }
353
+
354
+ createCloseError (code) {
355
+ if (code === 4001) {
356
+ return new SandboxUnauthorizedError(
357
+ `Sandbox '${this.id}' rejected the WebSocket authentication token`
358
+ )
359
+ }
360
+ if (code === 4003) {
361
+ return new ProtocolVersionMismatchError(
362
+ `Sandbox '${this.id}' WebSocket protocol version does not match this SDK`
363
+ )
364
+ }
365
+ if (code === 4004) {
366
+ return new SandboxMalformedFrameError(
367
+ `Sandbox '${this.id}' rejected a malformed WebSocket frame`
368
+ )
369
+ }
370
+ return new SandboxWebSocketError(
371
+ `Sandbox '${this.id}' WebSocket closed with code ${code}`
372
+ )
373
+ }
374
+
375
+ parseFrame (message) {
376
+ try {
377
+ return JSON.parse(message.toString())
378
+ } catch (_) {
379
+ return null
380
+ }
381
+ }
382
+
383
+ isAuthAckFrame (frame) {
384
+ return frame?.type === 'auth.ok' && (!frame.sandboxId || frame.sandboxId === this.id)
385
+ }
386
+
387
+ // ------------------------------------------------------------------
388
+ // Frame routing
389
+ // ------------------------------------------------------------------
390
+
391
+ handleExecFrame (frame) {
392
+ const pending = this.pendingExecs.get(frame.execId)
393
+ if (!pending) return
394
+
395
+ if (frame.type === 'exec.output') {
396
+ if (frame.stream === 'stderr') {
397
+ pending.stderr += frame.data || ''
398
+ } else {
399
+ pending.stdout += frame.data || ''
400
+ }
401
+ if (pending.onOutput) {
402
+ pending.onOutput(frame.data || '', frame.stream || 'stdout')
403
+ }
404
+ return
405
+ }
406
+
407
+ // For detached cmds, we will resolve with the server response, which is all the command info needed for a
408
+ // handle. The entry stays in pendingExecs to receive subsequent output and exec.exit.
409
+ if (frame.type === 'exec.detached') {
410
+ clearTimeout(pending.timeout)
411
+ pending.timeout = undefined
412
+ pending.resolved = true
413
+ pending.resolve({ pid: frame.pid, startedAt: frame.startedAt })
414
+ return
415
+ }
416
+
417
+ if (frame.type === 'exec.exit') {
418
+ this.pendingExecs.delete(frame.execId)
419
+ clearTimeout(pending.timeout)
420
+ if (pending.detached && pending.resolved) {
421
+ // For detached, the initial ack promise already resolved, so we resolve the wait promise
422
+ if (pending.waitResolve) {
423
+ pending.waitResolve({ exitCode: frame.exitCode })
424
+ }
425
+ } else {
426
+ pending.resolve({
427
+ execId: frame.execId,
428
+ stdout: pending.stdout,
429
+ stderr: pending.stderr,
430
+ exitCode: frame.exitCode
431
+ })
432
+ }
433
+ return
434
+ }
435
+
436
+ if (frame.type === 'error') {
437
+ this.rejectExec(frame.execId, new SandboxClientError(
438
+ frame.message || `Command '${frame.execId}' failed`
439
+ ))
440
+ }
441
+ }
442
+
443
+ handleFileFrame (frame) {
444
+ const pending = this.pendingFileOps.get(frame.execId)
445
+ if (!pending) return
446
+
447
+ if (frame.type === 'file.content') {
448
+ this.pendingFileOps.delete(frame.execId)
449
+ const content = frame.encoding === 'base64'
450
+ ? Buffer.from(frame.content, 'base64').toString('utf8')
451
+ : (frame.content || '')
452
+ pending.resolve(content)
453
+ return
454
+ }
455
+
456
+ if (frame.type === 'file.writeResult') {
457
+ this.pendingFileOps.delete(frame.execId)
458
+ if (!frame.ok) {
459
+ pending.reject(new SandboxClientError(
460
+ `file.write failed for path '${frame.path}'`
461
+ ))
462
+ } else {
463
+ pending.resolve({ path: frame.path, size: frame.size, ok: frame.ok })
464
+ }
465
+ return
466
+ }
467
+
468
+ if (frame.type === 'file.entries') {
469
+ this.pendingFileOps.delete(frame.execId)
470
+ pending.resolve(frame.entries || [])
471
+ return
472
+ }
473
+
474
+ if (frame.type === 'error') {
475
+ this.rejectFileOp(frame.execId, new SandboxClientError(
476
+ frame.message || `File operation '${frame.execId}' failed`
477
+ ))
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Handles exec.info (response to exec.get) and error frames routed from handleMessage.
483
+ *
484
+ * @param {object} frame exec.get response or error frame
485
+ */
486
+ handleGetFrame (frame) {
487
+ const pending = this.pendingGetOps.get(frame.execId)
488
+ if (!pending) return
489
+
490
+ if (frame.type === 'exec.info') {
491
+ this.resolveGetOp(frame, pending)
492
+ return
493
+ }
494
+
495
+ if (frame.type === 'error') {
496
+ this.rejectGetOp(frame, pending)
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Resolves a pending exec.get by building a command handle and resolving the caller's promise.
502
+ *
503
+ * @param {object} frame exec.info frame
504
+ * @param {object} pending entry from pendingGetOps
505
+ */
506
+ resolveGetOp (frame, pending) {
507
+ this.pendingGetOps.delete(frame.execId)
508
+ const waitPromise = this.resolveExecEntry(frame, pending)
509
+ const commandObj = this.buildCommandObject(frame, waitPromise, pending.sandbox)
510
+ pending.resolve(commandObj)
511
+ }
512
+
513
+ /**
514
+ * Rejects a pending exec.get with a not-found error.
515
+ *
516
+ * @param {object} frame error frame
517
+ * @param {object} pending entry from pendingGetOps
518
+ */
519
+ rejectGetOp (frame, pending) {
520
+ this.pendingGetOps.delete(frame.execId)
521
+ pending.reject(new SandboxCommandNotFoundError(
522
+ frame.message || `No running process for execId '${frame.execId}'`
523
+ ))
524
+ }
525
+
526
+ /**
527
+ * Returns the wait promise for the exec, either by reusing an existing pendingExecs entry
528
+ * (same session) or by registering a fresh reattached entry (new session / previous connection).
529
+ *
530
+ * @param {object} frame exec.info frame
531
+ * @param {object} pending entry from pendingGetOps
532
+ * @returns {Promise} wait promise for the running command
533
+ */
534
+ resolveExecEntry (frame, pending) {
535
+ const existingExec = this.pendingExecs.get(frame.execId)
536
+ if (existingExec) {
537
+ existingExec.onOutput = this.mergeOnOutputCallback(existingExec.onOutput, pending.onOutput)
538
+ return existingExec._waitPromise
539
+ }
540
+ return this.registerReattachedExec(frame, pending.onOutput)
541
+ }
542
+
543
+ /**
544
+ * Appends `onOutput` to an existing exec entry's callback chain, preserving the previous handler.
545
+ *
546
+ * @param {Function|null|undefined} prev existing callback
547
+ * @param {Function|undefined} onOutput new callback to add
548
+ * @returns {Function|null|undefined} merged callback
549
+ */
550
+ mergeOnOutputCallback (prev, onOutput) {
551
+ if (!onOutput) return prev
552
+ return (data, stream) => {
553
+ if (prev) prev(data, stream)
554
+ onOutput(data, stream)
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Creates a fresh pendingExecs entry for a process reattached from a previous connection,
560
+ * registers it, and returns its wait promise.
561
+ *
562
+ * @param {object} frame exec.info frame
563
+ * @param {Function|undefined} onOutput output callback
564
+ * @returns {Promise} wait promise for the reattached command
565
+ */
566
+ registerReattachedExec (frame, onOutput) {
567
+ let waitResolve, waitReject
568
+ const waitPromise = new Promise((resolve, reject) => { waitResolve = resolve; waitReject = reject })
569
+ this.pendingExecs.set(frame.execId, {
570
+ resolve: () => {},
571
+ reject: () => {},
572
+ waitResolve,
573
+ waitReject,
574
+ _waitPromise: waitPromise,
575
+ stdout: '',
576
+ stderr: '',
577
+ onOutput: onOutput || null,
578
+ timeout: undefined,
579
+ detached: frame.detached,
580
+ resolved: true
581
+ })
582
+ return waitPromise
583
+ }
584
+
585
+ /**
586
+ * Builds the command handle object returned to the caller of exec.get.
587
+ *
588
+ * @param {object} frame exec.info frame
589
+ * @param {Promise} waitPromise resolves when the process exits
590
+ * @param {object} sandbox Sandbox instance for delegating control operations
591
+ * @returns {object} command handle with wait and control helpers
592
+ */
593
+ buildCommandObject (frame, waitPromise, sandbox) {
594
+ const { execId } = frame
595
+ return {
596
+ execId,
597
+ command: frame.command,
598
+ pid: frame.pid,
599
+ startedAt: frame.startedAt,
600
+ detached: frame.detached,
601
+ wait: () => waitPromise,
602
+ writeStdin: (data) => sandbox.writeStdin(execId, data),
603
+ closeStdin: () => sandbox.closeStdin(execId),
604
+ kill: (signal) => sandbox.kill(execId, signal)
605
+ }
606
+ }
607
+ }
608
+
609
+ module.exports = { SandboxSocket }