@johnhenry/wsh 0.0.0

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.
@@ -0,0 +1,557 @@
1
+ /**
2
+ * WshSession — shared facade for stream-backed and virtual-backed channels.
3
+ *
4
+ * Stream-backed sessions use raw transport streams for stdin/stdout.
5
+ * Virtual-backed sessions use control messages (`SESSION_DATA`) for bytes.
6
+ */
7
+
8
+ import {
9
+ MSG, resize as resizeMsg, signal as signalMsg, close as closeMsg,
10
+ } from './messages.mjs';
11
+ import { WshVirtualSessionBackend } from './virtual-session.mjs';
12
+
13
+ // ── Session states ────────────────────────────────────────────────────
14
+
15
+ const STATE_OPENING = 'opening';
16
+ const STATE_ACTIVE = 'active';
17
+ const STATE_CLOSED = 'closed';
18
+
19
+ // ── Text encoding ─────────────────────────────────────────────────────
20
+
21
+ const textEncoder = new TextEncoder();
22
+
23
+ // ── Session class ─────────────────────────────────────────────────────
24
+
25
+ export class WshSession {
26
+ /** @type {string} Unique session identifier (typically matches channelId). */
27
+ id;
28
+
29
+ /** @type {'pty'|'exec'} Channel kind. */
30
+ kind;
31
+
32
+ /** @type {number} Channel identifier assigned by the server. */
33
+ channelId;
34
+
35
+ /** @type {'opening'|'active'|'closed'} Current session state. */
36
+ #state = STATE_OPENING;
37
+
38
+ /** @type {import('./transport.mjs').WshTransport} Transport reference. */
39
+ #transport;
40
+
41
+ /** @type {'stream'|'virtual'} Session data plane. */
42
+ #dataMode = 'stream';
43
+
44
+ /** @type {string[]} Advertised session capabilities. */
45
+ #capabilities = [];
46
+
47
+ /**
48
+ * Stream IDs returned by the server in OPEN_OK.
49
+ * Typically { stdin: number, stdout: number } or a single bidirectional ID.
50
+ * @type {object}
51
+ */
52
+ #streamIds;
53
+
54
+ /**
55
+ * The writable side of the stdin data stream.
56
+ * @type {WritableStreamDefaultWriter|null}
57
+ */
58
+ #stdinWriter = null;
59
+
60
+ /** @type {WshVirtualSessionBackend|null} */
61
+ #virtualBackend = null;
62
+
63
+ /**
64
+ * The readable side of the stdout data stream.
65
+ * @type {ReadableStream<Uint8Array>|null}
66
+ */
67
+ #stdoutReadable = null;
68
+
69
+ /** @type {AbortController} Cancels the background data pump. */
70
+ #abort = new AbortController();
71
+
72
+ /** @type {Promise<void>|null} Resolves when the data pump finishes. */
73
+ #pumpDone = null;
74
+
75
+ /** @type {number|null} Exit code received from the server. */
76
+ #exitCode = null;
77
+
78
+ // ── Callbacks ───────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Called when stdout/stderr data arrives.
82
+ * @type {function(Uint8Array): void|null}
83
+ */
84
+ onData = null;
85
+
86
+ /**
87
+ * Called when the remote process exits.
88
+ * @type {function(number): void|null}
89
+ */
90
+ onExit = null;
91
+
92
+ /**
93
+ * Called when the session is fully closed.
94
+ * @type {function(): void|null}
95
+ */
96
+ onClose = null;
97
+
98
+ /**
99
+ * Called when speculative local echo is acknowledged by the remote peer.
100
+ * @type {function(object): void|null}
101
+ */
102
+ onEchoAck = null;
103
+
104
+ /**
105
+ * Called when the remote peer reports current cursor/echo state.
106
+ * @type {function(object): void|null}
107
+ */
108
+ onEchoState = null;
109
+
110
+ /**
111
+ * Called when the remote peer emits a full terminal sync hash.
112
+ * @type {function(object): void|null}
113
+ */
114
+ onTermSync = null;
115
+
116
+ /**
117
+ * Called when the remote peer emits an incremental terminal diff.
118
+ * @type {function(object): void|null}
119
+ */
120
+ onTermDiff = null;
121
+
122
+ /** @type {object|null} */
123
+ #lastEchoAck = null;
124
+
125
+ /** @type {object|null} */
126
+ #lastEchoState = null;
127
+
128
+ /** @type {object|null} */
129
+ #lastTermSync = null;
130
+
131
+ /** @type {object|null} */
132
+ #lastTermDiff = null;
133
+
134
+ // ── Constructor ─────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * @param {import('./transport.mjs').WshTransport} transport
138
+ * @param {number} channelId
139
+ * @param {object} streamIds - Stream identifiers from OPEN_OK.
140
+ * @param {'pty'|'exec'} kind
141
+ * @param {object} [opts]
142
+ * @param {'stream'|'virtual'} [opts.dataMode='stream']
143
+ * @param {string[]} [opts.capabilities=[]]
144
+ */
145
+ constructor(transport, channelId, streamIds, kind, { dataMode = 'stream', capabilities = [] } = {}) {
146
+ this.#transport = transport;
147
+ this.channelId = channelId;
148
+ this.#streamIds = streamIds;
149
+ this.kind = kind;
150
+ this.id = String(channelId);
151
+ this.#dataMode = dataMode === 'virtual' ? 'virtual' : 'stream';
152
+ this.#capabilities = Array.isArray(capabilities) ? [...capabilities] : [];
153
+ }
154
+
155
+ /** Current session state. */
156
+ get state() {
157
+ return this.#state;
158
+ }
159
+
160
+ /** Exit code, if the process has exited. */
161
+ get exitCode() {
162
+ return this.#exitCode;
163
+ }
164
+
165
+ /** Session data plane: stream-backed or control-message-backed. */
166
+ get dataMode() {
167
+ return this.#dataMode;
168
+ }
169
+
170
+ /** Advertised session capabilities. */
171
+ get capabilities() {
172
+ return [...this.#capabilities];
173
+ }
174
+
175
+ /** Last echo acknowledgment received for this session. */
176
+ get lastEchoAck() {
177
+ return this.#lastEchoAck ? { ...this.#lastEchoAck } : null;
178
+ }
179
+
180
+ /** Last echo-state update received for this session. */
181
+ get lastEchoState() {
182
+ return this.#lastEchoState ? { ...this.#lastEchoState } : null;
183
+ }
184
+
185
+ /** Last terminal sync hash received for this session. */
186
+ get lastTermSync() {
187
+ if (!this.#lastTermSync) return null;
188
+ return {
189
+ ...this.#lastTermSync,
190
+ state_hash: this.#lastTermSync.state_hash?.slice?.() || this.#lastTermSync.state_hash,
191
+ };
192
+ }
193
+
194
+ /** Last terminal diff received for this session. */
195
+ get lastTermDiff() {
196
+ if (!this.#lastTermDiff) return null;
197
+ return {
198
+ ...this.#lastTermDiff,
199
+ patch: this.#lastTermDiff.patch?.slice?.() || this.#lastTermDiff.patch,
200
+ };
201
+ }
202
+
203
+ // ── Stream binding ──────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Bind the raw data streams to this session and start the read pump.
207
+ * Called by WshClient after stream setup.
208
+ *
209
+ * @param {ReadableStream<Uint8Array>} readable - stdout/stderr bytes from server
210
+ * @param {WritableStream<Uint8Array>} writable - stdin bytes to server
211
+ */
212
+ _bind(readable, writable) {
213
+ if (this.#dataMode !== 'stream') {
214
+ throw new Error('Cannot bind transport streams to a virtual session');
215
+ }
216
+ if (this.#state === STATE_CLOSED) {
217
+ throw new Error('Cannot bind streams to a closed session');
218
+ }
219
+ this.#stdoutReadable = readable;
220
+ this.#stdinWriter = writable.getWriter();
221
+ this.#state = STATE_ACTIVE;
222
+ this.#pumpDone = this._pumpDataStream();
223
+ }
224
+
225
+ /**
226
+ * Activate a message-backed virtual session.
227
+ *
228
+ * @param {function(object): Promise<void>} sendControl
229
+ */
230
+ _activateVirtual(sendControl) {
231
+ if (this.#dataMode !== 'virtual') {
232
+ throw new Error('Cannot activate virtual backend for a stream session');
233
+ }
234
+ if (this.#state === STATE_CLOSED) {
235
+ throw new Error('Cannot activate a closed session');
236
+ }
237
+ this.#virtualBackend = new WshVirtualSessionBackend(sendControl, this.channelId);
238
+ this.#state = STATE_ACTIVE;
239
+ }
240
+
241
+ // ── Public API ──────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Write data to the session's stdin stream.
245
+ * Accepts a Uint8Array for raw bytes or a string (UTF-8 encoded).
246
+ *
247
+ * @param {Uint8Array|string} data
248
+ */
249
+ async write(data) {
250
+ if (this.#state === STATE_CLOSED) {
251
+ throw new Error('Cannot write to a closed session');
252
+ }
253
+ if (this.#dataMode === 'virtual') {
254
+ if (this.#virtualBackend === null) {
255
+ throw new Error('Session not yet activated — virtual backend unavailable');
256
+ }
257
+ await this.#virtualBackend.write(data);
258
+ return;
259
+ }
260
+ if (this.#stdinWriter === null) {
261
+ throw new Error('Session not yet bound — stdin writer unavailable');
262
+ }
263
+
264
+ const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
265
+ await this.#stdinWriter.write(bytes);
266
+ }
267
+
268
+ /**
269
+ * Read one chunk from the session data plane.
270
+ *
271
+ * Returns `null` on EOF.
272
+ *
273
+ * @returns {Promise<Uint8Array|null>}
274
+ */
275
+ async read() {
276
+ if (this.#dataMode === 'virtual') {
277
+ if (this.#virtualBackend === null) {
278
+ if (this.#state === STATE_CLOSED) {
279
+ return null;
280
+ }
281
+ throw new Error('Session not yet activated — virtual backend unavailable');
282
+ }
283
+ const { done, value } = await this.#virtualBackend.read();
284
+ return done ? null : value || new Uint8Array();
285
+ }
286
+
287
+ if (!this.#stdoutReadable) {
288
+ throw new Error('Session not yet bound — stdout stream unavailable');
289
+ }
290
+
291
+ const reader = this.#stdoutReadable.getReader();
292
+ try {
293
+ const { done, value } = await reader.read();
294
+ return done ? null : value || new Uint8Array();
295
+ } finally {
296
+ reader.releaseLock();
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Request the remote PTY to resize.
302
+ *
303
+ * @param {number} cols - Terminal columns
304
+ * @param {number} rows - Terminal rows
305
+ */
306
+ async resize(cols, rows) {
307
+ this.#assertNotClosed('resize');
308
+ await this.#transport.sendControl(
309
+ resizeMsg({ channelId: this.channelId, cols, rows })
310
+ );
311
+ }
312
+
313
+ /**
314
+ * Send a signal to the remote process (e.g. 'SIGINT', 'SIGTERM').
315
+ *
316
+ * @param {string} sig - Signal name
317
+ */
318
+ async signal(sig) {
319
+ this.#assertNotClosed('signal');
320
+ await this.#transport.sendControl(
321
+ signalMsg({ channelId: this.channelId, signal: sig })
322
+ );
323
+ }
324
+
325
+ /**
326
+ * Close this session. Sends a CLOSE control message and tears down
327
+ * the data streams. Safe to call multiple times.
328
+ */
329
+ async close() {
330
+ if (this.#state === STATE_CLOSED) return;
331
+
332
+ // Optimistically mark closed so no further writes are accepted.
333
+ this.#state = STATE_CLOSED;
334
+ this.#abort.abort();
335
+
336
+ // Send CLOSE to the server (best-effort).
337
+ try {
338
+ await this.#transport.sendControl(
339
+ closeMsg({ channelId: this.channelId })
340
+ );
341
+ } catch {
342
+ // Transport may already be closed; ignore.
343
+ }
344
+
345
+ if (this.#dataMode === 'stream') {
346
+ // Release the stdin writer.
347
+ try {
348
+ await this.#stdinWriter?.close();
349
+ } catch {
350
+ // May already be closed or errored.
351
+ }
352
+
353
+ // Wait for the data pump to finish.
354
+ if (this.#pumpDone) {
355
+ await this.#pumpDone.catch(() => {});
356
+ }
357
+ }
358
+
359
+ this.#stdinWriter = null;
360
+ this.#stdoutReadable = null;
361
+ this.#emitClose();
362
+ }
363
+
364
+ // ── Control message dispatch ────────────────────────────────────────
365
+
366
+ /**
367
+ * Handle a control message dispatched by WshClient for this channel.
368
+ * @param {object} msg - Decoded CBOR control message
369
+ * @internal
370
+ */
371
+ _handleControlMessage(msg) {
372
+ switch (msg.type) {
373
+ case MSG.SESSION_DATA: {
374
+ if (msg.data && msg.data.byteLength > 0) {
375
+ this.#virtualBackend?.pushData(msg.data);
376
+ try {
377
+ this.onData?.(msg.data);
378
+ } catch (err) {
379
+ console.error('[wsh:session] onData handler error:', err);
380
+ }
381
+ }
382
+ break;
383
+ }
384
+
385
+ case MSG.EXIT: {
386
+ this.#exitCode = msg.code ?? -1;
387
+ try {
388
+ this.onExit?.(this.#exitCode);
389
+ } catch (err) {
390
+ console.error('[wsh:session] onExit handler error:', err);
391
+ }
392
+ break;
393
+ }
394
+
395
+ case MSG.CLOSE: {
396
+ // Server-initiated close.
397
+ if (this.#state !== STATE_CLOSED) {
398
+ this.#state = STATE_CLOSED;
399
+ this.#abort.abort();
400
+ this.#virtualBackend?.close();
401
+ this.#releaseStreams();
402
+ this.#emitClose();
403
+ }
404
+ break;
405
+ }
406
+
407
+ case MSG.RESIZE: {
408
+ // Server acknowledgment of resize; currently a no-op on the client.
409
+ break;
410
+ }
411
+
412
+ case MSG.ECHO_ACK:
413
+ this.#lastEchoAck = this.#virtualBackend?.recordEchoAck(msg) || {
414
+ channel_id: this.channelId,
415
+ echo_seq: msg.echo_seq ?? 0,
416
+ };
417
+ try {
418
+ this.onEchoAck?.(this.lastEchoAck);
419
+ } catch (err) {
420
+ console.error('[wsh:session] onEchoAck handler error:', err);
421
+ }
422
+ break;
423
+
424
+ case MSG.ECHO_STATE:
425
+ this.#lastEchoState = this.#virtualBackend?.recordEchoState(msg) || {
426
+ channel_id: this.channelId,
427
+ echo_seq: msg.echo_seq ?? 0,
428
+ cursor_x: msg.cursor_x ?? 0,
429
+ cursor_y: msg.cursor_y ?? 0,
430
+ pending: msg.pending ?? 0,
431
+ };
432
+ try {
433
+ this.onEchoState?.(this.lastEchoState);
434
+ } catch (err) {
435
+ console.error('[wsh:session] onEchoState handler error:', err);
436
+ }
437
+ break;
438
+
439
+ case MSG.TERM_SYNC:
440
+ this.#lastTermSync = this.#virtualBackend?.recordTermSync(msg) || {
441
+ channel_id: this.channelId,
442
+ frame_seq: msg.frame_seq ?? 0,
443
+ state_hash: msg.state_hash?.slice?.() || msg.state_hash || new Uint8Array(),
444
+ };
445
+ try {
446
+ this.onTermSync?.(this.lastTermSync);
447
+ } catch (err) {
448
+ console.error('[wsh:session] onTermSync handler error:', err);
449
+ }
450
+ break;
451
+
452
+ case MSG.TERM_DIFF:
453
+ this.#lastTermDiff = this.#virtualBackend?.recordTermDiff(msg) || {
454
+ channel_id: this.channelId,
455
+ frame_seq: msg.frame_seq ?? 0,
456
+ base_seq: msg.base_seq ?? 0,
457
+ patch: msg.patch?.slice?.() || msg.patch || new Uint8Array(),
458
+ };
459
+ try {
460
+ this.onTermDiff?.(this.lastTermDiff);
461
+ } catch (err) {
462
+ console.error('[wsh:session] onTermDiff handler error:', err);
463
+ }
464
+ break;
465
+
466
+ default:
467
+ // Unknown control message for this channel — ignore gracefully.
468
+ break;
469
+ }
470
+ }
471
+
472
+ // ── Data stream pump ────────────────────────────────────────────────
473
+
474
+ /**
475
+ * Continuously read from the stdout data stream and invoke onData.
476
+ * Runs until the stream ends, errors, or the session is aborted.
477
+ * @private
478
+ */
479
+ async _pumpDataStream() {
480
+ if (!this.#stdoutReadable) return;
481
+
482
+ const reader = this.#stdoutReadable.getReader();
483
+ try {
484
+ while (true) {
485
+ if (this.#abort.signal.aborted) break;
486
+
487
+ const { done, value } = await reader.read();
488
+ if (done) break;
489
+
490
+ if (value && value.byteLength > 0) {
491
+ try {
492
+ this.onData?.(value);
493
+ } catch (err) {
494
+ console.error('[wsh:session] onData handler error:', err);
495
+ }
496
+ }
497
+ }
498
+ } catch (err) {
499
+ // Only report errors if we haven't been intentionally aborted.
500
+ if (!this.#abort.signal.aborted) {
501
+ console.error('[wsh:session] data stream read error:', err);
502
+ }
503
+ } finally {
504
+ reader.releaseLock();
505
+ }
506
+
507
+ // If the data stream ended but we haven't received a CLOSE message,
508
+ // transition to closed state.
509
+ if (this.#state !== STATE_CLOSED) {
510
+ this.#state = STATE_CLOSED;
511
+ this.#releaseStreams();
512
+ this.#emitClose();
513
+ }
514
+ }
515
+
516
+ // ── Private helpers ─────────────────────────────────────────────────
517
+
518
+ /**
519
+ * Release stream resources without sending a CLOSE message.
520
+ * @private
521
+ */
522
+ #releaseStreams() {
523
+ try {
524
+ this.#stdinWriter?.close();
525
+ } catch {
526
+ // Ignore.
527
+ }
528
+ this.#stdinWriter = null;
529
+ this.#stdoutReadable = null;
530
+ this.#virtualBackend?.close();
531
+ }
532
+
533
+ /**
534
+ * Emit the onClose callback exactly once.
535
+ * @private
536
+ */
537
+ #emitClose() {
538
+ try {
539
+ this.onClose?.();
540
+ } catch (err) {
541
+ console.error('[wsh:session] onClose handler error:', err);
542
+ }
543
+ // Clear callbacks to prevent repeat invocations.
544
+ this.onClose = null;
545
+ }
546
+
547
+ /**
548
+ * Throw if the session is closed.
549
+ * @param {string} action - Description of the attempted action.
550
+ * @private
551
+ */
552
+ #assertNotClosed(action) {
553
+ if (this.#state === STATE_CLOSED) {
554
+ throw new Error(`Cannot ${action}: session is closed`);
555
+ }
556
+ }
557
+ }