agentchatme 1.0.2 → 1.0.21
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/CHANGELOG.md +268 -221
- package/LICENSE +21 -21
- package/README.md +573 -557
- package/dist/index.cjs +330 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +139 -21
- package/dist/index.d.ts +139 -21
- package/dist/index.js +330 -33
- package/dist/index.js.map +1 -1
- package/package.json +13 -12
package/dist/index.js
CHANGED
|
@@ -210,7 +210,7 @@ function createAgentChatError(body, status, headers) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
// src/version.ts
|
|
213
|
-
var VERSION = "1.0.
|
|
213
|
+
var VERSION = "1.0.21" ;
|
|
214
214
|
|
|
215
215
|
// src/runtime.ts
|
|
216
216
|
function detectRuntime() {
|
|
@@ -1255,22 +1255,36 @@ var AgentChatClient = class _AgentChatClient {
|
|
|
1255
1255
|
// ─── Sync / read-state ────────────────────────────────────────────────────
|
|
1256
1256
|
/**
|
|
1257
1257
|
* Fetch undelivered envelopes accumulated while the realtime stream was
|
|
1258
|
-
* disconnected.
|
|
1259
|
-
*
|
|
1258
|
+
* disconnected. Returns a **bare array** of rows, oldest first — see
|
|
1259
|
+
* `SyncEnvelope` for the shape and cursor semantics.
|
|
1260
|
+
*
|
|
1261
|
+
* Non-destructive: nothing is marked delivered until `syncAck()` is called
|
|
1262
|
+
* with the last non-null `delivery_id` of the rows you actually processed
|
|
1263
|
+
* (positional cursor — `delivery_id` is opaque, never compare it
|
|
1264
|
+
* numerically). `after` pages forward without committing anything: pass
|
|
1265
|
+
* the last `delivery_id` of the previous batch.
|
|
1266
|
+
*
|
|
1260
1267
|
* The WebSocket client drives this automatically on reconnect; most
|
|
1261
1268
|
* callers never need it directly.
|
|
1262
1269
|
*/
|
|
1263
1270
|
sync(opts) {
|
|
1264
1271
|
const params = new URLSearchParams();
|
|
1265
1272
|
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
1266
|
-
if (opts?.after !== void 0) params.set("after",
|
|
1273
|
+
if (opts?.after !== void 0) params.set("after", opts.after);
|
|
1267
1274
|
const qs = params.toString();
|
|
1268
1275
|
return this.get(`/v1/messages/sync${qs ? `?${qs}` : ""}`, opts);
|
|
1269
1276
|
}
|
|
1270
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* Commit every delivery at-or-before the cursor as delivered.
|
|
1279
|
+
* `lastDeliveryId` is the opaque string cursor from a `sync()` row.
|
|
1280
|
+
* Returns the number of envelopes that transitioned to `delivered`
|
|
1281
|
+
* (0 is a normal outcome — e.g. a repeated ack, or an ack while the
|
|
1282
|
+
* agent is owner-paused).
|
|
1283
|
+
*/
|
|
1284
|
+
syncAck(lastDeliveryId2, opts) {
|
|
1271
1285
|
return this.post(
|
|
1272
1286
|
"/v1/messages/sync/ack",
|
|
1273
|
-
{ last_delivery_id:
|
|
1287
|
+
{ last_delivery_id: lastDeliveryId2 },
|
|
1274
1288
|
opts
|
|
1275
1289
|
);
|
|
1276
1290
|
}
|
|
@@ -1307,6 +1321,36 @@ var HELLO_ACK_TIMEOUT_MS = 4e3;
|
|
|
1307
1321
|
var GAP_FILL_WINDOW_MS = 2e3;
|
|
1308
1322
|
var MAX_BUFFERED_PER_CONVERSATION = 500;
|
|
1309
1323
|
var GAP_FILL_LIMIT = 200;
|
|
1324
|
+
var SYNC_DRAIN_PAGE_SIZE = 200;
|
|
1325
|
+
var DEFAULT_DEDUP_CACHE_SIZE = 2048;
|
|
1326
|
+
var TERMINAL_CLOSE_CODES = /* @__PURE__ */ new Set([1008, 4401, 4403]);
|
|
1327
|
+
function isValidSyncRow(row) {
|
|
1328
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return false;
|
|
1329
|
+
const r = row;
|
|
1330
|
+
if (typeof r.id !== "string") return false;
|
|
1331
|
+
if (typeof r.conversation_id !== "string") return false;
|
|
1332
|
+
if (typeof r.delivery_id !== "string" && r.delivery_id !== null) return false;
|
|
1333
|
+
for (const key of ["sender", "sender_handle", "type", "created_at"]) {
|
|
1334
|
+
if (r[key] !== void 0 && typeof r[key] !== "string") return false;
|
|
1335
|
+
}
|
|
1336
|
+
if (r.content !== void 0 && (typeof r.content !== "object" || r.content === null || Array.isArray(r.content))) {
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
return true;
|
|
1340
|
+
}
|
|
1341
|
+
function lastDeliveryId(rows) {
|
|
1342
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
1343
|
+
const id = rows[i]?.delivery_id;
|
|
1344
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
1345
|
+
}
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
function isThenable(value) {
|
|
1349
|
+
return typeof value === "object" && value !== null && typeof value.then === "function";
|
|
1350
|
+
}
|
|
1351
|
+
function toError(reason, context) {
|
|
1352
|
+
return reason instanceof Error ? reason : new Error(`${context} handler failed: ${String(reason)}`);
|
|
1353
|
+
}
|
|
1310
1354
|
var RealtimeClient = class {
|
|
1311
1355
|
ws = null;
|
|
1312
1356
|
options;
|
|
@@ -1318,9 +1362,37 @@ var RealtimeClient = class {
|
|
|
1318
1362
|
reconnectTimer = null;
|
|
1319
1363
|
helloAckTimer = null;
|
|
1320
1364
|
authenticated = false;
|
|
1365
|
+
// True only when the server echoed the 'ack' capability in hello.ok.
|
|
1366
|
+
// Per-connection: reset on every close and re-negotiated on every HELLO.
|
|
1367
|
+
ackMode = false;
|
|
1368
|
+
// Set immediately before our own close(1008, 'HELLO ack timeout') so the
|
|
1369
|
+
// onclose handler can tell this transient self-close apart from a
|
|
1370
|
+
// server-initiated 1008 (which is terminal — invalid credentials).
|
|
1371
|
+
helloTimeoutClose = false;
|
|
1372
|
+
// Bounded LRU of recently-dispatched message ids (Set iteration order is
|
|
1373
|
+
// insertion order — delete + re-add refreshes recency). Ids are added
|
|
1374
|
+
// only AFTER a successful dispatch: adding earlier would let a failed
|
|
1375
|
+
// dispatch suppress its own redelivery.
|
|
1376
|
+
dedupSeen = /* @__PURE__ */ new Set();
|
|
1377
|
+
// Envelopes injected by the REST drain, as opposed to live WS frames.
|
|
1378
|
+
// Drain rows are acknowledged via the REST sync/ack cursor, never via a
|
|
1379
|
+
// WS ack frame; everything else on the message.new pipeline (live frames,
|
|
1380
|
+
// server-pushed reconnect backlog, gap-fill rows) takes the WS ack path
|
|
1381
|
+
// when ack-mode is negotiated.
|
|
1382
|
+
restDrainOrigin = /* @__PURE__ */ new WeakSet();
|
|
1383
|
+
// Per-envelope dispatch settlement for drain rows: resolves true when
|
|
1384
|
+
// every handler settled cleanly (or the row deduped), false when a
|
|
1385
|
+
// handler threw/rejected. The drain awaits these before advancing the
|
|
1386
|
+
// ack cursor. WeakMap so entries die with the envelope objects.
|
|
1387
|
+
drainSettlements = /* @__PURE__ */ new WeakMap();
|
|
1388
|
+
// Coalesces concurrent drains — the server-side ack pointer only moves
|
|
1389
|
+
// forward, so one drain at a time is both sufficient and simpler to
|
|
1390
|
+
// reason about than interleaved read cursors.
|
|
1391
|
+
drainInFlight = false;
|
|
1321
1392
|
orderStates = /* @__PURE__ */ new Map();
|
|
1322
1393
|
disposed = false;
|
|
1323
1394
|
constructor(options) {
|
|
1395
|
+
const dedupCacheSize = typeof options.dedupCacheSize === "number" && Number.isFinite(options.dedupCacheSize) && options.dedupCacheSize >= 1 ? Math.floor(options.dedupCacheSize) : DEFAULT_DEDUP_CACHE_SIZE;
|
|
1324
1396
|
this.options = {
|
|
1325
1397
|
baseUrl: options.baseUrl ?? "wss://api.agentchat.me",
|
|
1326
1398
|
reconnect: options.reconnect ?? true,
|
|
@@ -1331,6 +1403,7 @@ var RealtimeClient = class {
|
|
|
1331
1403
|
client: options.client,
|
|
1332
1404
|
onSequenceGap: options.onSequenceGap,
|
|
1333
1405
|
autoDrainOnConnect: options.autoDrainOnConnect ?? Boolean(options.client),
|
|
1406
|
+
dedupCacheSize,
|
|
1334
1407
|
webSocket: options.webSocket
|
|
1335
1408
|
};
|
|
1336
1409
|
}
|
|
@@ -1359,15 +1432,24 @@ var RealtimeClient = class {
|
|
|
1359
1432
|
const url = `${this.options.baseUrl}/v1/ws`;
|
|
1360
1433
|
this.ws = new WebSocketCtor(url);
|
|
1361
1434
|
this.authenticated = false;
|
|
1435
|
+
this.ackMode = false;
|
|
1436
|
+
this.helloTimeoutClose = false;
|
|
1362
1437
|
this.ws.onopen = () => {
|
|
1363
1438
|
try {
|
|
1364
|
-
this.ws.send(
|
|
1439
|
+
this.ws.send(
|
|
1440
|
+
JSON.stringify({
|
|
1441
|
+
type: "hello",
|
|
1442
|
+
api_key: this.options.apiKey,
|
|
1443
|
+
capabilities: ["ack"]
|
|
1444
|
+
})
|
|
1445
|
+
);
|
|
1365
1446
|
} catch (err) {
|
|
1366
1447
|
this.emitError(err instanceof Error ? err : new ConnectionError("HELLO send failed"));
|
|
1367
1448
|
return;
|
|
1368
1449
|
}
|
|
1369
1450
|
this.helloAckTimer = setTimeout(() => {
|
|
1370
1451
|
this.emitError(new ConnectionError("HELLO ack timeout"));
|
|
1452
|
+
this.helloTimeoutClose = true;
|
|
1371
1453
|
try {
|
|
1372
1454
|
this.ws?.close(1008, "HELLO ack timeout");
|
|
1373
1455
|
} catch {
|
|
@@ -1384,6 +1466,8 @@ var RealtimeClient = class {
|
|
|
1384
1466
|
if (!this.authenticated) {
|
|
1385
1467
|
if (message.type === "hello.ok") {
|
|
1386
1468
|
this.authenticated = true;
|
|
1469
|
+
const caps = message.capabilities;
|
|
1470
|
+
this.ackMode = Array.isArray(caps) && caps.includes("ack");
|
|
1387
1471
|
this.reconnectAttempts = 0;
|
|
1388
1472
|
if (this.helloAckTimer) {
|
|
1389
1473
|
clearTimeout(this.helloAckTimer);
|
|
@@ -1396,7 +1480,11 @@ var RealtimeClient = class {
|
|
|
1396
1480
|
}
|
|
1397
1481
|
}
|
|
1398
1482
|
if (this.options.autoDrainOnConnect && this.options.client) {
|
|
1399
|
-
|
|
1483
|
+
this.drainOfflineEnvelopes().catch((err) => {
|
|
1484
|
+
this.emitError(
|
|
1485
|
+
err instanceof Error ? err : new ConnectionError("sync drain failed")
|
|
1486
|
+
);
|
|
1487
|
+
});
|
|
1400
1488
|
}
|
|
1401
1489
|
}
|
|
1402
1490
|
return;
|
|
@@ -1416,6 +1504,9 @@ var RealtimeClient = class {
|
|
|
1416
1504
|
this.helloAckTimer = null;
|
|
1417
1505
|
}
|
|
1418
1506
|
this.authenticated = false;
|
|
1507
|
+
this.ackMode = false;
|
|
1508
|
+
const selfClosedForHelloTimeout = this.helloTimeoutClose;
|
|
1509
|
+
this.helloTimeoutClose = false;
|
|
1419
1510
|
for (const handler of this.disconnectHandlers) {
|
|
1420
1511
|
try {
|
|
1421
1512
|
handler({ code: event.code, reason: event.reason, wasClean: event.wasClean });
|
|
@@ -1423,53 +1514,155 @@ var RealtimeClient = class {
|
|
|
1423
1514
|
}
|
|
1424
1515
|
}
|
|
1425
1516
|
this.resetOrderStates();
|
|
1517
|
+
if (TERMINAL_CLOSE_CODES.has(event.code) && !selfClosedForHelloTimeout) {
|
|
1518
|
+
this.emitError(
|
|
1519
|
+
new ConnectionError(
|
|
1520
|
+
`WebSocket closed with terminal code ${event.code}${event.reason ? ` (${event.reason})` : ""}; the server rejected the session and auto-reconnect has stopped. Check the API key, then create a new RealtimeClient.`
|
|
1521
|
+
)
|
|
1522
|
+
);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1426
1525
|
this.scheduleReconnect();
|
|
1427
1526
|
};
|
|
1428
1527
|
}
|
|
1429
1528
|
/**
|
|
1430
1529
|
* Drain offline envelopes accumulated while the socket was disconnected.
|
|
1431
|
-
*
|
|
1432
|
-
* `
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1530
|
+
* Automatically invoked on every successful `hello.ok` when
|
|
1531
|
+
* `autoDrainOnConnect` is enabled and a client is configured.
|
|
1532
|
+
*
|
|
1533
|
+
* `GET /v1/messages/sync` returns a **bare array** of rows, oldest first
|
|
1534
|
+
* (see `SyncEnvelope`). Each page is dispatched through the same ordered
|
|
1535
|
+
* `message.new` pipeline as live frames, then acknowledged via
|
|
1536
|
+
* `POST /v1/messages/sync/ack` with a **positional** cursor — the last
|
|
1537
|
+
* non-null `delivery_id` of the fully-processed prefix. `delivery_id` is
|
|
1538
|
+
* an opaque string and is never compared numerically. Pages are fetched
|
|
1539
|
+
* with the `after` read cursor (non-committing) until a short page.
|
|
1435
1540
|
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1541
|
+
* Correctness rules, in cursor order:
|
|
1542
|
+
* - A row failing minimal validation stops the drain: the clean prefix
|
|
1543
|
+
* before it is processed and acked; the cursor never crosses the row.
|
|
1544
|
+
* - A row whose handler threw is not acked — nor is anything after it
|
|
1545
|
+
* (the ack cursor is at-or-before) — so the server re-offers it; the
|
|
1546
|
+
* dedup cache suppresses re-dispatch of its acked predecessors.
|
|
1547
|
+
* - A row parked in the out-of-order buffer (awaiting seq gap-fill) is
|
|
1548
|
+
* not acked until actually dispatched: acks FREEZE at the last settled
|
|
1549
|
+
* row for the remainder of the drain. Without this, a disconnect that
|
|
1550
|
+
* clears the ordering buffers (`resetOrderStates`) would silently drop
|
|
1551
|
+
* an already-acked message — acked-but-undispatched is exactly the
|
|
1552
|
+
* loss the ack protocol exists to prevent. Reading continues so the
|
|
1553
|
+
* in-session gap-fill still resolves; the frozen tail is re-offered on
|
|
1554
|
+
* the next drain and absorbed by the dedup cache.
|
|
1555
|
+
*
|
|
1556
|
+
* Concurrent calls are coalesced (the second returns immediately). The
|
|
1557
|
+
* server-side ack pointer only moves forward, so re-running after a
|
|
1558
|
+
* partial drain is always safe. REST-drained rows are acked via this
|
|
1559
|
+
* cursor, never via WS ack frames.
|
|
1440
1560
|
*/
|
|
1441
1561
|
async drainOfflineEnvelopes() {
|
|
1442
1562
|
const client = this.options.client;
|
|
1443
1563
|
if (!client) return;
|
|
1444
|
-
|
|
1564
|
+
if (this.drainInFlight) return;
|
|
1565
|
+
this.drainInFlight = true;
|
|
1566
|
+
try {
|
|
1567
|
+
await this.runDrain(client);
|
|
1568
|
+
} finally {
|
|
1569
|
+
this.drainInFlight = false;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
async runDrain(client) {
|
|
1573
|
+
let after;
|
|
1574
|
+
let acksFrozen = false;
|
|
1575
|
+
while (!this.disposed) {
|
|
1445
1576
|
let batch;
|
|
1446
1577
|
try {
|
|
1447
|
-
batch = await client.sync();
|
|
1578
|
+
batch = await client.sync({ after, limit: SYNC_DRAIN_PAGE_SIZE });
|
|
1448
1579
|
} catch (err) {
|
|
1449
1580
|
this.emitError(err instanceof Error ? err : new ConnectionError("sync drain failed"));
|
|
1450
1581
|
return;
|
|
1451
1582
|
}
|
|
1452
|
-
if (batch
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1583
|
+
if (!Array.isArray(batch)) {
|
|
1584
|
+
this.emitError(
|
|
1585
|
+
new ConnectionError(
|
|
1586
|
+
`sync drain: expected a bare array from /v1/messages/sync, got ${typeof batch}`
|
|
1587
|
+
)
|
|
1588
|
+
);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (this.disposed) return;
|
|
1592
|
+
if (batch.length === 0) return;
|
|
1593
|
+
const rows = [];
|
|
1594
|
+
let invalidIndex = -1;
|
|
1595
|
+
for (const [index, item] of batch.entries()) {
|
|
1596
|
+
if (!isValidSyncRow(item)) {
|
|
1597
|
+
invalidIndex = index;
|
|
1598
|
+
break;
|
|
1599
|
+
}
|
|
1600
|
+
rows.push(item);
|
|
1601
|
+
}
|
|
1602
|
+
const envelopes = rows.map((row) => {
|
|
1603
|
+
const envelope = {
|
|
1457
1604
|
type: "message.new",
|
|
1458
|
-
payload:
|
|
1605
|
+
payload: row
|
|
1459
1606
|
};
|
|
1460
|
-
this.
|
|
1607
|
+
this.restDrainOrigin.add(envelope);
|
|
1608
|
+
return envelope;
|
|
1609
|
+
});
|
|
1610
|
+
for (const envelope of envelopes) {
|
|
1611
|
+
this.processOrderedMessage(envelope);
|
|
1461
1612
|
}
|
|
1462
|
-
if (
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1613
|
+
if (!acksFrozen) {
|
|
1614
|
+
let ackCursor = null;
|
|
1615
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1616
|
+
const row = rows[i];
|
|
1617
|
+
const envelope = envelopes[i];
|
|
1618
|
+
if (!row || !envelope) break;
|
|
1619
|
+
const settlement = this.drainSettlements.get(envelope);
|
|
1620
|
+
if (settlement) {
|
|
1621
|
+
const ok = await settlement;
|
|
1622
|
+
if (!ok) {
|
|
1623
|
+
acksFrozen = true;
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
} else if (this.isBufferedInOrderState(row)) {
|
|
1627
|
+
acksFrozen = true;
|
|
1628
|
+
break;
|
|
1629
|
+
}
|
|
1630
|
+
const deliveryId = row.delivery_id;
|
|
1631
|
+
if (typeof deliveryId === "string" && deliveryId.length > 0) {
|
|
1632
|
+
ackCursor = deliveryId;
|
|
1633
|
+
}
|
|
1468
1634
|
}
|
|
1635
|
+
if (ackCursor !== null) {
|
|
1636
|
+
try {
|
|
1637
|
+
await client.syncAck(ackCursor);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
this.emitError(err instanceof Error ? err : new ConnectionError("sync ack failed"));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
if (invalidIndex >= 0) {
|
|
1645
|
+
this.emitError(
|
|
1646
|
+
new ConnectionError(
|
|
1647
|
+
`sync drain: row ${invalidIndex} failed validation \u2014 processed the ${rows.length}-row prefix and stopped; the ack cursor was not advanced past it`
|
|
1648
|
+
)
|
|
1649
|
+
);
|
|
1650
|
+
return;
|
|
1469
1651
|
}
|
|
1470
|
-
if (batch.
|
|
1652
|
+
if (batch.length < SYNC_DRAIN_PAGE_SIZE) return;
|
|
1653
|
+
const nextAfter = lastDeliveryId(rows);
|
|
1654
|
+
if (nextAfter === null) return;
|
|
1655
|
+
after = nextAfter;
|
|
1471
1656
|
}
|
|
1472
1657
|
}
|
|
1658
|
+
// True when a drain row is currently parked in the per-conversation
|
|
1659
|
+
// out-of-order buffer (its dispatch is deferred to the gap-fill
|
|
1660
|
+
// machinery — natural arrival, gap-fill fetch, or forced resolveGap).
|
|
1661
|
+
isBufferedInOrderState(row) {
|
|
1662
|
+
if (typeof row.seq !== "number") return false;
|
|
1663
|
+
const state = this.orderStates.get(row.conversation_id);
|
|
1664
|
+
return state !== void 0 && state.buffer.has(row.seq);
|
|
1665
|
+
}
|
|
1473
1666
|
scheduleReconnect() {
|
|
1474
1667
|
if (this.disposed) return;
|
|
1475
1668
|
if (!this.options.reconnect) return;
|
|
@@ -1586,11 +1779,115 @@ var RealtimeClient = class {
|
|
|
1586
1779
|
}
|
|
1587
1780
|
}
|
|
1588
1781
|
dispatch(message) {
|
|
1782
|
+
if (this.isMessageNew(message)) {
|
|
1783
|
+
void this.dispatchMessageNew(message);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1589
1786
|
const handlers = this.handlers.get(message.type);
|
|
1590
1787
|
if (!handlers) return;
|
|
1591
1788
|
for (const handler of handlers) {
|
|
1592
|
-
|
|
1789
|
+
try {
|
|
1790
|
+
const result = handler(message);
|
|
1791
|
+
if (isThenable(result)) {
|
|
1792
|
+
result.catch((err) => this.emitError(toError(err, message.type)));
|
|
1793
|
+
}
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
this.emitError(toError(err, message.type));
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Dedup + dispatch + acknowledge one `message.new` envelope. Never
|
|
1801
|
+
* rejects.
|
|
1802
|
+
*
|
|
1803
|
+
* Resolves `true` when the envelope is safe to acknowledge: every
|
|
1804
|
+
* handler settled without throwing (async handlers awaited), or the
|
|
1805
|
+
* message id was already in the dedup cache — prior successful
|
|
1806
|
+
* processing is the proof, so a duplicate skips dispatch but is still
|
|
1807
|
+
* acked. Resolves `false` when any handler threw or rejected: the
|
|
1808
|
+
* message is NOT acked on any path and the server re-offers it.
|
|
1809
|
+
*
|
|
1810
|
+
* Ack routing: live frames (including server-pushed reconnect backlog
|
|
1811
|
+
* and gap-fill rows) send a WS `{type:'ack'}` frame when ack-mode was
|
|
1812
|
+
* negotiated; REST-drain rows are covered by the drain's sync/ack
|
|
1813
|
+
* cursor instead — the drain awaits this settlement before advancing
|
|
1814
|
+
* that cursor.
|
|
1815
|
+
*/
|
|
1816
|
+
dispatchMessageNew(message) {
|
|
1817
|
+
const isDrainRow = this.restDrainOrigin.has(message);
|
|
1818
|
+
const messageId = this.extractMessageId(message);
|
|
1819
|
+
let settlement;
|
|
1820
|
+
if (messageId !== null && this.dedupHit(messageId)) {
|
|
1821
|
+
settlement = Promise.resolve(true);
|
|
1822
|
+
if (!isDrainRow) this.sendAckFrame(messageId);
|
|
1823
|
+
} else {
|
|
1824
|
+
const handlers = this.handlers.get("message.new");
|
|
1825
|
+
const pending = [];
|
|
1826
|
+
if (handlers) {
|
|
1827
|
+
for (const handler of handlers) {
|
|
1828
|
+
try {
|
|
1829
|
+
const result = handler(message);
|
|
1830
|
+
if (isThenable(result)) pending.push(result);
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
pending.push(Promise.reject(err));
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
settlement = Promise.allSettled(pending).then((outcomes) => {
|
|
1837
|
+
let ok = true;
|
|
1838
|
+
for (const outcome of outcomes) {
|
|
1839
|
+
if (outcome.status === "rejected") {
|
|
1840
|
+
ok = false;
|
|
1841
|
+
this.emitError(toError(outcome.reason, "message.new"));
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (!ok) return false;
|
|
1845
|
+
if (messageId !== null) {
|
|
1846
|
+
this.dedupAdd(messageId);
|
|
1847
|
+
if (!isDrainRow) this.sendAckFrame(messageId);
|
|
1848
|
+
}
|
|
1849
|
+
return true;
|
|
1850
|
+
});
|
|
1593
1851
|
}
|
|
1852
|
+
if (isDrainRow) this.drainSettlements.set(message, settlement);
|
|
1853
|
+
return settlement;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Best-effort delivery ack for one processed message. No-op unless the
|
|
1857
|
+
* server negotiated ack-mode on this connection. Send failures are
|
|
1858
|
+
* swallowed by design: a dying socket leaves the envelope `stored`
|
|
1859
|
+
* server-side, the next drain re-offers it, and the dedup cache absorbs
|
|
1860
|
+
* the duplicate.
|
|
1861
|
+
*/
|
|
1862
|
+
sendAckFrame(messageId) {
|
|
1863
|
+
if (!this.ackMode) return;
|
|
1864
|
+
if (!this.ws || this.ws.readyState !== 1 || !this.authenticated) return;
|
|
1865
|
+
try {
|
|
1866
|
+
this.ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
1867
|
+
} catch {
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
// Membership check that also refreshes recency on a hit (Set iteration
|
|
1871
|
+
// order is insertion order, so delete + re-add moves the id to the back
|
|
1872
|
+
// of the eviction queue).
|
|
1873
|
+
dedupHit(messageId) {
|
|
1874
|
+
if (!this.dedupSeen.has(messageId)) return false;
|
|
1875
|
+
this.dedupSeen.delete(messageId);
|
|
1876
|
+
this.dedupSeen.add(messageId);
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
dedupAdd(messageId) {
|
|
1880
|
+
this.dedupSeen.delete(messageId);
|
|
1881
|
+
this.dedupSeen.add(messageId);
|
|
1882
|
+
while (this.dedupSeen.size > this.options.dedupCacheSize) {
|
|
1883
|
+
const oldest = this.dedupSeen.values().next().value;
|
|
1884
|
+
if (oldest === void 0) break;
|
|
1885
|
+
this.dedupSeen.delete(oldest);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
extractMessageId(message) {
|
|
1889
|
+
const id = message.payload?.id;
|
|
1890
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
1594
1891
|
}
|
|
1595
1892
|
isMessageNew(message) {
|
|
1596
1893
|
return message.type === "message.new";
|