@ttt-productions/chat-react 0.13.15 → 0.13.17

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