@ttt-productions/chat-react 0.12.12 → 0.12.14

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.
@@ -0,0 +1,135 @@
1
+ // OPT-IN structured client diagnostics for the realtime chat transport.
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, whether an attachment
6
+ // row mutated in place, and whether a send was reconciled or failed. This module
7
+ // is that missing half: a bounded, structured, opt-in decision log.
8
+ //
9
+ // Rules this module exists to enforce:
10
+ // - OFF BY DEFAULT and zero behavior change. When the option is absent the
11
+ // factory returns `null`, every call site is `this.diag?.(...)`, and JS
12
+ // optional-call short-circuiting means the payload object is never even
13
+ // constructed. The cost when off is one nullish check.
14
+ // - NEVER user content. Ids, seqs, states, counts, codes, booleans only —
15
+ // never message text, captions, filenames, URLs, tokens, or previews. The
16
+ // INBOX events go one step further and log SIZES only: a `channelRef` names
17
+ // one specific conversation, so registry/unread lines carry counts and
18
+ // deltas, never the refs themselves.
19
+ // - STABLE event names (`chat_client_*`) suitable for log queries and later
20
+ // alert metrics; one line per DECISION, never per render or per heartbeat.
21
+ // - A diagnostics sink can never break the client: every emit is wrapped, so a
22
+ // throwing sink is swallowed rather than turned into a transport failure.
23
+ /**
24
+ * Stable event names. These are a QUERY CONTRACT — Cloud Logging filters, E2E
25
+ * console greps, and any later alert metric key on them, so a name is added or
26
+ * retired deliberately, never renamed in place.
27
+ */
28
+ export const CHAT_CLIENT_DIAGNOSTIC_EVENTS = {
29
+ /** A socket open is being attempted (per attempt, with the reconnect cause). */
30
+ CONNECT_ATTEMPT: 'chat_client_connect_attempt',
31
+ /** The grant mint failed before a socket existed (terminal vs. retryable). */
32
+ GRANT_FAILED: 'chat_client_grant_failed',
33
+ /** The socket reached OPEN (the point the resume cursor is sent from). */
34
+ SOCKET_OPEN: 'chat_client_socket_open',
35
+ /** The socket closed, with the code and what the client decided to do next. */
36
+ SOCKET_CLOSE: 'chat_client_socket_close',
37
+ /** A reconnect was scheduled (`delayMs`) or given up on (`delayMs: null`). */
38
+ RECONNECT_SCHEDULED: 'chat_client_reconnect_scheduled',
39
+ /** The resume cursor sent to the DO. */
40
+ RESUME_REQUEST: 'chat_client_resume_request',
41
+ /** The DO's snapshot answer + how the cursor moved because of it. */
42
+ RESUME_RESULT: 'chat_client_resume_result',
43
+ /** A `resync: true` snapshot dropped the local tail (removal, not upsert). */
44
+ RESYNC_DROPPED_TAIL: 'chat_client_resync_dropped_tail',
45
+ /** A frame was applied to UI state (insert / replace-by-seq / optimistic reconcile). */
46
+ FRAME_APPLIED: 'chat_client_frame_applied',
47
+ /** A frame was NOT applied, with the reason. */
48
+ FRAME_DROPPED: 'chat_client_frame_dropped',
49
+ /** An attachment lifecycle change AS SEEN BY THE CLIENT: seq, old -> new state. */
50
+ ATTACHMENT_TRANSITION: 'chat_client_attachment_transition',
51
+ /** A moderation revision overlay was recorded (and whether a loaded row re-rendered). */
52
+ REVISION_APPLIED: 'chat_client_revision_applied',
53
+ /** A history page was requested (cursor + limit). */
54
+ HISTORY_REQUEST: 'chat_client_history_request',
55
+ /** A history page was applied: size, seq range, cursor advance. */
56
+ HISTORY_PAGE: 'chat_client_history_page',
57
+ /** An optimistic send was rendered and its frame written. */
58
+ SEND_OPTIMISTIC: 'chat_client_send_optimistic',
59
+ /** A send was acked by the server (seq assigned). */
60
+ SEND_ACK: 'chat_client_send_ack',
61
+ /** A correlated `send-rejected` (or its legacy uncorrelated form) was handled. */
62
+ SEND_REJECTED: 'chat_client_send_rejected',
63
+ /** A send was re-emitted (reconnect resume / scheduled hint / manual retry). */
64
+ SEND_RETRY: 'chat_client_send_retry',
65
+ /** A send reached its visible failed state, with the reason. */
66
+ SEND_FAILED: 'chat_client_send_failed',
67
+ /** An uncorrelated server `error` frame code. */
68
+ ERROR_FRAME: 'chat_client_error_frame',
69
+ // ---- inbox socket (one per USER, separate from the per-thread channel socket) ----
70
+ /** An inbox socket open is being attempted (per attempt, with the reconnect cause). */
71
+ INBOX_CONNECT_ATTEMPT: 'chat_client_inbox_connect_attempt',
72
+ /** The inbox grant mint failed before a socket existed (terminal vs. retryable). */
73
+ INBOX_GRANT_FAILED: 'chat_client_inbox_grant_failed',
74
+ /** The inbox socket reached OPEN (the point `resume` is sent from). */
75
+ INBOX_SOCKET_OPEN: 'chat_client_inbox_socket_open',
76
+ /** The inbox socket closed, with the code and what the client decided to do next. */
77
+ INBOX_SOCKET_CLOSE: 'chat_client_inbox_socket_close',
78
+ /** An inbox reconnect was scheduled (`delayMs`) or given up on (`delayMs: null`). */
79
+ INBOX_RECONNECT_SCHEDULED: 'chat_client_inbox_reconnect_scheduled',
80
+ /** The cursorless inbox `resume` was sent (the DO answers with a full snapshot). */
81
+ INBOX_RESUME_REQUEST: 'chat_client_inbox_resume_request',
82
+ /** An authoritative inbox snapshot was applied — registry SIZES only, never refs. */
83
+ INBOX_SNAPSHOT_APPLIED: 'chat_client_inbox_snapshot_applied',
84
+ /** The unread projection actually CHANGED (dock dot and/or per-row dot count). */
85
+ INBOX_UNREAD_UPDATED: 'chat_client_inbox_unread_updated',
86
+ /** An inbox frame was NOT applied, with the reason. */
87
+ INBOX_FRAME_DROPPED: 'chat_client_inbox_frame_dropped',
88
+ /** A mark-read was written to the socket (no optimistic local clear). */
89
+ INBOX_MARK_READ: 'chat_client_inbox_mark_read',
90
+ };
91
+ /** Max length of a server-supplied label (frame type, error code) we echo into a log line. */
92
+ const MAX_LABEL_LEN = 48;
93
+ /**
94
+ * Clamp a server-supplied label before it enters a log line. Frame types and
95
+ * error codes are protocol tokens, not user content, but they arrive from the
96
+ * wire — bound them so a hostile/buggy peer cannot emit an unbounded line.
97
+ */
98
+ export function safeDiagnosticLabel(raw) {
99
+ if (typeof raw !== 'string')
100
+ return 'unknown';
101
+ const trimmed = raw.slice(0, MAX_LABEL_LEN);
102
+ return trimmed.length > 0 ? trimmed : 'unknown';
103
+ }
104
+ /**
105
+ * The default sink: ONE `console.debug` line per decision, shaped
106
+ * `chat_client_<event> {"compact":"json"}` so a browser console capture or a log
107
+ * query can filter on the stable prefix and parse the payload without a
108
+ * multi-line join.
109
+ */
110
+ const consoleDebugSink = (event, data) => {
111
+ if (typeof console === 'undefined' || typeof console.debug !== 'function')
112
+ return;
113
+ console.debug(`${event} ${JSON.stringify(data)}`);
114
+ };
115
+ /**
116
+ * Build the emitter for a `diagnostics` config value. Returns `null` when
117
+ * diagnostics are off so call sites can use `this.diag?.(...)` — with optional
118
+ * call syntax the argument object is not constructed at all on the off path.
119
+ */
120
+ export function createDiagnosticsEmitter(option) {
121
+ if (option == null || option === false)
122
+ return null;
123
+ const sink = option === true ? consoleDebugSink : option;
124
+ return (event, data) => {
125
+ // Diagnostics observe; they must never alter transport behavior — a sink that
126
+ // throws (or a payload that cannot be serialized) is swallowed here.
127
+ try {
128
+ sink(event, data);
129
+ }
130
+ catch {
131
+ /* diagnostics are never allowed to fail the client */
132
+ }
133
+ };
134
+ }
135
+ //# sourceMappingURL=diagnostics.js.map
@@ -0,0 +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, whether an attachment\n// row mutated in place, and whether a send was reconciled or failed. This module\n// is that missing half: a bounded, structured, opt-in decision log.\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 /** 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 /** An attachment lifecycle change AS SEEN BY THE CLIENT: seq, old -> new state. */\n ATTACHMENT_TRANSITION: 'chat_client_attachment_transition',\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,6 +1,7 @@
1
1
  import { type WireRegistryEntry } from './wire.js';
