@ttt-productions/chat-react 0.13.16 → 0.13.18

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.
Files changed (85) hide show
  1. package/README.md +3 -3
  2. package/dist/context/ChatNameResolverContext.d.ts +0 -23
  3. package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
  4. package/dist/context/ChatNameResolverContext.js +0 -16
  5. package/dist/context/ChatNameResolverContext.js.map +1 -1
  6. package/dist/hooks/useChatMessages.d.ts +0 -4
  7. package/dist/hooks/useChatMessages.d.ts.map +1 -1
  8. package/dist/hooks/useChatMessages.js +0 -10
  9. package/dist/hooks/useChatMessages.js.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +0 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/realtime/channel-client.d.ts +0 -248
  14. package/dist/realtime/channel-client.d.ts.map +1 -1
  15. package/dist/realtime/channel-client.js +5 -432
  16. package/dist/realtime/channel-client.js.map +1 -1
  17. package/dist/realtime/diagnostics.d.ts +0 -62
  18. package/dist/realtime/diagnostics.d.ts.map +1 -1
  19. package/dist/realtime/diagnostics.js +4 -89
  20. package/dist/realtime/diagnostics.js.map +1 -1
  21. package/dist/realtime/inbox-client.d.ts +0 -71
  22. package/dist/realtime/inbox-client.d.ts.map +1 -1
  23. package/dist/realtime/inbox-client.js +0 -98
  24. package/dist/realtime/inbox-client.js.map +1 -1
  25. package/dist/realtime/initial-load-budget.d.ts +1 -16
  26. package/dist/realtime/initial-load-budget.d.ts.map +1 -1
  27. package/dist/realtime/initial-load-budget.js +1 -20
  28. package/dist/realtime/initial-load-budget.js.map +1 -1
  29. package/dist/realtime/map.d.ts +0 -26
  30. package/dist/realtime/map.d.ts.map +1 -1
  31. package/dist/realtime/map.js +0 -33
  32. package/dist/realtime/map.js.map +1 -1
  33. package/dist/realtime/shared.d.ts +0 -78
  34. package/dist/realtime/shared.d.ts.map +1 -1
  35. package/dist/realtime/shared.js +2 -76
  36. package/dist/realtime/shared.js.map +1 -1
  37. package/dist/realtime/socket.d.ts +0 -15
  38. package/dist/realtime/socket.d.ts.map +1 -1
  39. package/dist/realtime/socket.js +0 -4
  40. package/dist/realtime/socket.js.map +1 -1
  41. package/dist/realtime/transport.d.ts +0 -23
  42. package/dist/realtime/transport.d.ts.map +1 -1
  43. package/dist/realtime/transport.js +0 -13
  44. package/dist/realtime/transport.js.map +1 -1
  45. package/dist/realtime/useRealtimeChatMessages.d.ts +0 -17
  46. package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
  47. package/dist/realtime/useRealtimeChatMessages.js +0 -22
  48. package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
  49. package/dist/realtime/wire.d.ts +0 -56
  50. package/dist/realtime/wire.d.ts.map +1 -1
  51. package/dist/realtime/wire.js +0 -16
  52. package/dist/realtime/wire.js.map +1 -1
  53. package/dist/types.d.ts +0 -39
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +0 -3
  56. package/dist/types.js.map +1 -1
  57. package/dist/ui/ChatShell.d.ts +0 -42
  58. package/dist/ui/ChatShell.d.ts.map +1 -1
  59. package/dist/ui/ChatShell.js +4 -41
  60. package/dist/ui/ChatShell.js.map +1 -1
  61. package/dist/ui/Composer.d.ts +0 -13
  62. package/dist/ui/Composer.d.ts.map +1 -1
  63. package/dist/ui/Composer.js +0 -13
  64. package/dist/ui/Composer.js.map +1 -1
  65. package/dist/ui/MessageItemDefault.d.ts +0 -5
  66. package/dist/ui/MessageItemDefault.d.ts.map +1 -1
  67. package/dist/ui/MessageItemDefault.js +2 -34
  68. package/dist/ui/MessageItemDefault.js.map +1 -1
  69. package/dist/ui/MessageList.d.ts +0 -24
  70. package/dist/ui/MessageList.d.ts.map +1 -1
  71. package/dist/ui/MessageList.js +4 -14
  72. package/dist/ui/MessageList.js.map +1 -1
  73. package/dist/ui/MessageText.d.ts +0 -13
  74. package/dist/ui/MessageText.d.ts.map +1 -1
  75. package/dist/ui/MessageText.js.map +1 -1
  76. package/dist/ui/SenderName.d.ts +0 -5
  77. package/dist/ui/SenderName.d.ts.map +1 -1
  78. package/dist/ui/SenderName.js +0 -5
  79. package/dist/ui/SenderName.js.map +1 -1
  80. package/dist/ui/menus.d.ts +0 -2
  81. package/dist/ui/menus.d.ts.map +1 -1
  82. package/dist/ui/menus.js +6 -10
  83. package/dist/ui/menus.js.map +1 -1
  84. package/package.json +10 -10
  85. package/src/styles/chat.css +2 -15
@@ -1,19 +1,3 @@
1
- // The Channel realtime client: ONE WebSocket to a single channel DO, driving the
2
- // Contract C wire protocol. Framework-neutral (a plain observable store the React
3
- // hook subscribes to) so it is unit-testable with a mock socket and no React.
4
- //
5
- // Responsibilities (Contract C):
6
- // - connect via subprotocol auth (grant offered by the injected grantProvider)
7
- // - receive versioned `message` frames; server owns `seq`
8
- // - optimistic send keyed by clientMessageId, reconciled on the server `ack` seq
9
- // - explicit read acks (delivery != read; focus signals "read")
10
- // - typing (>= 2s coalesced per the V1 limit)
11
- // - presence-subscribe/-unsubscribe + a 20s client heartbeat
12
- // - reconnect (realtime-core controller) with a resume cursor -> snapshot then deltas
13
- // - history pagination over the socket (page <= 50, epoch-aware via seq)
14
- // - error codes incl. 4401 AUTH_EXPIRED -> re-mint a grant ONCE via grantProvider
15
- //
16
- // Timers are injected (setTimeout/clearTimeout/now) so tests run deterministically.
17
1
  import { createReconnectController, } from '@ttt-productions/realtime-core';
18
2
  import { CLIENT_KINDS, CHAT_CLOSE_CODES, ChatSendRejectedPayloadSchema, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, } from '@ttt-productions/chat-schemas';
