@ttt-productions/chat-react 0.13.7 → 0.13.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/ChatNameResolverContext.d.ts +24 -16
- package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
- package/dist/context/ChatNameResolverContext.js +15 -19
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts +9 -0
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +10 -10
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +32 -0
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +114 -15
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/inbox-client.d.ts +50 -6
- package/dist/realtime/inbox-client.d.ts.map +1 -1
- package/dist/realtime/inbox-client.js +133 -18
- package/dist/realtime/inbox-client.js.map +1 -1
- package/dist/realtime/index.d.ts +3 -3
- package/dist/realtime/index.d.ts.map +1 -1
- package/dist/realtime/index.js +1 -1
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/initial-load-budget.d.ts +31 -0
- package/dist/realtime/initial-load-budget.d.ts.map +1 -0
- package/dist/realtime/initial-load-budget.js +69 -0
- package/dist/realtime/initial-load-budget.js.map +1 -0
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +3 -2
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +13 -0
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +18 -0
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/transport.d.ts +6 -3
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +1 -0
- package/dist/realtime/transport.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +4 -0
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +7 -8
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +11 -6
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +2 -2
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +16 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/ChatShell.d.ts +27 -1
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +27 -7
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +4 -2
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +1 -1
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +10 -6
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +7 -1
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +5 -4
- package/dist/ui/MessageList.js.map +1 -1
- package/dist/ui/SenderName.d.ts +9 -0
- package/dist/ui/SenderName.d.ts.map +1 -0
- package/dist/ui/SenderName.js +16 -0
- package/dist/ui/SenderName.js.map +1 -0
- package/package.json +9 -10
- package/dist/hooks/useChatThreadAccess.d.ts +0 -8
- package/dist/hooks/useChatThreadAccess.d.ts.map +0 -1
- package/dist/hooks/useChatThreadAccess.js +0 -19
- package/dist/hooks/useChatThreadAccess.js.map +0 -1
|
@@ -19,6 +19,7 @@ import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot
|
|
|
19
19
|
import { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, } from './map.js';
|
|
20
20
|
import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
|
|
21
21
|
import { defaultTimers, isChatAccessDeniedError, isTerminalErrorCode, TERMINAL_ERROR_CODE, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, MAX_PENDING_SEND_ATTEMPTS, PENDING_SEND_MAX_AGE_MS, SERVER_RETRYABLE_MAX_AGE_MS, SEND_RETRY_MIN_DELAY_MS, SEND_RETRY_MAX_DELAY_MS, } from './shared.js';
|
|
22
|
+
import { InitialLoadBudget } from './initial-load-budget.js';
|
|
22
23
|
const INITIAL = {
|
|
23
24
|
status: 'idle',
|
|
24
25
|
messages: [],
|
|
@@ -28,6 +29,7 @@ const INITIAL = {
|
|
|
28
29
|
hasOlder: true,
|
|
29
30
|
lastErrorCode: null,
|
|
30
31
|
hasLoadedInitialData: false,
|
|
32
|
+
initialLoadFailed: false,
|
|
31
33
|
};
|
|
32
34
|
/**
|
|
33
35
|
* Retryable error-frame code the channel DO sends when a send arrives BEFORE the
|
|
@@ -99,6 +101,15 @@ export class ChannelClient {
|
|
|
99
101
|
/** Single scheduled re-send for the membership-pending window (never stacks). */
|
|
100
102
|
membershipRetryTimer = null;
|
|
101
103
|
closedByUs = false;
|
|
104
|
+
/**
|
|
105
|
+
* The current lifecycle's number, advanced by every `connect()` and `close()`. A grant
|
|
106
|
+
* that resolves, or a timer that fires, for an older lifecycle is dropped — so a
|
|
107
|
+
* close → connect while an earlier grant is still minting can never open a second
|
|
108
|
+
* socket or touch the new lifecycle's state (FRONTEND-108).
|
|
109
|
+
*/
|
|
110
|
+
lifecycle = 0;
|
|
111
|
+
/** Reports the first open as failed once its attempts have failed for the budget. */
|
|
112
|
+
initialLoad;
|
|
102
113
|
/**
|
|
103
114
|
* Per-seq moderation overlay (the max-revision wins). A `revision` frame writes
|
|
104
115
|
* here keyed by `messageSeq` so it applies whether it arrives BEFORE its base
|
|
@@ -138,6 +149,7 @@ export class ChannelClient {
|
|
|
138
149
|
this.timers = config.timers ?? defaultTimers;
|
|
139
150
|
this.controller = createReconnectController(config.reconnect ?? {});
|
|
140
151
|
this.diag = createDiagnosticsEmitter(config.diagnostics);
|
|
152
|
+
this.initialLoad = new InitialLoadBudget(this.timers, () => this.reportInitialLoadFailed());
|
|
141
153
|
}
|
|
142
154
|
// ---- observable store ----
|
|
143
155
|
getState() {
|
|
@@ -154,6 +166,37 @@ export class ChannelClient {
|
|
|
154
166
|
}
|
|
155
167
|
// ---- connection lifecycle ----
|
|
156
168
|
async connect() {
|
|
169
|
+
return this.startLifecycle(true);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Retry now (the first-open failure's Retry). While a reconnect is waiting out its
|
|
173
|
+
* backoff, the wait is cancelled and a socket opens at once; a lifecycle that stopped
|
|
174
|
+
* (reconnect gave up, or the socket was revoked) starts again; an attempt already in
|
|
175
|
+
* flight is left to finish. Either way the first-open failure clears, so the opening state
|
|
176
|
+
* shows while the retry runs. The spent budget stays spent: a retry that fails is reported
|
|
177
|
+
* again at once, and one that hangs a full budget later.
|
|
178
|
+
*/
|
|
179
|
+
retryNow() {
|
|
180
|
+
if (!this.state.hasLoadedInitialData)
|
|
181
|
+
this.initialLoad.manualRetry();
|
|
182
|
+
if (this.state.status === 'closed') {
|
|
183
|
+
void this.startLifecycle(false);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (this.reconnectTimer == null) {
|
|
187
|
+
if (this.state.initialLoadFailed)
|
|
188
|
+
this.setState({ initialLoadFailed: false });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.timers.clearTimeout(this.reconnectTimer);
|
|
192
|
+
this.reconnectTimer = null;
|
|
193
|
+
this.controller.reset();
|
|
194
|
+
this.controller.start();
|
|
195
|
+
this.reconnectCause = 'manual-retry';
|
|
196
|
+
this.setState({ status: 'reconnecting', initialLoadFailed: false });
|
|
197
|
+
void this.openSocket();
|
|
198
|
+
}
|
|
199
|
+
async startLifecycle(freshBudget) {
|
|
157
200
|
// Idempotency guard (C-B7): connect() only starts a lifecycle from a fresh
|
|
158
201
|
// (idle) or fully torn-down (closed) state. A second connect() while one is
|
|
159
202
|
// already connecting / open / reconnecting is a no-op, so a stray double-owner
|
|
@@ -180,12 +223,27 @@ export class ChannelClient {
|
|
|
180
223
|
if (isTerminalErrorCode(this.state.lastErrorCode))
|
|
181
224
|
this.setState({ lastErrorCode: null });
|
|
182
225
|
this.closedByUs = false;
|
|
226
|
+
this.lifecycle += 1;
|
|
183
227
|
this.reconnectCause = 'initial';
|
|
228
|
+
if (!this.state.hasLoadedInitialData)
|
|
229
|
+
this.initialLoad.begin(freshBudget);
|
|
184
230
|
this.controller.start();
|
|
185
|
-
this.setState({ status: 'connecting' });
|
|
231
|
+
this.setState({ status: 'connecting', initialLoadFailed: false });
|
|
186
232
|
await this.openSocket();
|
|
187
233
|
}
|
|
234
|
+
/** The first open has failed for long enough to say so (see `initialLoadFailed`). */
|
|
235
|
+
reportInitialLoadFailed() {
|
|
236
|
+
if (this.state.hasLoadedInitialData || this.state.initialLoadFailed)
|
|
237
|
+
return;
|
|
238
|
+
this.setState({ initialLoadFailed: true });
|
|
239
|
+
}
|
|
240
|
+
/** The first authoritative data is applied; it ends any first-open failure. */
|
|
241
|
+
initialDataLoaded() {
|
|
242
|
+
this.initialLoad.loaded();
|
|
243
|
+
return { hasLoadedInitialData: true, initialLoadFailed: false };
|
|
244
|
+
}
|
|
188
245
|
async openSocket() {
|
|
246
|
+
const lifecycle = this.lifecycle;
|
|
189
247
|
this.connectAttempt += 1;
|
|
190
248
|
this.diag?.(DIAG.CONNECT_ATTEMPT, {
|
|
191
249
|
attempt: this.connectAttempt,
|
|
@@ -199,6 +257,9 @@ export class ChannelClient {
|
|
|
199
257
|
grantToken = await this.config.grantProvider();
|
|
200
258
|
}
|
|
201
259
|
catch (err) {
|
|
260
|
+
// A grant for a lifecycle that has since been closed or restarted answers nobody.
|
|
261
|
+
if (lifecycle !== this.lifecycle)
|
|
262
|
+
return;
|
|
202
263
|
// A TERMINAL access denial (the app's grant provider translated an
|
|
203
264
|
// authoritative policy "no" into the package-owned ChatAccessDeniedError)
|
|
204
265
|
// must NOT reconnect — the next mint would just re-deny, stranding the shell
|
|
@@ -210,24 +271,44 @@ export class ChannelClient {
|
|
|
210
271
|
if (terminal)
|
|
211
272
|
return this.denyAccessTerminally();
|
|
212
273
|
this.reconnectCause = 'grant-error';
|
|
274
|
+
if (!this.state.hasLoadedInitialData)
|
|
275
|
+
this.initialLoad.noteFailedAttempt();
|
|
213
276
|
return this.scheduleReconnect();
|
|
214
277
|
}
|
|
215
|
-
|
|
278
|
+
// A grant minted for an earlier lifecycle must never open a socket in this one.
|
|
279
|
+
if (lifecycle !== this.lifecycle || this.closedByUs)
|
|
216
280
|
return;
|
|
217
281
|
// Socket REPLACEMENT: the previous socket's liveness record dies with it, so
|
|
218
282
|
// the watchdog never judges a new socket against an old deadline.
|
|
219
283
|
this.lastInboundAt = null;
|
|
220
284
|
const url = `${this.config.endpoint.replace(/\/$/, '')}/channel`;
|
|
221
|
-
|
|
285
|
+
// Every callback is fenced to THIS socket: once it has been replaced or torn down,
|
|
286
|
+
// its late open, frames, and close are ignored.
|
|
287
|
+
let socket = null;
|
|
288
|
+
const isCurrent = () => socket !== null && this.socket === socket;
|
|
289
|
+
socket = this.config.socketFactory({
|
|
222
290
|
url,
|
|
223
291
|
grantToken,
|
|
224
292
|
handlers: {
|
|
225
|
-
onOpen: () =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
293
|
+
onOpen: () => {
|
|
294
|
+
if (isCurrent())
|
|
295
|
+
this.onOpen();
|
|
296
|
+
},
|
|
297
|
+
onMessage: (data) => {
|
|
298
|
+
if (isCurrent())
|
|
299
|
+
this.onMessage(data);
|
|
300
|
+
},
|
|
301
|
+
onClose: (code, reason) => {
|
|
302
|
+
if (isCurrent())
|
|
303
|
+
this.onClose(code, reason);
|
|
304
|
+
},
|
|
305
|
+
onError: () => {
|
|
306
|
+
if (isCurrent())
|
|
307
|
+
this.onError();
|
|
308
|
+
},
|
|
229
309
|
},
|
|
230
310
|
});
|
|
311
|
+
this.socket = socket;
|
|
231
312
|
}
|
|
232
313
|
onOpen() {
|
|
233
314
|
this.controller.onOpen();
|
|
@@ -575,6 +656,9 @@ export class ChannelClient {
|
|
|
575
656
|
this.socket = null;
|
|
576
657
|
this.lastInboundAt = null;
|
|
577
658
|
this.lastCloseCode = code;
|
|
659
|
+
// An older-history request in flight died with the socket; nothing will answer it.
|
|
660
|
+
if (this.state.isFetchingOlder)
|
|
661
|
+
this.setState({ isFetchingOlder: false });
|
|
578
662
|
if (this.closedByUs) {
|
|
579
663
|
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed', resumeSeq: this.resumeSeq });
|
|
580
664
|
this.setState({ status: 'closed' });
|
|
@@ -599,9 +683,13 @@ export class ChannelClient {
|
|
|
599
683
|
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked', resumeSeq: this.resumeSeq });
|
|
600
684
|
this.failAllPendingSends();
|
|
601
685
|
this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.REVOKED });
|
|
686
|
+
// Revoked before anything loaded: the first open has failed, and nothing retries it.
|
|
687
|
+
this.reportInitialLoadFailed();
|
|
602
688
|
return;
|
|
603
689
|
}
|
|
604
690
|
this.reconnectCause = 'transient-close';
|
|
691
|
+
if (!this.state.hasLoadedInitialData)
|
|
692
|
+
this.initialLoad.noteFailedAttempt();
|
|
605
693
|
this.diag?.(DIAG.SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect', resumeSeq: this.resumeSeq });
|
|
606
694
|
this.scheduleReconnect();
|
|
607
695
|
}
|
|
@@ -618,12 +706,15 @@ export class ChannelClient {
|
|
|
618
706
|
// an eternal "Sending…"; flip them to the visible failed/retry state.
|
|
619
707
|
this.failAllPendingSends();
|
|
620
708
|
this.setState({ status: 'closed' });
|
|
709
|
+
// Gave up before anything loaded: nothing retries the first open any more.
|
|
710
|
+
this.reportInitialLoadFailed();
|
|
621
711
|
return;
|
|
622
712
|
}
|
|
623
713
|
this.setState({ status: 'reconnecting' });
|
|
714
|
+
const lifecycle = this.lifecycle;
|
|
624
715
|
this.reconnectTimer = this.timers.setTimeout(() => {
|
|
625
716
|
this.reconnectTimer = null;
|
|
626
|
-
if (this.closedByUs)
|
|
717
|
+
if (this.closedByUs || lifecycle !== this.lifecycle)
|
|
627
718
|
return;
|
|
628
719
|
void this.openSocket();
|
|
629
720
|
}, delay);
|
|
@@ -640,6 +731,7 @@ export class ChannelClient {
|
|
|
640
731
|
denyAccessTerminally() {
|
|
641
732
|
this.closedByUs = true;
|
|
642
733
|
this.controller.close();
|
|
734
|
+
this.initialLoad.dispose();
|
|
643
735
|
this.failAllPendingSends();
|
|
644
736
|
this.setState({ status: 'closed', lastErrorCode: TERMINAL_ERROR_CODE.ACCESS_DENIED });
|
|
645
737
|
}
|
|
@@ -838,7 +930,7 @@ export class ChannelClient {
|
|
|
838
930
|
// caught up) — end initial loading. A resync snapshot instead defers to the
|
|
839
931
|
// re-paged first history page below, so it must NOT end it here.
|
|
840
932
|
if (!this.state.hasLoadedInitialData)
|
|
841
|
-
this.setState(
|
|
933
|
+
this.setState(this.initialDataLoaded());
|
|
842
934
|
return;
|
|
843
935
|
}
|
|
844
936
|
// Resync: drop the local tail and re-page from the top per the pinned contract.
|
|
@@ -1003,9 +1095,9 @@ export class ChannelClient {
|
|
|
1003
1095
|
isFetchingOlder: false,
|
|
1004
1096
|
hasOlder: rows.length >= HISTORY_PAGE_MAX,
|
|
1005
1097
|
// The first history page — INCLUDING an empty one — is a successful authoritative
|
|
1006
|
-
// load, so it ends initial loading.
|
|
1007
|
-
//
|
|
1008
|
-
hasLoadedInitialData:
|
|
1098
|
+
// load, so it ends initial loading. An older page arrives only after it, so this
|
|
1099
|
+
// never runs a second time.
|
|
1100
|
+
...(this.state.hasLoadedInitialData ? {} : this.initialDataLoaded()),
|
|
1009
1101
|
});
|
|
1010
1102
|
if (!this.diag)
|
|
1011
1103
|
return;
|
|
@@ -1126,11 +1218,14 @@ export class ChannelClient {
|
|
|
1126
1218
|
const oldest = this.state.messages.find((m) => typeof m.meta?.seq === 'number');
|
|
1127
1219
|
const beforeSeq = oldest && typeof oldest.meta?.seq === 'number' ? oldest.meta.seq : null;
|
|
1128
1220
|
this.setState({ isFetchingOlder: true });
|
|
1129
|
-
|
|
1221
|
+
// Unsent (the socket is down): nothing will answer, so it is not in flight.
|
|
1222
|
+
if (!this.requestHistory(beforeSeq))
|
|
1223
|
+
this.setState({ isFetchingOlder: false });
|
|
1130
1224
|
}
|
|
1131
1225
|
requestHistory(beforeSeq) {
|
|
1132
1226
|
const sent = this.sendFrame(CLIENT_FRAME.HISTORY, { beforeSeq, limit: HISTORY_PAGE_MAX });
|
|
1133
1227
|
this.diag?.(DIAG.HISTORY_REQUEST, { beforeSeq, limit: HISTORY_PAGE_MAX, sent });
|
|
1228
|
+
return sent;
|
|
1134
1229
|
}
|
|
1135
1230
|
// ---- heartbeat (20s) + dead-socket watchdog ----
|
|
1136
1231
|
startHeartbeat() {
|
|
@@ -1187,7 +1282,9 @@ export class ChannelClient {
|
|
|
1187
1282
|
/** Permanently close this client (auth-user switch / unmount). Idempotent. */
|
|
1188
1283
|
close() {
|
|
1189
1284
|
this.closedByUs = true;
|
|
1285
|
+
this.lifecycle += 1;
|
|
1190
1286
|
this.controller.close();
|
|
1287
|
+
this.initialLoad.dispose();
|
|
1191
1288
|
this.stopHeartbeat();
|
|
1192
1289
|
// The heartbeat timer IS the watchdog's timer — stopping it above ends both.
|
|
1193
1290
|
// Clearing the record too means a later connect() starts from a clean deadline.
|
|
@@ -1210,10 +1307,12 @@ export class ChannelClient {
|
|
|
1210
1307
|
this.membershipRetryTimer = null;
|
|
1211
1308
|
}
|
|
1212
1309
|
if (this.socket) {
|
|
1213
|
-
|
|
1310
|
+
// Detach first: the socket's own late close is not this lifecycle's event.
|
|
1311
|
+
const socket = this.socket;
|
|
1214
1312
|
this.socket = null;
|
|
1313
|
+
socket.close(1000, 'client teardown');
|
|
1215
1314
|
}
|
|
1216
|
-
this.setState({ status: 'closed', typing: [], presence: [] });
|
|
1315
|
+
this.setState({ status: 'closed', typing: [], presence: [], isFetchingOlder: false });
|
|
1217
1316
|
}
|
|
1218
1317
|
}
|
|
1219
1318
|
function sortBySeq(a, b) {
|