replica-bridge 0.3.5 → 0.3.6
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/package.json +2 -2
- package/src/bridge/service.js +77 -22
- package/src/protocol.js +91 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replica-bridge",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Lets the ChatReplica iPhone app use Codex on this computer through a relay, end-to-end encrypted.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"qrcode": "1.5.4",
|
|
14
|
-
"ws": "8.
|
|
14
|
+
"ws": "8.22.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "UNLICENSED"
|
|
17
17
|
}
|
package/src/bridge/service.js
CHANGED
|
@@ -10,7 +10,7 @@ import { Duplex } from "node:stream";
|
|
|
10
10
|
import { WebSocket } from "ws";
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
-
CloseCode, FrameCipher, MAX_FRAME_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, b64url, fromB64url, generateKeyPair, hostSessionKeys,
|
|
13
|
+
CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, encodeLine, b64url, fromB64url, generateKeyPair, hostSessionKeys,
|
|
14
14
|
} from "../protocol.js";
|
|
15
15
|
|
|
16
16
|
import { relaySocketURL } from "../http.js";
|
|
@@ -50,6 +50,12 @@ import { annotateWriterError } from "./writer.js";
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
53
59
|
|
|
54
60
|
|
|
55
61
|
|
|
@@ -125,6 +131,14 @@ export class BridgeService {
|
|
|
125
131
|
});
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Waits while the relay socket has more than a few chunks queued, so a long message goes out at the network's pace
|
|
136
|
+
* instead of tens of MiB piling up in memory ahead of everything else on this computer's socket.
|
|
137
|
+
*/
|
|
138
|
+
async drained() {
|
|
139
|
+
while (this.relay?.readyState === WebSocket.OPEN && this.relay.bufferedAmount > RELAY_SEND_QUEUE_BYTES) await new Promise((resolve) => setTimeout(resolve, 10));
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
sendEnvelope(envelope ) {
|
|
129
143
|
if (this.relay?.readyState === WebSocket.OPEN) this.relay.send(JSON.stringify(envelope));
|
|
130
144
|
}
|
|
@@ -191,12 +205,20 @@ export class BridgeService {
|
|
|
191
205
|
const message = conn.cipher.openFrame(frame);
|
|
192
206
|
if (message.t === "resume") {
|
|
193
207
|
if (conn.stream) throw new Error("second resume");
|
|
194
|
-
await this.resume(conn, message.stream, message.ack);
|
|
208
|
+
await this.resume(conn, message.stream, message.ack, message.seg === 1);
|
|
195
209
|
} else if (message.t === "msg") {
|
|
196
210
|
const stream = conn.stream;
|
|
197
211
|
if (!stream) throw new Error("message before resume");
|
|
198
212
|
if (!stream.in.accept(message.seq)) return;
|
|
199
|
-
if (stream.
|
|
213
|
+
if (stream.chunks.busy) throw new Error("message in the middle of a chunked one");
|
|
214
|
+
this.sendToAppServer(stream, message.line);
|
|
215
|
+
this.scheduleAck(stream);
|
|
216
|
+
} else if (message.t === "chunk") {
|
|
217
|
+
const stream = conn.stream;
|
|
218
|
+
if (!stream) throw new Error("message before resume");
|
|
219
|
+
if (!stream.in.accept(message.seq)) return;
|
|
220
|
+
const line = stream.chunks.take(message);
|
|
221
|
+
if (line !== null) this.sendToAppServer(stream, line);
|
|
200
222
|
this.scheduleAck(stream);
|
|
201
223
|
} else if (message.t === "ack") {
|
|
202
224
|
conn.stream?.out.ack(message.seq);
|
|
@@ -209,7 +231,7 @@ export class BridgeService {
|
|
|
209
231
|
|
|
210
232
|
// MARK: - Streams
|
|
211
233
|
|
|
212
|
-
async resume(conn , streamId , peerAck ) {
|
|
234
|
+
async resume(conn , streamId , peerAck , segmented ) {
|
|
213
235
|
let stream = streamId ? this.streams.get(streamId) : undefined;
|
|
214
236
|
const resumed = !!stream && stream.deviceId === conn.deviceId;
|
|
215
237
|
if (!resumed) {
|
|
@@ -219,31 +241,40 @@ export class BridgeService {
|
|
|
219
241
|
if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
|
|
220
242
|
if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
|
|
221
243
|
s.conn = conn;
|
|
244
|
+
s.segmented = segmented;
|
|
222
245
|
conn.stream = s;
|
|
223
|
-
this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received });
|
|
246
|
+
this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1 });
|
|
224
247
|
// A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
|
|
225
248
|
const after = resumed ? peerAck : 0;
|
|
226
249
|
s.out.ack(after);
|
|
227
|
-
for (const
|
|
250
|
+
for (const message of s.out.pending(after)) this.sendInner(conn, message);
|
|
228
251
|
this.log(`${resumed ? "resumed" : "opened"} stream ${s.id} for ${conn.deviceId}`);
|
|
229
252
|
}
|
|
230
253
|
|
|
231
254
|
async openStream(deviceId ) {
|
|
232
|
-
const appServer = await this.connectAppServer();
|
|
233
|
-
const stream = {
|
|
255
|
+
const { ws: appServer, frameBytes } = await this.connectAppServer();
|
|
256
|
+
const stream = {
|
|
257
|
+
id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(STREAM_BUFFER_BYTES), in: new StreamBuffer(STREAM_BUFFER_BYTES),
|
|
258
|
+
conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes,
|
|
259
|
+
};
|
|
234
260
|
this.streams.set(stream.id, stream);
|
|
235
261
|
appServer.on("message", (data ) => {
|
|
236
262
|
const text = data.toString();
|
|
237
|
-
const
|
|
263
|
+
const segmented = stream.segmented;
|
|
264
|
+
const fitted = fitLine(text, segmented ? REASSEMBLED_MAX_BYTES : RELAY_REPLY_LIMIT);
|
|
238
265
|
if (fitted === null) return this.log(`stream ${stream.id}: dropped a ${text.length}-byte app-server notification too large for the relay`);
|
|
239
266
|
if (fitted !== text) this.log(`stream ${stream.id}: answered a ${text.length}-byte app-server reply too large for the relay with an error`);
|
|
240
267
|
const annotated = annotateWriterError(fitted, this.options.findWriter);
|
|
241
268
|
stream.relayed = stream.relayed.then(async () => {
|
|
242
269
|
const line = await annotated;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
const parts = segmented ? await encodeLine(line) : [line];
|
|
271
|
+
for (const part of parts) {
|
|
272
|
+
if (!this.streams.has(stream.id)) return;
|
|
273
|
+
let message;
|
|
274
|
+
try { message = stream.out.push(part); } catch { return this.endStream(stream, "the phone stayed away too long and the buffer filled up"); }
|
|
275
|
+
if (stream.conn) this.sendInner(stream.conn, message);
|
|
276
|
+
if (parts.length > 1) await this.drained();
|
|
277
|
+
}
|
|
247
278
|
});
|
|
248
279
|
});
|
|
249
280
|
appServer.on("close", () => this.endStream(stream, "codex app-server closed the connection"));
|
|
@@ -282,11 +313,22 @@ export class BridgeService {
|
|
|
282
313
|
|
|
283
314
|
// MARK: - codex app-server
|
|
284
315
|
|
|
316
|
+
/** Sends a line to the app-server, as WebSocket fragments when it is longer than the app-server takes in one frame. */
|
|
317
|
+
sendToAppServer(stream , line ) {
|
|
318
|
+
const ws = stream.appServer;
|
|
319
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
320
|
+
const bytes = Buffer.from(line, "utf8");
|
|
321
|
+
if (bytes.length <= stream.appServerFrameBytes) return ws.send(line);
|
|
322
|
+
for (let at = 0; at < bytes.length; at += stream.appServerFrameBytes) {
|
|
323
|
+
ws.send(bytes.subarray(at, at + stream.appServerFrameBytes), { binary: false, fin: at + stream.appServerFrameBytes >= bytes.length });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
285
327
|
/**
|
|
286
328
|
* One `codex app-server proxy` per stream, with WebSocket over its stdin and stdout. The proxy only relays bytes to
|
|
287
329
|
* the app-server's control socket, wherever Codex keeps it; it exits at once when no app-server is running.
|
|
288
330
|
*/
|
|
289
|
-
openProxy()
|
|
331
|
+
openProxy() {
|
|
290
332
|
return new Promise((resolve, reject) => {
|
|
291
333
|
const child = spawnCommand(this.options.codexPath ?? "codex", ["app-server", "proxy"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
292
334
|
let stderr = "";
|
|
@@ -297,13 +339,18 @@ export class BridgeService {
|
|
|
297
339
|
// Read app-server messages well past what one relay frame holds, so fitLine answers a big reply with an error
|
|
298
340
|
// rather than the oversized message closing the app-server connection.
|
|
299
341
|
const ws = new WebSocket("ws://localhost/", { createConnection: () => pipe , perMessageDeflate: false, maxPayload: APP_SERVER_MAX_MESSAGE_BYTES });
|
|
342
|
+
let frameBytes = APP_SERVER_FRAME_BYTES;
|
|
343
|
+
ws.once("upgrade", (res) => {
|
|
344
|
+
const advertised = Number(res.headers["x-codex-websocket-max-unfragmented-message-bytes"]);
|
|
345
|
+
if (Number.isInteger(advertised) && advertised >= 64 * 1024) frameBytes = advertised;
|
|
346
|
+
});
|
|
300
347
|
const fail = (why ) => { ws.removeAllListeners("open"); killCommand(child); reject(new Error(why)); };
|
|
301
348
|
child.once("error", (error) => fail(`could not run codex: ${error.message}`));
|
|
302
349
|
child.once("exit", (code) => { if (ws.readyState !== WebSocket.OPEN) fail(`codex app-server proxy exited (${code})${stderr ? `: ${stderr.trim()}` : ""}`); });
|
|
303
350
|
ws.once("error", (error) => { if (ws.readyState !== WebSocket.OPEN) fail(error.message); });
|
|
304
351
|
ws.once("open", () => {
|
|
305
352
|
ws.on("close", () => killCommand(child));
|
|
306
|
-
resolve(ws);
|
|
353
|
+
resolve({ ws, frameBytes });
|
|
307
354
|
});
|
|
308
355
|
});
|
|
309
356
|
}
|
|
@@ -318,7 +365,7 @@ export class BridgeService {
|
|
|
318
365
|
});
|
|
319
366
|
}
|
|
320
367
|
|
|
321
|
-
async connectAppServer()
|
|
368
|
+
async connectAppServer() {
|
|
322
369
|
try {
|
|
323
370
|
return await this.openProxy();
|
|
324
371
|
} catch (error) {
|
|
@@ -349,16 +396,24 @@ export class BridgeService {
|
|
|
349
396
|
/** Largest app-server message the bridge reads before refusing it. */
|
|
350
397
|
const APP_SERVER_MAX_MESSAGE_BYTES = 256 * 1024 * 1024;
|
|
351
398
|
|
|
399
|
+
/** What the bridge keeps per direction for a phone that is away: room for a REASSEMBLED_MAX_BYTES line deflate could not shrink. */
|
|
400
|
+
const STREAM_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
401
|
+
/** Chunks queued on the relay socket before the next one waits. */
|
|
402
|
+
const RELAY_SEND_QUEUE_BYTES = 1024 * 1024;
|
|
403
|
+
/** What codex-cli 0.163.0 sends in x-codex-websocket-max-unfragmented-message-bytes, for an app-server that sends none. */
|
|
404
|
+
const APP_SERVER_FRAME_BYTES = 16 * 1024 * 1024;
|
|
405
|
+
|
|
352
406
|
/**
|
|
353
|
-
* An app-server line as it can cross the relay: unchanged when it fits RELAY_REPLY_LIMIT,
|
|
354
|
-
*
|
|
355
|
-
* notification that does not, which
|
|
407
|
+
* An app-server line as it can cross the relay: unchanged when it fits `limit` (RELAY_REPLY_LIMIT, or
|
|
408
|
+
* REASSEMBLED_MAX_BYTES for a phone that takes chunks), a JSON-RPC error with the same id when it is a reply that does
|
|
409
|
+
* not (`error.data.replicaTooLarge: {bytes, limit}`, PROTOCOL.md), and null for a notification that does not, which
|
|
410
|
+
* has no one to answer.
|
|
356
411
|
*/
|
|
357
|
-
export function fitLine(line ) {
|
|
412
|
+
export function fitLine(line , limit = RELAY_REPLY_LIMIT) {
|
|
358
413
|
const bytes = Buffer.byteLength(line, "utf8");
|
|
359
|
-
if (bytes <=
|
|
414
|
+
if (bytes <= limit) return line;
|
|
360
415
|
let id ;
|
|
361
416
|
try { id = (JSON.parse(line) ).id; } catch { return null; }
|
|
362
417
|
if (id === undefined || id === null) return null;
|
|
363
|
-
return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${
|
|
418
|
+
return JSON.stringify({ id, error: { code: -32010, message: `the reply is ${bytes} bytes, more than the relay carries (${limit})`, data: { replicaTooLarge: { bytes, limit } } } });
|
|
364
419
|
}
|
package/src/protocol.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
|
|
3
3
|
// Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
|
+
import zlib from "node:zlib";
|
|
6
|
+
import { promisify } from "node:util";
|
|
5
7
|
|
|
6
8
|
export const PROTOCOL = "chatreplica-relay-v1";
|
|
7
9
|
export const LINK_SCHEME = "chatreplica:";
|
|
@@ -15,6 +17,17 @@ export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
|
15
17
|
* of a big file) come back as a JSON-RPC error instead of a frame the relay would refuse by closing the connection.
|
|
16
18
|
*/
|
|
17
19
|
export const RELAY_REPLY_LIMIT = 11 * 1024 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Long messages, the way Codex remote-control splits them (openai/codex app-server-transport remote_control/segment.rs):
|
|
22
|
+
* when both sides say `seg` (the phone in resume, the bridge in ready), a line over SEGMENT_BYTES goes as `chunk`
|
|
23
|
+
* messages of at most SEGMENT_BYTES each, at most SEGMENT_COUNT_MAX of them, up to REASSEMBLED_MAX_BYTES; the receiver
|
|
24
|
+
* checks every chunk's segment, count and size. Unlike remote-control the payload is raw deflate of the line (`z`),
|
|
25
|
+
* since the relay's frames are encrypted and nothing on the way can compress them. A chunk travels sealed and
|
|
26
|
+
* base64url encoded in about 180 KiB of frame, so pings still get through while a long message is on its way.
|
|
27
|
+
*/
|
|
28
|
+
export const SEGMENT_BYTES = 100 * 1024;
|
|
29
|
+
export const SEGMENT_COUNT_MAX = 1024;
|
|
30
|
+
export const REASSEMBLED_MAX_BYTES = 100 * 1024 * 1024;
|
|
18
31
|
|
|
19
32
|
export const b64url = (data ) => Buffer.from(data).toString("base64url");
|
|
20
33
|
export const fromB64url = (text ) => Buffer.from(text, "base64url");
|
|
@@ -208,12 +221,78 @@ export class FrameCipher {
|
|
|
208
221
|
|
|
209
222
|
/** What is inside a data frame. */
|
|
210
223
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
214
228
|
|
|
215
229
|
|
|
216
230
|
|
|
231
|
+
|
|
232
|
+
/** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
/** What a StreamBuffer keeps until the peer acknowledges it. */
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
/** The payload cut into chunks: SEGMENT_BYTES each, or larger only to stay within SEGMENT_COUNT_MAX. */
|
|
239
|
+
export function chunkParts(payload , z , segmentBytes = SEGMENT_BYTES) {
|
|
240
|
+
const size = payload.length;
|
|
241
|
+
const chunkSize = Math.max(segmentBytes, Math.ceil(size / SEGMENT_COUNT_MAX));
|
|
242
|
+
const segments = Math.max(1, Math.ceil(size / chunkSize));
|
|
243
|
+
return Array.from({ length: segments }, (_, segment) => ({
|
|
244
|
+
segment, segments, size, ...(z ? { z: 1 } : {}), data: payload.subarray(segment * chunkSize, (segment + 1) * chunkSize).toString("base64"),
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const deflateRaw = promisify(zlib.deflateRaw);
|
|
249
|
+
|
|
250
|
+
/** A line as a `seg` peer takes it: as it is up to SEGMENT_BYTES, otherwise deflated and in chunks. */
|
|
251
|
+
export async function encodeLine(line ) {
|
|
252
|
+
const bytes = Buffer.from(line, "utf8");
|
|
253
|
+
if (bytes.length <= SEGMENT_BYTES) return [line];
|
|
254
|
+
return chunkParts(await deflateRaw(bytes), true);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** encodeLine without yielding, for the test phone. */
|
|
258
|
+
export function encodeLineSync(line ) {
|
|
259
|
+
const bytes = Buffer.from(line, "utf8");
|
|
260
|
+
if (bytes.length <= SEGMENT_BYTES) return [line];
|
|
261
|
+
return chunkParts(zlib.deflateRawSync(bytes), true);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Puts one stream's chunks back together. Chunks arrive in seq order, so a line's segments are consecutive; anything
|
|
266
|
+
* else (a skipped segment, a changed count or size, more bytes than announced, a `msg` in the middle) is a protocol
|
|
267
|
+
* error, since the peer is authenticated and only a bug would send it.
|
|
268
|
+
*/
|
|
269
|
+
export class ChunkAssembler {
|
|
270
|
+
parts = [];
|
|
271
|
+
received = 0;
|
|
272
|
+
first = null;
|
|
273
|
+
|
|
274
|
+
get busy() { return this.first !== null; }
|
|
275
|
+
|
|
276
|
+
take(message ) {
|
|
277
|
+
const { segment, segments, size } = message;
|
|
278
|
+
if (!Number.isInteger(segments) || segments < 1 || segments > SEGMENT_COUNT_MAX || !Number.isInteger(size) || size < 1 || size > REASSEMBLED_MAX_BYTES) {
|
|
279
|
+
throw new Error("chunk count or size out of range");
|
|
280
|
+
}
|
|
281
|
+
const first = this.first ?? message;
|
|
282
|
+
if (segment !== this.parts.length || first.segments !== segments || first.size !== size || first.z !== message.z) throw new Error(`chunk ${segment} out of order`);
|
|
283
|
+
const data = Buffer.from(message.data, "base64");
|
|
284
|
+
this.received += data.length;
|
|
285
|
+
if (data.length === 0 || this.received > size) throw new Error("chunk carries more than its size");
|
|
286
|
+
this.first = first;
|
|
287
|
+
this.parts.push(data);
|
|
288
|
+
if (segment < segments - 1) return null;
|
|
289
|
+
if (this.received !== size) throw new Error("chunks carry less than their size");
|
|
290
|
+
const payload = Buffer.concat(this.parts);
|
|
291
|
+
this.parts = []; this.received = 0; this.first = null;
|
|
292
|
+
return (message.z ? zlib.inflateRawSync(payload, { maxOutputLength: REASSEMBLED_MAX_BYTES }) : payload).toString("utf8");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
217
296
|
/** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
|
|
218
297
|
|
|
219
298
|
|
|
@@ -236,26 +315,28 @@ export const CloseCode = {
|
|
|
236
315
|
export class StreamBuffer {
|
|
237
316
|
nextSeq = 1;
|
|
238
317
|
received = 0;
|
|
239
|
-
unacked
|
|
318
|
+
unacked = [];
|
|
240
319
|
bytes = 0;
|
|
241
320
|
|
|
242
321
|
maxBytes ;
|
|
243
322
|
constructor(maxBytes = 32 * 1024 * 1024) { this.maxBytes = maxBytes; }
|
|
244
323
|
|
|
245
|
-
push(
|
|
246
|
-
const
|
|
324
|
+
push(part ) {
|
|
325
|
+
const seq = this.nextSeq++;
|
|
326
|
+
const message = typeof part === "string" ? { t: "msg", seq, line: part } : { t: "chunk", seq, ...part };
|
|
327
|
+
const item = { message, size: Buffer.byteLength(message.t === "msg" ? message.line : message.data) };
|
|
247
328
|
this.unacked.push(item);
|
|
248
329
|
this.bytes += item.size;
|
|
249
330
|
if (this.bytes > this.maxBytes) throw new Error("unacknowledged buffer full");
|
|
250
|
-
return
|
|
331
|
+
return message;
|
|
251
332
|
}
|
|
252
333
|
|
|
253
334
|
ack(seq ) {
|
|
254
|
-
while (this.unacked.length && this.unacked[0].seq <= seq) this.bytes -= this.unacked.shift() .size;
|
|
335
|
+
while (this.unacked.length && this.unacked[0].message.seq <= seq) this.bytes -= this.unacked.shift() .size;
|
|
255
336
|
}
|
|
256
337
|
|
|
257
|
-
pending(after )
|
|
258
|
-
return this.unacked.filter((item) => item.seq > after);
|
|
338
|
+
pending(after ) {
|
|
339
|
+
return this.unacked.filter((item) => item.message.seq > after).map((item) => item.message);
|
|
259
340
|
}
|
|
260
341
|
|
|
261
342
|
/** True when the line is new; duplicates from a resend are dropped. Gaps cannot happen on one ordered socket. */
|