19
3
  import { buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
@@ -33,46 +17,12 @@ const INITIAL = {
33
17
  initialLoadFailed: false,
34
18
  isSynced: false,
35
19
  };
36
- /**
37
- * Retryable error-frame code the channel DO sends when a send arrives BEFORE the
38
- * member row has synced through the async backend→worker pipeline (a just-created
39
- * invite/channel). The socket stays open; the client re-sends after `retryAfterMs`
40
- * through the normal resendPendingSends machinery (same caps). The string is part
41
- * of the wire contract with the worker runtime's send handler.
42
- */
43
20
  const MEMBERSHIP_PENDING_CODE = 'membership-pending';
44
- /** Fallback retry delay when the frame omits/breaks `retryAfterMs`. */
45
21
  const MEMBERSHIP_PENDING_DEFAULT_RETRY_MS = 2000;
46
- /**
47
- * Dead-socket watchdog: how long inbound silence may last before this client
48
- * treats the socket as dead and closes it locally.
49
- *
50
- * CHANNEL-ONLY, and this is why it is sound here: the channel DO answers every
51
- * 20 s client `heartbeat` through the hibernation AUTO-RESPONSE API, so a healthy
52
- * channel socket is NEVER silent for a full heartbeat window. Inbound silence past
53
- * this threshold is therefore provable death (a half-open TCP connection whose
54
- * `onclose` never fires), not idleness. The INBOX socket has no such auto-response
55
- * and a healthy idle inbox is legitimately silent for hours — applying this rule
56
- * there would endlessly reconnect healthy sockets, so it is deliberately absent
57
- * from `inbox-client.ts`.
58
- *
59
- * PRIVATE CLIENT POLICY, not a wire constant: nothing on the worker side agrees to
60
- * it, so it does not belong in `@ttt-productions/chat-schemas` or in this package's
61
- * exported surface. The check rides the existing 20 s heartbeat tick (no second
62
- * timer), so detection lands on the NEXT tick after the deadline rather than at
63
- * exactly 50 s — intended: 2.5 missed windows is unambiguous, and it costs no
64
- * extra timer.
65
- */
22
+ // Sound only on the channel socket: its DO auto-answers every heartbeat, so silence this long
23
+ // means a half-open socket. A healthy idle inbox socket is silent for hours.
66
24
  const INBOUND_SILENCE_LIMIT_MS = 50_000;
67
- /**
68
- * Close code used for the watchdog's LOCAL close. A browser `WebSocket.close()`
69
- * accepts only 1000 or 3000–4999, and the private 4xxx range is the chat wire's
70
- * (`CHAT_CLOSE_CODES`) — a client-invented code there could collide with a future
71
- * server code. 1000 keeps the close frame valid and, being neither 4401 nor 4403,
72
- * routes through `onClose`'s ordinary transient path, which owns reconnect,
73
- * backoff, and resume. The reason string + the liveness diagnostic are what
74
- * identify it.
75
- */
25
+ // Browser `WebSocket.close()` accepts only 1000 or 3000–4999, and 4xxx belongs to the chat wire.
76
26
  const WATCHDOG_CLOSE_CODE = 1000;
77
27
  export class ChannelClient {
78
28
  config;
@@ -81,72 +31,26 @@ export class ChannelClient {
81
31
  socket = null;
82
32
  state = INITIAL;
83
33
  listeners = new Set();
84
- /** Resume cursor: highest seq we've durably applied. Sent on resume after reconnect. */
85
34
  resumeSeq = 0;
86
- /** Last read-ack the UI requested; re-sent on reconnect so a drop during a closed
87
- * socket window is recovered (M2). */
88
35
  lastReadAck = null;
89
- /** True after a `4401` re-grant has been attempted for the CURRENT connect cycle (once-only). */
90
36
  reauthAttempted = false;
91
37
  presenceSubscribed = false;
92
38
  heartbeatTimer = null;
93
- /**
94
- * When the CURRENT socket last proved it was alive: set on open, refreshed by
95
- * every successfully parsed inbound frame (`heartbeat-ack` included — that IS
96
- * the DO's liveness answer). Null whenever no socket is live, so the watchdog
97
- * can never judge a socket that does not exist. See {@link INBOUND_SILENCE_LIMIT_MS}.
98
- */
99
39
  lastInboundAt = null;
100
- // History requests sent on the current socket and not yet answered, oldest first.
101
40
  historyRequests = [];
102
41
  typingTimers = new Map();
103
42
  lastTypingSentAt = Number.NEGATIVE_INFINITY;
104
43
  reconnectTimer = null;
105
- /** Single scheduled re-send for the membership-pending window (never stacks). */
106
44
  membershipRetryTimer = null;
107
45
  closedByUs = false;
108
- /**
109
- * The current lifecycle's number, advanced by every `connect()` and `close()`. A grant
110
- * that resolves, or a timer that fires, for an older lifecycle is dropped — so a
111
- * close → connect while an earlier grant is still minting can never open a second
112
- * socket or touch the new lifecycle's state (FRONTEND-108).
113
- */
114
46
  lifecycle = 0;
115
- /** Reports the first open as failed once its attempts have failed for the budget. */
116
47
  initialLoad;
117
- /**
118
- * Per-seq moderation overlay (the max-revision wins). A `revision` frame writes
119
- * here keyed by `messageSeq` so it applies whether it arrives BEFORE its base
120
- * row (re-applied when the row later renders) or AFTER (re-renders the present
121
- * row in place). Merged with the row's own inline `moderationKind` so a row that
122
- * arrives already-moderated from the DO AND one covered by a live frame both
123
- * render the moderated state. */
124
48
  overlays = new Map();
125
- /**
126
- * The ORIGINAL (un-overlaid) mapped message per seq. A `restore` revision must
127
- * revert a previously-redacted message to its original text, so re-renders start
128
- * from this cache rather than the (possibly already-blanked) message in state. */
129
49
  originalRows = new Map();
130
- /**
131
- * Un-acked sends keyed by clientMessageId. A send is added here when its frame is
132
- * written and removed when its `ack` (or the canonical `message` echo carrying the
133
- * same clientMessageId) arrives. On every reconnect resume they are RE-SENT with
134
- * the SAME clientMessageId — the DO dedups (re-acks without re-broadcast), so a
135
- * send that lost its socket before the ack is delivered exactly once. A pending
136
- * send that exceeds the attempt/age cap is flipped to a visible failed state
137
- * (retry affordance) instead of staying a ghost. */
138
50
  pendingSends = new Map();
139
- /**
140
- * The structured diagnostics emitter, or null when diagnostics are off (the
141
- * default). Every call site uses `this.diag?.(...)` so the off path constructs
142
- * no payload at all.
143
- */
144
51
  diag;
145
- /** Monotonic socket-open attempt counter (diagnostics correlation only). */
146
52
  connectAttempt = 0;
147
- /** Why the CURRENT connect attempt is happening (diagnostics only). */
148
53
  reconnectCause = 'initial';
149
- /** The last close code observed, carried into the next attempt's cause line. */
150
54
  lastCloseCode = null;
151
55
  constructor(config) {
152
56
  this.config = config;
@@ -155,7 +59,6 @@ export class ChannelClient {
155
59
  this.diag = createDiagnosticsEmitter(config.diagnostics);
156
60
  this.initialLoad = new InitialLoadBudget(this.timers, () => this.reportInitialLoadFailed());
157
61
  }
158
- // ---- observable store ----
159
62
  getState() {
160
63
  return this.state;
161
64
  }
@@ -165,24 +68,14 @@ export class ChannelClient {
165
68
  }
166
69
  setState(patch) {
167
70
  this.state = { ...this.state, ...patch };
168
- // Only an open socket can be current.
169
71
  if (this.state.status !== 'open')
170
72
  this.state.isSynced = false;
171
73
  for (const fn of this.listeners)
172
74
  fn(this.state);
173
75
  }
174
- // ---- connection lifecycle ----
175
76
  async connect() {
176
77
  return this.startLifecycle(true);
177
78
  }
178
- /**
179
- * Retry now (the first-open failure's Retry). While a reconnect is waiting out its
180
- * backoff, the wait is cancelled and a socket opens at once; a lifecycle that stopped
181
- * (reconnect gave up, or the socket was revoked) starts again; an attempt already in
182
- * flight is left to finish. Either way the first-open failure clears, so the opening state
183
- * shows while the retry runs. The spent budget stays spent: a retry that fails is reported
184
- * again at once, and one that hangs a full budget later.
185
- */
186
79
  retryNow() {
187
80
  if (!this.state.hasLoadedInitialData)
188
81
  this.initialLoad.manualRetry();
@@ -204,29 +97,9 @@ export class ChannelClient {
204
97
  void this.openSocket();
205
98
  }
206
99
  async startLifecycle(freshBudget) {
207
- // Idempotency guard (C-B7): connect() only starts a lifecycle from a fresh
208
- // (idle) or fully torn-down (closed) state. A second connect() while one is
209
- // already connecting / open / reconnecting is a no-op, so a stray double-owner
210
- // can't open a second WebSocket. The reconnect path uses openSocket() /
211
- // scheduleReconnect() directly (never connect()), so this never blocks a
212
- // legitimate reconnect.
213
100
  if (this.state.status !== 'idle' && this.state.status !== 'closed')
214
101
  return;
215
- // REVIVE the controller. `controller.close()` is PERMANENT: while it is in
216
- // state `closed`, `start()` and `onOpen()` no-op and every later `onClose()`
217
- // returns null. So without this reset a close() → connect() client opens a
218
- // socket and works normally until its FIRST transient close, at which point
219
- // `scheduleReconnect()` gets null and parks it terminally in `closed` with
220
- // reconnect disabled and no surfaced error code. The idempotency guard above
221
- // is what keeps this from disturbing a live lifecycle: only an `idle` or
222
- // fully torn-down (`closed`) client ever reaches here.
223
102
  this.controller.reset();
224
- // A terminal verdict (4403 REVOKED / ChatAccessDeniedError) is terminal WITHIN
225
- // its lifecycle; an explicit connect() is a fresh attempt the authority may
226
- // legitimately re-deny, so the previous lifecycle's code must not persist into
227
- // this one (the hook maps `access-denied` to `allowed: false`). Loaded
228
- // messages and the initial-load flag are deliberately KEPT — a lifecycle
229
- // restart must not churn the UI back to an empty opening state.
230
103
  if (isTerminalErrorCode(this.state.lastErrorCode))
231
104
  this.setState({ lastErrorCode: null });
232
105
  this.closedByUs = false;
@@ -238,13 +111,11 @@ export class ChannelClient {
238
111
  this.setState({ status: 'connecting', initialLoadFailed: false });
239
112
  await this.openSocket();
240
113
  }
241
- /** The first open has failed for long enough to say so (see `initialLoadFailed`). */
242
114
  reportInitialLoadFailed() {
243
115
  if (this.state.hasLoadedInitialData || this.state.initialLoadFailed)
244
116
  return;
245
117
  this.setState({ initialLoadFailed: true });
246
118
  }
247
- /** The first authoritative data is applied; it ends any first-open failure. */
248
119
  initialDataLoaded() {
249
120
  this.initialLoad.loaded();
250
121
  return { hasLoadedInitialData: true, initialLoadFailed: false };
@@ -264,15 +135,8 @@ export class ChannelClient {
264
135
  grantToken = await this.config.grantProvider();
265
136
  }
266
137
  catch (err) {
267
- // A grant for a lifecycle that has since been closed or restarted answers nobody.
268
138
  if (lifecycle !== this.lifecycle)
269
139
  return;
270
- // A TERMINAL access denial (the app's grant provider translated an
271
- // authoritative policy "no" into the package-owned ChatAccessDeniedError)
272
- // must NOT reconnect — the next mint would just re-deny, stranding the shell
273
- // on the opening loader forever. Every other mint failure (network, transient
274
- // 'unavailable'/'internal', post-login 'unauthenticated') stays retryable and
275
- // backs off exactly as before.
276
140
  const terminal = isChatAccessDeniedError(err);
277
141
  this.diag?.(DIAG.GRANT_FAILED, { attempt: this.connectAttempt, terminal });
278
142
  if (terminal)
@@ -282,15 +146,10 @@ export class ChannelClient {
282
146
  this.initialLoad.noteFailedAttempt();
283
147
  return this.scheduleReconnect();
284
148
  }
285
- // A grant minted for an earlier lifecycle must never open a socket in this one.
286
149
  if (lifecycle !== this.lifecycle || this.closedByUs)
287
150
  return;
288
- // Socket REPLACEMENT: the previous socket's liveness record dies with it, so
289
- // the watchdog never judges a new socket against an old deadline.
290
151
  this.lastInboundAt = null;
291
152
  const url = `${this.config.endpoint.replace(/\/$/, '')}/channel`;
292
- // Every callback is fenced to THIS socket: once it has been replaced or torn down,
293
- // its late open, frames, and close are ignored.
294
153
  let socket = null;
295
154
  const isCurrent = () => socket !== null && this.socket === socket;
296
155
  socket = this.config.socketFactory({
@@ -320,10 +179,7 @@ export class ChannelClient {
320
179
  onOpen() {
321
180
  this.controller.onOpen();
322
181
  this.reauthAttempted = false;
323
- // Start the liveness clock at open — the first heartbeat window is measured
324
- // from here, not from a stale previous socket.
325
182
  this.lastInboundAt = this.timers.now();
326
- // A new socket is current only once its resume lands.
327
183
  this.historyRequests = [];
328
184
  this.setState({ status: 'open', isSynced: false });
329
185
  this.diag?.(DIAG.SOCKET_OPEN, {
@@ -333,57 +189,30 @@ export class ChannelClient {
333
189
  pendingSends: this.pendingSends.size,
334
190
  presenceSubscribed: this.presenceSubscribed,
335
191
  });
336
- // Resume: ask the DO for the authoritative snapshot, then live deltas flow. The
337
- // field name MUST be `afterSeq` — the Channel DO reads `payload.afterSeq`; any other
338
- // name reads as cursorless and forces a full resync every reconnect.
339
192
  this.sendFrame(CLIENT_KINDS.RESUME, { afterSeq: this.resumeSeq });
340
193
  this.diag?.(DIAG.RESUME_REQUEST, { afterSeq: this.resumeSeq, attempt: this.connectAttempt });
341
- // If the UI had presence open, re-subscribe after a reconnect.
342
194
  if (this.presenceSubscribed)
343
195
  this.sendFrame(CLIENT_KINDS.PRESENCE_SUBSCRIBE, {});
344
- // Re-send the last read cursor so a read-ack dropped during the disconnect window
345
- // (sendFrame returns false on a closed socket) is not lost until the next message.
346
196
  if (this.lastReadAck)
347
197
  this.sendFrame(CLIENT_KINDS.READ_ACK, { ...this.lastReadAck });
348
- // Re-send every un-acked pending send with its ORIGINAL clientMessageId so a send
349
- // that lost its socket before the ack is not silently dropped. The DO dedups
350
- // (re-acks, no re-broadcast, no flood-token) — this is the client half of the
351
- // Contract C send-idempotency the server side was built for.
352
198
  this.resendPendingSends('reconnect');
353
199
  this.startHeartbeat();
354
200
  }
355
- /**
356
- * On reconnect, re-send each un-acked pending send with the SAME clientMessageId
357
- * (the DO dedups). A send that has exceeded the attempt or age cap is flipped to a
358
- * visible failed state instead of being resent forever — the ghost becomes a
359
- * retryable failure the UI can surface.
360
- */
361
201
  resendPendingSends(reason) {
362
202
  if (this.pendingSends.size === 0)
363
203
  return;
364
204
  const now = this.timers.now();
365
205
  for (const pending of [...this.pendingSends.values()]) {
366
- // Already flipped to the visible failed state — only an explicit retrySend()
367
- // re-queues it; the automatic reconnect resend leaves it alone.
368
206
  if (pending.failed)
369
207
  continue;
370
208
  if (pending.serverRetryable) {
371
- // The Worker declared this send retryable — give-up is the wall-clock budget
372
- // ONLY (the 5-attempt cap must not fail a server-declared transient after
373
- // ~10 s while the backend projection window is up to 60+ s).
374
209
  if (now - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
375
210
  this.failPendingSend(pending.clientMessageId, pending.lastRejectionCode, true);
376
211
  continue;
377
212
  }
378
- // This reconnect resend supersedes any per-send timer scheduled before the
379
- // socket dropped — clear it so the send doesn't go out twice (the DO would
380
- // dedup, but the duplicate frame is pure waste). A fresh rejection frame
381
- // re-schedules as usual.
382
213
  this.clearSendRetryTimer(pending);
383
214
  }
384
215
  else {
385
- // A blind reconnect resend of a send the server has said nothing about: the
386
- // generic attempt/age caps still protect against a permanent ghost.
387
216
  const tooOld = now - pending.sentAt >= PENDING_SEND_MAX_AGE_MS;
388
217
  const tooManyAttempts = pending.attempts >= MAX_PENDING_SEND_ATTEMPTS;
389
218
  if (tooOld || tooManyAttempts) {
@@ -407,24 +236,11 @@ export class ChannelClient {
407
236
  });
408
237
  }
409
238
  }
410
- /**
411
- * Flip an un-acked optimistic row to a visible failed state. The pending entry is
412
- * RETAINED (marked `failed`) so retrySend() can re-queue it with the SAME
413
- * clientMessageId; the row is marked `meta.sendFailed` (plus the correlated
414
- * `sendFailureCode`/`sendRetryable` when a `send-rejected` frame supplied them) so
415
- * the default renderer shows the right reason + retry affordance rather than an
416
- * indistinguishable "sent" bubble. A late ack/echo still reconciles + clears it.
417
- *
418
- * `code`/`retryable` are present for a correlated rejection (terminal) or a
419
- * budget-exhausted server-retryable send; absent for a blind reconnect give-up
420
- * (which keeps the neutral `send-failed` code).
421
- */
422
239
  failPendingSend(clientMessageId, code, retryable) {
423
240
  const pending = this.pendingSends.get(clientMessageId);
424
241
  if (!pending || pending.failed)
425
242
  return;
426
243
  pending.failed = true;
427
- // A terminal/exhausted send never retries on a timer — cancel any scheduled one.
428
244
  this.clearSendRetryTimer(pending);
429
245
  this.diag?.(DIAG.SEND_FAILED, {
430
246
  clientMessageId,
@@ -447,25 +263,18 @@ export class ChannelClient {
447
263
  : m);
448
264
  this.setState({ messages: next, lastErrorCode: code ?? 'send-failed' });
449
265
  }
450
- /** Clear a pending send's per-send retry timer (idempotent). */
451
266
  clearSendRetryTimer(pending) {
452
267
  if (pending.retryTimer != null) {
453
268
  this.timers.clearTimeout(pending.retryTimer);
454
269
  pending.retryTimer = null;
455
270
  }
456
271
  }
457
- /**
458
- * Stop tracking a send (acked / reconciled by an echo / history row): cancel its
459
- * per-send retry timer first so a late reconcile never leaves a stray timer that
460
- * would resend after the send already landed, then drop it from the map.
461
- */
462
272
  clearPendingSend(clientMessageId) {
463
273
  const pending = this.pendingSends.get(clientMessageId);
464
274
  if (pending)
465
275
  this.clearSendRetryTimer(pending);
466
276
  this.pendingSends.delete(clientMessageId);
467
277
  }
468
- /** One timer for the membership-pending re-send window; a newer frame resets it. */
469
278
  scheduleMembershipPendingRetry(delayMs) {
470
279
  if (this.membershipRetryTimer != null)
471
280
  this.timers.clearTimeout(this.membershipRetryTimer);
@@ -476,28 +285,9 @@ export class ChannelClient {
476
285
  this.resendPendingSends('membership-pending');
477
286
  }, delayMs);
478
287
  }
479
- /**
480
- * Handle a CORRELATED `send-rejected` frame (the target protocol): parse the
481
- * untrusted payload with the shared schema, correlate to EXACTLY ONE pending send
482
- * by clientMessageId, and either fail just that bubble (terminal) or schedule a
483
- * single per-send resend (retryable).
484
- *
485
- * A frame that fails validation is NOT simply dropped when it still names one of
486
- * OUR pending sends: unlike an unknown frame type, an id-correlated rejection is
487
- * the server explicitly telling us this send did not land — swallowing it would
488
- * strand the bubble on "Sending…" forever (no further frame, no reconnect, no
489
- * budget check ever runs). A future/unknown rejection code (version skew during a
490
- * rollout) therefore fails that send CLOSED with the neutral copy + Retry. A
491
- * malformed frame with no recognizable pending id is dropped (forward-compat
492
- * noise), never surfaced as an error.
493
- */
494
288
  applySendRejected(payload) {
495
289
  const parsed = ChatSendRejectedPayloadSchema.safeParse(payload);
496
290
  if (!parsed.success) {
497
- // Defensive fail-closed: if the unparseable rejection still carries a string
498
- // id matching one of our un-failed pending sends, that send was rejected by a
499
- // server speaking a newer/buggy dialect — fail it visibly (neutral code-less
500
- // "Couldn't send" + Retry) instead of stranding it.
501
291
  const rawId = payload.clientMessageId;
502
292
  if (typeof rawId === 'string' && rawId.length > 0 && rawId.length <= 200) {
503
293
  const orphaned = this.pendingSends.get(rawId);
@@ -515,8 +305,6 @@ export class ChannelClient {
515
305
  }
516
306
  const { clientMessageId, code, retryable, retryAfterMs } = parsed.data;
517
307
  const pending = this.pendingSends.get(clientMessageId);
518
- // No matching pending send — already acked/reconciled, or never ours. A late
519
- // ack/echo (which may have already landed) always wins; nothing to correlate.
520
308
  if (!pending || pending.failed) {
521
309
  this.diag?.(DIAG.SEND_REJECTED, {
522
310
  clientMessageId,
@@ -527,8 +315,6 @@ export class ChannelClient {
527
315
  return;
528
316
  }
529
317
  if (!retryable) {
530
- // Terminal: an unchanged resend cannot succeed (blocked-word / archived /
531
- // deleted). Fail ONLY this bubble, recording the reason + terminal class.
532
318
  this.diag?.(DIAG.SEND_REJECTED, {
533
319
  clientMessageId,
534
320
  code,
@@ -538,13 +324,8 @@ export class ChannelClient {
538
324
  this.failPendingSend(clientMessageId, code, false);
539
325
  return;
540
326
  }
541
- // Retryable: server-declared, so switch this send onto the wall-clock budget
542
- // (the 5-attempt cap no longer applies) and record the reason for a possible
543
- // budget-exhausted failure.
544
327
  pending.serverRetryable = true;
545
328
  pending.lastRejectionCode = code;
546
- // If the budget already elapsed (a long stream of retryable rejections), fail
547
- // now rather than scheduling yet another doomed resend.
548
329
  if (this.timers.now() - pending.sentAt >= SERVER_RETRYABLE_MAX_AGE_MS) {
549
330
  this.diag?.(DIAG.SEND_REJECTED, {
550
331
  clientMessageId,
@@ -565,12 +346,6 @@ export class ChannelClient {
565
346
  });
566
347
  this.scheduleSendRetry(clientMessageId, delayMs);
567
348
  }
568
- /**
569
- * Schedule EXACTLY ONE per-send resend of a correlated retryable rejection (never
570
- * stacks — a newer rejection clears the prior timer first). When it fires it
571
- * re-sends the SAME clientMessageId (the DO dedups) unless the wall-clock budget
572
- * has elapsed, in which case the send fails with its recorded reason.
573
- */
574
349
  scheduleSendRetry(clientMessageId, delayMs) {
575
350
  const pending = this.pendingSends.get(clientMessageId);
576
351
  if (!pending)
@@ -587,8 +362,6 @@ export class ChannelClient {
587
362
  this.failPendingSend(clientMessageId, current.lastRejectionCode, true);
588
363
  return;
589
364
  }
590
- // A server-retryable resend does NOT count toward the attempt cap (that cap is
591
- // for blind reconnect resends only) — leave `attempts` untouched.
592
365
  const sent = this.sendFrame(CLIENT_KINDS.SEND, {
593
366
  clientMessageId: current.clientMessageId,
594
367
  text: current.text,
@@ -601,11 +374,6 @@ export class ChannelClient {
601
374
  ageMs: this.timers.now() - current.sentAt,
602
375
  sent,
603
376
  });
604
- // Budget backstop: the contract says every send gets an ack or a rejection,
605
- // and either one replaces/clears this timer. If the server goes silent (a
606
- // contract violation), NOTHING else would ever re-check the budget on a
607
- // stable socket — so arm a deadline check that fails the send at budget end.
608
- // This makes SERVER_RETRYABLE_MAX_AGE_MS a hard ceiling, not a best effort.
609
377
  const remainingMs = SERVER_RETRYABLE_MAX_AGE_MS -
610
378
  (this.timers.now() - current.sentAt) +
611
379
  SEND_RETRY_MIN_DELAY_MS;
@@ -622,38 +390,21 @@ export class ChannelClient {
622
390
  }, remainingMs);
623
391
  }, delayMs);
624
392
  }
625
- /** Clamp a server `retryAfterMs` hint into a sane resend delay (defaulting when absent). */
626
393
  clampRetryDelay(retryAfterMs) {
627
394
  const raw = typeof retryAfterMs === 'number' && retryAfterMs > 0
628
395
  ? retryAfterMs
629
396
  : MEMBERSHIP_PENDING_DEFAULT_RETRY_MS;
630
397
  return Math.min(Math.max(raw, SEND_RETRY_MIN_DELAY_MS), SEND_RETRY_MAX_DELAY_MS);
631
398
  }
632
- /**
633
- * Flip EVERY un-acked pending send to the visible failed state. Called on the
634
- * terminal close paths (4403 REVOKED, reconnect give-up) — without this, a send
635
- * in flight when the socket dies for good shows "Sending…" until unmount.
636
- */
637
399
  failAllPendingSends() {
638
400
  for (const clientMessageId of [...this.pendingSends.keys()]) {
639
401
  this.failPendingSend(clientMessageId);
640
402
  }
641
403
  }
642
- /**
643
- * Retry a FAILED (or still-pending) send, RE-USING its original clientMessageId
644
- * so the DO's send-idempotency dedups against any copy that actually landed.
645
- * Resets the attempt/age budget, clears the row's `sendFailed` marker (back to
646
- * the normal pending/optimistic look), and re-sends immediately when the socket
647
- * is open — otherwise the entry rides the next reconnect's automatic resend.
648
- * Returns false when the clientMessageId is not a tracked pending send (already
649
- * acked/reconciled, or never sent from this client).
650
- */
651
404
  retrySend(clientMessageId) {
652
405
  const pending = this.pendingSends.get(clientMessageId);
653
406
  if (!pending)
654
407
  return false;
655
- // A manual retry supersedes any scheduled server-retryable resend and starts a
656
- // fresh budget from now; the server re-declares retryability if it rejects again.
657
408
  this.clearSendRetryTimer(pending);
658
409
  pending.failed = false;
659
410
  pending.serverRetryable = false;
@@ -678,7 +429,6 @@ export class ChannelClient {
678
429
  m.meta?.sendFailed)) {
679
430
  return m;
680
431
  }
681
- // Back to the normal pending look — drop every failure marker.
682
432
  const { sendFailed: _sendFailed, sendFailureCode: _sendFailureCode, sendRetryable: _sendRetryable, ...restMeta } = m.meta;
683
433
  return { ...m, meta: { ...restMeta } };
684
434
  });
@@ -693,7 +443,6 @@ export class ChannelClient {
693
443
  this.socket = null;
694
444
  this.lastInboundAt = null;
695
445
  this.lastCloseCode = code;
696
- // An older-history request in flight died with the socket; nothing will answer it.
697
446
  if (this.state.isFetchingOlder)
698
447
  this.setState({ isFetchingOlder: false });
699
448
  if (this.closedByUs) {
@@ -706,7 +455,6 @@ export class ChannelClient {
706
455
  this.setState({ status: 'closed' });
707
456
  return;
708
457
  }
709
- // 4401 AUTH_EXPIRED: re-mint a grant ONCE and reconnect immediately, no backoff.
710
458
  if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {
711
459
  this.reauthAttempted = true;
712
460
  this.reconnectCause = 'auth-expired';
@@ -720,10 +468,6 @@ export class ChannelClient {
720
468
  void this.openSocket();
721
469
  return;
722
470
  }
723
- // 4403 REVOKED: access pulled. Stop — a reconnect would just re-deny at mint.
724
- // Terminal: flip every un-acked pending send to the visible failed state first
725
- // (failPendingSend sets lastErrorCode 'send-failed'; the 'revoked' set below
726
- // wins as the final surfaced code).
727
471
  if (code === CHAT_CLOSE_CODES.REVOKED) {
728
472
  this.closedByUs = true;
729
473
  this.controller.close();
@@ -735,7 +479,6 @@ export class ChannelClient {
735
479
  });
736
480
  this.failAllPendingSends();
737
481
  this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.REVOKED });
738
- // Revoked before anything loaded: the first open has failed, and nothing retries it.
739
482
  this.reportInitialLoadFailed();
740
483
  return;
741
484
  }
@@ -759,11 +502,8 @@ export class ChannelClient {
759
502
  resumeSeq: this.resumeSeq,
760
503
  });
761
504
  if (delay == null) {
762
- // Reconnect give-up: terminal, same as REVOKED — pending sends must not stay
763
- // an eternal "Sending…"; flip them to the visible failed/retry state.
764
505
  this.failAllPendingSends();
765
506
  this.setState({ status: 'closed' });
766
- // Gave up before anything loaded: nothing retries the first open any more.
767
507
  this.reportInitialLoadFailed();
768
508
  return;
769
509
  }
@@ -776,15 +516,6 @@ export class ChannelClient {
776
516
  void this.openSocket();
777
517
  }, delay);
778
518
  }
779
- /**
780
- * Terminal access denial surfaced by the grant provider (ChatAccessDeniedError).
781
- * A reconnect would just re-deny at the next mint, so stop for good — the same
782
- * terminal posture as a 4403 REVOKED close: mark closed-by-us, close the reconnect
783
- * controller, and flip every un-acked pending send to the visible failed state.
784
- * `access-denied` is set AFTER failAllPendingSends so its 'send-failed' code does
785
- * not win as the surfaced error; the React hook maps 'access-denied' to
786
- * `allowed: false` so the shell renders its existing no-access surface.
787
- */
788
519
  denyAccessTerminally() {
789
520
  this.closedByUs = true;
790
521
  this.controller.close();
@@ -792,18 +523,12 @@ export class ChannelClient {
792
523
  this.failAllPendingSends();
793
524
  this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.ACCESS_DENIED });
794
525
  }
795
- // ---- inbound frames ----
796
526
  onMessage(data) {
797
527
  const frame = parseFrame(data);
798
528
  if (!frame || !frame.type) {
799
- // Unparseable: bytes arrived, but nothing proves they came from a DO
800
- // speaking this protocol, so this must NOT refresh the liveness deadline.
801
529
  this.diag?.(DIAG.FRAME_DROPPED, { kind: 'unparseable', reason: 'bad-envelope' });
802
530
  return;
803
531
  }
804
- // A well-formed envelope IS the liveness proof — including the `heartbeat-ack`
805
- // the DO auto-responds with, and a forward-compat type this build does not
806
- // handle yet (a newer DO's frames must never be read as a dead socket).
807
532
  this.lastInboundAt = this.timers.now();
808
533
  const payload = frame.payload ?? {};
809
534
  switch (frame.type) {
@@ -823,13 +548,7 @@ export class ChannelClient {
823
548
  return this.applySendRejected(payload);
824
549
  case 'error': {
825
550
  const code = String(payload.code ?? 'error');
826
- // ROLLOUT COMPATIBILITY ONLY (not the target protocol): a Worker that predates
827
- // the correlated `send-rejected` frame emits an UNCORRELATED
828
- // error{code:'membership-pending'} with no clientMessageId, so we cannot target
829
- // one send. Keep the send(s) pending (no visible error — the bubble stays
830
- // "Sending…"), mark every in-flight send server-retryable so the wall-clock
831
- // budget (NOT the 5-attempt cap) governs give-up, and re-send all after the
832
- // server-suggested delay. The correlated path below is the target protocol.
551
+ // An older Worker sends this uncorrelated form, with no clientMessageId to target one send.
833
552
  if (code === MEMBERSHIP_PENDING_CODE) {
834
553
  const raw = payload.retryAfterMs;
835
554
  const delay = this.clampRetryDelay(typeof raw === 'number' ? raw : undefined);
@@ -853,17 +572,8 @@ export class ChannelClient {
853
572
  case 'revision':
854
573
  return this.applyRevisionFrame(payload);
855
574
  case 'heartbeat-ack':
856
- // The DO runtime's hibernation AUTO-RESPONSE to our 20s `heartbeat`. Per
857
- // Contract C liveness is tracked SERVER-side (the auto-response timestamp
858
- // the dueWork sweep reads), so the client heartbeat is fire-and-forget: the
859
- // ack needs no state change and the next tick is already scheduled. Explicit
860
- // no-op — and deliberately NO diagnostic, not even frame_applied: at ~20s
861
- // this is the one frame that recurs forever, so logging it would violate the
862
- // bounded "one line per DECISION, never per heartbeat" contract and drown the
863
- // frame_dropped signal that distinguishes "never arrived" from "dropped".
864
575
  return;
865
576
  default:
866
- // unknown/optional types tolerated (forward compat)
867
577
  this.diag?.(DIAG.FRAME_DROPPED, {
868
578
  kind: safeDiagnosticLabel(frame.type),
869
579
  reason: 'unknown-type',
@@ -871,13 +581,6 @@ export class ChannelClient {
871
581
  return;
872
582
  }
873
583
  }
874
- /**
875
- * Apply a live moderation `revision` frame: record the overlay keyed by seq
876
- * (max-`messageRevision` wins — a stale/older frame is ignored) and re-render the
877
- * matching message in place if it is already loaded. If the base row hasn't
878
- * arrived yet the overlay is retained and re-applied when the row renders, so a
879
- * frame that races ahead of its message never lets the original content show.
880
- */
881
584
  applyRevisionFrame(payload) {
882
585
  const seq = payload.messageSeq;
883
586
  const kind = payload.kind;
@@ -887,7 +590,6 @@ export class ChannelClient {
887
590
  return;
888
591
  }
889
592
  const prev = this.overlays.get(seq);
890
- // Max-revision merge: a higher messageRevision supersedes; an older one is dropped.
891
593
  if (prev && prev.messageRevision >= messageRevision) {
892
594
  this.diag?.(DIAG.FRAME_DROPPED, {
893
595
  kind: 'revision',
@@ -899,8 +601,6 @@ export class ChannelClient {
899
601
  return;
900
602
  }
901
603
  this.overlays.set(seq, { kind, messageRevision });
902
- // Re-render the matching message in place if it is already present. Re-derive
903
- // from the ORIGINAL row so a `restore` reverts a previously-blanked text.
904
604
  const messageId = seqToMessageId(seq);
905
605
  const original = this.originalRows.get(seq);
906
606
  const rendered = Boolean(original) && this.state.messages.some((m) => m.messageId === messageId);
@@ -908,7 +608,6 @@ export class ChannelClient {
908
608
  seq,
909
609
  kind,
910
610
  messageRevision,
911
- // false = overlay retained for a row not loaded yet (re-applied when it renders).
912
611
  rendered,
913
612
  });
914
613
  if (rendered && original) {
@@ -917,11 +616,6 @@ export class ChannelClient {
917
616
  this.setState({ messages: next });
918
617
  }
919
618
  }
920
- /**
921
- * The effective overlay for a seq = the max-`messageRevision` between the row's
922
- * own inline overlay (from the DO history/delta merge) and any received `revision`
923
- * frame stored in `this.overlays`. Either source may be ahead of the other.
924
- */
925
619
  effectiveOverlay(seq, rowOverlay) {
926
620
  const fromFrame = this.overlays.get(seq) ?? null;
927
621
  if (!rowOverlay)
@@ -930,34 +624,12 @@ export class ChannelClient {
930
624
  return rowOverlay;
931
625
  return fromFrame.messageRevision >= rowOverlay.messageRevision ? fromFrame : rowOverlay;
932
626
  }
933
- /** Map a DO row to a UI message with its effective moderation overlay applied. */
934
627
  renderRow(row) {
935
628
  const mapped = wireRowToMessage(row, this.config.threadId);
936
- // Cache the ORIGINAL so a later `restore` frame can revert a blanked message.
937
629
  this.originalRows.set(row.seq, mapped);
938
630
  return applyModerationOverlay(mapped, this.effectiveOverlay(row.seq, overlayFromRow(row)));
939
631
  }
940
- /**
941
- * Apply the Contract C resume snapshot. The DO always returns the authoritative
942
- * tail + read cursor and, per the pinned contract, EITHER a contiguous delta
943
- * (`resync:false`) OR a resync directive (`resync:true`).
944
- *
945
- * - `resync === false`: apply the contiguous `delta` (the ≤500-message backlog
946
- * after our cursor, oldest-first) through the same render/merge path as a live
947
- * `message`, then advance `resumeSeq` to `lastMessageSeq`. This heals ANY gap
948
- * within the backlog — including moderation `revision`s the DO baked into the
949
- * delta rows — with no history fetch, so a >50-message reconnect gap is no
950
- * longer a permanent hole.
951
- * - `resync === true`: the gap is beyond the backlog (or we sent no cursor). Drop
952
- * the local tail (messages + overlays + original-row cache) and re-page history
953
- * from the top via a cursorless `requestHistory(null)`.
954
- *
955
- * A pre-contract DO (no `resync`/`delta`) is treated as `resync:true` ⇒ the safe
956
- * re-page path, so a stale worker never strands the client. An empty delta with
957
- * `resync:false` (already caught up) is a no-op beyond advancing the cursor.
958
- */
959
632
  applyChannelSnapshot(snap) {
960
- // Guard: an inbox snapshot must never reach a channel client.
961
633
  if (isInboxSnapshot(snap)) {
962
634
  this.diag?.(DIAG.FRAME_DROPPED, { kind: 'snapshot', reason: 'inbox-snapshot-on-channel' });
963
635
  return;
@@ -966,14 +638,10 @@ export class ChannelClient {
966
638
  this.diag?.(DIAG.FRAME_DROPPED, { kind: 'snapshot', reason: 'missing-last-message-seq' });
967
639
  return;
968
640
  }
969
- // Absent `resync` (legacy/pre-contract DO) is treated as a resync directive.
970
641
  const resync = snap.resync !== false;
971
642
  const cursorBefore = this.resumeSeq;
972
643
  const delta = Array.isArray(snap.delta) ? snap.delta : [];
973
644
  if (!resync) {
974
- // Apply the contiguous delta oldest-first, exactly like live messages, then
975
- // advance the cursor to the authoritative tail so the NEXT resume tells the DO
976
- // we're caught up only after we actually applied the backlog.
977
645
  for (const row of delta)
978
646
  this.applyMessage(row, 'delta');
979
647
  if (snap.lastMessageSeq > this.resumeSeq)
@@ -986,20 +654,12 @@ export class ChannelClient {
986
654
  cursorAfter: this.resumeSeq,
987
655
  readSeq: typeof snap.readSeq === 'number' ? snap.readSeq : null,
988
656
  });
989
- // A non-resync snapshot IS the first authoritative data (even an empty delta =
990
- // caught up) — end initial loading. A resync snapshot instead defers to the
991
- // re-paged first history page below, so it must NOT end it here.
992
657
  this.setState({
993
658
  ...(this.state.hasLoadedInitialData ? {} : this.initialDataLoaded()),
994
659
  isSynced: true,
995
660
  });
996
661
  return;
997
662
  }
998
- // Resync: drop the local tail and re-page from the top per the pinned contract.
999
- // Clear the derived caches too so a stale overlay/original-row can't resurrect a
1000
- // message the re-page will reload fresh (with its baked-in moderation state).
1001
- // KEEP un-acked optimistic rows — they aren't server history yet; their pending
1002
- // sends were already re-sent in onOpen and reconcile when the DO echoes them.
1003
663
  this.overlays.clear();
1004
664
  this.originalRows.clear();
1005
665
  this.resumeSeq = 0;
@@ -1014,29 +674,17 @@ export class ChannelClient {
1014
674
  readSeq: typeof snap.readSeq === 'number' ? snap.readSeq : null,
1015
675
  });
1016
676
  this.diag?.(DIAG.RESYNC_DROPPED_TAIL, {
1017
- // The client REMOVED rows from the UI here — the counterpart of an upsert.
1018
677
  removedCount: priorCount - keptOptimistic.length,
1019
678
  keptOptimistic: keptOptimistic.length,
1020
679
  });
1021
680
  this.setState({ messages: keptOptimistic, hasOlder: true, isFetchingOlder: false });
1022
681
  this.requestHistory(null, 'resync');
1023
682
  }
1024
- /**
1025
- * Apply one server row (a live `message` frame or a resume-delta row) and record
1026
- * the decision the client made about it.
1027
- *
1028
- * Diagnostics bounding: a LIVE frame always emits one `chat_client_frame_applied`
1029
- * line; a DELTA row emits one only when it is materially interesting (carries a
1030
- * moderation kind, or reconciles one of our optimistic sends) — a 500-row backlog
1031
- * is otherwise summarized by `chat_client_resume_result` rather than spamming 500
1032
- * lines.
1033
- */
1034
683
  applyMessage(row, source) {
1035
684
  if (!row || typeof row.seq !== 'number') {
1036
685
  this.diag?.(DIAG.FRAME_DROPPED, { kind: 'message', source, reason: 'missing-seq' });
1037
686
  return;
1038
687
  }
1039
- // Read the pre-merge view ONLY when diagnostics are on (these are O(n) scans).
1040
688
  const prior = this.diag ? this.priorRowFacts(row) : null;
1041
689
  const cursorBefore = this.resumeSeq;
1042
690
  const mapped = this.renderRow(row);
@@ -1063,11 +711,6 @@ export class ChannelClient {
1063
711
  cursorAfter: this.resumeSeq,
1064
712
  });
1065
713
  }
1066
- /**
1067
- * The pre-merge view of a row's seq: whether we already hold that seq, and whether
1068
- * this row reconciles a live optimistic send.
1069
- * Diagnostics-only — never called when diagnostics are off.
1070
- */
1071
714
  priorRowFacts(row) {
1072
715
  const messageId = seqToMessageId(row.seq);
1073
716
  const existing = this.state.messages.find((m) => m.messageId === messageId);
@@ -1077,31 +720,18 @@ export class ChannelClient {
1077
720
  this.state.messages.some((m) => m.meta?.optimistic && m.meta?.clientMessageId === row.clientMessageId)),
1078
721
  };
1079
722
  }
1080
- /**
1081
- * Merge a server message: reconcile an optimistic echo by clientMessageId (swap
1082
- * the `optimistic:` row for the canonical seq row), or insert/replace by seq.
1083
- * Keeps the list sorted ascending by seq with optimistic rows at the tail.
1084
- */
1085
723
  mergeMessage(mapped, row) {
1086
- // If this canonical row carries a clientMessageId we still have pending (the
1087
- // sender's own broadcast, or a resume delta that included our send), the send
1088
- // landed — stop tracking it for reconnect resend (and cancel any retry timer).
1089
724
  if (row.clientMessageId)
1090
725
  this.clearPendingSend(row.clientMessageId);
1091
726
  const next = this.state.messages.filter((m) => m.messageId !== mapped.messageId &&
1092
- // drop the optimistic placeholder this server row reconciles
1093
727
  (m.meta?.clientMessageId !== row.clientMessageId || !m.meta?.optimistic));
1094
728
  next.push(mapped);
1095
729
  next.sort(sortBySeq);
1096
730
  this.setState({ messages: next });
1097
731
  }
1098
732
  applyAck(payload) {
1099
- // A read-ack echo carries readSeq only — nothing to reconcile locally.
1100
733
  if (payload.clientMessageId == null || typeof payload.seq !== 'number')
1101
734
  return;
1102
- // Reconcile the optimistic echo: stamp the server seq onto it. The canonical
1103
- // `message` broadcast (which the sender also receives) replaces it fully; this
1104
- // ack guarantees the seq even if the broadcast is dropped/reordered.
1105
735
  const seq = payload.seq;
1106
736
  const cmid = payload.clientMessageId;
1107
737
  this.diag?.(DIAG.SEND_ACK, {
@@ -1111,15 +741,10 @@ export class ChannelClient {
1111
741
  cursorAfter: seq > this.resumeSeq ? seq : this.resumeSeq,
1112
742
  wasPending: this.pendingSends.has(cmid),
1113
743
  });
1114
- // The send is acked — stop tracking it for reconnect resend (and cancel any
1115
- // per-send retry timer so a scheduled resend never fires after it landed).
1116
744
  this.clearPendingSend(cmid);
1117
745
  const next = this.state.messages.map((m) => {
1118
746
  if (!m.meta?.optimistic || m.meta?.clientMessageId !== cmid)
1119
747
  return m;
1120
- // Strip any failed markers — a late ack (e.g. a retry raced the cap, or a
1121
- // correlated rejection was superseded) means the send actually landed, so the
1122
- // row must render as delivered, not failed.
1123
748
  const { sendFailed: _sendFailed, sendFailureCode: _sendFailureCode, sendRetryable: _sendRetryable, ...restMeta } = m.meta;
1124
749
  return {
1125
750
  ...m,
@@ -1134,15 +759,8 @@ export class ChannelClient {
1134
759
  }
1135
760
  applyHistoryPage(rows) {
1136
761
  const cursorBefore = this.resumeSeq;
1137
- // The socket answers history requests in the order they were sent; the page answering a
1138
- // resync is the one that makes the channel current again.
1139
762
  const answersResync = this.historyRequests.shift() === 'resync';
1140
763
  const mapped = rows.map((r) => this.renderRow(r));
1141
- // Mirror mergeMessage's optimistic reconciliation: a history row carrying a
1142
- // clientMessageId we still hold as an optimistic placeholder IS that send (the
1143
- // DO stored it; the ack was lost). Drop the placeholder — keyed `optimistic:{cmid}`,
1144
- // it would otherwise survive alongside its seq-keyed server row as a duplicate
1145
- // bubble after a resync + re-page — and stop tracking it for resend.
1146
764
  const incomingCmids = new Set();
1147
765
  for (const r of rows) {
1148
766
  if (r.clientMessageId) {
@@ -1155,7 +773,7 @@ export class ChannelClient {
1155
773
  if (m.meta?.optimistic &&
1156
774
  typeof m.meta?.clientMessageId === 'string' &&
1157
775
  incomingCmids.has(m.meta.clientMessageId)) {
1158
- continue; // reconciled by the incoming server row
776
+ continue;
1159
777
  }
1160
778
  bySeq.set(m.messageId, m);
1161
779
  }
@@ -1169,9 +787,6 @@ export class ChannelClient {
1169
787
  messages: merged,
1170
788
  isFetchingOlder: false,
1171
789
  hasOlder: rows.length >= HISTORY_PAGE_MAX,
1172
- // The first history page — INCLUDING an empty one — is a successful authoritative
1173
- // load, so it ends initial loading. An older page arrives only after it, so this
1174
- // never runs a second time.
1175
790
  ...(this.state.hasLoadedInitialData ? {} : this.initialDataLoaded()),
1176
791
  ...(answersResync ? { isSynced: true } : {}),
1177
792
  });
@@ -1202,22 +817,13 @@ export class ChannelClient {
1202
817
  this.setState({ typing: this.state.typing.filter((u) => u !== uid) });
1203
818
  }, TYPING_COALESCE_MS * 2));
1204
819
  }
1205
- // ---- outbound actions ----
1206
820
  sendFrame(type, payload) {
1207
821
  if (!this.socket || !this.socket.isOpen)
1208
822
  return false;
1209
823
  this.socket.send(buildFrame(type, payload));
1210
824
  return true;
1211
825
  }
1212
- /**
1213
- * Optimistic send: render the echo immediately keyed by clientMessageId, then
1214
- * emit the `send` frame. The server `ack`/`message` reconciles on the seq.
1215
- */
1216
826
  send(args) {
1217
- // C-B8: write the frame FIRST and render the optimistic echo ONLY if it was
1218
- // actually sent over an open socket. A closed socket returns false — no phantom
1219
- // "sent" bubble, and the caller keeps the composer text — so a disconnect between
1220
- // render and click can't silently swallow the message.
1221
827
  const sent = this.sendFrame(CLIENT_KINDS.SEND, {
1222
828
  clientMessageId: args.clientMessageId,
1223
829
  text: args.text,
@@ -1234,8 +840,6 @@ export class ChannelClient {
1234
840
  return false;
1235
841
  }
1236
842
  const now = this.timers.now();
1237
- // Track the un-acked send so a reconnect re-sends it with the same
1238
- // clientMessageId (the DO dedups) — until the ack/echo clears it.
1239
843
  this.pendingSends.set(args.clientMessageId, {
1240
844
  clientMessageId: args.clientMessageId,
1241
845
  text: args.text,
@@ -1260,17 +864,10 @@ export class ChannelClient {
1260
864
  });
1261
865
  return true;
1262
866
  }
1263
- /**
1264
- * Explicit read ack (delivery != read). `focused` => the channel is open + at latest.
1265
- * Remembers the cursor (re-sent on reconnect) and returns whether the frame was
1266
- * actually sent over an open socket, so the caller only advances its local
1267
- * already-acked marker on a real send (M2).
1268
- */
1269
867
  readAck(readSeq, focused) {
1270
868
  this.lastReadAck = { readSeq, focused };
1271
869
  return this.sendFrame(CLIENT_KINDS.READ_ACK, { readSeq, focused });
1272
870
  }
1273
- /** Coalesced typing signal: at most one frame per TYPING_COALESCE_MS. */
1274
871
  typing() {
1275
872
  const now = this.timers.now();
1276
873
  if (now - this.lastTypingSentAt < TYPING_COALESCE_MS)
@@ -1287,14 +884,12 @@ export class ChannelClient {
1287
884
  this.sendFrame(CLIENT_KINDS.PRESENCE_UNSUBSCRIBE, {});
1288
885
  this.setState({ presence: [] });
1289
886
  }
1290
- /** Fetch the page of history older than the oldest loaded message (epoch-aware by seq). */
1291
887
  fetchOlder() {
1292
888
  if (this.state.isFetchingOlder || !this.state.hasOlder)
1293
889
  return;
1294
890
  const oldest = this.state.messages.find((m) => typeof m.meta?.seq === 'number');
1295
891
  const beforeSeq = oldest && typeof oldest.meta?.seq === 'number' ? oldest.meta.seq : null;
1296
892
  this.setState({ isFetchingOlder: true });
1297
- // Unsent (the socket is down): nothing will answer, so it is not in flight.
1298
893
  if (!this.requestHistory(beforeSeq, 'older'))
1299
894
  this.setState({ isFetchingOlder: false });
1300
895
  }
@@ -1305,15 +900,11 @@ export class ChannelClient {
1305
900
  this.diag?.(DIAG.HISTORY_REQUEST, { beforeSeq, limit: HISTORY_PAGE_MAX, sent });
1306
901
  return sent;
1307
902
  }
1308
- // ---- heartbeat (20s) + dead-socket watchdog ----
1309
903
  startHeartbeat() {
1310
904
  this.stopHeartbeat();
1311
905
  const tick = () => {
1312
906
  if (!this.socket || !this.socket.isOpen)
1313
907
  return;
1314
- // Liveness is checked BEFORE the write: a socket already proven dead must
1315
- // not be handed another frame. Closing here does not reschedule — onClose
1316
- // stops the heartbeat and owns what happens next.
1317
908
  if (this.isInboundSilent())
1318
909
  return this.closeDeadSocket();
1319
910
  this.sendFrame(CLIENT_KINDS.HEARTBEAT, {});
@@ -1327,23 +918,11 @@ export class ChannelClient {
1327
918
  this.heartbeatTimer = null;
1328
919
  }
1329
920
  }
1330
- /** True when the live socket has produced no parseable frame for too long. */
1331
921
  isInboundSilent() {
1332
922
  if (this.lastInboundAt == null)
1333
923
  return false;
1334
924
  return this.timers.now() - this.lastInboundAt >= INBOUND_SILENCE_LIMIT_MS;
1335
925
  }
1336
- /**
1337
- * The channel DO auto-acks every heartbeat, so silence past
1338
- * {@link INBOUND_SILENCE_LIMIT_MS} means this socket is dead even though no
1339
- * `onclose` ever fired (the half-open-connection case that otherwise strands
1340
- * the UI on a socket that will never deliver another message).
1341
- *
1342
- * Close it LOCALLY and deliberately WITHOUT setting `closedByUs`, so the close
1343
- * lands on `onClose`'s ordinary transient path — which already owns the
1344
- * reconnect scheduling, the backoff, and the resume-cursor recovery. Nothing
1345
- * about reconnect policy is duplicated here.
1346
- */
1347
926
  closeDeadSocket() {
1348
927
  const socket = this.socket;
1349
928
  this.diag?.(DIAG.SOCKET_LIVENESS_TIMEOUT, {
@@ -1356,23 +935,18 @@ export class ChannelClient {
1356
935
  this.lastInboundAt = null;
1357
936
  socket?.close(WATCHDOG_CLOSE_CODE, 'client liveness timeout');
1358
937
  }
1359
- // ---- teardown ----
1360
- /** Permanently close this client (auth-user switch / unmount). Idempotent. */
1361
938
  close() {
1362
939
  this.closedByUs = true;
1363
940
  this.lifecycle += 1;
1364
941
  this.controller.close();
1365
942
  this.initialLoad.dispose();
1366
943
  this.stopHeartbeat();
1367
- // The heartbeat timer IS the watchdog's timer — stopping it above ends both.
1368
- // Clearing the record too means a later connect() starts from a clean deadline.
1369
944
  this.lastInboundAt = null;
1370
945
  for (const t of this.typingTimers.values())
1371
946
  this.timers.clearTimeout(t);
1372
947
  this.typingTimers.clear();
1373
948
  this.overlays.clear();
1374
949
  this.originalRows.clear();
1375
- // Cancel every per-send retry timer before dropping the tracking map.
1376
950
  for (const pending of this.pendingSends.values())
1377
951
  this.clearSendRetryTimer(pending);
1378
952
  this.pendingSends.clear();
@@ -1385,7 +959,6 @@ export class ChannelClient {
1385
959
  this.membershipRetryTimer = null;
1386
960
  }
1387
961
  if (this.socket) {
1388
- // Detach first: the socket's own late close is not this lifecycle's event.
1389
962
  const socket = this.socket;
1390
963
  this.socket = null;
1391
964
  socket.close(1000, 'client teardown');