@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,144 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// The E2E/browser harness can already capture the raw WebSocket frames; what it
|
|
4
|
-
// cannot see is what the CLIENT DECIDED about them — which frame it applied,
|
|
5
|
-
// which it dropped and why, what resume cursor it sent, and whether a send was
|
|
6
|
-
// reconciled or failed. This module is that missing half: a bounded, structured,
|
|
7
|
-
// opt-in decision log.
|
|
8
|
-
//
|
|
9
|
-
// The connection-time, resume, presence, and socket-timing instruments are the
|
|
10
|
-
// baseline-campaign metric families — they stay. Only the attachment-serving
|
|
11
|
-
// instruments were retired with the chat-attachment architecture.
|
|
12
|
-
//
|
|
13
|
-
// Rules this module exists to enforce:
|
|
14
|
-
// - OFF BY DEFAULT and zero behavior change. When the option is absent the
|
|
15
|
-
// factory returns `null`, every call site is `this.diag?.(...)`, and JS
|
|
16
|
-
// optional-call short-circuiting means the payload object is never even
|
|
17
|
-
// constructed. The cost when off is one nullish check.
|
|
18
|
-
// - NEVER user content. Ids, seqs, states, counts, codes, booleans only —
|
|
19
|
-
// never message text, captions, filenames, URLs, tokens, or previews. The
|
|
20
|
-
// INBOX events go one step further and log SIZES only: a `channelRef` names
|
|
21
|
-
// one specific conversation, so registry/unread lines carry counts and
|
|
22
|
-
// deltas, never the refs themselves.
|
|
23
|
-
// - STABLE event names (`chat_client_*`) suitable for log queries and later
|
|
24
|
-
// alert metrics; one line per DECISION, never per render or per heartbeat.
|
|
25
|
-
// - A diagnostics sink can never break the client: every emit is wrapped, so a
|
|
26
|
-
// throwing sink is swallowed rather than turned into a transport failure.
|
|
27
|
-
/**
|
|
28
|
-
* Stable event names. These are a QUERY CONTRACT — Cloud Logging filters, E2E
|
|
29
|
-
* console greps, and any later alert metric key on them, so a name is added or
|
|
30
|
-
* retired deliberately, never renamed in place.
|
|
31
|
-
*/
|
|
1
|
+
// Log queries key on these names: add or retire one, never rename it in place.
|
|
32
2
|
export const CHAT_CLIENT_DIAGNOSTIC_EVENTS = {
|
|
33
|
-
/** A socket open is being attempted (per attempt, with the reconnect cause). */
|
|
34
3
|
CONNECT_ATTEMPT: 'chat_client_connect_attempt',
|
|
35
|
-
/** The grant mint failed before a socket existed (terminal vs. retryable). */
|
|
36
4
|
GRANT_FAILED: 'chat_client_grant_failed',
|
|
37
|
-
/** The socket reached OPEN (the point the resume cursor is sent from). */
|
|
38
5
|
SOCKET_OPEN: 'chat_client_socket_open',
|
|
39
|
-
/** The socket closed, with the code and what the client decided to do next. */
|
|
40
6
|
SOCKET_CLOSE: 'chat_client_socket_close',
|
|
41
|
-
/**
|
|
42
|
-
* CHANNEL ONLY. The dead-socket watchdog closed a socket that had gone silent
|
|
43
|
-
* past the client's inbound-silence threshold (the channel DO auto-acks every
|
|
44
|
-
* heartbeat, so silence there is provable death). One line per detection —
|
|
45
|
-
* never per heartbeat — followed by the ordinary socket_close/reconnect lines.
|
|
46
|
-
*/
|
|
47
7
|
SOCKET_LIVENESS_TIMEOUT: 'chat_client_socket_liveness_timeout',
|
|
48
|
-
/** A reconnect was scheduled (`delayMs`) or given up on (`delayMs: null`). */
|
|
49
8
|
RECONNECT_SCHEDULED: 'chat_client_reconnect_scheduled',
|
|
50
|
-
/** The resume cursor sent to the DO. */
|
|
51
9
|
RESUME_REQUEST: 'chat_client_resume_request',
|
|
52
|
-
/** The DO's snapshot answer + how the cursor moved because of it. */
|
|
53
10
|
RESUME_RESULT: 'chat_client_resume_result',
|
|
54
|
-
/** A `resync: true` snapshot dropped the local tail (removal, not upsert). */
|
|
55
11
|
RESYNC_DROPPED_TAIL: 'chat_client_resync_dropped_tail',
|
|
56
|
-
/** A frame was applied to UI state (insert / replace-by-seq / optimistic reconcile). */
|
|
57
12
|
FRAME_APPLIED: 'chat_client_frame_applied',
|
|
58
|
-
/** A frame was NOT applied, with the reason. */
|
|
59
13
|
FRAME_DROPPED: 'chat_client_frame_dropped',
|
|
60
|
-
/** A moderation revision overlay was recorded (and whether a loaded row re-rendered). */
|
|
61
14
|
REVISION_APPLIED: 'chat_client_revision_applied',
|
|
62
|
-
/** A history page was requested (cursor + limit). */
|
|
63
15
|
HISTORY_REQUEST: 'chat_client_history_request',
|
|
64
|
-
/** A history page was applied: size, seq range, cursor advance. */
|
|
65
16
|
HISTORY_PAGE: 'chat_client_history_page',
|
|
66
|
-
/** An optimistic send was rendered and its frame written. */
|
|
67
17
|
SEND_OPTIMISTIC: 'chat_client_send_optimistic',
|
|
68
|
-
/** A send was acked by the server (seq assigned). */
|
|
69
18
|
SEND_ACK: 'chat_client_send_ack',
|
|
70
|
-
/** A correlated `send-rejected` (or its legacy uncorrelated form) was handled. */
|
|
71
19
|
SEND_REJECTED: 'chat_client_send_rejected',
|
|
72
|
-
/** A send was re-emitted (reconnect resume / scheduled hint / manual retry). */
|
|
73
20
|
SEND_RETRY: 'chat_client_send_retry',
|
|
74
|
-
/** A send reached its visible failed state, with the reason. */
|
|
75
21
|
SEND_FAILED: 'chat_client_send_failed',
|
|
76
|
-
/** An uncorrelated server `error` frame code. */
|
|
77
22
|
ERROR_FRAME: 'chat_client_error_frame',
|
|
78
|
-
// ---- inbox socket (one per USER, separate from the per-thread channel socket) ----
|
|
79
|
-
/** An inbox socket open is being attempted (per attempt, with the reconnect cause). */
|
|
80
23
|
INBOX_CONNECT_ATTEMPT: 'chat_client_inbox_connect_attempt',
|
|
81
|
-
/** The inbox grant mint failed before a socket existed (terminal vs. retryable). */
|
|
82
24
|
INBOX_GRANT_FAILED: 'chat_client_inbox_grant_failed',
|
|
83
|
-
/** The inbox socket reached OPEN (the point `resume` is sent from). */
|
|
84
25
|
INBOX_SOCKET_OPEN: 'chat_client_inbox_socket_open',
|
|
85
|
-
/** The inbox socket closed, with the code and what the client decided to do next. */
|
|
86
26
|
INBOX_SOCKET_CLOSE: 'chat_client_inbox_socket_close',
|
|
87
|
-
/** An inbox reconnect was scheduled (`delayMs`) or given up on (`delayMs: null`). */
|
|
88
27
|
INBOX_RECONNECT_SCHEDULED: 'chat_client_inbox_reconnect_scheduled',
|
|
89
|
-
/** The cursorless inbox `resume` was sent (the DO answers with a full snapshot). */
|
|
90
28
|
INBOX_RESUME_REQUEST: 'chat_client_inbox_resume_request',
|
|
91
|
-
/** An authoritative inbox snapshot was applied — registry SIZES only, never refs. */
|
|
92
29
|
INBOX_SNAPSHOT_APPLIED: 'chat_client_inbox_snapshot_applied',
|
|
93
|
-
/** The unread projection actually CHANGED (dock dot and/or per-row dot count). */
|
|
94
30
|
INBOX_UNREAD_UPDATED: 'chat_client_inbox_unread_updated',
|
|
95
|
-
/** An inbox frame was NOT applied, with the reason. */
|
|
96
31
|
INBOX_FRAME_DROPPED: 'chat_client_inbox_frame_dropped',
|
|
97
|
-
/** A mark-read was written to the socket (no optimistic local clear). */
|
|
98
32
|
INBOX_MARK_READ: 'chat_client_inbox_mark_read',
|
|
99
33
|
};
|
|
100
|
-
/** Max length of a server-supplied label (frame type, error code) we echo into a log line. */
|
|
101
34
|
const MAX_LABEL_LEN = 48;
|
|
102
|
-
/**
|
|
103
|
-
* Clamp a server-supplied label before it enters a log line. Frame types and
|
|
104
|
-
* error codes are protocol tokens, not user content, but they arrive from the
|
|
105
|
-
* wire — bound them so a hostile/buggy peer cannot emit an unbounded line.
|
|
106
|
-
*/
|
|
107
35
|
export function safeDiagnosticLabel(raw) {
|
|
108
36
|
if (typeof raw !== 'string')
|
|
109
37
|
return 'unknown';
|
|
110
38
|
const trimmed = raw.slice(0, MAX_LABEL_LEN);
|
|
111
39
|
return trimmed.length > 0 ? trimmed : 'unknown';
|
|
112
40
|
}
|
|
113
|
-
/**
|
|
114
|
-
* The default sink: ONE `console.debug` line per decision, shaped
|
|
115
|
-
* `chat_client_<event> {"compact":"json"}` so a browser console capture or a log
|
|
116
|
-
* query can filter on the stable prefix and parse the payload without a
|
|
117
|
-
* multi-line join.
|
|
118
|
-
*/
|
|
119
41
|
const consoleDebugSink = (event, data) => {
|
|
120
42
|
if (typeof console === 'undefined' || typeof console.debug !== 'function')
|
|
121
43
|
return;
|
|
122
44
|
console.debug(`${event} ${JSON.stringify(data)}`);
|
|
123
45
|
};
|
|
124
|
-
/**
|
|
125
|
-
* Build the emitter for a `diagnostics` config value. Returns `null` when
|
|
126
|
-
* diagnostics are off so call sites can use `this.diag?.(...)` — with optional
|
|
127
|
-
* call syntax the argument object is not constructed at all on the off path.
|
|
128
|
-
*/
|
|
129
46
|
export function createDiagnosticsEmitter(option) {
|
|
130
47
|
if (option == null || option === false)
|
|
131
48
|
return null;
|
|
132
49
|
const sink = option === true ? consoleDebugSink : option;
|
|
133
50
|
return (event, data) => {
|
|
134
|
-
//
|
|
135
|
-
//
|
|
51
|
+
// RULE-STD-ENG-009 exception: diagnostics must never fail the client, so a throwing sink is
|
|
52
|
+
// swallowed.
|
|
136
53
|
try {
|
|
137
54
|
sink(event, data);
|
|
138
55
|
}
|
|
139
|
-
catch {
|
|
140
|
-
/* diagnostics are never allowed to fail the client */
|
|
141
|
-
}
|
|
56
|
+
catch { }
|
|
142
57
|
};
|
|
143
58
|
}
|
|
144
59
|
//# sourceMappingURL=diagnostics.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"diagnostics.js","sourceRoot":"","sources":["../../src/realtime/diagnostics.ts"],"names":[],"mappings":"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","sourcesContent":["// OPT-IN structured client diagnostics for the realtime chat transport.\n//\n// The E2E/browser harness can already capture the raw WebSocket frames; what it\n// cannot see is what the CLIENT DECIDED about them — which frame it applied,\n// which it dropped and why, what resume cursor it sent, and whether a send was\n// reconciled or failed. This module is that missing half: a bounded, structured,\n// opt-in decision log.\n//\n// The connection-time, resume, presence, and socket-timing instruments are the\n// baseline-campaign metric families — they stay. Only the attachment-serving\n// instruments were retired with the chat-attachment architecture.\n//\n// Rules this module exists to enforce:\n// - OFF BY DEFAULT and zero behavior change. When the option is absent the\n// factory returns `null`, every call site is `this.diag?.(...)`, and JS\n// optional-call short-circuiting means the payload object is never even\n// constructed. The cost when off is one nullish check.\n// - NEVER user content. Ids, seqs, states, counts, codes, booleans only —\n// never message text, captions, filenames, URLs, tokens, or previews. The\n// INBOX events go one step further and log SIZES only: a `channelRef` names\n// one specific conversation, so registry/unread lines carry counts and\n// deltas, never the refs themselves.\n// - STABLE event names (`chat_client_*`) suitable for log queries and later\n// alert metrics; one line per DECISION, never per render or per heartbeat.\n// - A diagnostics sink can never break the client: every emit is wrapped, so a\n// throwing sink is swallowed rather than turned into a transport failure.\n\n/**\n * A structured diagnostic sink. `event` is one of the stable\n * {@link CHAT_CLIENT_DIAGNOSTIC_EVENTS} names; `data` is a compact, content-free\n * payload (ids/seqs/states/counts/codes only). Supplying a sink lets the app\n * route entries into its own logger; `true` uses the console.debug default.\n */\nexport type ChatClientDiagnosticsSink = (event: string, data: Record<string, unknown>) => void;\n\n/**\n * The additive `diagnostics` config value: `false`/absent = off (the default and\n * the only production posture), `true` = the single-line `console.debug` sink,\n * or a custom {@link ChatClientDiagnosticsSink}.\n */\nexport type ChatClientDiagnosticsOption = boolean | ChatClientDiagnosticsSink;\n\n/** The bound emitter, or `null` when diagnostics are off (the zero-overhead path). */\nexport type ChatClientDiagnosticsEmitter = ChatClientDiagnosticsSink | null;\n\n/**\n * Stable event names. These are a QUERY CONTRACT — Cloud Logging filters, E2E\n * console greps, and any later alert metric key on them, so a name is added or\n * retired deliberately, never renamed in place.\n */\nexport const CHAT_CLIENT_DIAGNOSTIC_EVENTS = {\n /** A socket open is being attempted (per attempt, with the reconnect cause). */\n CONNECT_ATTEMPT: 'chat_client_connect_attempt',\n /** The grant mint failed before a socket existed (terminal vs. retryable). */\n GRANT_FAILED: 'chat_client_grant_failed',\n /** The socket reached OPEN (the point the resume cursor is sent from). */\n SOCKET_OPEN: 'chat_client_socket_open',\n /** The socket closed, with the code and what the client decided to do next. */\n SOCKET_CLOSE: 'chat_client_socket_close',\n /**\n * CHANNEL ONLY. The dead-socket watchdog closed a socket that had gone silent\n * past the client's inbound-silence threshold (the channel DO auto-acks every\n * heartbeat, so silence there is provable death). One line per detection —\n * never per heartbeat — followed by the ordinary socket_close/reconnect lines.\n */\n SOCKET_LIVENESS_TIMEOUT: 'chat_client_socket_liveness_timeout',\n /** A reconnect was scheduled (`delayMs`) or given up on (`delayMs: null`). */\n RECONNECT_SCHEDULED: 'chat_client_reconnect_scheduled',\n /** The resume cursor sent to the DO. */\n RESUME_REQUEST: 'chat_client_resume_request',\n /** The DO's snapshot answer + how the cursor moved because of it. */\n RESUME_RESULT: 'chat_client_resume_result',\n /** A `resync: true` snapshot dropped the local tail (removal, not upsert). */\n RESYNC_DROPPED_TAIL: 'chat_client_resync_dropped_tail',\n /** A frame was applied to UI state (insert / replace-by-seq / optimistic reconcile). */\n FRAME_APPLIED: 'chat_client_frame_applied',\n /** A frame was NOT applied, with the reason. */\n FRAME_DROPPED: 'chat_client_frame_dropped',\n /** A moderation revision overlay was recorded (and whether a loaded row re-rendered). */\n REVISION_APPLIED: 'chat_client_revision_applied',\n /** A history page was requested (cursor + limit). */\n HISTORY_REQUEST: 'chat_client_history_request',\n /** A history page was applied: size, seq range, cursor advance. */\n HISTORY_PAGE: 'chat_client_history_page',\n /** An optimistic send was rendered and its frame written. */\n SEND_OPTIMISTIC: 'chat_client_send_optimistic',\n /** A send was acked by the server (seq assigned). */\n SEND_ACK: 'chat_client_send_ack',\n /** A correlated `send-rejected` (or its legacy uncorrelated form) was handled. */\n SEND_REJECTED: 'chat_client_send_rejected',\n /** A send was re-emitted (reconnect resume / scheduled hint / manual retry). */\n SEND_RETRY: 'chat_client_send_retry',\n /** A send reached its visible failed state, with the reason. */\n SEND_FAILED: 'chat_client_send_failed',\n /** An uncorrelated server `error` frame code. */\n ERROR_FRAME: 'chat_client_error_frame',\n\n // ---- inbox socket (one per USER, separate from the per-thread channel socket) ----\n\n /** An inbox socket open is being attempted (per attempt, with the reconnect cause). */\n INBOX_CONNECT_ATTEMPT: 'chat_client_inbox_connect_attempt',\n /** The inbox grant mint failed before a socket existed (terminal vs. retryable). */\n INBOX_GRANT_FAILED: 'chat_client_inbox_grant_failed',\n /** The inbox socket reached OPEN (the point `resume` is sent from). */\n INBOX_SOCKET_OPEN: 'chat_client_inbox_socket_open',\n /** The inbox socket closed, with the code and what the client decided to do next. */\n INBOX_SOCKET_CLOSE: 'chat_client_inbox_socket_close',\n /** An inbox reconnect was scheduled (`delayMs`) or given up on (`delayMs: null`). */\n INBOX_RECONNECT_SCHEDULED: 'chat_client_inbox_reconnect_scheduled',\n /** The cursorless inbox `resume` was sent (the DO answers with a full snapshot). */\n INBOX_RESUME_REQUEST: 'chat_client_inbox_resume_request',\n /** An authoritative inbox snapshot was applied — registry SIZES only, never refs. */\n INBOX_SNAPSHOT_APPLIED: 'chat_client_inbox_snapshot_applied',\n /** The unread projection actually CHANGED (dock dot and/or per-row dot count). */\n INBOX_UNREAD_UPDATED: 'chat_client_inbox_unread_updated',\n /** An inbox frame was NOT applied, with the reason. */\n INBOX_FRAME_DROPPED: 'chat_client_inbox_frame_dropped',\n /** A mark-read was written to the socket (no optimistic local clear). */\n INBOX_MARK_READ: 'chat_client_inbox_mark_read',\n} as const;\n\nexport type ChatClientDiagnosticEventName =\n (typeof CHAT_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof CHAT_CLIENT_DIAGNOSTIC_EVENTS];\n\n/** Max length of a server-supplied label (frame type, error code) we echo into a log line. */\nconst MAX_LABEL_LEN = 48;\n\n/**\n * Clamp a server-supplied label before it enters a log line. Frame types and\n * error codes are protocol tokens, not user content, but they arrive from the\n * wire — bound them so a hostile/buggy peer cannot emit an unbounded line.\n */\nexport function safeDiagnosticLabel(raw: unknown): string {\n if (typeof raw !== 'string') return 'unknown';\n const trimmed = raw.slice(0, MAX_LABEL_LEN);\n return trimmed.length > 0 ? trimmed : 'unknown';\n}\n\n/**\n * The default sink: ONE `console.debug` line per decision, shaped\n * `chat_client_<event> {\"compact\":\"json\"}` so a browser console capture or a log\n * query can filter on the stable prefix and parse the payload without a\n * multi-line join.\n */\nconst consoleDebugSink: ChatClientDiagnosticsSink = (event, data) => {\n if (typeof console === 'undefined' || typeof console.debug !== 'function') return;\n console.debug(`${event} ${JSON.stringify(data)}`);\n};\n\n/**\n * Build the emitter for a `diagnostics` config value. Returns `null` when\n * diagnostics are off so call sites can use `this.diag?.(...)` — with optional\n * call syntax the argument object is not constructed at all on the off path.\n */\nexport function createDiagnosticsEmitter(\n option: ChatClientDiagnosticsOption | undefined,\n): ChatClientDiagnosticsEmitter {\n if (option == null || option === false) return null;\n const sink = option === true ? consoleDebugSink : option;\n return (event, data) => {\n // Diagnostics observe; they must never alter transport behavior — a sink that\n // throws (or a payload that cannot be serialized) is swallowed here.\n try {\n sink(event, data);\n } catch {\n /* diagnostics are never allowed to fail the client */\n }\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"diagnostics.js","sourceRoot":"","sources":["../../src/realtime/diagnostics.ts"],"names":[],"mappings":"AAMA,+EAA+E;AAC/E,MAAM,CAAC,MAAM,6BAA6B,GAAG;IAC3C,eAAe,EAAE,6BAA6B;IAC9C,YAAY,EAAE,0BAA0B;IACxC,WAAW,EAAE,yBAAyB;IACtC,YAAY,EAAE,0BAA0B;IACxC,uBAAuB,EAAE,qCAAqC;IAC9D,mBAAmB,EAAE,iCAAiC;IACtD,cAAc,EAAE,4BAA4B;IAC5C,aAAa,EAAE,2BAA2B;IAC1C,mBAAmB,EAAE,iCAAiC;IACtD,aAAa,EAAE,2BAA2B;IAC1C,aAAa,EAAE,2BAA2B;IAC1C,gBAAgB,EAAE,8BAA8B;IAChD,eAAe,EAAE,6BAA6B;IAC9C,YAAY,EAAE,0BAA0B;IACxC,eAAe,EAAE,6BAA6B;IAC9C,QAAQ,EAAE,sBAAsB;IAChC,aAAa,EAAE,2BAA2B;IAC1C,UAAU,EAAE,wBAAwB;IACpC,WAAW,EAAE,yBAAyB;IACtC,WAAW,EAAE,yBAAyB;IAEtC,qBAAqB,EAAE,mCAAmC;IAC1D,kBAAkB,EAAE,gCAAgC;IACpD,iBAAiB,EAAE,+BAA+B;IAClD,kBAAkB,EAAE,gCAAgC;IACpD,yBAAyB,EAAE,uCAAuC;IAClE,oBAAoB,EAAE,kCAAkC;IACxD,sBAAsB,EAAE,oCAAoC;IAC5D,oBAAoB,EAAE,kCAAkC;IACxD,mBAAmB,EAAE,iCAAiC;IACtD,eAAe,EAAE,6BAA6B;CACtC,CAAC;AAKX,MAAM,aAAa,GAAG,EAAE,CAAC;AAEzB,MAAM,UAAU,mBAAmB,CAAC,GAAY;IAC9C,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAC9C,MAAM,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC;IAC5C,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAClD,CAAC;AAED,MAAM,gBAAgB,GAA8B,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;IAClE,IAAI,OAAO,OAAO,KAAK,WAAW,IAAI,OAAO,OAAO,CAAC,KAAK,KAAK,UAAU;QAAE,OAAO;IAClF,OAAO,CAAC,KAAK,CAAC,GAAG,KAAK,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC;AAEF,MAAM,UAAU,wBAAwB,CACtC,MAA+C;IAE/C,IAAI,MAAM,IAAI,IAAI,IAAI,MAAM,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IACpD,MAAM,IAAI,GAAG,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,MAAM,CAAC;IACzD,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;QACrB,4FAA4F;QAC5F,aAAa;QACb,IAAI,CAAC;YACH,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;QACpB,CAAC;QAAC,MAAM,CAAC,CAAA,CAAC;IACZ,CAAC,CAAC;AACJ,CAAC","sourcesContent":["export type ChatClientDiagnosticsSink = (event: string, data: Record<string, unknown>) => void;\n\nexport type ChatClientDiagnosticsOption = boolean | ChatClientDiagnosticsSink;\n\nexport type ChatClientDiagnosticsEmitter = ChatClientDiagnosticsSink | null;\n\n// Log queries key on these names: add or retire one, never rename it in place.\nexport const CHAT_CLIENT_DIAGNOSTIC_EVENTS = {\n CONNECT_ATTEMPT: 'chat_client_connect_attempt',\n GRANT_FAILED: 'chat_client_grant_failed',\n SOCKET_OPEN: 'chat_client_socket_open',\n SOCKET_CLOSE: 'chat_client_socket_close',\n SOCKET_LIVENESS_TIMEOUT: 'chat_client_socket_liveness_timeout',\n RECONNECT_SCHEDULED: 'chat_client_reconnect_scheduled',\n RESUME_REQUEST: 'chat_client_resume_request',\n RESUME_RESULT: 'chat_client_resume_result',\n RESYNC_DROPPED_TAIL: 'chat_client_resync_dropped_tail',\n FRAME_APPLIED: 'chat_client_frame_applied',\n FRAME_DROPPED: 'chat_client_frame_dropped',\n REVISION_APPLIED: 'chat_client_revision_applied',\n HISTORY_REQUEST: 'chat_client_history_request',\n HISTORY_PAGE: 'chat_client_history_page',\n SEND_OPTIMISTIC: 'chat_client_send_optimistic',\n SEND_ACK: 'chat_client_send_ack',\n SEND_REJECTED: 'chat_client_send_rejected',\n SEND_RETRY: 'chat_client_send_retry',\n SEND_FAILED: 'chat_client_send_failed',\n ERROR_FRAME: 'chat_client_error_frame',\n\n INBOX_CONNECT_ATTEMPT: 'chat_client_inbox_connect_attempt',\n INBOX_GRANT_FAILED: 'chat_client_inbox_grant_failed',\n INBOX_SOCKET_OPEN: 'chat_client_inbox_socket_open',\n INBOX_SOCKET_CLOSE: 'chat_client_inbox_socket_close',\n INBOX_RECONNECT_SCHEDULED: 'chat_client_inbox_reconnect_scheduled',\n INBOX_RESUME_REQUEST: 'chat_client_inbox_resume_request',\n INBOX_SNAPSHOT_APPLIED: 'chat_client_inbox_snapshot_applied',\n INBOX_UNREAD_UPDATED: 'chat_client_inbox_unread_updated',\n INBOX_FRAME_DROPPED: 'chat_client_inbox_frame_dropped',\n INBOX_MARK_READ: 'chat_client_inbox_mark_read',\n} as const;\n\nexport type ChatClientDiagnosticEventName =\n (typeof CHAT_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof CHAT_CLIENT_DIAGNOSTIC_EVENTS];\n\nconst MAX_LABEL_LEN = 48;\n\nexport function safeDiagnosticLabel(raw: unknown): string {\n if (typeof raw !== 'string') return 'unknown';\n const trimmed = raw.slice(0, MAX_LABEL_LEN);\n return trimmed.length > 0 ? trimmed : 'unknown';\n}\n\nconst consoleDebugSink: ChatClientDiagnosticsSink = (event, data) => {\n if (typeof console === 'undefined' || typeof console.debug !== 'function') return;\n console.debug(`${event} ${JSON.stringify(data)}`);\n};\n\nexport function createDiagnosticsEmitter(\n option: ChatClientDiagnosticsOption | undefined,\n): ChatClientDiagnosticsEmitter {\n if (option == null || option === false) return null;\n const sink = option === true ? consoleDebugSink : option;\n return (event, data) => {\n // RULE-STD-ENG-009 exception: diagnostics must never fail the client, so a throwing sink is\n // swallowed.\n try {\n sink(event, data);\n } catch {}\n };\n}\n"]}
|
|
@@ -5,39 +5,13 @@ import type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js
|
|
|
5
5
|
import { type ChatClientDiagnosticsOption } from './diagnostics.js';
|
|
6
6
|
export interface InboxClientState {
|
|
7
7
|
status: RealtimeStatus;
|
|
8
|
-
/** Active (non-tombstoned) channel/invite registry entries — inbox-view visibility. */
|
|
9
8
|
registry: WireRegistryEntry[];
|
|
10
|
-
/** True if ANY active channel/invite has unread (drives the dock dot). */
|
|
11
9
|
hasUnread: boolean;
|
|
12
|
-
/** The set of channelRefs that currently carry an unread dot (per-row dots). */
|
|
13
10
|
unreadChannelRefs: string[];
|
|
14
|
-
/**
|
|
15
|
-
* The last TERMINAL error code (mirrors `ChannelClientState.lastErrorCode`):
|
|
16
|
-
* `'access-denied'` when the inbox grant provider throws `ChatAccessDeniedError`
|
|
17
|
-
* (a genuine, terminal policy "no" — e.g. a banned/suspended account), and
|
|
18
|
-
* `'revoked'` on a 4403 REVOKED close. Null while healthy and across transient
|
|
19
|
-
* reconnects. The app subscribes to inbox state directly (there is no inbox React
|
|
20
|
-
* hook in this package), so this stable code is the surface — it invents no new UI.
|
|
21
|
-
*/
|
|
22
11
|
lastErrorCode: string | null;
|
|
23
|
-
/**
|
|
24
|
-
* True once the first authoritative snapshot has been applied — the registry and dots
|
|
25
|
-
* are then the inbox runtime's answer, so an empty registry means "nothing here"
|
|
26
|
-
* rather than "not loaded yet". Once true it stays true.
|
|
27
|
-
*/
|
|
28
12
|
hasLoadedInitialData: boolean;
|
|
29
|
-
/**
|
|
30
|
-
* True once the first open has failed for long enough to say so — the same rule as
|
|
31
|
-
* `ChannelClientState.initialLoadFailed`. Cleared by the first snapshot or `retryNow()`.
|
|
32
|
-
*/
|
|
33
13
|
initialLoadFailed: boolean;
|
|
34
14
|
}
|
|
35
|
-
/**
|
|
36
|
-
* How one `markRead` ended: `ok` — the inbox runtime advanced the read cursor (its
|
|
37
|
-
* fresh snapshot clears the dot); otherwise `code` says why not — the runtime's own
|
|
38
|
-
* reason, `not-sent` (no open socket, nothing was sent), or `connection-lost` (the
|
|
39
|
-
* socket closed before the answer arrived, so the outcome is unknown and the dot stays).
|
|
40
|
-
*/
|
|
41
15
|
export type ChatMarkReadOutcome = {
|
|
42
16
|
ok: true;
|
|
43
17
|
} | {
|
|
@@ -46,9 +20,7 @@ export type ChatMarkReadOutcome = {
|
|
|
46
20
|
};
|
|
47
21
|
export interface InboxClientConfig {
|
|
48
22
|
endpoint: string;
|
|
49
|
-
/** The authenticated uid (the inbox owner; the DO serves no one else's data). */
|
|
50
23
|
currentUserId: string;
|
|
51
|
-
/** Mints/refreshes an INBOX-scope grant (scope `{ kind:'inbox', uid }`). */
|
|
52
24
|
grantProvider: GrantProvider;
|
|
53
25
|
socketFactory: SocketFactory;
|
|
54
26
|
timers?: TransportTimers;
|
|
@@ -58,15 +30,6 @@ export interface InboxClientConfig {
|
|
|
58
30
|
maxAttempts?: number | null;
|
|
59
31
|
random?: () => number;
|
|
60
32
|
};
|
|
61
|
-
/**
|
|
62
|
-
* OPT-IN structured client diagnostics — the SAME option type and emitter the
|
|
63
|
-
* channel client uses (one owner, `./diagnostics.ts`). Default OFF: absent or
|
|
64
|
-
* `false` changes nothing and costs one nullish check. `true` emits one
|
|
65
|
-
* single-line `console.debug` entry per decision under the stable
|
|
66
|
-
* `chat_client_inbox_*` names; a function routes them to the app's logger.
|
|
67
|
-
* Inbox payloads are SIZES ONLY — registry/unread counts and deltas, never a
|
|
68
|
-
* `channelRef` (which names one specific conversation).
|
|
69
|
-
*/
|
|
70
33
|
diagnostics?: ChatClientDiagnosticsOption;
|
|
71
34
|
}
|
|
72
35
|
export declare class InboxClient {
|
|
@@ -79,67 +42,33 @@ export declare class InboxClient {
|
|
|
79
42
|
private reauthAttempted;
|
|
80
43
|
private reconnectTimer;
|
|
81
44
|
private closedByUs;
|
|
82
|
-
/**
|
|
83
|
-
* The current lifecycle's number, advanced by every `connect()` and `close()`; a grant,
|
|
84
|
-
* timer, or socket callback of an older lifecycle is ignored (FRONTEND-108).
|
|
85
|
-
*/
|
|
86
45
|
private lifecycle;
|
|
87
46
|
private readonly initialLoad;
|
|
88
|
-
/** Mark-reads sent on the current socket, awaiting their correlated result, by requestId. */
|
|
89
47
|
private readonly pendingMarkReads;
|
|
90
|
-
/** Structured diagnostics emitter, or null when off (the default, zero-overhead). */
|
|
91
48
|
private readonly diag;
|
|
92
|
-
/** Monotonic socket-open attempt counter (diagnostics correlation only). */
|
|
93
49
|
private connectAttempt;
|
|
94
|
-
/** Why the CURRENT connect attempt is happening (diagnostics only). */
|
|
95
50
|
private reconnectCause;
|
|
96
|
-
/** The last close code observed, carried into the next attempt's cause line. */
|
|
97
51
|
private lastCloseCode;
|
|
98
52
|
constructor(config: InboxClientConfig);
|
|
99
53
|
getState(): InboxClientState;
|
|
100
54
|
subscribe(fn: (s: InboxClientState) => void): () => void;
|
|
101
55
|
private setState;
|
|
102
56
|
connect(): Promise<void>;
|
|
103
|
-
/**
|
|
104
|
-
* Retry now (the first-open failure's Retry) — the same rule as `ChannelClient.retryNow`:
|
|
105
|
-
* a reconnect waiting out its backoff opens at once; a stopped lifecycle starts again; an
|
|
106
|
-
* attempt in flight is left to finish; the first-open failure clears either way.
|
|
107
|
-
*/
|
|
108
57
|
retryNow(): void;
|
|
109
58
|
private startLifecycle;
|
|
110
59
|
private reportInitialLoadFailed;
|
|
111
60
|
private openSocket;
|
|
112
61
|
private onOpen;
|
|
113
62
|
private onClose;
|
|
114
|
-
/**
|
|
115
|
-
* Terminal access denial surfaced by the inbox grant provider
|
|
116
|
-
* (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark
|
|
117
|
-
* closed-by-us, close the reconnect controller, and surface the stable
|
|
118
|
-
* `access-denied` code. The inbox client tracks NO optimistic/pending sends
|
|
119
|
-
* (mark-read is fire-and-forget with no local pending state), so — unlike
|
|
120
|
-
* ChannelClient — there is nothing to fail here; only the lifecycle stops.
|
|
121
|
-
*/
|
|
122
63
|
private denyAccessTerminally;
|
|
123
64
|
private scheduleReconnect;
|
|
124
65
|
private onMessage;
|
|
125
66
|
private applySnapshot;
|
|
126
67
|
private sendFrame;
|
|
127
|
-
/** True if a specific channel/invite currently carries an unread dot. */
|
|
128
68
|
channelHasUnread(channelRef: string): boolean;
|
|
129
|
-
/**
|
|
130
|
-
* Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read
|
|
131
|
-
* controls). Sends `mark-read` with a fresh `requestId`; the inbox runtime advances the
|
|
132
|
-
* member's read cursor, pushes a fresh authoritative snapshot (which is what removes
|
|
133
|
-
* the dot — there is NO optimistic local clear), and answers with a
|
|
134
|
-
* `mark-read-result` naming the same request. The promise settles on that answer —
|
|
135
|
-
* never on a timer — or with `not-sent` when no socket is open, or `connection-lost`
|
|
136
|
-
* when the socket closes first.
|
|
137
|
-
*/
|
|
138
69
|
markRead(channelRef: string): Promise<ChatMarkReadOutcome>;
|
|
139
|
-
/** Settle the mark-read the result names; a result naming no pending request is ignored. */
|
|
140
70
|
private applyMarkReadResult;
|
|
141
71
|
private settlePendingMarkReads;
|
|
142
|
-
/** Permanently close (auth-user switch / unmount). Idempotent. */
|
|
143
72
|
close(): void;
|
|
144
73
|
}
|
|
145
74
|
//# sourceMappingURL=inbox-client.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbox-client.d.ts","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"inbox-client.d.ts","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
// The inbox DO pushes a full `snapshot` ({ registry, hasUnread }) on connect, on
|
|
6
|
-
// `resume`, and on every live delta (a projection apply). This client just
|
|
7
|
-
// mirrors the latest snapshot into an observable store + a per-channel unread set.
|
|
8
|
-
// Same connection lifecycle as the channel client (subprotocol auth, reconnect,
|
|
9
|
-
// 4401 re-grant once, 4403 stop, auth-switch teardown).
|
|
10
|
-
import { createReconnectController } from '@ttt-productions/realtime-core';
|
|
11
|
-
import { ChatMarkReadResultPayloadSchema, SERVER_KINDS, } from '@ttt-productions/chat-schemas';
|
|
12
|
-
import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
|
|
1
|
+
import { createReconnectController, } from '@ttt-productions/realtime-core';
|
|
2
|
+
import { ChatMarkReadResultPayloadSchema, SERVER_KINDS, CLIENT_KINDS, CHAT_CLOSE_CODES, } from '@ttt-productions/chat-schemas';
|
|
3
|
+
import { buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
|
|
13
4
|
import { defaultTimers, isChatAccessDeniedError, isTerminalErrorCode, newCorrelationId, TERMINAL_ERROR_CODE, } from './shared.js';
|
|
14
5
|
import { InitialLoadBudget } from './initial-load-budget.js';
|
|
15
6
|
import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
|
|
@@ -32,21 +23,12 @@ export class InboxClient {
|
|
|
32
23
|
reauthAttempted = false;
|
|
33
24
|
reconnectTimer = null;
|
|
34
25
|
closedByUs = false;
|
|
35
|
-
/**
|
|
36
|
-
* The current lifecycle's number, advanced by every `connect()` and `close()`; a grant,
|
|
37
|
-
* timer, or socket callback of an older lifecycle is ignored (FRONTEND-108).
|
|
38
|
-
*/
|
|
39
26
|
lifecycle = 0;
|
|
40
27
|
initialLoad;
|
|
41
|
-
/** Mark-reads sent on the current socket, awaiting their correlated result, by requestId. */
|
|
42
28
|
pendingMarkReads = new Map();
|
|
43
|
-
/** Structured diagnostics emitter, or null when off (the default, zero-overhead). */
|
|
44
29
|
diag;
|
|
45
|
-
/** Monotonic socket-open attempt counter (diagnostics correlation only). */
|
|
46
30
|
connectAttempt = 0;
|
|
47
|
-
/** Why the CURRENT connect attempt is happening (diagnostics only). */
|
|
48
31
|
reconnectCause = 'initial';
|
|
49
|
-
/** The last close code observed, carried into the next attempt's cause line. */
|
|
50
32
|
lastCloseCode = null;
|
|
51
33
|
constructor(config) {
|
|
52
34
|
this.config = config;
|
|
@@ -70,11 +52,6 @@ export class InboxClient {
|
|
|
70
52
|
async connect() {
|
|
71
53
|
return this.startLifecycle(true);
|
|
72
54
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Retry now (the first-open failure's Retry) — the same rule as `ChannelClient.retryNow`:
|
|
75
|
-
* a reconnect waiting out its backoff opens at once; a stopped lifecycle starts again; an
|
|
76
|
-
* attempt in flight is left to finish; the first-open failure clears either way.
|
|
77
|
-
*/
|
|
78
55
|
retryNow() {
|
|
79
56
|
if (!this.state.hasLoadedInitialData)
|
|
80
57
|
this.initialLoad.manualRetry();
|
|
@@ -96,25 +73,9 @@ export class InboxClient {
|
|
|
96
73
|
void this.openSocket();
|
|
97
74
|
}
|
|
98
75
|
async startLifecycle(freshBudget) {
|
|
99
|
-
// Idempotency guard (mirrors ChannelClient): connect() only starts a lifecycle
|
|
100
|
-
// from a fresh (idle) or fully torn-down (closed) state. A second connect()
|
|
101
|
-
// while one is already connecting / open / reconnecting is a no-op, so a
|
|
102
|
-
// double-mounted dock cannot open a SECOND inbox socket against the per-uid
|
|
103
|
-
// cap. The reconnect path uses openSocket() / scheduleReconnect() directly
|
|
104
|
-
// (never connect()), so this never blocks a legitimate reconnect.
|
|
105
76
|
if (this.state.status !== 'idle' && this.state.status !== 'closed')
|
|
106
77
|
return;
|
|
107
|
-
// REVIVE the controller. `controller.close()` is PERMANENT: while it is in
|
|
108
|
-
// state `closed`, `start()` and `onOpen()` no-op and every later `onClose()`
|
|
109
|
-
// returns null — so without this reset a close() → connect() client opens a
|
|
110
|
-
// socket and works normally until its FIRST transient close, then parks
|
|
111
|
-
// terminally in `closed` with reconnect disabled and no surfaced error code.
|
|
112
78
|
this.controller.reset();
|
|
113
|
-
// A terminal verdict (4403 REVOKED / ChatAccessDeniedError) is terminal WITHIN
|
|
114
|
-
// its lifecycle; an explicit connect() is a fresh attempt the authority may
|
|
115
|
-
// legitimately re-deny, so the previous lifecycle's code must not persist.
|
|
116
|
-
// The registry/unread projection is deliberately KEPT — the DO's next snapshot
|
|
117
|
-
// is authoritative, and blanking the dock on every restart is pure UI churn.
|
|
118
79
|
if (isTerminalErrorCode(this.state.lastErrorCode))
|
|
119
80
|
this.setState({ lastErrorCode: null });
|
|
120
81
|
this.closedByUs = false;
|
|
@@ -147,11 +108,6 @@ export class InboxClient {
|
|
|
147
108
|
catch (err) {
|
|
148
109
|
if (lifecycle !== this.lifecycle)
|
|
149
110
|
return;
|
|
150
|
-
// A TERMINAL access denial (the app translated an authoritative policy "no"
|
|
151
|
-
// — e.g. Firebase `permission-denied` for a banned/suspended account — into the
|
|
152
|
-
// package-owned ChatAccessDeniedError) must NOT reconnect: the next mint would
|
|
153
|
-
// just re-deny, reconnect-looping forever and warning every cycle. Every other
|
|
154
|
-
// mint failure (network/transient) stays retryable and backs off as before.
|
|
155
111
|
const terminal = isChatAccessDeniedError(err);
|
|
156
112
|
this.diag?.(DIAG.INBOX_GRANT_FAILED, { attempt: this.connectAttempt, terminal });
|
|
157
113
|
if (terminal)
|
|
@@ -161,12 +117,9 @@ export class InboxClient {
|
|
|
161
117
|
this.initialLoad.noteFailedAttempt();
|
|
162
118
|
return this.scheduleReconnect();
|
|
163
119
|
}
|
|
164
|
-
// A grant minted for an earlier lifecycle must never open a socket in this one.
|
|
165
120
|
if (lifecycle !== this.lifecycle || this.closedByUs)
|
|
166
121
|
return;
|
|
167
122
|
const url = `${this.config.endpoint.replace(/\/$/, '')}/inbox`;
|
|
168
|
-
// Every callback is fenced to THIS socket: once it has been replaced or torn down, its
|
|
169
|
-
// late open, frames, and close are ignored.
|
|
170
123
|
let socket = null;
|
|
171
124
|
const isCurrent = () => socket !== null && this.socket === socket;
|
|
172
125
|
socket = this.config.socketFactory({
|
|
@@ -200,16 +153,16 @@ export class InboxClient {
|
|
|
200
153
|
registryCount: this.state.registry.length,
|
|
201
154
|
hasUnread: this.state.hasUnread,
|
|
202
155
|
});
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
156
|
+
const sent = this.sendFrame(CLIENT_KINDS.RESUME, {});
|
|
157
|
+
this.diag?.(DIAG.INBOX_RESUME_REQUEST, {
|
|
158
|
+
attempt: this.connectAttempt,
|
|
159
|
+
cursorless: true,
|
|
160
|
+
sent,
|
|
161
|
+
});
|
|
208
162
|
}
|
|
209
163
|
onClose(code, _reason) {
|
|
210
164
|
this.socket = null;
|
|
211
165
|
this.lastCloseCode = code;
|
|
212
|
-
// A mark-read sent on this socket can no longer be answered.
|
|
213
166
|
this.settlePendingMarkReads({ ok: false, code: 'connection-lost' });
|
|
214
167
|
if (this.closedByUs) {
|
|
215
168
|
this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed' });
|
|
@@ -238,14 +191,6 @@ export class InboxClient {
|
|
|
238
191
|
this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect' });
|
|
239
192
|
this.scheduleReconnect();
|
|
240
193
|
}
|
|
241
|
-
/**
|
|
242
|
-
* Terminal access denial surfaced by the inbox grant provider
|
|
243
|
-
* (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark
|
|
244
|
-
* closed-by-us, close the reconnect controller, and surface the stable
|
|
245
|
-
* `access-denied` code. The inbox client tracks NO optimistic/pending sends
|
|
246
|
-
* (mark-read is fire-and-forget with no local pending state), so — unlike
|
|
247
|
-
* ChannelClient — there is nothing to fail here; only the lifecycle stops.
|
|
248
|
-
*/
|
|
249
194
|
denyAccessTerminally() {
|
|
250
195
|
this.closedByUs = true;
|
|
251
196
|
this.controller.close();
|
|
@@ -281,18 +226,19 @@ export class InboxClient {
|
|
|
281
226
|
}
|
|
282
227
|
if (frame.type === SERVER_KINDS.MARK_READ_RESULT)
|
|
283
228
|
return this.applyMarkReadResult(frame.payload);
|
|
284
|
-
// The inbox DO pushes a full `snapshot`; a future lightweight `unread` push
|
|
285
|
-
// carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both
|
|
286
|
-
// instead of silently dropping the `unread` type (C-M2).
|
|
287
229
|
if (frame.type !== 'snapshot' && frame.type !== 'unread') {
|
|
288
|
-
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
230
|
+
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
231
|
+
kind: safeDiagnosticLabel(frame.type),
|
|
232
|
+
reason: 'unknown-type',
|
|
233
|
+
});
|
|
289
234
|
return;
|
|
290
235
|
}
|
|
291
236
|
const payload = frame.payload;
|
|
292
237
|
if (isInboxSnapshot(payload)) {
|
|
293
238
|
this.applySnapshot(payload, frame.type);
|
|
294
239
|
}
|
|
295
|
-
else if (frame.type === 'unread' &&
|
|
240
|
+
else if (frame.type === 'unread' &&
|
|
241
|
+
typeof payload.hasUnread === 'boolean') {
|
|
296
242
|
const hasUnread = Boolean(payload.hasUnread);
|
|
297
243
|
const before = this.state.hasUnread;
|
|
298
244
|
this.setState({ hasUnread });
|
|
@@ -308,8 +254,6 @@ export class InboxClient {
|
|
|
308
254
|
}
|
|
309
255
|
}
|
|
310
256
|
else {
|
|
311
|
-
// A stray CHANNEL snapshot ({ lastMessageSeq, readSeq }) on the inbox socket —
|
|
312
|
-
// deliberately inert, but never silently invisible under diagnostics.
|
|
313
257
|
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
314
258
|
kind: safeDiagnosticLabel(frame.type),
|
|
315
259
|
reason: 'not-an-inbox-payload',
|
|
@@ -317,34 +261,32 @@ export class InboxClient {
|
|
|
317
261
|
}
|
|
318
262
|
}
|
|
319
263
|
applySnapshot(snap, source) {
|
|
320
|
-
// Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can
|
|
321
|
-
// render both the active list and the Archived toggle. `archived` is a distinct
|
|
322
|
-
// dimension from `tombstoned`; archived rows are still `state: 'active'`.
|
|
323
264
|
const registry = snap.registry.filter((e) => e.state === 'active');
|
|
324
|
-
// The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from
|
|
325
|
-
// the same projection but exclude ARCHIVED rows (archive = done — an archived row
|
|
326
|
-
// never shows a dot). The DO clears unread on archive, so this is belt-and-braces.
|
|
327
265
|
const unreadChannelRefs = deriveUnreadRefs(snap);
|
|
328
266
|
const hasUnread = Boolean(snap.hasUnread);
|
|
329
267
|
const before = this.state;
|
|
330
268
|
if (!before.hasLoadedInitialData)
|
|
331
269
|
this.initialLoad.loaded();
|
|
332
|
-
this.setState({
|
|
270
|
+
this.setState({
|
|
271
|
+
registry,
|
|
272
|
+
hasUnread,
|
|
273
|
+
unreadChannelRefs,
|
|
274
|
+
hasLoadedInitialData: true,
|
|
275
|
+
initialLoadFailed: false,
|
|
276
|
+
});
|
|
333
277
|
if (!this.diag)
|
|
334
278
|
return;
|
|
335
|
-
// SIZES ONLY — a channelRef names one specific conversation and never enters a
|
|
336
|
-
// diagnostic line.
|
|
337
279
|
this.diag(DIAG.INBOX_SNAPSHOT_APPLIED, {
|
|
338
280
|
source: safeDiagnosticLabel(source),
|
|
339
281
|
received: snap.registry.length,
|
|
340
|
-
// `received - active` is the tombstoned count the client filtered out.
|
|
341
282
|
active: registry.length,
|
|
342
283
|
archived: registry.filter((e) => e.archived === true).length,
|
|
343
284
|
hasUnread,
|
|
344
285
|
unreadCount: unreadChannelRefs.length,
|
|
345
286
|
registryDelta: registry.length - before.registry.length,
|
|
346
287
|
});
|
|
347
|
-
const unreadChanged = before.hasUnread !== hasUnread ||
|
|
288
|
+
const unreadChanged = before.hasUnread !== hasUnread ||
|
|
289
|
+
before.unreadChannelRefs.length !== unreadChannelRefs.length;
|
|
348
290
|
if (unreadChanged) {
|
|
349
291
|
this.diag(DIAG.INBOX_UNREAD_UPDATED, {
|
|
350
292
|
source: safeDiagnosticLabel(source),
|
|
@@ -362,34 +304,24 @@ export class InboxClient {
|
|
|
362
304
|
this.socket.send(buildFrame(type, payload));
|
|
363
305
|
return true;
|
|
364
306
|
}
|
|
365
|
-
/** True if a specific channel/invite currently carries an unread dot. */
|
|
366
307
|
channelHasUnread(channelRef) {
|
|
367
308
|
return this.state.unreadChannelRefs.includes(channelRef);
|
|
368
309
|
}
|
|
369
|
-
/**
|
|
370
|
-
* Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read
|
|
371
|
-
* controls). Sends `mark-read` with a fresh `requestId`; the inbox runtime advances the
|
|
372
|
-
* member's read cursor, pushes a fresh authoritative snapshot (which is what removes
|
|
373
|
-
* the dot — there is NO optimistic local clear), and answers with a
|
|
374
|
-
* `mark-read-result` naming the same request. The promise settles on that answer —
|
|
375
|
-
* never on a timer — or with `not-sent` when no socket is open, or `connection-lost`
|
|
376
|
-
* when the socket closes first.
|
|
377
|
-
*/
|
|
378
310
|
markRead(channelRef) {
|
|
379
311
|
const requestId = newCorrelationId();
|
|
380
|
-
const sent = this.sendFrame(
|
|
381
|
-
// The ref itself is never logged — only that a mark-read was written and what the
|
|
382
|
-
// unread projection looked like at that moment (the DO's snapshot is what clears it).
|
|
312
|
+
const sent = this.sendFrame(CLIENT_KINDS.MARK_READ, { channelRef, requestId });
|
|
383
313
|
this.diag?.(DIAG.INBOX_MARK_READ, { sent, unreadCount: this.state.unreadChannelRefs.length });
|
|
384
314
|
if (!sent)
|
|
385
315
|
return Promise.resolve({ ok: false, code: 'not-sent' });
|
|
386
316
|
return new Promise((resolve) => this.pendingMarkReads.set(requestId, resolve));
|
|
387
317
|
}
|
|
388
|
-
/** Settle the mark-read the result names; a result naming no pending request is ignored. */
|
|
389
318
|
applyMarkReadResult(payload) {
|
|
390
319
|
const parsed = ChatMarkReadResultPayloadSchema.safeParse(payload);
|
|
391
320
|
if (!parsed.success) {
|
|
392
|
-
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
321
|
+
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
322
|
+
kind: SERVER_KINDS.MARK_READ_RESULT,
|
|
323
|
+
reason: 'malformed-payload',
|
|
324
|
+
});
|
|
393
325
|
return;
|
|
394
326
|
}
|
|
395
327
|
const resolve = this.pendingMarkReads.get(parsed.data.requestId);
|
|
@@ -404,7 +336,6 @@ export class InboxClient {
|
|
|
404
336
|
for (const resolve of pending)
|
|
405
337
|
resolve(outcome);
|
|
406
338
|
}
|
|
407
|
-
/** Permanently close (auth-user switch / unmount). Idempotent. */
|
|
408
339
|
close() {
|
|
409
340
|
this.closedByUs = true;
|
|
410
341
|
this.lifecycle += 1;
|
|
@@ -415,7 +346,6 @@ export class InboxClient {
|
|
|
415
346
|
this.reconnectTimer = null;
|
|
416
347
|
}
|
|
417
348
|
if (this.socket) {
|
|
418
|
-
// Detach first: the socket's own late close is not this lifecycle's event.
|
|
419
349
|
const socket = this.socket;
|
|
420
350
|
this.socket = null;
|
|
421
351
|
socket.close(1000, 'client teardown');
|
|
@@ -424,16 +354,6 @@ export class InboxClient {
|
|
|
424
354
|
this.setState({ status: 'closed' });
|
|
425
355
|
}
|
|
426
356
|
}
|
|
427
|
-
/**
|
|
428
|
-
* Derive the per-channel unread set from a snapshot. The inbox DO's snapshot
|
|
429
|
-
* carries a per-entry `unread` boolean on every active registry entry alongside
|
|
430
|
-
* the global `hasUnread` roll-up that drives the dock dot. An entry without the
|
|
431
|
-
* field (a legacy/pre-per-entry row) simply carries no per-row dot — the dock dot
|
|
432
|
-
* still lights off `hasUnread`.
|
|
433
|
-
*
|
|
434
|
-
* ARCHIVED rows are excluded from the unread roll-up (archive = done — the DO clears
|
|
435
|
-
* unread on archive; the client also never counts them).
|
|
436
|
-
*/
|
|
437
357
|
function deriveUnreadRefs(snap) {
|
|
438
358
|
const refs = [];
|
|
439
359
|
for (const e of snap.registry) {
|