2
2
  import type { SocketFactory } from './socket.js';
3
3
  import type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';
4
+ import { type ChatClientDiagnosticsOption } from './diagnostics.js';
4
5
  export interface InboxClientState {
5
6
  status: RealtimeStatus;
6
7
  /** Active (non-tombstoned) channel/invite registry entries — inbox-view visibility. */
@@ -33,6 +34,16 @@ export interface InboxClientConfig {
33
34
  maxAttempts?: number | null;
34
35
  random?: () => number;
35
36
  };
37
+ /**
38
+ * OPT-IN structured client diagnostics — the SAME option type and emitter the
39
+ * channel client uses (one owner, `./diagnostics.ts`). Default OFF: absent or
40
+ * `false` changes nothing and costs one nullish check. `true` emits one
41
+ * single-line `console.debug` entry per decision under the stable
42
+ * `chat_client_inbox_*` names; a function routes them to the app's logger.
43
+ * Inbox payloads are SIZES ONLY — registry/unread counts and deltas, never a
44
+ * `channelRef` (which names one specific conversation).
45
+ */
46
+ diagnostics?: ChatClientDiagnosticsOption;
36
47
  }
37
48
  export declare class InboxClient {
38
49
  private readonly config;
@@ -44,6 +55,14 @@ export declare class InboxClient {
44
55
  private reauthAttempted;
45
56
  private reconnectTimer;
46
57
  private closedByUs;
58
+ /** Structured diagnostics emitter, or null when off (the default, zero-overhead). */
59
+ private readonly diag;
60
+ /** Monotonic socket-open attempt counter (diagnostics correlation only). */
61
+ private connectAttempt;
62
+ /** Why the CURRENT connect attempt is happening (diagnostics only). */
63
+ private reconnectCause;
64
+ /** The last close code observed, carried into the next attempt's cause line. */
65
+ private lastCloseCode;
47
66
  constructor(config: InboxClientConfig);
48
67
  getState(): InboxClientState;
49
68
  subscribe(fn: (s: InboxClientState) => void): () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"inbox-client.d.ts","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,iBAAiB,EACvB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlF,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,aAAa,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,EAAE,aAAa,CAAC;IAC7B,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,SAAS,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAA;KAAE,CAAC;CAC/G;AAID,qBAAa,WAAW;IAUV,OAAO,CAAC,QAAQ,CAAC,MAAM;IATnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAsB;IACjD,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4C;IACtE,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,cAAc,CAA0D;IAChF,OAAO,CAAC,UAAU,CAAS;gBAEE,MAAM,EAAE,iBAAiB;IAKtD,QAAQ,IAAI,gBAAgB;IAI5B,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD,OAAO,CAAC,QAAQ;IAKV,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAOhB,UAAU;IA2BxB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,OAAO;IAqBf;;;;;;;OAOG;IACH,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,aAAa;IAerB,OAAO,CAAC,SAAS;IAMjB,yEAAyE;IACzE,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI7C;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAIrC,kEAAkE;IAClE,KAAK,IAAI,IAAI;CAad"}
1
+ {"version":3,"file":"inbox-client.d.ts","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,iBAAiB,EACvB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAkB,aAAa,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElF,OAAO,EAKL,KAAK,2BAA2B,EACjC,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,aAAa,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,EAAE,aAAa,CAAC;IAC7B,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,SAAS,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,MAAM,CAAA;KAAE,CAAC;IAC9G;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,2BAA2B,CAAC;CAC3C;AAID,qBAAa,WAAW;IAkBV,OAAO,CAAC,QAAQ,CAAC,MAAM;IAjBnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAsB;IACjD,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,KAAK,CAA6B;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4C;IACtE,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,cAAc,CAA0D;IAChF,OAAO,CAAC,UAAU,CAAS;IAC3B,qFAAqF;IACrF,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA+B;IACpD,4EAA4E;IAC5E,OAAO,CAAC,cAAc,CAAK;IAC3B,uEAAuE;IACvE,OAAO,CAAC,cAAc,CAA6E;IACnG,gFAAgF;IAChF,OAAO,CAAC,aAAa,CAAuB;gBAEf,MAAM,EAAE,iBAAiB;IAMtD,QAAQ,IAAI,gBAAgB;IAI5B,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,gBAAgB,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD,OAAO,CAAC,QAAQ;IAKV,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAQhB,UAAU;IAqCxB,OAAO,CAAC,MAAM;IAiBd,OAAO,CAAC,OAAO;IA4Bf;;;;;;;OAOG;IACH,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,iBAAiB;IAmBzB,OAAO,CAAC,SAAS;IAwCjB,OAAO,CAAC,aAAa;IAuCrB,OAAO,CAAC,SAAS;IAMjB,yEAAyE;IACzE,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI7C;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAQrC,kEAAkE;IAClE,KAAK,IAAI,IAAI;CAad"}
@@ -10,6 +10,7 @@
10
10
  import { createReconnectController } from '@ttt-productions/realtime-core';
11
11
  import { CLIENT_FRAME, CHAT_CLOSE_CODES, buildFrame, parseFrame, isInboxSnapshot, } from './wire.js';
12
12
  import { defaultTimers, isChatAccessDeniedError } from './shared.js';
13
+ import { createDiagnosticsEmitter, safeDiagnosticLabel, CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG, } from './diagnostics.js';
13
14
  const INITIAL = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [], lastErrorCode: null };
