@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.
- package/README.md +3 -3
- package/dist/context/ChatNameResolverContext.d.ts +3 -26
- package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
- package/dist/context/ChatNameResolverContext.js +3 -19
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/firestore/queries.js +1 -1
- package/dist/firestore/queries.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts +3 -7
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +20 -22
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts +32 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -14
- 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 +102 -464
- 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 +29 -109
- 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 -28
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +3 -37
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +0 -79
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +2 -80
- 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 +1 -15
- package/dist/realtime/socket.js.map +1 -1
- package/dist/realtime/transport.d.ts +1 -25
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +1 -14
- package/dist/realtime/transport.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +4 -21
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +5 -27
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +0 -58
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +0 -22
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +3 -41
- 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 +5 -47
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +39 -73
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts +1 -14
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +12 -25
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +2 -6
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +26 -56
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +3 -26
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +15 -28
- 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 +3 -8
- package/dist/ui/SenderName.js.map +1 -1
- package/dist/ui/menus.d.ts +1 -1
- package/dist/ui/menus.d.ts.map +1 -1
- package/dist/ui/menus.js +5 -7
- package/dist/ui/menus.js.map +1 -1
- package/package.json +10 -10
- package/src/styles/chat.css +2 -15
- package/dist/realtime/index.d.ts +0 -19
- package/dist/realtime/index.d.ts.map +0 -1
- package/dist/realtime/index.js +0 -15
- package/dist/realtime/index.js.map +0 -1
|
@@ -1,24 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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(
|
|
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 &&
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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
|
-
//
|
|
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
|
-
|
|
818
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
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 {
|
|
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 &&
|
|
1093
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
876
|
+
this.sendFrame(CLIENT_KINDS.TYPING, {});
|
|
1215
877
|
}
|
|
1216
878
|
presenceSubscribe() {
|
|
1217
879
|
this.presenceSubscribed = true;
|
|
1218
|
-
this.sendFrame(
|
|
880
|
+
this.sendFrame(CLIENT_KINDS.PRESENCE_SUBSCRIBE, {});
|
|
1219
881
|
}
|
|
1220
882
|
presenceUnsubscribe() {
|
|
1221
883
|
this.presenceSubscribed = false;
|
|
1222
|
-
this.sendFrame(
|
|
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(
|
|
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(
|
|
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');
|