@ttt-productions/chat-react 0.12.11 → 0.12.13
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/dist/context/ChatAttachmentMediaContext.d.ts +22 -9
- package/dist/context/ChatAttachmentMediaContext.d.ts.map +1 -1
- package/dist/context/ChatAttachmentMediaContext.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +46 -0
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +294 -21
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/diagnostics.d.ts +76 -0
- package/dist/realtime/diagnostics.d.ts.map +1 -0
- package/dist/realtime/diagnostics.js +111 -0
- package/dist/realtime/diagnostics.js.map +1 -0
- package/dist/realtime/index.d.ts +2 -0
- package/dist/realtime/index.d.ts.map +1 -1
- package/dist/realtime/index.js +1 -0
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/transport.d.ts +9 -0
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +1 -0
- package/dist/realtime/transport.js.map +1 -1
- package/package.json +3 -3
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { createReconnectController } from '@ttt-productions/realtime-core';
|
|
18
18
|
import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, ChatSendRejectedPayloadSchema, } from './wire.js';
|
|
19
19
|
import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
|
|
20
|
+
import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
|
|
20
21
|
import { defaultTimers, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
|
|
21
22
|
const INITIAL = {
|
|
22
23
|
status: 'idle',
|
|
@@ -82,10 +83,23 @@ export class ChannelClient {
|
|
|
82
83
|
* send that exceeds the attempt/age cap is flipped to a visible failed state
|
|
83
84
|
* (retry affordance) instead of staying a ghost. */
|
|
84
85
|
pendingSends = new Map();
|
|
86
|
+
/**
|
|
87
|
+
* The structured diagnostics emitter, or null when diagnostics are off (the
|
|
88
|
+
* default). Every call site uses `this.diag?.(...)` so the off path constructs
|
|
89
|
+
* no payload at all.
|
|
90
|
+
*/
|
|
91
|
+
diag;
|
|
92
|
+
/** Monotonic socket-open attempt counter (diagnostics correlation only). */
|
|
93
|
+
connectAttempt = 0;
|
|
94
|
+
/** Why the CURRENT connect attempt is happening (diagnostics only). */
|
|
95
|
+
reconnectCause = 'initial';
|
|
96
|
+
/** The last close code observed, carried into the next attempt's cause line. */
|
|
97
|
+
lastCloseCode = null;
|
|
85
98
|
constructor(config) {
|
|
86
99
|
this.config = config;
|
|
87
100
|
this.timers = config.timers ?? defaultTimers;
|
|
88
101
|
this.controller = createReconnectController(config.reconnect ?? {});
|
|
102
|
+
this.diag = createDiagnosticsEmitter(config.diagnostics);
|
|
89
103
|
}
|
|
90
104
|
// ---- observable store ----
|
|
91
105
|
getState() {
|
|
@@ -111,11 +125,20 @@ export class ChannelClient {
|
|
|
111
125
|
if (this.state.status !== 'idle' && this.state.status !== 'closed')
|
|
112
126
|
return;
|
|
113
127
|
this.closedByUs = false;
|
|
128
|
+
this.reconnectCause = 'initial';
|
|
114
129
|
this.controller.start();
|
|
115
130
|
this.setState({ status: 'connecting' });
|
|
116
131
|
await this.openSocket();
|
|
117
132
|
}
|
|
118
133
|
async openSocket() {
|
|
134
|
+
this.connectAttempt += 1;
|
|
135
|
+
this.diag?.(DIAG.CONNECT_ATTEMPT, {
|
|
136
|
+
attempt: this.connectAttempt,
|
|
137
|
+
cause: this.reconnectCause,
|
|
138
|
+
closeCode: this.lastCloseCode,
|
|
139
|
+
resumeSeq: this.resumeSeq,
|
|
140
|
+
pendingSends: this.pendingSends.size,
|
|
141
|
+
});
|
|
119
142
|
let grantToken;
|
|
120
143
|
try {
|
|
121
144
|
grantToken = await this.config.grantProvider();
|
|
@@ -127,8 +150,11 @@ export class ChannelClient {
|
|
|
127
150
|
// on the opening loader forever. Every other mint failure (network, transient
|
|
128
151
|
// 'unavailable'/'internal', post-login 'unauthenticated') stays retryable and
|
|
129
152
|
// backs off exactly as before.
|
|
130
|
-
|
|
153
|
+
const terminal = isChatAccessDeniedError(err);
|
|
154
|
+
this.diag?.(DIAG.GRANT_FAILED, { attempt: this.connectAttempt, terminal });
|
|
155
|
+
if (terminal)
|
|
131
156
|
return this.denyAccessTerminally();
|
|
157
|
+
this.reconnectCause = 'grant-error';
|
|
132
158
|
return this.scheduleReconnect();
|
|
133
159
|
}
|
|
134
160
|
if (this.closedByUs)
|
|
@@ -149,10 +175,18 @@ export class ChannelClient {
|
|
|
149
175
|
this.controller.onOpen();
|
|
150
176
|
this.reauthAttempted = false;
|
|
151
177
|
this.setState({ status: 'open' });
|
|
178
|
+
this.diag?.(DIAG.SOCKET_OPEN, {
|
|
179
|
+
attempt: this.connectAttempt,
|
|
180
|
+
cause: this.reconnectCause,
|
|
181
|
+
resumeSeq: this.resumeSeq,
|
|
182
|
+
pendingSends: this.pendingSends.size,
|
|
183
|
+
presenceSubscribed: this.presenceSubscribed,
|
|
184
|
+
});
|
|
152
185
|
// Resume: ask the DO for the authoritative snapshot, then live deltas flow. The
|
|
153
186
|
// field name MUST be `afterSeq` — the Channel DO reads `payload.afterSeq`; any other
|
|
154
187
|
// name reads as cursorless and forces a full resync every reconnect.
|
|
155
188
|
this.sendFrame(CLIENT_FRAME.RESUME, { afterSeq: this.resumeSeq });
|
|
189
|
+
this.diag?.(DIAG.RESUME_REQUEST, { afterSeq: this.resumeSeq, attempt: this.connectAttempt });
|
|
156
190
|
// If the UI had presence open, re-subscribe after a reconnect.
|
|
157
191
|
if (this.presenceSubscribed)
|
|
158
192
|
this.sendFrame(CLIENT_FRAME.PRESENCE_SUBSCRIBE, {});
|
|
@@ -164,7 +198,7 @@ export class ChannelClient {
|
|
|
164
198
|
// that lost its socket before the ack is not silently dropped. The DO dedups
|
|
165
199
|
// (re-acks, no re-broadcast, no flood-token) — this is the client half of the
|
|
166
200
|
// Contract C send-idempotency the server side was built for.
|
|
167
|
-
this.resendPendingSends();
|
|
201
|
+
this.resendPendingSends('reconnect');
|
|
168
202
|
this.startHeartbeat();
|
|
169
203
|
}
|
|
170
204
|
/**
|
|
@@ -173,7 +207,7 @@ export class ChannelClient {
|
|
|
173
207
|
* visible failed state instead of being resent forever — the ghost becomes a
|
|
174
208
|
* retryable failure the UI can surface.
|
|
175
209
|
*/
|
|
176
|
-
resendPendingSends() {
|
|
210
|
+
resendPendingSends(reason) {
|
|
177
211
|
if (this.pendingSends.size === 0)
|
|
178
212
|
return;
|
|
179
213
|
const now = this.timers.now();
|
|
@@ -213,6 +247,14 @@ export class ChannelClient {
|
|
|
213
247
|
});
|
|
214
248
|
if (sent)
|
|
215
249
|
pending.attempts += 1;
|
|
250
|
+
this.diag?.(DIAG.SEND_RETRY, {
|
|
251
|
+
clientMessageId: pending.clientMessageId,
|
|
252
|
+
reason,
|
|
253
|
+
attempts: pending.attempts,
|
|
254
|
+
serverRetryable: pending.serverRetryable,
|
|
255
|
+
ageMs: now - pending.sentAt,
|
|
256
|
+
sent,
|
|
257
|
+
});
|
|
216
258
|
}
|
|
217
259
|
}
|
|
218
260
|
/**
|
|
@@ -234,6 +276,14 @@ export class ChannelClient {
|
|
|
234
276
|
pending.failed = true;
|
|
235
277
|
// A terminal/exhausted send never retries on a timer — cancel any scheduled one.
|
|
236
278
|
this.clearSendRetryTimer(pending);
|
|
279
|
+
this.diag?.(DIAG.SEND_FAILED, {
|
|
280
|
+
clientMessageId,
|
|
281
|
+
code: code ?? 'send-failed',
|
|
282
|
+
retryable: retryable ?? null,
|
|
283
|
+
attempts: pending.attempts,
|
|
284
|
+
serverRetryable: pending.serverRetryable,
|
|
285
|
+
ageMs: this.timers.now() - pending.sentAt,
|
|
286
|
+
});
|
|
237
287
|
const next = this.state.messages.map((m) => m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId
|
|
238
288
|
? {
|
|
239
289
|
...m,
|
|
@@ -273,7 +323,7 @@ export class ChannelClient {
|
|
|
273
323
|
this.membershipRetryTimer = null;
|
|
274
324
|
if (this.closedByUs)
|
|
275
325
|
return;
|
|
276
|
-
this.resendPendingSends();
|
|
326
|
+
this.resendPendingSends('membership-pending');
|
|
277
327
|
}, delayMs);
|
|
278
328
|
}
|
|
279
329
|
/**
|
|
@@ -301,20 +351,27 @@ export class ChannelClient {
|
|
|
301
351
|
const rawId = payload.clientMessageId;
|
|
302
352
|
if (typeof rawId === 'string' && rawId.length > 0 && rawId.length <= 200) {
|
|
303
353
|
const orphaned = this.pendingSends.get(rawId);
|
|
304
|
-
if (orphaned && !orphaned.failed)
|
|
354
|
+
if (orphaned && !orphaned.failed) {
|
|
355
|
+
this.diag?.(DIAG.SEND_REJECTED, { clientMessageId: rawId, outcome: 'unparseable-fail-closed' });
|
|
305
356
|
this.failPendingSend(rawId);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
306
359
|
}
|
|
360
|
+
this.diag?.(DIAG.SEND_REJECTED, { clientMessageId: null, outcome: 'unparseable-dropped' });
|
|
307
361
|
return;
|
|
308
362
|
}
|
|
309
363
|
const { clientMessageId, code, retryable, retryAfterMs } = parsed.data;
|
|
310
364
|
const pending = this.pendingSends.get(clientMessageId);
|
|
311
365
|
// No matching pending send — already acked/reconciled, or never ours. A late
|
|
312
366
|
// ack/echo (which may have already landed) always wins; nothing to correlate.
|
|
313
|
-
if (!pending || pending.failed)
|
|
367
|
+
if (!pending || pending.failed) {
|
|
368
|
+
this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable, outcome: 'no-pending-send' });
|
|
314
369
|
return;
|
|
370
|
+
}
|
|
315
371
|
if (!retryable) {
|
|
316
372
|
// Terminal: an unchanged resend cannot succeed (blocked-word / archived /
|
|
317
373
|
// deleted). Fail ONLY this bubble, recording the reason + terminal class.
|
|
374
|
+
this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable: false, outcome: 'terminal' });
|
|
318
375
|
this.failPendingSend(clientMessageId, code, false);
|
|
319
376
|
return;
|
|
320
377
|
}
|
|
@@ -326,10 +383,13 @@ export class ChannelClient {
|
|
|
326
383
|
// If the budget already elapsed (a long stream of retryable rejections), fail
|
|
327
384
|
// now rather than scheduling yet another doomed resend.
|
|
328
385
|
if (this.timers.now() - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
|
|
386
|
+
this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable: true, outcome: 'budget-exhausted' });
|
|
329
387
|
this.failPendingSend(clientMessageId, code, true);
|
|
330
388
|
return;
|
|
331
389
|
}
|
|
332
|
-
|
|
390
|
+
const delayMs = this.clampRetryDelay(retryAfterMs);
|
|
391
|
+
this.diag?.(DIAG.SEND_REJECTED, { clientMessageId, code, retryable: true, outcome: 'retry-scheduled', delayMs });
|
|
392
|
+
this.scheduleSendRetry(clientMessageId, delayMs);
|
|
333
393
|
}
|
|
334
394
|
/**
|
|
335
395
|
* Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
|
|
@@ -355,11 +415,19 @@ export class ChannelClient {
|
|
|
355
415
|
}
|
|
356
416
|
// A server-retryable resend does NOT count toward the attempt cap (that cap is
|
|
357
417
|
// for blind reconnect resends only) — leave `attempts` untouched.
|
|
358
|
-
this.sendFrame(CLIENT_FRAME.SEND, {
|
|
418
|
+
const sent = this.sendFrame(CLIENT_FRAME.SEND, {
|
|
359
419
|
clientMessageId: current.clientMessageId,
|
|
360
420
|
text: current.text,
|
|
361
421
|
replyTo: current.replyTo,
|
|
362
422
|
});
|
|
423
|
+
this.diag?.(DIAG.SEND_RETRY, {
|
|
424
|
+
clientMessageId: current.clientMessageId,
|
|
425
|
+
reason: 'scheduled',
|
|
426
|
+
attempts: current.attempts,
|
|
427
|
+
serverRetryable: current.serverRetryable,
|
|
428
|
+
ageMs: this.timers.now() - current.sentAt,
|
|
429
|
+
sent,
|
|
430
|
+
});
|
|
363
431
|
// Budget backstop: the contract says every send gets an ack or a rejection,
|
|
364
432
|
// and either one replaces/clears this timer. If the server goes silent (a
|
|
365
433
|
// contract violation), NOTHING else would ever re-check the budget on a
|
|
@@ -422,6 +490,14 @@ export class ChannelClient {
|
|
|
422
490
|
replyTo: pending.replyTo,
|
|
423
491
|
});
|
|
424
492
|
pending.attempts = sent ? 1 : 0;
|
|
493
|
+
this.diag?.(DIAG.SEND_RETRY, {
|
|
494
|
+
clientMessageId,
|
|
495
|
+
reason: 'manual',
|
|
496
|
+
attempts: pending.attempts,
|
|
497
|
+
serverRetryable: false,
|
|
498
|
+
ageMs: 0,
|
|
499
|
+
sent,
|
|
500
|
+
});
|
|
425
501
|
const next = this.state.messages.map((m) => {
|
|
426
502
|
if (!(m.meta?.optimistic && m.meta?.clientMessageId === clientMessageId && m.meta?.sendFailed)) {
|
|
427
503
|
return m;
|
|
@@ -439,13 +515,17 @@ export class ChannelClient {
|
|
|
439
515
|
onClose(code, _reason) {
|
|
440
516
|
this.stopHeartbeat();
|
|
441
517
|
this.socket = null;
|
|
518
|
+
this.lastCloseCode = code;
|
|
442
519
|
if (this.closedByUs) {
|
|
520
|
+
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed', resumeSeq: this.resumeSeq });
|
|
443
521
|
this.setState({ status: 'closed' });
|
|
444
522
|
return;
|
|
445
523
|
}
|
|
446
524
|
// 4401 AUTH_EXPIRED: re-mint a grant ONCE and reconnect immediately, no backoff.
|
|
447
525
|
if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {
|
|
448
526
|
this.reauthAttempted = true;
|
|
527
|
+
this.reconnectCause = 'auth-expired';
|
|
528
|
+
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 're-grant', resumeSeq: this.resumeSeq });
|
|
449
529
|
this.setState({ status: 'reconnecting' });
|
|
450
530
|
void this.openSocket();
|
|
451
531
|
return;
|
|
@@ -457,14 +537,23 @@ export class ChannelClient {
|
|
|
457
537
|
if (code === CHAT_CLOSE_CODES.REVOKED) {
|
|
458
538
|
this.closedByUs = true;
|
|
459
539
|
this.controller.close();
|
|
540
|
+
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked', resumeSeq: this.resumeSeq });
|
|
460
541
|
this.failAllPendingSends();
|
|
461
542
|
this.setState({ status: 'closed', lastErrorCode: 'revoked' });
|
|
462
543
|
return;
|
|
463
544
|
}
|
|
545
|
+
this.reconnectCause = 'transient-close';
|
|
546
|
+
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect', resumeSeq: this.resumeSeq });
|
|
464
547
|
this.scheduleReconnect();
|
|
465
548
|
}
|
|
466
549
|
scheduleReconnect() {
|
|
467
550
|
const delay = this.controller.onClose();
|
|
551
|
+
this.diag?.(DIAG.RECONNECT_SCHEDULED, {
|
|
552
|
+
delayMs: delay,
|
|
553
|
+
cause: this.reconnectCause,
|
|
554
|
+
attempt: this.connectAttempt,
|
|
555
|
+
resumeSeq: this.resumeSeq,
|
|
556
|
+
});
|
|
468
557
|
if (delay == null) {
|
|
469
558
|
// Reconnect give-up: terminal, same as REVOKED — pending sends must not stay
|
|
470
559
|
// an eternal "Sending…"; flip them to the visible failed/retry state.
|
|
@@ -498,12 +587,14 @@ export class ChannelClient {
|
|
|
498
587
|
// ---- inbound frames ----
|
|
499
588
|
onMessage(data) {
|
|
500
589
|
const frame = parseFrame(data);
|
|
501
|
-
if (!frame || !frame.type)
|
|
590
|
+
if (!frame || !frame.type) {
|
|
591
|
+
this.diag?.(DIAG.FRAME_DROPPED, { kind: 'unparseable', reason: 'bad-envelope' });
|
|
502
592
|
return;
|
|
593
|
+
}
|
|
503
594
|
const payload = frame.payload ?? {};
|
|
504
595
|
switch (frame.type) {
|
|
505
596
|
case 'message':
|
|
506
|
-
return this.applyMessage(payload.message);
|
|
597
|
+
return this.applyMessage(payload.message, 'live');
|
|
507
598
|
case 'ack':
|
|
508
599
|
return this.applyAck(payload);
|
|
509
600
|
case 'history-page':
|
|
@@ -528,6 +619,12 @@ export class ChannelClient {
|
|
|
528
619
|
if (code === MEMBERSHIP_PENDING_CODE) {
|
|
529
620
|
const raw = payload.retryAfterMs;
|
|
530
621
|
const delay = this.clampRetryDelay(typeof raw === 'number' ? raw : undefined);
|
|
622
|
+
this.diag?.(DIAG.ERROR_FRAME, {
|
|
623
|
+
code: MEMBERSHIP_PENDING_CODE,
|
|
624
|
+
correlated: false,
|
|
625
|
+
delayMs: delay,
|
|
626
|
+
pendingSends: this.pendingSends.size,
|
|
627
|
+
});
|
|
531
628
|
for (const pending of this.pendingSends.values()) {
|
|
532
629
|
if (!pending.failed) {
|
|
533
630
|
pending.serverRetryable = true;
|
|
@@ -536,12 +633,15 @@ export class ChannelClient {
|
|
|
536
633
|
}
|
|
537
634
|
return this.scheduleMembershipPendingRetry(delay);
|
|
538
635
|
}
|
|
636
|
+
this.diag?.(DIAG.ERROR_FRAME, { code: safeDiagnosticLabel(code), correlated: false });
|
|
539
637
|
return this.setState({ lastErrorCode: code });
|
|
540
638
|
}
|
|
541
639
|
case 'revision':
|
|
542
640
|
return this.applyRevisionFrame(payload);
|
|
543
641
|
default:
|
|
544
|
-
|
|
642
|
+
// unknown/optional types tolerated (forward compat)
|
|
643
|
+
this.diag?.(DIAG.FRAME_DROPPED, { kind: safeDiagnosticLabel(frame.type), reason: 'unknown-type' });
|
|
644
|
+
return;
|
|
545
645
|
}
|
|
546
646
|
}
|
|
547
647
|
/**
|
|
@@ -555,18 +655,36 @@ export class ChannelClient {
|
|
|
555
655
|
const seq = payload.messageSeq;
|
|
556
656
|
const kind = payload.kind;
|
|
557
657
|
const messageRevision = payload.messageRevision;
|
|
558
|
-
if (typeof seq !== 'number' || kind == null || typeof messageRevision !== 'number')
|
|
658
|
+
if (typeof seq !== 'number' || kind == null || typeof messageRevision !== 'number') {
|
|
659
|
+
this.diag?.(DIAG.FRAME_DROPPED, { kind: 'revision', reason: 'malformed-payload' });
|
|
559
660
|
return;
|
|
661
|
+
}
|
|
560
662
|
const prev = this.overlays.get(seq);
|
|
561
663
|
// Max-revision merge: a higher messageRevision supersedes; an older one is dropped.
|
|
562
|
-
if (prev && prev.messageRevision >= messageRevision)
|
|
664
|
+
if (prev && prev.messageRevision >= messageRevision) {
|
|
665
|
+
this.diag?.(DIAG.FRAME_DROPPED, {
|
|
666
|
+
kind: 'revision',
|
|
667
|
+
reason: 'stale-revision',
|
|
668
|
+
seq,
|
|
669
|
+
messageRevision,
|
|
670
|
+
heldRevision: prev.messageRevision,
|
|
671
|
+
});
|
|
563
672
|
return;
|
|
673
|
+
}
|
|
564
674
|
this.overlays.set(seq, { kind, messageRevision });
|
|
565
675
|
// Re-render the matching message in place if it is already present. Re-derive
|
|
566
676
|
// from the ORIGINAL row so a `restore` reverts a previously-blanked text.
|
|
567
677
|
const messageId = seqToMessageId(seq);
|
|
568
678
|
const original = this.originalRows.get(seq);
|
|
569
|
-
|
|
679
|
+
const rendered = Boolean(original) && this.state.messages.some((m) => m.messageId === messageId);
|
|
680
|
+
this.diag?.(DIAG.REVISION_APPLIED, {
|
|
681
|
+
seq,
|
|
682
|
+
kind,
|
|
683
|
+
messageRevision,
|
|
684
|
+
// false = overlay retained for a row not loaded yet (re-applied when it renders).
|
|
685
|
+
rendered,
|
|
686
|
+
});
|
|
687
|
+
if (rendered && original) {
|
|
570
688
|
const overlaid = applyModerationOverlay(original, this.effectiveOverlay(seq, null));
|
|
571
689
|
const next = this.state.messages.map((m) => (m.messageId === messageId ? overlaid : m));
|
|
572
690
|
this.setState({ messages: next });
|
|
@@ -613,21 +731,34 @@ export class ChannelClient {
|
|
|
613
731
|
*/
|
|
614
732
|
applyChannelSnapshot(snap) {
|
|
615
733
|
// Guard: an inbox snapshot must never reach a channel client.
|
|
616
|
-
if (isInboxSnapshot(snap))
|
|
734
|
+
if (isInboxSnapshot(snap)) {
|
|
735
|
+
this.diag?.(DIAG.FRAME_DROPPED, { kind: 'snapshot', reason: 'inbox-snapshot-on-channel' });
|
|
617
736
|
return;
|
|
618
|
-
|
|
737
|
+
}
|
|
738
|
+
if (typeof snap.lastMessageSeq !== 'number') {
|
|
739
|
+
this.diag?.(DIAG.FRAME_DROPPED, { kind: 'snapshot', reason: 'missing-last-message-seq' });
|
|
619
740
|
return;
|
|
741
|
+
}
|
|
620
742
|
// Absent `resync` (legacy/pre-contract DO) is treated as a resync directive.
|
|
621
743
|
const resync = snap.resync !== false;
|
|
744
|
+
const cursorBefore = this.resumeSeq;
|
|
745
|
+
const delta = Array.isArray(snap.delta) ? snap.delta : [];
|
|
622
746
|
if (!resync) {
|
|
623
747
|
// Apply the contiguous delta oldest-first, exactly like live messages, then
|
|
624
748
|
// advance the cursor to the authoritative tail so the NEXT resume tells the DO
|
|
625
749
|
// we're caught up only after we actually applied the backlog.
|
|
626
|
-
const delta = Array.isArray(snap.delta) ? snap.delta : [];
|
|
627
750
|
for (const row of delta)
|
|
628
|
-
this.applyMessage(row);
|
|
751
|
+
this.applyMessage(row, 'delta');
|
|
629
752
|
if (snap.lastMessageSeq > this.resumeSeq)
|
|
630
753
|
this.resumeSeq = snap.lastMessageSeq;
|
|
754
|
+
this.diag?.(DIAG.RESUME_RESULT, {
|
|
755
|
+
lastMessageSeq: snap.lastMessageSeq,
|
|
756
|
+
resync: false,
|
|
757
|
+
deltaCount: delta.length,
|
|
758
|
+
cursorBefore,
|
|
759
|
+
cursorAfter: this.resumeSeq,
|
|
760
|
+
readSeq: typeof snap.readSeq === 'number' ? snap.readSeq : null,
|
|
761
|
+
});
|
|
631
762
|
// A non-resync snapshot IS the first authoritative data (even an empty delta =
|
|
632
763
|
// caught up) — end initial loading. A resync snapshot instead defers to the
|
|
633
764
|
// re-paged first history page below, so it must NOT end it here.
|
|
@@ -643,17 +774,91 @@ export class ChannelClient {
|
|
|
643
774
|
this.overlays.clear();
|
|
644
775
|
this.originalRows.clear();
|
|
645
776
|
this.resumeSeq = 0;
|
|
777
|
+
const priorCount = this.state.messages.length;
|
|
646
778
|
const keptOptimistic = this.state.messages.filter((m) => m.meta?.optimistic);
|
|
779
|
+
this.diag?.(DIAG.RESUME_RESULT, {
|
|
780
|
+
lastMessageSeq: snap.lastMessageSeq,
|
|
781
|
+
resync: true,
|
|
782
|
+
deltaCount: delta.length,
|
|
783
|
+
cursorBefore,
|
|
784
|
+
cursorAfter: 0,
|
|
785
|
+
readSeq: typeof snap.readSeq === 'number' ? snap.readSeq : null,
|
|
786
|
+
});
|
|
787
|
+
this.diag?.(DIAG.RESYNC_DROPPED_TAIL, {
|
|
788
|
+
// The client REMOVED rows from the UI here — the counterpart of an upsert.
|
|
789
|
+
removedCount: priorCount - keptOptimistic.length,
|
|
790
|
+
keptOptimistic: keptOptimistic.length,
|
|
791
|
+
});
|
|
647
792
|
this.setState({ messages: keptOptimistic, hasOlder: true, isFetchingOlder: false });
|
|
648
793
|
this.requestHistory(null);
|
|
649
794
|
}
|
|
650
|
-
|
|
651
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Apply one server row (a live `message` frame or a resume-delta row) and record
|
|
797
|
+
* the decision the client made about it.
|
|
798
|
+
*
|
|
799
|
+
* Diagnostics bounding: a LIVE frame always emits one `chat_client_frame_applied`
|
|
800
|
+
* line; a DELTA row emits one only when it is materially interesting (carries an
|
|
801
|
+
* attachment state, carries a moderation kind, or reconciles one of our optimistic
|
|
802
|
+
* sends) — a 500-row backlog is otherwise summarized by `chat_client_resume_result`
|
|
803
|
+
* rather than spamming 500 lines. Attachment transitions ALWAYS emit, from either
|
|
804
|
+
* source, because an in-place attachment mutation is the exact event this
|
|
805
|
+
* instrumentation exists to prove.
|
|
806
|
+
*/
|
|
807
|
+
applyMessage(row, source) {
|
|
808
|
+
if (!row || typeof row.seq !== 'number') {
|
|
809
|
+
this.diag?.(DIAG.FRAME_DROPPED, { kind: 'message', source, reason: 'missing-seq' });
|
|
652
810
|
return;
|
|
811
|
+
}
|
|
812
|
+
// Read the pre-merge view ONLY when diagnostics are on (these are O(n) scans).
|
|
813
|
+
const prior = this.diag ? this.priorRowFacts(row) : null;
|
|
814
|
+
const cursorBefore = this.resumeSeq;
|
|
653
815
|
const mapped = this.renderRow(row);
|
|
654
816
|
this.mergeMessage(mapped, row);
|
|
655
817
|
if (row.seq > this.resumeSeq)
|
|
656
818
|
this.resumeSeq = row.seq;
|
|
819
|
+
if (!this.diag || !prior)
|
|
820
|
+
return;
|
|
821
|
+
const attachmentState = typeof row.attachmentState === 'string' ? row.attachmentState : null;
|
|
822
|
+
// Emit a transition for a real change only: first arrival of an attachment row
|
|
823
|
+
// (null -> pending) and every later in-place mutation (pending -> ready/failed).
|
|
824
|
+
if (attachmentState !== prior.attachmentState && (attachmentState != null || prior.hadRow)) {
|
|
825
|
+
this.diag(DIAG.ATTACHMENT_TRANSITION, {
|
|
826
|
+
seq: row.seq,
|
|
827
|
+
from: prior.attachmentState,
|
|
828
|
+
to: attachmentState,
|
|
829
|
+
source,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
const notable = attachmentState != null || row.moderationKind != null || prior.reconciledOptimistic;
|
|
833
|
+
if (source === 'delta' && !notable)
|
|
834
|
+
return;
|
|
835
|
+
this.diag(DIAG.FRAME_APPLIED, {
|
|
836
|
+
kind: 'message',
|
|
837
|
+
source,
|
|
838
|
+
seq: row.seq,
|
|
839
|
+
hasClientMessageId: Boolean(row.clientMessageId),
|
|
840
|
+
attachmentState,
|
|
841
|
+
moderationKind: row.moderationKind ?? null,
|
|
842
|
+
op: prior.reconciledOptimistic ? 'optimistic-reconcile' : prior.hadRow ? 'replace-by-seq' : 'insert',
|
|
843
|
+
cursorBefore,
|
|
844
|
+
cursorAfter: this.resumeSeq,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The pre-merge view of a row's seq: whether we already hold that seq, its current
|
|
849
|
+
* attachment state, and whether this row reconciles a live optimistic send.
|
|
850
|
+
* Diagnostics-only — never called when diagnostics are off.
|
|
851
|
+
*/
|
|
852
|
+
priorRowFacts(row) {
|
|
853
|
+
const messageId = seqToMessageId(row.seq);
|
|
854
|
+
const existing = this.state.messages.find((m) => m.messageId === messageId);
|
|
855
|
+
const raw = existing?.meta?.attachmentState;
|
|
856
|
+
return {
|
|
857
|
+
hadRow: existing != null,
|
|
858
|
+
attachmentState: typeof raw === 'string' ? raw : null,
|
|
859
|
+
reconciledOptimistic: Boolean(row.clientMessageId &&
|
|
860
|
+
this.state.messages.some((m) => m.meta?.optimistic && m.meta?.clientMessageId === row.clientMessageId)),
|
|
861
|
+
};
|
|
657
862
|
}
|
|
658
863
|
/**
|
|
659
864
|
* Merge a server message: reconcile an optimistic echo by clientMessageId (swap
|
|
@@ -682,6 +887,13 @@ export class ChannelClient {
|
|
|
682
887
|
// ack guarantees the seq even if the broadcast is dropped/reordered.
|
|
683
888
|
const seq = payload.seq;
|
|
684
889
|
const cmid = payload.clientMessageId;
|
|
890
|
+
this.diag?.(DIAG.SEND_ACK, {
|
|
891
|
+
clientMessageId: cmid,
|
|
892
|
+
seq,
|
|
893
|
+
cursorBefore: this.resumeSeq,
|
|
894
|
+
cursorAfter: seq > this.resumeSeq ? seq : this.resumeSeq,
|
|
895
|
+
wasPending: this.pendingSends.has(cmid),
|
|
896
|
+
});
|
|
685
897
|
// The send is acked — stop tracking it for reconnect resend (and cancel any
|
|
686
898
|
// per-send retry timer so a scheduled resend never fires after it landed).
|
|
687
899
|
this.clearPendingSend(cmid);
|
|
@@ -700,6 +912,12 @@ export class ChannelClient {
|
|
|
700
912
|
this.setState({ messages: next });
|
|
701
913
|
}
|
|
702
914
|
applyHistoryPage(rows) {
|
|
915
|
+
const cursorBefore = this.resumeSeq;
|
|
916
|
+
// Pre-page attachment states for the seqs we already hold — a history page that
|
|
917
|
+
// carries a DIFFERENT state for a row we already rendered is an in-place mutation
|
|
918
|
+
// the client learned about by re-fetching, which is exactly the reveal path under
|
|
919
|
+
// investigation. Diagnostics-only, so only built when they are on.
|
|
920
|
+
const priorAttachment = this.diag ? this.snapshotAttachmentStates() : null;
|
|
703
921
|
const mapped = rows.map((r) => this.renderRow(r));
|
|
704
922
|
// Mirror mergeMessage's optimistic reconciliation: a history row carrying a
|
|
705
923
|
// clientMessageId we still hold as an optimistic placeholder IS that send (the
|
|
@@ -735,6 +953,47 @@ export class ChannelClient {
|
|
|
735
953
|
// which is a harmless no-op since the flag never flips back to false.)
|
|
736
954
|
hasLoadedInitialData: true,
|
|
737
955
|
});
|
|
956
|
+
if (!this.diag)
|
|
957
|
+
return;
|
|
958
|
+
if (priorAttachment) {
|
|
959
|
+
for (const r of rows) {
|
|
960
|
+
if (typeof r.seq !== 'number')
|
|
961
|
+
continue;
|
|
962
|
+
const before = priorAttachment.get(r.seq);
|
|
963
|
+
if (before === undefined)
|
|
964
|
+
continue; // a row we did not hold — not a transition
|
|
965
|
+
const after = typeof r.attachmentState === 'string' ? r.attachmentState : null;
|
|
966
|
+
if (before !== after) {
|
|
967
|
+
this.diag(DIAG.ATTACHMENT_TRANSITION, { seq: r.seq, from: before, to: after, source: 'history' });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const seqs = rows.map((r) => r.seq).filter((s) => typeof s === 'number');
|
|
972
|
+
this.diag(DIAG.HISTORY_PAGE, {
|
|
973
|
+
count: rows.length,
|
|
974
|
+
minSeq: seqs.length > 0 ? Math.min(...seqs) : null,
|
|
975
|
+
maxSeq: seqs.length > 0 ? Math.max(...seqs) : null,
|
|
976
|
+
cursorBefore,
|
|
977
|
+
cursorAfter: this.resumeSeq,
|
|
978
|
+
reconciledOptimistic: incomingCmids.size,
|
|
979
|
+
hasOlder: rows.length >= HISTORY_PAGE_MAX,
|
|
980
|
+
total: merged.length,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* A seq -> attachmentState map of the rows currently held (diagnostics-only, so a
|
|
985
|
+
* later in-place mutation can be reported as an explicit `old -> new` transition).
|
|
986
|
+
*/
|
|
987
|
+
snapshotAttachmentStates() {
|
|
988
|
+
const out = new Map();
|
|
989
|
+
for (const m of this.state.messages) {
|
|
990
|
+
const seq = m.meta?.seq;
|
|
991
|
+
if (typeof seq !== 'number')
|
|
992
|
+
continue;
|
|
993
|
+
const raw = m.meta?.attachmentState;
|
|
994
|
+
out.set(seq, typeof raw === 'string' ? raw : null);
|
|
995
|
+
}
|
|
996
|
+
return out;
|
|
738
997
|
}
|
|
739
998
|
applyTyping(uid) {
|
|
740
999
|
if (!uid || uid === this.config.currentUserId)
|
|
@@ -771,6 +1030,13 @@ export class ChannelClient {
|
|
|
771
1030
|
replyTo: args.replyTo ?? null,
|
|
772
1031
|
});
|
|
773
1032
|
if (!sent) {
|
|
1033
|
+
this.diag?.(DIAG.SEND_FAILED, {
|
|
1034
|
+
clientMessageId: args.clientMessageId,
|
|
1035
|
+
code: 'send-failed',
|
|
1036
|
+
retryable: null,
|
|
1037
|
+
attempts: 0,
|
|
1038
|
+
reason: 'socket-not-open',
|
|
1039
|
+
});
|
|
774
1040
|
this.setState({ lastErrorCode: 'send-failed' });
|
|
775
1041
|
return false;
|
|
776
1042
|
}
|
|
@@ -797,6 +1063,12 @@ export class ChannelClient {
|
|
|
797
1063
|
});
|
|
798
1064
|
const next = [...this.state.messages, echo].sort(sortBySeq);
|
|
799
1065
|
this.setState({ messages: next, lastErrorCode: null });
|
|
1066
|
+
this.diag?.(DIAG.SEND_OPTIMISTIC, {
|
|
1067
|
+
clientMessageId: args.clientMessageId,
|
|
1068
|
+
hasReplyTo: Boolean(args.replyTo),
|
|
1069
|
+
replyToSeq: args.replyTo?.messageSeq ?? null,
|
|
1070
|
+
pendingSends: this.pendingSends.size,
|
|
1071
|
+
});
|
|
800
1072
|
return true;
|
|
801
1073
|
}
|
|
802
1074
|
/**
|
|
@@ -836,7 +1108,8 @@ export class ChannelClient {
|
|
|
836
1108
|
this.requestHistory(beforeSeq);
|
|
837
1109
|
}
|
|
838
1110
|
requestHistory(beforeSeq) {
|
|
839
|
-
this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
|
|
1111
|
+
const sent = this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
|
|
1112
|
+
this.diag?.(DIAG.HISTORY_REQUEST, { beforeSeq, limit: HISTORY_PAGE_MAX, sent });
|
|
840
1113
|
}
|
|
841
1114
|
// ---- heartbeat (20s) ----
|
|
842
1115
|
startHeartbeat() {
|