@ttt-productions/chat-react 0.13.16 → 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.
- package/README.md +3 -3
- package/dist/context/ChatNameResolverContext.d.ts +0 -23
- package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
- package/dist/context/ChatNameResolverContext.js +0 -16
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts +0 -4
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +0 -10
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +0 -248
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +5 -432
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/diagnostics.d.ts +0 -62
- package/dist/realtime/diagnostics.d.ts.map +1 -1
- package/dist/realtime/diagnostics.js +4 -89
- package/dist/realtime/diagnostics.js.map +1 -1
- package/dist/realtime/inbox-client.d.ts +0 -71
- package/dist/realtime/inbox-client.d.ts.map +1 -1
- package/dist/realtime/inbox-client.js +0 -98
- package/dist/realtime/inbox-client.js.map +1 -1
- package/dist/realtime/initial-load-budget.d.ts +1 -16
- package/dist/realtime/initial-load-budget.d.ts.map +1 -1
- package/dist/realtime/initial-load-budget.js +1 -20
- package/dist/realtime/initial-load-budget.js.map +1 -1
- package/dist/realtime/map.d.ts +0 -26
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +0 -33
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +0 -78
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +2 -76
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/socket.d.ts +0 -15
- package/dist/realtime/socket.d.ts.map +1 -1
- package/dist/realtime/socket.js +0 -4
- package/dist/realtime/socket.js.map +1 -1
- package/dist/realtime/transport.d.ts +0 -23
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +0 -13
- package/dist/realtime/transport.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +0 -17
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +0 -22
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +0 -56
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +0 -16
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +0 -38
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +1 -1
- package/dist/ui/ChatShell.d.ts +0 -42
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +2 -39
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts +0 -13
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +0 -13
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +0 -4
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +0 -32
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +0 -23
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +2 -12
- package/dist/ui/MessageList.js.map +1 -1
- package/dist/ui/MessageText.d.ts +0 -13
- package/dist/ui/MessageText.d.ts.map +1 -1
- package/dist/ui/MessageText.js.map +1 -1
- package/dist/ui/SenderName.d.ts +0 -5
- package/dist/ui/SenderName.d.ts.map +1 -1
- package/dist/ui/SenderName.js +0 -5
- package/dist/ui/SenderName.js.map +1 -1
- package/dist/ui/menus.d.ts.map +1 -1
- package/dist/ui/menus.js +1 -3
- package/dist/ui/menus.js.map +1 -1
- package/package.json +10 -10
- 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
|
-
|
|
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
|
-
//
|
|
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;
|
|
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');
|