@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.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/package.json +42 -0
- package/src/auth.mjs +314 -0
- package/src/cbor.mjs +389 -0
- package/src/client.mjs +1842 -0
- package/src/file-transfer.mjs +413 -0
- package/src/index.d.ts +1454 -0
- package/src/index.mjs +70 -0
- package/src/keystore.mjs +473 -0
- package/src/mcp-bridge.mjs +228 -0
- package/src/messages.gen.mjs +928 -0
- package/src/messages.mjs +3 -0
- package/src/recording.mjs +402 -0
- package/src/session.mjs +557 -0
- package/src/transport-ws.mjs +462 -0
- package/src/transport.mjs +343 -0
- package/src/virtual-session.mjs +160 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wsh transport layer — abstract base and WebTransport implementation.
|
|
3
|
+
*
|
|
4
|
+
* Control messages use length-prefixed CBOR framing (frameEncode / FrameDecoder).
|
|
5
|
+
* Data streams carry raw bytes with no framing overhead.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { frameEncode, FrameDecoder, cborEncode } from './cbor.mjs';
|
|
9
|
+
|
|
10
|
+
// ── Transport states ─────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const STATE_DISCONNECTED = 'disconnected';
|
|
13
|
+
const STATE_CONNECTING = 'connecting';
|
|
14
|
+
const STATE_CONNECTED = 'connected';
|
|
15
|
+
const STATE_CLOSED = 'closed';
|
|
16
|
+
|
|
17
|
+
// ── Abstract base class ──────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Abstract transport for the wsh protocol.
|
|
21
|
+
*
|
|
22
|
+
* Subclasses must implement:
|
|
23
|
+
* - _doConnect(url)
|
|
24
|
+
* - _doClose()
|
|
25
|
+
* - _doSendControl(msg)
|
|
26
|
+
* - _doOpenStream()
|
|
27
|
+
*/
|
|
28
|
+
export class WshTransport {
|
|
29
|
+
#state = STATE_DISCONNECTED;
|
|
30
|
+
|
|
31
|
+
/** @type {function(object): void} Callback for incoming control messages. */
|
|
32
|
+
onControl = null;
|
|
33
|
+
|
|
34
|
+
/** @type {function({readable: ReadableStream, writable: WritableStream, id: number}): void} */
|
|
35
|
+
onStreamOpen = null;
|
|
36
|
+
|
|
37
|
+
/** @type {function(): void} */
|
|
38
|
+
onClose = null;
|
|
39
|
+
|
|
40
|
+
/** @type {function(Error): void} */
|
|
41
|
+
onError = null;
|
|
42
|
+
|
|
43
|
+
/** Current transport state. */
|
|
44
|
+
get state() {
|
|
45
|
+
return this.#state;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @protected Update internal state. */
|
|
49
|
+
_setState(s) {
|
|
50
|
+
this.#state = s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Connect to a wsh server.
|
|
55
|
+
* @param {string} url
|
|
56
|
+
*/
|
|
57
|
+
async connect(url) {
|
|
58
|
+
if (this.#state === STATE_CONNECTED || this.#state === STATE_CONNECTING) {
|
|
59
|
+
throw new Error(`Transport already ${this.#state}`);
|
|
60
|
+
}
|
|
61
|
+
this.#state = STATE_CONNECTING;
|
|
62
|
+
try {
|
|
63
|
+
await this._doConnect(url);
|
|
64
|
+
this.#state = STATE_CONNECTED;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
this.#state = STATE_CLOSED;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Gracefully close the transport.
|
|
73
|
+
*/
|
|
74
|
+
async close() {
|
|
75
|
+
if (this.#state === STATE_CLOSED || this.#state === STATE_DISCONNECTED) return;
|
|
76
|
+
this.#state = STATE_CLOSED;
|
|
77
|
+
try {
|
|
78
|
+
await this._doClose();
|
|
79
|
+
} finally {
|
|
80
|
+
this._emitClose();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Send a control message (CBOR-framed).
|
|
86
|
+
* @param {object} msg - Plain object to CBOR-encode and frame.
|
|
87
|
+
*/
|
|
88
|
+
async sendControl(msg) {
|
|
89
|
+
if (this.#state !== STATE_CONNECTED) {
|
|
90
|
+
throw new Error(`Cannot send: transport is ${this.#state}`);
|
|
91
|
+
}
|
|
92
|
+
await this._doSendControl(msg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Open a new bidirectional data stream.
|
|
97
|
+
* @returns {Promise<{readable: ReadableStream<Uint8Array>, writable: WritableStream<Uint8Array>, id: number}>}
|
|
98
|
+
*/
|
|
99
|
+
async openStream() {
|
|
100
|
+
if (this.#state !== STATE_CONNECTED) {
|
|
101
|
+
throw new Error(`Cannot open stream: transport is ${this.#state}`);
|
|
102
|
+
}
|
|
103
|
+
return this._doOpenStream();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Protected helpers ────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** @protected Emit a control message to the callback. */
|
|
109
|
+
_emitControl(msg) {
|
|
110
|
+
try {
|
|
111
|
+
this.onControl?.(msg);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('[wsh:transport] onControl handler error:', err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @protected Emit a new server-initiated stream. */
|
|
118
|
+
_emitStreamOpen(stream) {
|
|
119
|
+
try {
|
|
120
|
+
this.onStreamOpen?.(stream);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error('[wsh:transport] onStreamOpen handler error:', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @protected Emit close event. */
|
|
127
|
+
_emitClose() {
|
|
128
|
+
try {
|
|
129
|
+
this.onClose?.();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error('[wsh:transport] onClose handler error:', err);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @protected Emit error event. */
|
|
136
|
+
_emitError(err) {
|
|
137
|
+
try {
|
|
138
|
+
this.onError?.(err);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error('[wsh:transport] onError handler error:', e);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Abstract methods (must be overridden) ────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** @protected */
|
|
147
|
+
async _doConnect(_url) {
|
|
148
|
+
throw new Error('_doConnect not implemented');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @protected */
|
|
152
|
+
async _doClose() {
|
|
153
|
+
throw new Error('_doClose not implemented');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @protected */
|
|
157
|
+
async _doSendControl(_msg) {
|
|
158
|
+
throw new Error('_doSendControl not implemented');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @protected */
|
|
162
|
+
async _doOpenStream() {
|
|
163
|
+
throw new Error('_doOpenStream not implemented');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── WebTransport implementation ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* wsh transport over the WebTransport API.
|
|
171
|
+
*
|
|
172
|
+
* - The first bidirectional stream opened becomes the control stream.
|
|
173
|
+
* - Control messages are length-prefixed CBOR (frameEncode / FrameDecoder).
|
|
174
|
+
* - Subsequent streams carry raw byte data (no framing).
|
|
175
|
+
* - Server-initiated streams are surfaced via onStreamOpen.
|
|
176
|
+
*/
|
|
177
|
+
export class WebTransportTransport extends WshTransport {
|
|
178
|
+
/** @type {WebTransport} */
|
|
179
|
+
#wt = null;
|
|
180
|
+
|
|
181
|
+
/** @type {WritableStreamDefaultWriter} Control stream writer. */
|
|
182
|
+
#controlWriter = null;
|
|
183
|
+
|
|
184
|
+
/** @type {AbortController} For cancelling background tasks. */
|
|
185
|
+
#abort = new AbortController();
|
|
186
|
+
|
|
187
|
+
/** @type {FrameDecoder} Decoder for inbound control messages. */
|
|
188
|
+
#decoder = new FrameDecoder();
|
|
189
|
+
|
|
190
|
+
/** Incremental stream ID counter (control stream = 0). */
|
|
191
|
+
#nextStreamId = 1;
|
|
192
|
+
|
|
193
|
+
/** @type {Promise<void>} Resolves when the control reader loop finishes. */
|
|
194
|
+
#controlReaderDone = null;
|
|
195
|
+
|
|
196
|
+
/** @type {Promise<void>} Resolves when the incoming-stream acceptor finishes. */
|
|
197
|
+
#incomingAcceptorDone = null;
|
|
198
|
+
|
|
199
|
+
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** @override */
|
|
202
|
+
async _doConnect(url) {
|
|
203
|
+
this.#abort = new AbortController();
|
|
204
|
+
this.#decoder.reset();
|
|
205
|
+
this.#nextStreamId = 1;
|
|
206
|
+
|
|
207
|
+
// Create the WebTransport session.
|
|
208
|
+
const wt = new WebTransport(url);
|
|
209
|
+
this.#wt = wt;
|
|
210
|
+
|
|
211
|
+
// Wait for the connection to be ready.
|
|
212
|
+
await wt.ready;
|
|
213
|
+
|
|
214
|
+
// Open the control stream (stream ID 0 by convention).
|
|
215
|
+
const controlStream = await wt.createBidirectionalStream();
|
|
216
|
+
this.#controlWriter = controlStream.writable.getWriter();
|
|
217
|
+
|
|
218
|
+
// Start reading control messages in the background.
|
|
219
|
+
this.#controlReaderDone = this.#readControlStream(controlStream.readable);
|
|
220
|
+
|
|
221
|
+
// Start accepting server-initiated streams.
|
|
222
|
+
this.#incomingAcceptorDone = this.#acceptIncomingStreams();
|
|
223
|
+
|
|
224
|
+
// Monitor the session closing.
|
|
225
|
+
this.#monitorClosed(wt);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @override */
|
|
229
|
+
async _doClose() {
|
|
230
|
+
this.#abort.abort();
|
|
231
|
+
try {
|
|
232
|
+
this.#controlWriter?.releaseLock?.();
|
|
233
|
+
} catch { /* already released */ }
|
|
234
|
+
try {
|
|
235
|
+
this.#wt?.close();
|
|
236
|
+
} catch { /* may already be closed */ }
|
|
237
|
+
|
|
238
|
+
// Wait for background loops to wind down.
|
|
239
|
+
await Promise.allSettled([
|
|
240
|
+
this.#controlReaderDone,
|
|
241
|
+
this.#incomingAcceptorDone,
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
this.#wt = null;
|
|
245
|
+
this.#controlWriter = null;
|
|
246
|
+
this.#decoder.reset();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @override */
|
|
250
|
+
async _doSendControl(msg) {
|
|
251
|
+
const frame = frameEncode(msg);
|
|
252
|
+
await this.#controlWriter.write(frame);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @override */
|
|
256
|
+
async _doOpenStream() {
|
|
257
|
+
const bidi = await this.#wt.createBidirectionalStream();
|
|
258
|
+
const id = this.#nextStreamId++;
|
|
259
|
+
return {
|
|
260
|
+
readable: bidi.readable,
|
|
261
|
+
writable: bidi.writable,
|
|
262
|
+
id,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Background loops ───────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read control stream, decode CBOR frames, emit messages.
|
|
270
|
+
* @param {ReadableStream<Uint8Array>} readable
|
|
271
|
+
*/
|
|
272
|
+
async #readControlStream(readable) {
|
|
273
|
+
const reader = readable.getReader();
|
|
274
|
+
try {
|
|
275
|
+
while (true) {
|
|
276
|
+
if (this.#abort.signal.aborted) break;
|
|
277
|
+
|
|
278
|
+
const { done, value } = await reader.read();
|
|
279
|
+
if (done) break;
|
|
280
|
+
|
|
281
|
+
const messages = this.#decoder.feed(value);
|
|
282
|
+
for (const msg of messages) {
|
|
283
|
+
this._emitControl(msg);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (!this.#abort.signal.aborted) {
|
|
288
|
+
this._emitError(new Error(`Control stream read error: ${err.message}`));
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
reader.releaseLock();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Accept server-initiated bidirectional streams and emit them.
|
|
297
|
+
*/
|
|
298
|
+
async #acceptIncomingStreams() {
|
|
299
|
+
const reader = this.#wt.incomingBidirectionalStreams.getReader();
|
|
300
|
+
try {
|
|
301
|
+
while (true) {
|
|
302
|
+
if (this.#abort.signal.aborted) break;
|
|
303
|
+
|
|
304
|
+
const { done, value: stream } = await reader.read();
|
|
305
|
+
if (done) break;
|
|
306
|
+
|
|
307
|
+
const id = this.#nextStreamId++;
|
|
308
|
+
this._emitStreamOpen({
|
|
309
|
+
readable: stream.readable,
|
|
310
|
+
writable: stream.writable,
|
|
311
|
+
id,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (!this.#abort.signal.aborted) {
|
|
316
|
+
this._emitError(new Error(`Incoming stream acceptor error: ${err.message}`));
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
reader.releaseLock();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Watch the WebTransport session for closure and propagate.
|
|
325
|
+
* @param {WebTransport} wt
|
|
326
|
+
*/
|
|
327
|
+
#monitorClosed(wt) {
|
|
328
|
+
wt.closed
|
|
329
|
+
.then(() => {
|
|
330
|
+
if (this.state !== 'closed') {
|
|
331
|
+
this._setState('closed');
|
|
332
|
+
this._emitClose();
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
.catch((err) => {
|
|
336
|
+
if (this.state !== 'closed') {
|
|
337
|
+
this._setState('closed');
|
|
338
|
+
this._emitError(err);
|
|
339
|
+
this._emitClose();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sessionData as sessionDataMsg } from './messages.mjs';
|
|
2
|
+
|
|
3
|
+
const textEncoder = new TextEncoder();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize user input into session bytes for a virtual terminal channel.
|
|
7
|
+
*
|
|
8
|
+
* @param {Uint8Array|string} data
|
|
9
|
+
* @returns {Uint8Array}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeSessionData(data) {
|
|
12
|
+
if (data instanceof Uint8Array) {
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
return textEncoder.encode(String(data));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Message-backed data plane for browser-hosted virtual sessions.
|
|
20
|
+
*/
|
|
21
|
+
export class WshVirtualSessionBackend {
|
|
22
|
+
/** @type {function(object): Promise<void>} */
|
|
23
|
+
#sendControl;
|
|
24
|
+
|
|
25
|
+
/** @type {number} */
|
|
26
|
+
#channelId;
|
|
27
|
+
|
|
28
|
+
/** @type {object|null} */
|
|
29
|
+
#lastEchoAck = null;
|
|
30
|
+
|
|
31
|
+
/** @type {object|null} */
|
|
32
|
+
#lastEchoState = null;
|
|
33
|
+
|
|
34
|
+
/** @type {object|null} */
|
|
35
|
+
#lastTermSync = null;
|
|
36
|
+
|
|
37
|
+
/** @type {object|null} */
|
|
38
|
+
#lastTermDiff = null;
|
|
39
|
+
/** @type {Uint8Array[]} */
|
|
40
|
+
#readQueue = [];
|
|
41
|
+
/** @type {Array<{resolve: function({done:boolean,value?:Uint8Array}):void,reject:function(Error):void}>} */
|
|
42
|
+
#readWaiters = [];
|
|
43
|
+
/** @type {boolean} */
|
|
44
|
+
#closed = false;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {function(object): Promise<void>} sendControl
|
|
48
|
+
* @param {number} channelId
|
|
49
|
+
*/
|
|
50
|
+
constructor(sendControl, channelId) {
|
|
51
|
+
this.#sendControl = sendControl;
|
|
52
|
+
this.#channelId = channelId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {Uint8Array|string} data
|
|
57
|
+
* @returns {Promise<void>}
|
|
58
|
+
*/
|
|
59
|
+
async write(data) {
|
|
60
|
+
await this.#sendControl(
|
|
61
|
+
sessionDataMsg({
|
|
62
|
+
channelId: this.#channelId,
|
|
63
|
+
data: normalizeSessionData(data),
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async read() {
|
|
69
|
+
if (this.#readQueue.length > 0) {
|
|
70
|
+
return { done: false, value: this.#readQueue.shift() };
|
|
71
|
+
}
|
|
72
|
+
if (this.#closed) {
|
|
73
|
+
return { done: true, value: undefined };
|
|
74
|
+
}
|
|
75
|
+
return await new Promise((resolve, reject) => {
|
|
76
|
+
this.#readWaiters.push({ resolve, reject });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pushData(data) {
|
|
81
|
+
const bytes = data instanceof Uint8Array ? data : normalizeSessionData(data);
|
|
82
|
+
const waiter = this.#readWaiters.shift();
|
|
83
|
+
if (waiter) {
|
|
84
|
+
waiter.resolve({ done: false, value: bytes });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.#readQueue.push(bytes);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
close() {
|
|
91
|
+
if (this.#closed) return;
|
|
92
|
+
this.#closed = true;
|
|
93
|
+
while (this.#readWaiters.length > 0) {
|
|
94
|
+
const waiter = this.#readWaiters.shift();
|
|
95
|
+
waiter?.resolve({ done: true, value: undefined });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get lastEchoAck() {
|
|
100
|
+
return this.#lastEchoAck ? { ...this.#lastEchoAck } : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get lastEchoState() {
|
|
104
|
+
return this.#lastEchoState ? { ...this.#lastEchoState } : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get lastTermSync() {
|
|
108
|
+
if (!this.#lastTermSync) return null;
|
|
109
|
+
return {
|
|
110
|
+
...this.#lastTermSync,
|
|
111
|
+
state_hash: this.#lastTermSync.state_hash?.slice?.() || this.#lastTermSync.state_hash,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get lastTermDiff() {
|
|
116
|
+
if (!this.#lastTermDiff) return null;
|
|
117
|
+
return {
|
|
118
|
+
...this.#lastTermDiff,
|
|
119
|
+
patch: this.#lastTermDiff.patch?.slice?.() || this.#lastTermDiff.patch,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recordEchoAck(msg) {
|
|
124
|
+
this.#lastEchoAck = {
|
|
125
|
+
channel_id: this.#channelId,
|
|
126
|
+
echo_seq: msg.echo_seq ?? 0,
|
|
127
|
+
};
|
|
128
|
+
return this.lastEchoAck;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
recordEchoState(msg) {
|
|
132
|
+
this.#lastEchoState = {
|
|
133
|
+
channel_id: this.#channelId,
|
|
134
|
+
echo_seq: msg.echo_seq ?? 0,
|
|
135
|
+
cursor_x: msg.cursor_x ?? 0,
|
|
136
|
+
cursor_y: msg.cursor_y ?? 0,
|
|
137
|
+
pending: msg.pending ?? 0,
|
|
138
|
+
};
|
|
139
|
+
return this.lastEchoState;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
recordTermSync(msg) {
|
|
143
|
+
this.#lastTermSync = {
|
|
144
|
+
channel_id: this.#channelId,
|
|
145
|
+
frame_seq: msg.frame_seq ?? 0,
|
|
146
|
+
state_hash: msg.state_hash?.slice?.() || msg.state_hash || new Uint8Array(),
|
|
147
|
+
};
|
|
148
|
+
return this.lastTermSync;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
recordTermDiff(msg) {
|
|
152
|
+
this.#lastTermDiff = {
|
|
153
|
+
channel_id: this.#channelId,
|
|
154
|
+
frame_seq: msg.frame_seq ?? 0,
|
|
155
|
+
base_seq: msg.base_seq ?? 0,
|
|
156
|
+
patch: msg.patch?.slice?.() || msg.patch || new Uint8Array(),
|
|
157
|
+
};
|
|
158
|
+
return this.lastTermDiff;
|
|
159
|
+
}
|
|
160
|
+
}
|