14
15
  export class InboxClient {
15
16
  config;
@@ -21,10 +22,19 @@ export class InboxClient {
21
22
  reauthAttempted = false;
22
23
  reconnectTimer = null;
23
24
  closedByUs = false;
25
+ /** Structured diagnostics emitter, or null when off (the default, zero-overhead). */
26
+ diag;
27
+ /** Monotonic socket-open attempt counter (diagnostics correlation only). */
28
+ connectAttempt = 0;
29
+ /** Why the CURRENT connect attempt is happening (diagnostics only). */
30
+ reconnectCause = 'initial';
31
+ /** The last close code observed, carried into the next attempt's cause line. */
32
+ lastCloseCode = null;
24
33
  constructor(config) {
25
34
  this.config = config;
26
35
  this.timers = config.timers ?? defaultTimers;
27
36
  this.controller = createReconnectController(config.reconnect ?? {});
37
+ this.diag = createDiagnosticsEmitter(config.diagnostics);
28
38
  }
29
39
  getState() {
30
40
  return this.state;
@@ -40,11 +50,19 @@ export class InboxClient {
40
50
  }
41
51
  async connect() {
42
52
  this.closedByUs = false;
53
+ this.reconnectCause = 'initial';
43
54
  this.controller.start();
44
55
  this.setState({ status: 'connecting' });
45
56
  await this.openSocket();
46
57
  }
47
58
  async openSocket() {
59
+ this.connectAttempt += 1;
60
+ this.diag?.(DIAG.INBOX_CONNECT_ATTEMPT, {
61
+ attempt: this.connectAttempt,
62
+ cause: this.reconnectCause,
63
+ closeCode: this.lastCloseCode,
64
+ registryCount: this.state.registry.length,
65
+ });
48
66
  let grantToken;
49
67
  try {
50
68
  grantToken = await this.config.grantProvider();
@@ -55,8 +73,11 @@ export class InboxClient {
55
73
  // package-owned ChatAccessDeniedError) must NOT reconnect: the next mint would
56
74
  // just re-deny, reconnect-looping forever and warning every cycle. Every other
57
75
  // mint failure (network/transient) stays retryable and backs off as before.
58
- if (isChatAccessDeniedError(err))
76
+ const terminal = isChatAccessDeniedError(err);
77
+ this.diag?.(DIAG.INBOX_GRANT_FAILED, { attempt: this.connectAttempt, terminal });
78
+ if (terminal)
59
79
  return this.denyAccessTerminally();
80
+ this.reconnectCause = 'grant-error';
60
81
  return this.scheduleReconnect();
61
82
  }
62
83
  if (this.closedByUs)
@@ -77,17 +98,30 @@ export class InboxClient {
77
98
  this.controller.onOpen();
78
99
  this.reauthAttempted = false;
79
100
  this.setState({ status: 'open' });
101
+ this.diag?.(DIAG.INBOX_SOCKET_OPEN, {
102
+ attempt: this.connectAttempt,
103
+ cause: this.reconnectCause,
104
+ registryCount: this.state.registry.length,
105
+ hasUnread: this.state.hasUnread,
106
+ });
80
107
  // The DO sends a snapshot on accept; a `resume` re-requests it after a reconnect gap.
81
- this.sendFrame(CLIENT_FRAME.RESUME, {});
108
+ const sent = this.sendFrame(CLIENT_FRAME.RESUME, {});
109
+ // The inbox resume is CURSORLESS by contract (the DO always answers with a full
110
+ // authoritative snapshot) — there is no `afterSeq` half to record here.
111
+ this.diag?.(DIAG.INBOX_RESUME_REQUEST, { attempt: this.connectAttempt, cursorless: true, sent });
82
112
  }
83
113
  onClose(code, _reason) {
84
114
  this.socket = null;
115
+ this.lastCloseCode = code;
85
116
  if (this.closedByUs) {
117
+ this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed' });
86
118
  this.setState({ status: 'closed' });
87
119
  return;
88
120
  }
89
121
  if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {
90
122
  this.reauthAttempted = true;
123
+ this.reconnectCause = 'auth-expired';
124
+ this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 're-grant' });
91
125
  this.setState({ status: 'reconnecting' });
92
126
  void this.openSocket();
93
127
  return;
@@ -95,9 +129,12 @@ export class InboxClient {
95
129
  if (code === CHAT_CLOSE_CODES.REVOKED) {
96
130
  this.closedByUs = true;
97
131
  this.controller.close();
132
+ this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked' });
98
133
  this.setState({ status: 'closed', lastErrorCode: 'revoked' });
99
134
  return;
100
135
  }
136
+ this.reconnectCause = 'transient-close';
137
+ this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect' });
101
138
  this.scheduleReconnect();
102
139
  }
103
140
  /**
@@ -115,6 +152,11 @@ export class InboxClient {
115
152
  }
116
153
  scheduleReconnect() {
117
154
  const delay = this.controller.onClose();
155
+ this.diag?.(DIAG.INBOX_RECONNECT_SCHEDULED, {
156
+ delayMs: delay,
157
+ cause: this.reconnectCause,
158
+ attempt: this.connectAttempt,
159
+ });
118
160
  if (delay == null) {
119
161
  this.setState({ status: 'closed' });
120
162
  return;
@@ -129,22 +171,46 @@ export class InboxClient {
129
171
  }
130
172
  onMessage(data) {
131
173
  const frame = parseFrame(data);
132
- if (!frame || !frame.payload)
174
+ if (!frame || !frame.payload) {
175
+ this.diag?.(DIAG.INBOX_FRAME_DROPPED, { kind: 'unparseable', reason: 'bad-envelope' });
133
176
  return;
177
+ }
134
178
  // The inbox DO pushes a full `snapshot`; a future lightweight `unread` push
135
179
  // carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both
136
180
  // instead of silently dropping the `unread` type (C-M2).
137
- if (frame.type !== 'snapshot' && frame.type !== 'unread')
181
+ if (frame.type !== 'snapshot' && frame.type !== 'unread') {
182
+ this.diag?.(DIAG.INBOX_FRAME_DROPPED, { kind: safeDiagnosticLabel(frame.type), reason: 'unknown-type' });
138
183
  return;
184
+ }
139
185
  const payload = frame.payload;
140
186
  if (isInboxSnapshot(payload)) {
141
- this.applySnapshot(payload);
187
+ this.applySnapshot(payload, frame.type);
142
188
  }
143
189
  else if (frame.type === 'unread' && typeof payload.hasUnread === 'boolean') {
144
- this.setState({ hasUnread: Boolean(payload.hasUnread) });
190
+ const hasUnread = Boolean(payload.hasUnread);
191
+ const before = this.state.hasUnread;
192
+ this.setState({ hasUnread });
193
+ if (before !== hasUnread) {
194
+ this.diag?.(DIAG.INBOX_UNREAD_UPDATED, {
195
+ source: 'unread-patch',
196
+ hasUnreadBefore: before,
197
+ hasUnreadAfter: hasUnread,
198
+ unreadCountBefore: this.state.unreadChannelRefs.length,
199
+ unreadCountAfter: this.state.unreadChannelRefs.length,
200
+ unreadCountDelta: 0,
201
+ });
202
+ }
203
+ }
204
+ else {
205
+ // A stray CHANNEL snapshot ({ lastMessageSeq, readSeq }) on the inbox socket —
206
+ // deliberately inert, but never silently invisible under diagnostics.
207
+ this.diag?.(DIAG.INBOX_FRAME_DROPPED, {
208
+ kind: safeDiagnosticLabel(frame.type),
209
+ reason: 'not-an-inbox-payload',
210
+ });
145
211
  }
146
212
  }
147
- applySnapshot(snap) {
213
+ applySnapshot(snap, source) {
148
214
  // Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can
149
215
  // render both the active list and the Archived toggle. `archived` is a distinct
150
216
  // dimension from `tombstoned`; archived rows are still `state: 'active'`.
@@ -152,11 +218,35 @@ export class InboxClient {
152
218
  // The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from
153
219
  // the same projection but exclude ARCHIVED rows (archive = done — an archived row
154
220
  // never shows a dot). The DO clears unread on archive, so this is belt-and-braces.
155
- this.setState({
156
- registry,
157
- hasUnread: Boolean(snap.hasUnread),
158
- unreadChannelRefs: deriveUnreadRefs(snap),
221
+ const unreadChannelRefs = deriveUnreadRefs(snap);
222
+ const hasUnread = Boolean(snap.hasUnread);
223
+ const before = this.state;
224
+ this.setState({ registry, hasUnread, unreadChannelRefs });
225
+ if (!this.diag)
226
+ return;
227
+ // SIZES ONLY — a channelRef names one specific conversation and never enters a
228
+ // diagnostic line.
229
+ this.diag(DIAG.INBOX_SNAPSHOT_APPLIED, {
230
+ source: safeDiagnosticLabel(source),
231
+ received: snap.registry.length,
232
+ // `received - active` is the tombstoned count the client filtered out.
233
+ active: registry.length,
234
+ archived: registry.filter((e) => e.archived === true).length,
235
+ hasUnread,
236
+ unreadCount: unreadChannelRefs.length,
237
+ registryDelta: registry.length - before.registry.length,
159
238
  });
239
+ const unreadChanged = before.hasUnread !== hasUnread || before.unreadChannelRefs.length !== unreadChannelRefs.length;
240
+ if (unreadChanged) {
241
+ this.diag(DIAG.INBOX_UNREAD_UPDATED, {
242
+ source: safeDiagnosticLabel(source),
243
+ hasUnreadBefore: before.hasUnread,
244
+ hasUnreadAfter: hasUnread,
245
+ unreadCountBefore: before.unreadChannelRefs.length,
246
+ unreadCountAfter: unreadChannelRefs.length,
247
+ unreadCountDelta: unreadChannelRefs.length - before.unreadChannelRefs.length,
248
+ });
249
+ }
160
250
  }
161
251
  sendFrame(type, payload) {
162
252
  if (!this.socket || !this.socket.isOpen)
@@ -177,7 +267,11 @@ export class InboxClient {
177
267
  * Returns false when the socket is not open (the caller may surface a retry).
178
268
  */
179
269
  markRead(channelRef) {
180
- return this.sendFrame(CLIENT_FRAME.MARK_READ, { channelRef });
270
+ const sent = this.sendFrame(CLIENT_FRAME.MARK_READ, { channelRef });
271
+ // The ref itself is never logged — only that a mark-read was written and what the
272
+ // unread projection looked like at that moment (the DO's snapshot is what clears it).
273
+ this.diag?.(DIAG.INBOX_MARK_READ, { sent, unreadCount: this.state.unreadChannelRefs.length });
274
+ return sent;
181
275
  }
182
276
  /** Permanently close (auth-user switch / unmount). Idempotent. */
183
277
  close() {
@@ -1 +1 @@
1
- {"version":3,"file":"inbox-client.js","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"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","sourcesContent":["// The Inbox realtime client: a SEPARATE WebSocket (scope `inbox`) to the user's\n// inbox DO. It maintains the channel/invite registry + unread projection that\n// drives the inbox view — DOTS ONLY, no counts (Contract C / \"Unread + inbox\").\n//\n// The inbox DO pushes a full `snapshot` ({ registry, hasUnread }) on connect, on\n// `resume`, and on every live delta (a projection apply). This client just\n// mirrors the latest snapshot into an observable store + a per-channel unread set.\n// Same connection lifecycle as the channel client (subprotocol auth, reconnect,\n// 4401 re-grant once, 4403 stop, auth-switch teardown).\n\nimport { createReconnectController, type ReconnectController } from '@ttt-productions/realtime-core';\nimport {\n CLIENT_FRAME,\n CHAT_CLOSE_CODES,\n buildFrame,\n parseFrame,\n isInboxSnapshot,\n type WireInboxSnapshot,\n type WireRegistryEntry,\n} from './wire.js';\nimport type { RealtimeSocket, SocketFactory } from './socket.js';\nimport type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';\nimport { defaultTimers, isChatAccessDeniedError } from './shared.js';\n\nexport interface InboxClientState {\n status: RealtimeStatus;\n /** Active (non-tombstoned) channel/invite registry entries — inbox-view visibility. */\n registry: WireRegistryEntry[];\n /** True if ANY active channel/invite has unread (drives the dock dot). */\n hasUnread: boolean;\n /** The set of channelRefs that currently carry an unread dot (per-row dots). */\n unreadChannelRefs: string[];\n /**\n * The last TERMINAL error code (mirrors `ChannelClientState.lastErrorCode`):\n * `'access-denied'` when the inbox grant provider throws `ChatAccessDeniedError`\n * (a genuine, terminal policy \"no\" — e.g. a banned/suspended account), and\n * `'revoked'` on a 4403 REVOKED close. Null while healthy and across transient\n * reconnects. The app subscribes to inbox state directly (there is no inbox React\n * hook in this package), so this stable code is the surface — it invents no new UI.\n */\n lastErrorCode: string | null;\n}\n\nexport interface InboxClientConfig {\n endpoint: string;\n /** The authenticated uid (the inbox owner; the DO serves no one else's data). */\n currentUserId: string;\n /** Mints/refreshes an INBOX-scope grant (scope `{ kind:'inbox', uid }`). */\n grantProvider: GrantProvider;\n socketFactory: SocketFactory;\n timers?: TransportTimers;\n reconnect?: { baseDelayMs?: number; maxDelayMs?: number; maxAttempts?: number | null; random?: () => number };\n}\n\nconst INITIAL: InboxClientState = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [], lastErrorCode: null };\n\nexport class InboxClient {\n private readonly timers: TransportTimers;\n private readonly controller: ReconnectController;\n private socket: RealtimeSocket | null = null;\n private state: InboxClientState = INITIAL;\n private readonly listeners = new Set<(s: InboxClientState) => void>();\n private reauthAttempted = false;\n private reconnectTimer: ReturnType<TransportTimers['setTimeout']> | null = null;\n private closedByUs = false;\n\n constructor(private readonly config: InboxClientConfig) {\n this.timers = config.timers ?? defaultTimers;\n this.controller = createReconnectController(config.reconnect ?? {});\n }\n\n getState(): InboxClientState {\n return this.state;\n }\n\n subscribe(fn: (s: InboxClientState) => void): () => void {\n this.listeners.add(fn);\n return () => this.listeners.delete(fn);\n }\n\n private setState(patch: Partial<InboxClientState>): void {\n this.state = { ...this.state, ...patch };\n for (const fn of this.listeners) fn(this.state);\n }\n\n async connect(): Promise<void> {\n this.closedByUs = false;\n this.controller.start();\n this.setState({ status: 'connecting' });\n await this.openSocket();\n }\n\n private async openSocket(): Promise<void> {\n let grantToken: string;\n try {\n grantToken = await this.config.grantProvider();\n } catch (err) {\n // A TERMINAL access denial (the app translated an authoritative policy \"no\"\n // — e.g. Firebase `permission-denied` for a banned/suspended account — into the\n // package-owned ChatAccessDeniedError) must NOT reconnect: the next mint would\n // just re-deny, reconnect-looping forever and warning every cycle. Every other\n // mint failure (network/transient) stays retryable and backs off as before.\n if (isChatAccessDeniedError(err)) return this.denyAccessTerminally();\n return this.scheduleReconnect();\n }\n if (this.closedByUs) return;\n const url = `${this.config.endpoint.replace(/\\/$/, '')}/inbox`;\n this.socket = this.config.socketFactory({\n url,\n grantToken,\n handlers: {\n onOpen: () => this.onOpen(),\n onMessage: (data) => this.onMessage(data),\n onClose: (code, reason) => this.onClose(code, reason),\n onError: () => undefined,\n },\n });\n }\n\n private onOpen(): void {\n this.controller.onOpen();\n this.reauthAttempted = false;\n this.setState({ status: 'open' });\n // The DO sends a snapshot on accept; a `resume` re-requests it after a reconnect gap.\n this.sendFrame(CLIENT_FRAME.RESUME, {});\n }\n\n private onClose(code: number, _reason: string): void {\n this.socket = null;\n if (this.closedByUs) {\n this.setState({ status: 'closed' });\n return;\n }\n if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {\n this.reauthAttempted = true;\n this.setState({ status: 'reconnecting' });\n void this.openSocket();\n return;\n }\n if (code === CHAT_CLOSE_CODES.REVOKED) {\n this.closedByUs = true;\n this.controller.close();\n this.setState({ status: 'closed', lastErrorCode: 'revoked' });\n return;\n }\n this.scheduleReconnect();\n }\n\n /**\n * Terminal access denial surfaced by the inbox grant provider\n * (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark\n * closed-by-us, close the reconnect controller, and surface the stable\n * `access-denied` code. The inbox client tracks NO optimistic/pending sends\n * (mark-read is fire-and-forget with no local pending state), so — unlike\n * ChannelClient — there is nothing to fail here; only the lifecycle stops.\n */\n private denyAccessTerminally(): void {\n this.closedByUs = true;\n this.controller.close();\n this.setState({ status: 'closed', lastErrorCode: 'access-denied' });\n }\n\n private scheduleReconnect(): void {\n const delay = this.controller.onClose();\n if (delay == null) {\n this.setState({ status: 'closed' });\n return;\n }\n this.setState({ status: 'reconnecting' });\n this.reconnectTimer = this.timers.setTimeout(() => {\n this.reconnectTimer = null;\n if (this.closedByUs) return;\n void this.openSocket();\n }, delay);\n }\n\n private onMessage(data: string): void {\n const frame = parseFrame(data);\n if (!frame || !frame.payload) return;\n // The inbox DO pushes a full `snapshot`; a future lightweight `unread` push\n // carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both\n // instead of silently dropping the `unread` type (C-M2).\n if (frame.type !== 'snapshot' && frame.type !== 'unread') return;\n const payload = frame.payload as unknown as WireInboxSnapshot | { hasUnread: boolean };\n if (isInboxSnapshot(payload as WireInboxSnapshot)) {\n this.applySnapshot(payload as WireInboxSnapshot);\n } else if (frame.type === 'unread' && typeof (payload as { hasUnread?: unknown }).hasUnread === 'boolean') {\n this.setState({ hasUnread: Boolean((payload as { hasUnread: boolean }).hasUnread) });\n }\n }\n\n private applySnapshot(snap: WireInboxSnapshot): void {\n // Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can\n // render both the active list and the Archived toggle. `archived` is a distinct\n // dimension from `tombstoned`; archived rows are still `state: 'active'`.\n const registry = snap.registry.filter((e) => e.state === 'active');\n // The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from\n // the same projection but exclude ARCHIVED rows (archive = done — an archived row\n // never shows a dot). The DO clears unread on archive, so this is belt-and-braces.\n this.setState({\n registry,\n hasUnread: Boolean(snap.hasUnread),\n unreadChannelRefs: deriveUnreadRefs(snap),\n });\n }\n\n private sendFrame(type: string, payload: Record<string, unknown>): boolean {\n if (!this.socket || !this.socket.isOpen) return false;\n this.socket.send(buildFrame(type, payload));\n return true;\n }\n\n /** True if a specific channel/invite currently carries an unread dot. */\n channelHasUnread(channelRef: string): boolean {\n return this.state.unreadChannelRefs.includes(channelRef);\n }\n\n /**\n * Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read\n * controls). Sends `mark-read` on the inbox socket; the inbox DO advances the\n * member's read cursor to tail on the channel DO and then pushes a fresh\n * authoritative snapshot, which is what removes the row — there is NO optimistic\n * local clear, so a failed mark-read honestly leaves the dot in place.\n * Returns false when the socket is not open (the caller may surface a retry).\n */\n markRead(channelRef: string): boolean {\n return this.sendFrame(CLIENT_FRAME.MARK_READ, { channelRef });\n }\n\n /** Permanently close (auth-user switch / unmount). Idempotent. */\n close(): void {\n this.closedByUs = true;\n this.controller.close();\n if (this.reconnectTimer != null) {\n this.timers.clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n if (this.socket) {\n this.socket.close(1000, 'client teardown');\n this.socket = null;\n }\n this.setState({ status: 'closed' });\n }\n}\n\n/**\n * Derive the per-channel unread set from a snapshot. The current inbox DO snapshot\n * exposes a single `hasUnread` boolean (dock dot) + the active registry; it does\n * NOT yet enumerate which entries are unread. We surface a per-entry `unread`\n * field when the DO provides it (forward-compatible), else fall back to empty\n * per-row dots (the dock dot still lights). See the ASSUMPTIONS note in the docs.\n *\n * ARCHIVED rows are excluded from the unread roll-up (archive = done — the DO clears\n * unread on archive; the client also never counts them).\n */\nfunction deriveUnreadRefs(snap: WireInboxSnapshot): string[] {\n const refs: string[] = [];\n for (const e of snap.registry) {\n if (e.state !== 'active') continue;\n if (e.archived === true) continue;\n if (e.unread === true) refs.push(e.channelRef);\n }\n return refs;\n}\n"]}
1
+ {"version":3,"file":"inbox-client.js","sourceRoot":"","sources":["../../src/realtime/inbox-client.ts"],"names":[],"mappings":"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","sourcesContent":["// The Inbox realtime client: a SEPARATE WebSocket (scope `inbox`) to the user's\n// inbox DO. It maintains the channel/invite registry + unread projection that\n// drives the inbox view — DOTS ONLY, no counts (Contract C / \"Unread + inbox\").\n//\n// The inbox DO pushes a full `snapshot` ({ registry, hasUnread }) on connect, on\n// `resume`, and on every live delta (a projection apply). This client just\n// mirrors the latest snapshot into an observable store + a per-channel unread set.\n// Same connection lifecycle as the channel client (subprotocol auth, reconnect,\n// 4401 re-grant once, 4403 stop, auth-switch teardown).\n\nimport { createReconnectController, type ReconnectController } from '@ttt-productions/realtime-core';\nimport {\n CLIENT_FRAME,\n CHAT_CLOSE_CODES,\n buildFrame,\n parseFrame,\n isInboxSnapshot,\n type WireInboxSnapshot,\n type WireRegistryEntry,\n} from './wire.js';\nimport type { RealtimeSocket, SocketFactory } from './socket.js';\nimport type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';\nimport { defaultTimers, isChatAccessDeniedError } from './shared.js';\nimport {\n createDiagnosticsEmitter,\n safeDiagnosticLabel,\n CHAT_CLIENT_DIAGNOSTIC_EVENTS as DIAG,\n type ChatClientDiagnosticsEmitter,\n type ChatClientDiagnosticsOption,\n} from './diagnostics.js';\n\nexport interface InboxClientState {\n status: RealtimeStatus;\n /** Active (non-tombstoned) channel/invite registry entries — inbox-view visibility. */\n registry: WireRegistryEntry[];\n /** True if ANY active channel/invite has unread (drives the dock dot). */\n hasUnread: boolean;\n /** The set of channelRefs that currently carry an unread dot (per-row dots). */\n unreadChannelRefs: string[];\n /**\n * The last TERMINAL error code (mirrors `ChannelClientState.lastErrorCode`):\n * `'access-denied'` when the inbox grant provider throws `ChatAccessDeniedError`\n * (a genuine, terminal policy \"no\" — e.g. a banned/suspended account), and\n * `'revoked'` on a 4403 REVOKED close. Null while healthy and across transient\n * reconnects. The app subscribes to inbox state directly (there is no inbox React\n * hook in this package), so this stable code is the surface — it invents no new UI.\n */\n lastErrorCode: string | null;\n}\n\nexport interface InboxClientConfig {\n endpoint: string;\n /** The authenticated uid (the inbox owner; the DO serves no one else's data). */\n currentUserId: string;\n /** Mints/refreshes an INBOX-scope grant (scope `{ kind:'inbox', uid }`). */\n grantProvider: GrantProvider;\n socketFactory: SocketFactory;\n timers?: TransportTimers;\n reconnect?: { baseDelayMs?: number; maxDelayMs?: number; maxAttempts?: number | null; random?: () => number };\n /**\n * OPT-IN structured client diagnostics — the SAME option type and emitter the\n * channel client uses (one owner, `./diagnostics.ts`). Default OFF: absent or\n * `false` changes nothing and costs one nullish check. `true` emits one\n * single-line `console.debug` entry per decision under the stable\n * `chat_client_inbox_*` names; a function routes them to the app's logger.\n * Inbox payloads are SIZES ONLY — registry/unread counts and deltas, never a\n * `channelRef` (which names one specific conversation).\n */\n diagnostics?: ChatClientDiagnosticsOption;\n}\n\nconst INITIAL: InboxClientState = { status: 'idle', registry: [], hasUnread: false, unreadChannelRefs: [], lastErrorCode: null };\n\nexport class InboxClient {\n private readonly timers: TransportTimers;\n private readonly controller: ReconnectController;\n private socket: RealtimeSocket | null = null;\n private state: InboxClientState = INITIAL;\n private readonly listeners = new Set<(s: InboxClientState) => void>();\n private reauthAttempted = false;\n private reconnectTimer: ReturnType<TransportTimers['setTimeout']> | null = null;\n private closedByUs = false;\n /** Structured diagnostics emitter, or null when off (the default, zero-overhead). */\n private readonly diag: ChatClientDiagnosticsEmitter;\n /** Monotonic socket-open attempt counter (diagnostics correlation only). */\n private connectAttempt = 0;\n /** Why the CURRENT connect attempt is happening (diagnostics only). */\n private reconnectCause: 'initial' | 'auth-expired' | 'transient-close' | 'grant-error' = 'initial';\n /** The last close code observed, carried into the next attempt's cause line. */\n private lastCloseCode: number | null = null;\n\n constructor(private readonly config: InboxClientConfig) {\n this.timers = config.timers ?? defaultTimers;\n this.controller = createReconnectController(config.reconnect ?? {});\n this.diag = createDiagnosticsEmitter(config.diagnostics);\n }\n\n getState(): InboxClientState {\n return this.state;\n }\n\n subscribe(fn: (s: InboxClientState) => void): () => void {\n this.listeners.add(fn);\n return () => this.listeners.delete(fn);\n }\n\n private setState(patch: Partial<InboxClientState>): void {\n this.state = { ...this.state, ...patch };\n for (const fn of this.listeners) fn(this.state);\n }\n\n async connect(): Promise<void> {\n this.closedByUs = false;\n this.reconnectCause = 'initial';\n this.controller.start();\n this.setState({ status: 'connecting' });\n await this.openSocket();\n }\n\n private async openSocket(): Promise<void> {\n this.connectAttempt += 1;\n this.diag?.(DIAG.INBOX_CONNECT_ATTEMPT, {\n attempt: this.connectAttempt,\n cause: this.reconnectCause,\n closeCode: this.lastCloseCode,\n registryCount: this.state.registry.length,\n });\n let grantToken: string;\n try {\n grantToken = await this.config.grantProvider();\n } catch (err) {\n // A TERMINAL access denial (the app translated an authoritative policy \"no\"\n // — e.g. Firebase `permission-denied` for a banned/suspended account — into the\n // package-owned ChatAccessDeniedError) must NOT reconnect: the next mint would\n // just re-deny, reconnect-looping forever and warning every cycle. Every other\n // mint failure (network/transient) stays retryable and backs off as before.\n const terminal = isChatAccessDeniedError(err);\n this.diag?.(DIAG.INBOX_GRANT_FAILED, { attempt: this.connectAttempt, terminal });\n if (terminal) return this.denyAccessTerminally();\n this.reconnectCause = 'grant-error';\n return this.scheduleReconnect();\n }\n if (this.closedByUs) return;\n const url = `${this.config.endpoint.replace(/\\/$/, '')}/inbox`;\n this.socket = this.config.socketFactory({\n url,\n grantToken,\n handlers: {\n onOpen: () => this.onOpen(),\n onMessage: (data) => this.onMessage(data),\n onClose: (code, reason) => this.onClose(code, reason),\n onError: () => undefined,\n },\n });\n }\n\n private onOpen(): void {\n this.controller.onOpen();\n this.reauthAttempted = false;\n this.setState({ status: 'open' });\n this.diag?.(DIAG.INBOX_SOCKET_OPEN, {\n attempt: this.connectAttempt,\n cause: this.reconnectCause,\n registryCount: this.state.registry.length,\n hasUnread: this.state.hasUnread,\n });\n // The DO sends a snapshot on accept; a `resume` re-requests it after a reconnect gap.\n const sent = this.sendFrame(CLIENT_FRAME.RESUME, {});\n // The inbox resume is CURSORLESS by contract (the DO always answers with a full\n // authoritative snapshot) — there is no `afterSeq` half to record here.\n this.diag?.(DIAG.INBOX_RESUME_REQUEST, { attempt: this.connectAttempt, cursorless: true, sent });\n }\n\n private onClose(code: number, _reason: string): void {\n this.socket = null;\n this.lastCloseCode = code;\n if (this.closedByUs) {\n this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: true, outcome: 'closed' });\n this.setState({ status: 'closed' });\n return;\n }\n if (code === CHAT_CLOSE_CODES.AUTH_EXPIRED && !this.reauthAttempted) {\n this.reauthAttempted = true;\n this.reconnectCause = 'auth-expired';\n this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 're-grant' });\n this.setState({ status: 'reconnecting' });\n void this.openSocket();\n return;\n }\n if (code === CHAT_CLOSE_CODES.REVOKED) {\n this.closedByUs = true;\n this.controller.close();\n this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 'revoked' });\n this.setState({ status: 'closed', lastErrorCode: 'revoked' });\n return;\n }\n this.reconnectCause = 'transient-close';\n this.diag?.(DIAG.INBOX_SOCKET_CLOSE, { code, closedByUs: false, outcome: 'reconnect' });\n this.scheduleReconnect();\n }\n\n /**\n * Terminal access denial surfaced by the inbox grant provider\n * (`ChatAccessDeniedError`). Same terminal posture as the 4403 REVOKED close: mark\n * closed-by-us, close the reconnect controller, and surface the stable\n * `access-denied` code. The inbox client tracks NO optimistic/pending sends\n * (mark-read is fire-and-forget with no local pending state), so — unlike\n * ChannelClient — there is nothing to fail here; only the lifecycle stops.\n */\n private denyAccessTerminally(): void {\n this.closedByUs = true;\n this.controller.close();\n this.setState({ status: 'closed', lastErrorCode: 'access-denied' });\n }\n\n private scheduleReconnect(): void {\n const delay = this.controller.onClose();\n this.diag?.(DIAG.INBOX_RECONNECT_SCHEDULED, {\n delayMs: delay,\n cause: this.reconnectCause,\n attempt: this.connectAttempt,\n });\n if (delay == null) {\n this.setState({ status: 'closed' });\n return;\n }\n this.setState({ status: 'reconnecting' });\n this.reconnectTimer = this.timers.setTimeout(() => {\n this.reconnectTimer = null;\n if (this.closedByUs) return;\n void this.openSocket();\n }, delay);\n }\n\n private onMessage(data: string): void {\n const frame = parseFrame(data);\n if (!frame || !frame.payload) {\n this.diag?.(DIAG.INBOX_FRAME_DROPPED, { kind: 'unparseable', reason: 'bad-envelope' });\n return;\n }\n // The inbox DO pushes a full `snapshot`; a future lightweight `unread` push\n // carries either a full snapshot or a `{ hasUnread }` dock-dot patch. Route both\n // instead of silently dropping the `unread` type (C-M2).\n if (frame.type !== 'snapshot' && frame.type !== 'unread') {\n this.diag?.(DIAG.INBOX_FRAME_DROPPED, { kind: safeDiagnosticLabel(frame.type), reason: 'unknown-type' });\n return;\n }\n const payload = frame.payload as unknown as WireInboxSnapshot | { hasUnread: boolean };\n if (isInboxSnapshot(payload as WireInboxSnapshot)) {\n this.applySnapshot(payload as WireInboxSnapshot, frame.type);\n } else if (frame.type === 'unread' && typeof (payload as { hasUnread?: unknown }).hasUnread === 'boolean') {\n const hasUnread = Boolean((payload as { hasUnread: boolean }).hasUnread);\n const before = this.state.hasUnread;\n this.setState({ hasUnread });\n if (before !== hasUnread) {\n this.diag?.(DIAG.INBOX_UNREAD_UPDATED, {\n source: 'unread-patch',\n hasUnreadBefore: before,\n hasUnreadAfter: hasUnread,\n unreadCountBefore: this.state.unreadChannelRefs.length,\n unreadCountAfter: this.state.unreadChannelRefs.length,\n unreadCountDelta: 0,\n });\n }\n } else {\n // A stray CHANNEL snapshot ({ lastMessageSeq, readSeq }) on the inbox socket —\n // deliberately inert, but never silently invisible under diagnostics.\n this.diag?.(DIAG.INBOX_FRAME_DROPPED, {\n kind: safeDiagnosticLabel(frame.type),\n reason: 'not-an-inbox-payload',\n });\n }\n }\n\n private applySnapshot(snap: WireInboxSnapshot, source: string): void {\n // Keep every non-tombstoned entry — including ARCHIVED rows — so the inbox view can\n // render both the active list and the Archived toggle. `archived` is a distinct\n // dimension from `tombstoned`; archived rows are still `state: 'active'`.\n const registry = snap.registry.filter((e) => e.state === 'active');\n // The DO's `hasUnread` is the authoritative dock dot. Per-row dots are derived from\n // the same projection but exclude ARCHIVED rows (archive = done — an archived row\n // never shows a dot). The DO clears unread on archive, so this is belt-and-braces.\n const unreadChannelRefs = deriveUnreadRefs(snap);\n const hasUnread = Boolean(snap.hasUnread);\n const before = this.state;\n this.setState({ registry, hasUnread, unreadChannelRefs });\n if (!this.diag) return;\n // SIZES ONLY — a channelRef names one specific conversation and never enters a\n // diagnostic line.\n this.diag(DIAG.INBOX_SNAPSHOT_APPLIED, {\n source: safeDiagnosticLabel(source),\n received: snap.registry.length,\n // `received - active` is the tombstoned count the client filtered out.\n active: registry.length,\n archived: registry.filter((e) => e.archived === true).length,\n hasUnread,\n unreadCount: unreadChannelRefs.length,\n registryDelta: registry.length - before.registry.length,\n });\n const unreadChanged =\n before.hasUnread !== hasUnread || before.unreadChannelRefs.length !== unreadChannelRefs.length;\n if (unreadChanged) {\n this.diag(DIAG.INBOX_UNREAD_UPDATED, {\n source: safeDiagnosticLabel(source),\n hasUnreadBefore: before.hasUnread,\n hasUnreadAfter: hasUnread,\n unreadCountBefore: before.unreadChannelRefs.length,\n unreadCountAfter: unreadChannelRefs.length,\n unreadCountDelta: unreadChannelRefs.length - before.unreadChannelRefs.length,\n });\n }\n }\n\n private sendFrame(type: string, payload: Record<string, unknown>): boolean {\n if (!this.socket || !this.socket.isOpen) return false;\n this.socket.send(buildFrame(type, payload));\n return true;\n }\n\n /** True if a specific channel/invite currently carries an unread dot. */\n channelHasUnread(channelRef: string): boolean {\n return this.state.unreadChannelRefs.includes(channelRef);\n }\n\n /**\n * Clear a channel/invite's unread WITHOUT opening it (the inbox-view mark-read\n * controls). Sends `mark-read` on the inbox socket; the inbox DO advances the\n * member's read cursor to tail on the channel DO and then pushes a fresh\n * authoritative snapshot, which is what removes the row — there is NO optimistic\n * local clear, so a failed mark-read honestly leaves the dot in place.\n * Returns false when the socket is not open (the caller may surface a retry).\n */\n markRead(channelRef: string): boolean {\n const sent = this.sendFrame(CLIENT_FRAME.MARK_READ, { channelRef });\n // The ref itself is never logged — only that a mark-read was written and what the\n // unread projection looked like at that moment (the DO's snapshot is what clears it).\n this.diag?.(DIAG.INBOX_MARK_READ, { sent, unreadCount: this.state.unreadChannelRefs.length });\n return sent;\n }\n\n /** Permanently close (auth-user switch / unmount). Idempotent. */\n close(): void {\n this.closedByUs = true;\n this.controller.close();\n if (this.reconnectTimer != null) {\n this.timers.clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n if (this.socket) {\n this.socket.close(1000, 'client teardown');\n this.socket = null;\n }\n this.setState({ status: 'closed' });\n }\n}\n\n/**\n * Derive the per-channel unread set from a snapshot. The current inbox DO snapshot\n * exposes a single `hasUnread` boolean (dock dot) + the active registry; it does\n * NOT yet enumerate which entries are unread. We surface a per-entry `unread`\n * field when the DO provides it (forward-compatible), else fall back to empty\n * per-row dots (the dock dot still lights). See the ASSUMPTIONS note in the docs.\n *\n * ARCHIVED rows are excluded from the unread roll-up (archive = done — the DO clears\n * unread on archive; the client also never counts them).\n */\nfunction deriveUnreadRefs(snap: WireInboxSnapshot): string[] {\n const refs: string[] = [];\n for (const e of snap.registry) {\n if (e.state !== 'active') continue;\n if (e.archived === true) continue;\n if (e.unread === true) refs.push(e.channelRef);\n }\n return refs;\n}\n"]}
@@ -6,6 +6,8 @@ export type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js
6
6
  export { defaultTimers, ChatAccessDeniedError, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, } from './shared.js';
7
7
  export { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, MODERATION_REDACTED_TEXT, } from './map.js';
8
8
  export type { ModerationOverlay } from './map.js';
9
+ export { CHAT_CLIENT_DIAGNOSTIC_EVENTS } from './diagnostics.js';
10
+ export type { ChatClientDiagnosticsOption, ChatClientDiagnosticsSink, ChatClientDiagnosticEventName, } from './diagnostics.js';
9
11
  export { ChannelClient } from './channel-client.js';
10
12
  export type { ChannelClientState, ChannelClientConfig } from './channel-client.js';
11
13
  export { InboxClient } from './inbox-client.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,EACV,eAAe,GAChB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,eAAe,EACf,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AAEnB,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClF,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,uBAAuB,EACvB,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,sBAAsB,EACtB,cAAc,EACd,wBAAwB,GACzB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAElD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AACnF,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EACL,wBAAwB,EACxB,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAElF,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,YAAY,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,EACV,eAAe,GAChB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,eAAe,EACf,aAAa,EACb,cAAc,EACd,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,YAAY,GACb,MAAM,WAAW,CAAC;AAEnB,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnD,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClF,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,uBAAuB,EACvB,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,sBAAsB,EACtB,cAAc,EACd,wBAAwB,GACzB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAElD,OAAO,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AACjE,YAAY,EACV,2BAA2B,EAC3B,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AACnF,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EACL,wBAAwB,EACxB,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAElF,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,YAAY,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC"}
@@ -7,6 +7,7 @@ export { CHAT_SUBPROTOCOL, CHAT_WIRE_VERSION, CHAT_CLOSE_CODES, CLIENT_FRAME, SE
7
7
  export { browserSocketFactory } from './socket.js';
8
8
  export { defaultTimers, ChatAccessDeniedError, isChatAccessDeniedError, HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX, } from './shared.js';
9
9
  export { wireRowToMessage, optimisticMessage, seqToMessageId, applyModerationOverlay, overlayFromRow, MODERATION_REDACTED_TEXT, } from './map.js';
10
+ export { CHAT_CLIENT_DIAGNOSTIC_EVENTS } from './diagnostics.js';
10
11
  export { ChannelClient } from './channel-client.js';
11
12
  export { InboxClient } from './inbox-client.js';
12
13
  export { createRealtimeChatClient, createInboxClient, } from './transport.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,+EAA+E;AAC/E,sEAAsE;AACtE,uEAAuE;AACvE,mCAAmC;AAEnC,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,EACV,eAAe,GAChB,MAAM,WAAW,CAAC;AAanB,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAGnD,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,uBAAuB,EACvB,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,sBAAsB,EACtB,cAAc,EACd,wBAAwB,GACzB,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhD,OAAO,EACL,wBAAwB,EACxB,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC","sourcesContent":["// The realtime (Cloudflare Durable Object) chat transport — the client half of\n// the chat Worker wire protocol (Contract A connection + Contract C wire). The\n// FIRESTORE transport stays the unchanged default; this is opt-in via\n// `ChatCoreConfig.transport === 'realtime'` + a `realtime.client` from\n// `createRealtimeChatClient(...)`.\n\nexport {\n CHAT_SUBPROTOCOL,\n CHAT_WIRE_VERSION,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n isInboxSnapshot,\n} from './wire.js';\nexport type {\n ChannelRefTuple,\n ChatCloseCode,\n WireMessageRow,\n WireChannelSnapshot,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n RevisionKind,\n} from './wire.js';\n\nexport type { RealtimeSocket, SocketFactory, SocketHandlers } from './socket.js';\nexport { browserSocketFactory } from './socket.js';\n\nexport type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';\nexport {\n defaultTimers,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n HEARTBEAT_MS,\n TYPING_COALESCE_MS,\n HISTORY_PAGE_MAX,\n} from './shared.js';\n\nexport {\n wireRowToMessage,\n optimisticMessage,\n seqToMessageId,\n applyModerationOverlay,\n overlayFromRow,\n MODERATION_REDACTED_TEXT,\n} from './map.js';\nexport type { ModerationOverlay } from './map.js';\n\nexport { ChannelClient } from './channel-client.js';\nexport type { ChannelClientState, ChannelClientConfig } from './channel-client.js';\nexport { InboxClient } from './inbox-client.js';\nexport type { InboxClientState, InboxClientConfig } from './inbox-client.js';\n\nexport {\n createRealtimeChatClient,\n createInboxClient,\n} from './transport.js';\nexport type { RealtimeChatClient, RealtimeTransportConfig } from './transport.js';\n\nexport { useRealtimeChatMessages } from './useRealtimeChatMessages.js';\nexport type { UseRealtimeChatMessagesResult } from './useRealtimeChatMessages.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,+EAA+E;AAC/E,sEAAsE;AACtE,uEAAuE;AACvE,mCAAmC;AAEnC,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,UAAU,EACV,eAAe,GAChB,MAAM,WAAW,CAAC;AAanB,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAGnD,OAAO,EACL,aAAa,EACb,qBAAqB,EACrB,uBAAuB,EACvB,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,cAAc,EACd,sBAAsB,EACtB,cAAc,EACd,wBAAwB,GACzB,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AAOjE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhD,OAAO,EACL,wBAAwB,EACxB,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC","sourcesContent":["// The realtime (Cloudflare Durable Object) chat transport — the client half of\n// the chat Worker wire protocol (Contract A connection + Contract C wire). The\n// FIRESTORE transport stays the unchanged default; this is opt-in via\n// `ChatCoreConfig.transport === 'realtime'` + a `realtime.client` from\n// `createRealtimeChatClient(...)`.\n\nexport {\n CHAT_SUBPROTOCOL,\n CHAT_WIRE_VERSION,\n CHAT_CLOSE_CODES,\n CLIENT_FRAME,\n SERVER_FRAME,\n buildFrame,\n parseFrame,\n isInboxSnapshot,\n} from './wire.js';\nexport type {\n ChannelRefTuple,\n ChatCloseCode,\n WireMessageRow,\n WireChannelSnapshot,\n WireInboxSnapshot,\n WireRegistryEntry,\n ServerFrame,\n RevisionKind,\n} from './wire.js';\n\nexport type { RealtimeSocket, SocketFactory, SocketHandlers } from './socket.js';\nexport { browserSocketFactory } from './socket.js';\n\nexport type { GrantProvider, TransportTimers, RealtimeStatus } from './shared.js';\nexport {\n defaultTimers,\n ChatAccessDeniedError,\n isChatAccessDeniedError,\n HEARTBEAT_MS,\n TYPING_COALESCE_MS,\n HISTORY_PAGE_MAX,\n} from './shared.js';\n\nexport {\n wireRowToMessage,\n optimisticMessage,\n seqToMessageId,\n applyModerationOverlay,\n overlayFromRow,\n MODERATION_REDACTED_TEXT,\n} from './map.js';\nexport type { ModerationOverlay } from './map.js';\n\nexport { CHAT_CLIENT_DIAGNOSTIC_EVENTS } from './diagnostics.js';\nexport type {\n ChatClientDiagnosticsOption,\n ChatClientDiagnosticsSink,\n ChatClientDiagnosticEventName,\n} from './diagnostics.js';\n\nexport { ChannelClient } from './channel-client.js';\nexport type { ChannelClientState, ChannelClientConfig } from './channel-client.js';\nexport { InboxClient } from './inbox-client.js';\nexport type { InboxClientState, InboxClientConfig } from './inbox-client.js';\n\nexport {\n createRealtimeChatClient,\n createInboxClient,\n} from './transport.js';\nexport type { RealtimeChatClient, RealtimeTransportConfig } from './transport.js';\n\nexport { useRealtimeChatMessages } from './useRealtimeChatMessages.js';\nexport type { UseRealtimeChatMessagesResult } from './useRealtimeChatMessages.js';\n"]}
@@ -2,6 +2,7 @@ import { ChannelClient, type ChannelClientConfig, type ChannelClientState } from
2
2
  import { InboxClient, type InboxClientConfig, type InboxClientState } from './inbox-client.js';
3
3
  import { type SocketFactory } from './socket.js';
4
4
  import type { GrantProvider, TransportTimers } from './shared.js';
5
+ import type { ChatClientDiagnosticsOption } from './diagnostics.js';
5
6
  import type { ChannelRefTuple } from './wire.js';
6
7
  /**
7
8
  * Everything the channel transport needs. The app supplies `grantProvider`
@@ -17,6 +18,14 @@ export interface RealtimeTransportConfig {
17
18
  socketFactory?: SocketFactory;
18
19
  timers?: TransportTimers;
19
20
  reconnect?: ChannelClientConfig['reconnect'];
21
+ /**
22
+ * OPT-IN structured client diagnostics, forwarded to the ChannelClient. Absent
23
+ * or `false` (the default) changes nothing and costs one nullish check; `true`
24
+ * emits one single-line `console.debug` entry per client DECISION with a stable
25
+ * `chat_client_*` event name; a function routes those entries into the app's own
26
+ * logger. Content-free by construction (ids/seqs/states/counts/codes only).
27
+ */
28
+ diagnostics?: ChatClientDiagnosticsOption;
20
29
  }
21
30
  /**
22
31
  * The opaque realtime client handle stored on `ChatRealtimeTransportConfig.client`.
@@ -43,6 +52,8 @@ export declare function createInboxClient(config: {
43
52
  socketFactory?: SocketFactory;
44
53
  timers?: TransportTimers;
45
54
  reconnect?: InboxClientConfig['reconnect'];
55
+ /** OPT-IN structured diagnostics, forwarded to the InboxClient (default OFF). */
56
+ diagnostics?: ChatClientDiagnosticsOption;
46
57
  }): InboxClient;
47
58
  export type { ChannelClientState, InboxClientState };
48
59
  //# sourceMappingURL=transport.d.ts.map