@ttt-productions/chat-react 0.13.16 → 0.13.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/context/ChatNameResolverContext.d.ts +0 -23
- package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
- package/dist/context/ChatNameResolverContext.js +0 -16
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts +0 -4
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +0 -10
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +0 -248
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +5 -432
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/diagnostics.d.ts +0 -62
- package/dist/realtime/diagnostics.d.ts.map +1 -1
- package/dist/realtime/diagnostics.js +4 -89
- package/dist/realtime/diagnostics.js.map +1 -1
- package/dist/realtime/inbox-client.d.ts +0 -71
- package/dist/realtime/inbox-client.d.ts.map +1 -1
- package/dist/realtime/inbox-client.js +0 -98
- package/dist/realtime/inbox-client.js.map +1 -1
- package/dist/realtime/initial-load-budget.d.ts +1 -16
- package/dist/realtime/initial-load-budget.d.ts.map +1 -1
- package/dist/realtime/initial-load-budget.js +1 -20
- package/dist/realtime/initial-load-budget.js.map +1 -1
- package/dist/realtime/map.d.ts +0 -26
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +0 -33
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +0 -78
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +2 -76
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/socket.d.ts +0 -15
- package/dist/realtime/socket.d.ts.map +1 -1
- package/dist/realtime/socket.js +0 -4
- package/dist/realtime/socket.js.map +1 -1
- package/dist/realtime/transport.d.ts +0 -23
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +0 -13
- package/dist/realtime/transport.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +0 -17
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +0 -22
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +0 -56
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +0 -16
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +0 -39
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +1 -1
- package/dist/ui/ChatShell.d.ts +0 -42
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +4 -41
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts +0 -13
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +0 -13
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +0 -5
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +2 -34
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +0 -24
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +4 -14
- package/dist/ui/MessageList.js.map +1 -1
- package/dist/ui/MessageText.d.ts +0 -13
- package/dist/ui/MessageText.d.ts.map +1 -1
- package/dist/ui/MessageText.js.map +1 -1
- package/dist/ui/SenderName.d.ts +0 -5
- package/dist/ui/SenderName.d.ts.map +1 -1
- package/dist/ui/SenderName.js +0 -5
- package/dist/ui/SenderName.js.map +1 -1
- package/dist/ui/menus.d.ts +0 -2
- package/dist/ui/menus.d.ts.map +1 -1
- package/dist/ui/menus.js +6 -10
- package/dist/ui/menus.js.map +1 -1
- package/package.json +10 -10
- package/src/styles/chat.css +2 -15
|
@@ -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,12 +1,3 @@
|
|
|
1
|
-
// The Inbox realtime client: a SEPARATE WebSocket (scope `inbox`) to the user's
|
|
2
|
-
// inbox DO. It maintains the channel/invite registry + unread projection that
|
|
3
|
-
// drives the inbox view — DOTS ONLY, no counts (Contract C / "Unread + inbox").
|
|
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
1
|
import { createReconnectController, } from '@ttt-productions/realtime-core';
|
|
11
2
|
import { ChatMarkReadResultPayloadSchema, SERVER_KINDS, CLIENT_KINDS, CHAT_CLOSE_CODES, } from '@ttt-productions/chat-schemas';
|
|
12
3
|
import { buildFrame, parseFrame, isInboxSnapshot, } from './wire.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,10 +153,7 @@ export class InboxClient {
|
|
|
200
153
|
registryCount: this.state.registry.length,
|
|
201
154
|
hasUnread: this.state.hasUnread,
|
|
202
155
|
});
|
|
203
|
-
// The DO sends a snapshot on accept; a `resume` re-requests it after a reconnect gap.
|
|
204
156
|
const sent = this.sendFrame(CLIENT_KINDS.RESUME, {});
|
|
205
|
-
// The inbox resume is CURSORLESS by contract (the DO always answers with a full
|
|
206
|
-
// authoritative snapshot) — there is no `afterSeq` half to record here.
|
|
207
157
|
this.diag?.(DIAG.INBOX_RESUME_REQUEST, {
|
|
208
158
|
attempt: this.connectAttempt,
|
|
209
159
|
cursorless: true,
|
|
@@ -213,7 +163,6 @@ export class InboxClient {
|
|
|
213
163
|
onClose(code, _reason) {
|
|
214
164
|
this.socket = null;
|
|
215
165
|
this.lastCloseCode = code;
|
|
216
|
-
// A mark-read sent on this socket can no longer be answered.
|
|
217
166
|
this.settlePendingMarkReads({ ok: false, code: 'connection-lost' });
|
|
218
167
|
if (this.closedByUs) {
|
|
219
168
|
this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed' });
|
|
@@ -242,14 +191,6 @@ export class InboxClient {
|
|
|
242
191
|
this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect' });
|
|
243
192
|
this.scheduleReconnect();
|
|
244
193
|
}
|
|
245
|
-
/**
|
|
246
|
-
* Terminal access denial surfaced by the inbox grant provider
|
|
247
|
-
* (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark
|
|
248
|
-
* closed-by-us, close the reconnect controller, and surface the stable
|
|
249
|
-
* `access-denied` code. The inbox client tracks NO optimistic/pending sends
|
|
250
|
-
* (mark-read is fire-and-forget with no local pending state), so — unlike
|
|
251
|
-
* ChannelClient — there is nothing to fail here; only the lifecycle stops.
|
|
252
|
-
*/
|
|
253
194
|
denyAccessTerminally() {
|
|
254
195
|
this.closedByUs = true;
|
|
255
196
|
this.controller.close();
|
|
@@ -285,9 +226,6 @@ export class InboxClient {
|
|
|
285
226
|
}
|
|
286
227
|
if (frame.type === SERVER_KINDS.MARK_READ_RESULT)
|
|
287
228
|
return this.applyMarkReadResult(frame.payload);
|
|
288
|
-
// The inbox DO pushes a full `snapshot`; a future lightweight `unread` push
|
|
289
|
-
// carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both
|
|
290
|
-
// instead of silently dropping the `unread` type (C-M2).
|
|
291
229
|
if (frame.type !== 'snapshot' && frame.type !== 'unread') {
|
|
292
230
|
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
293
231
|
kind: safeDiagnosticLabel(frame.type),
|
|
@@ -316,8 +254,6 @@ export class InboxClient {
|
|
|
316
254
|
}
|
|
317
255
|
}
|
|
318
256
|
else {
|
|
319
|
-
// A stray CHANNEL snapshot ({ lastMessageSeq, readSeq }) on the inbox socket —
|
|
320
|
-
// deliberately inert, but never silently invisible under diagnostics.
|
|
321
257
|
this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
|
|
322
258
|
kind: safeDiagnosticLabel(frame.type),
|
|
323
259
|
reason: 'not-an-inbox-payload',
|
|
@@ -325,13 +261,7 @@ export class InboxClient {
|
|
|
325
261
|
}
|
|
326
262
|
}
|
|
327
263
|
applySnapshot(snap, source) {
|
|
328
|
-
// Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can
|
|
329
|
-
// render both the active list and the Archived toggle. `archived` is a distinct
|
|
330
|
-
// dimension from `tombstoned`; archived rows are still `state: 'active'`.
|
|
331
264
|
const registry = snap.registry.filter((e) => e.state === 'active');
|
|
332
|
-
// The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from
|
|
333
|
-
// the same projection but exclude ARCHIVED rows (archive = done — an archived row
|
|
334
|
-
// never shows a dot). The DO clears unread on archive, so this is belt-and-braces.
|
|
335
265
|
const unreadChannelRefs = deriveUnreadRefs(snap);
|
|
336
266
|
const hasUnread = Boolean(snap.hasUnread);
|
|
337
267
|
const before = this.state;
|
|
@@ -346,12 +276,9 @@ export class InboxClient {
|
|
|
346
276
|
});
|
|
347
277
|
if (!this.diag)
|
|
348
278
|
return;
|
|
349
|
-
// SIZES ONLY — a channelRef names one specific conversation and never enters a
|
|
350
|
-
// diagnostic line.
|
|
351
279
|
this.diag(DIAG.INBOX_SNAPSHOT_APPLIED, {
|
|
352
280
|
source: safeDiagnosticLabel(source),
|
|
353
281
|
received: snap.registry.length,
|
|
354
|
-
// `received - active` is the tombstoned count the client filtered out.
|
|
355
282
|
active: registry.length,
|
|
356
283
|
archived: registry.filter((e) => e.archived === true).length,
|
|
357
284
|
hasUnread,
|
|
@@ -377,30 +304,17 @@ export class InboxClient {
|
|
|
377
304
|
this.socket.send(buildFrame(type, payload));
|
|
378
305
|
return true;
|
|
379
306
|
}
|
|
380
|
-
/** True if a specific channel/invite currently carries an unread dot. */
|
|
381
307
|
channelHasUnread(channelRef) {
|
|
382
308
|
return this.state.unreadChannelRefs.includes(channelRef);
|
|
383
309
|
}
|
|
384
|
-
/**
|
|
385
|
-
* Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read
|
|
386
|
-
* controls). Sends `mark-read` with a fresh `requestId`; the inbox runtime advances the
|
|
387
|
-
* member's read cursor, pushes a fresh authoritative snapshot (which is what removes
|
|
388
|
-
* the dot — there is NO optimistic local clear), and answers with a
|
|
389
|
-
* `mark-read-result` naming the same request. The promise settles on that answer —
|
|
390
|
-
* never on a timer — or with `not-sent` when no socket is open, or `connection-lost`
|
|
391
|
-
* when the socket closes first.
|
|
392
|
-
*/
|
|
393
310
|
markRead(channelRef) {
|
|
394
311
|
const requestId = newCorrelationId();
|
|
395
312
|
const sent = this.sendFrame(CLIENT_KINDS.MARK_READ, { channelRef, requestId });
|
|
396
|
-
// The ref itself is never logged — only that a mark-read was written and what the
|
|
397
|
-
// unread projection looked like at that moment (the DO's snapshot is what clears it).
|
|
398
313
|
this.diag?.(DIAG.INBOX_MARK_READ, { sent, unreadCount: this.state.unreadChannelRefs.length });
|
|
399
314
|
if (!sent)
|
|
400
315
|
return Promise.resolve({ ok: false, code: 'not-sent' });
|
|
401
316
|
return new Promise((resolve) => this.pendingMarkReads.set(requestId, resolve));
|
|
402
317
|
}
|
|
403
|
-
/** Settle the mark-read the result names; a result naming no pending request is ignored. */
|
|
404
318
|
applyMarkReadResult(payload) {
|
|
405
319
|
const parsed = ChatMarkReadResultPayloadSchema.safeParse(payload);
|
|
406
320
|
if (!parsed.success) {
|
|
@@ -422,7 +336,6 @@ export class InboxClient {
|
|
|
422
336
|
for (const resolve of pending)
|
|
423
337
|
resolve(outcome);
|
|
424
338
|
}
|
|
425
|
-
/** Permanently close (auth-user switch / unmount). Idempotent. */
|
|
426
339
|
close() {
|
|
427
340
|
this.closedByUs = true;
|
|
428
341
|
this.lifecycle += 1;
|
|
@@ -433,7 +346,6 @@ export class InboxClient {
|
|
|
433
346
|
this.reconnectTimer = null;
|
|
434
347
|
}
|
|
435
348
|
if (this.socket) {
|
|
436
|
-
// Detach first: the socket's own late close is not this lifecycle's event.
|
|
437
349
|
const socket = this.socket;
|
|
438
350
|
this.socket = null;
|
|
439
351
|
socket.close(1000, 'client teardown');
|
|
@@ -442,16 +354,6 @@ export class InboxClient {
|
|
|
442
354
|
this.setState({ status: 'closed' });
|
|
443
355
|
}
|
|
444
356
|
}
|
|
445
|
-
/**
|
|
446
|
-
* Derive the per-channel unread set from a snapshot. The inbox DO's snapshot
|
|
447
|
-
* carries a per-entry `unread` boolean on every active registry entry alongside
|
|
448
|
-
* the global `hasUnread` roll-up that drives the dock dot. An entry without the
|
|
449
|
-
* field (a legacy/pre-per-entry row) simply carries no per-row dot — the dock dot
|
|
450
|
-
* still lights off `hasUnread`.
|
|
451
|
-
*
|
|
452
|
-
* ARCHIVED rows are excluded from the unread roll-up (archive = done — the DO clears
|
|
453
|
-
* unread on archive; the client also never counts them).
|
|
454
|
-
*/
|
|
455
357
|
function deriveUnreadRefs(snap) {
|
|
456
358
|
const refs = [];
|
|
457
359
|
for (const e of snap.registry) {
|