@ttt-productions/chat-react 0.13.6 → 0.13.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/ChatNameResolverContext.d.ts +24 -16
- package/dist/context/ChatNameResolverContext.d.ts.map +1 -1
- package/dist/context/ChatNameResolverContext.js +15 -19
- package/dist/context/ChatNameResolverContext.js.map +1 -1
- package/dist/hooks/useChatMessages.d.ts +9 -0
- package/dist/hooks/useChatMessages.d.ts.map +1 -1
- package/dist/hooks/useChatMessages.js +10 -10
- package/dist/hooks/useChatMessages.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/realtime/channel-client.d.ts +32 -0
- package/dist/realtime/channel-client.d.ts.map +1 -1
- package/dist/realtime/channel-client.js +114 -15
- package/dist/realtime/channel-client.js.map +1 -1
- package/dist/realtime/inbox-client.d.ts +50 -6
- package/dist/realtime/inbox-client.d.ts.map +1 -1
- package/dist/realtime/inbox-client.js +133 -18
- package/dist/realtime/inbox-client.js.map +1 -1
- package/dist/realtime/index.d.ts +3 -3
- package/dist/realtime/index.d.ts.map +1 -1
- package/dist/realtime/index.js +1 -1
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/initial-load-budget.d.ts +31 -0
- package/dist/realtime/initial-load-budget.d.ts.map +1 -0
- package/dist/realtime/initial-load-budget.js +69 -0
- package/dist/realtime/initial-load-budget.js.map +1 -0
- package/dist/realtime/map.d.ts.map +1 -1
- package/dist/realtime/map.js +3 -2
- package/dist/realtime/map.js.map +1 -1
- package/dist/realtime/shared.d.ts +13 -0
- package/dist/realtime/shared.d.ts.map +1 -1
- package/dist/realtime/shared.js +18 -0
- package/dist/realtime/shared.js.map +1 -1
- package/dist/realtime/transport.d.ts +6 -3
- package/dist/realtime/transport.d.ts.map +1 -1
- package/dist/realtime/transport.js +1 -0
- package/dist/realtime/transport.js.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.d.ts +4 -0
- package/dist/realtime/useRealtimeChatMessages.d.ts.map +1 -1
- package/dist/realtime/useRealtimeChatMessages.js +7 -8
- package/dist/realtime/useRealtimeChatMessages.js.map +1 -1
- package/dist/realtime/wire.d.ts +11 -6
- package/dist/realtime/wire.d.ts.map +1 -1
- package/dist/realtime/wire.js +2 -2
- package/dist/realtime/wire.js.map +1 -1
- package/dist/types.d.ts +16 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/ChatShell.d.ts +27 -1
- package/dist/ui/ChatShell.d.ts.map +1 -1
- package/dist/ui/ChatShell.js +27 -7
- package/dist/ui/ChatShell.js.map +1 -1
- package/dist/ui/Composer.d.ts.map +1 -1
- package/dist/ui/Composer.js +4 -2
- package/dist/ui/Composer.js.map +1 -1
- package/dist/ui/MessageItemDefault.d.ts +1 -1
- package/dist/ui/MessageItemDefault.d.ts.map +1 -1
- package/dist/ui/MessageItemDefault.js +10 -6
- package/dist/ui/MessageItemDefault.js.map +1 -1
- package/dist/ui/MessageList.d.ts +7 -1
- package/dist/ui/MessageList.d.ts.map +1 -1
- package/dist/ui/MessageList.js +6 -5
- package/dist/ui/MessageList.js.map +1 -1
- package/dist/ui/SenderName.d.ts +9 -0
- package/dist/ui/SenderName.d.ts.map +1 -0
- package/dist/ui/SenderName.js +16 -0
- package/dist/ui/SenderName.js.map +1 -0
- package/package.json +12 -12
- package/dist/hooks/useChatThreadAccess.d.ts +0 -8
- package/dist/hooks/useChatThreadAccess.d.ts.map +0 -1
- package/dist/hooks/useChatThreadAccess.js +0 -19
- package/dist/hooks/useChatThreadAccess.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"initial-load-budget.js","sourceRoot":"","sources":["../../src/realtime/initial-load-budget.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,mGAAmG;AACnG,iGAAiG;AACjG,gGAAgG;AAChG,oEAAoE;AAEpE,OAAO,EAAE,mCAAmC,EAAwB,MAAM,aAAa,CAAC;AAExF,MAAM,OAAO,iBAAiB;IAMT;IAEA;IAPnB,4FAA4F;IACpF,SAAS,GAAkB,IAAI,CAAC;IAChC,KAAK,GAAqD,IAAI,CAAC;IAEvE,YACmB,MAAuB;IACxC,0DAA0D;IACzC,WAAuB;QAFvB,WAAM,GAAN,MAAM,CAAiB;QAEvB,gBAAW,GAAX,WAAW,CAAY;IACvC,CAAC;IAEJ;;;OAGG;IACH,KAAK,CAAC,KAAc;QAClB,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;YAAE,OAAO;QAC7C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC;QACnC,IAAI,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;IAChD,CAAC;IAED,oGAAoG;IACpG,iBAAiB;QACf,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;YAAE,OAAO;QACnC,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,SAAS,IAAI,mCAAmC;YAAE,IAAI,CAAC,WAAW,EAAE,CAAC;IACpG,CAAC;IAED;;;OAGG;IACH,WAAW;QACT,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;YAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC;QAC/D,IAAI,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;IAChD,CAAC;IAED,6EAA6E;IAC7E,MAAM;QACJ,IAAI,CAAC,UAAU,EAAE,CAAC;QAClB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;IACxB,CAAC;IAED,oDAAoD;IACpD,OAAO;QACL,IAAI,CAAC,UAAU,EAAE,CAAC;IACpB,CAAC;IAEO,GAAG,CAAC,EAAU;QACpB,IAAI,CAAC,UAAU,EAAE,CAAC;QAClB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE;YACvC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;YAClB,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;gBAAE,IAAI,CAAC,WAAW,EAAE,CAAC;QACjD,CAAC,EAAE,EAAE,CAAC,CAAC;IACT,CAAC;IAEO,UAAU;QAChB,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,EAAE,CAAC;YACvB,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACrC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QACpB,CAAC;IACH,CAAC;CACF","sourcesContent":["// The first-open failure budget both realtime clients share: a client that has not loaded its\n// first data CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS after its first attempt started reports the first\n// open as failed — whether its attempts failed or one is still hanging (a grant mint stuck until\n// its timeout, a socket that opened but never sent a snapshot) — while it keeps retrying in the\n// background. One implementation for the channel and inbox sockets.\n\nimport { CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS, type TransportTimers } from './shared.js';\n\nexport class InitialLoadBudget {\n /** When the first attempt of the current no-data run started; null once data has loaded. */\n private startedAt: number | null = null;\n private timer: ReturnType<TransportTimers['setTimeout']> | null = null;\n\n constructor(\n private readonly timers: TransportTimers,\n /** Called when the budget runs out with still no data. */\n private readonly onExhausted: () => void,\n ) {}\n\n /**\n * A lifecycle is starting without data. A fresh start opens a new budget; otherwise the budget\n * already running (or spent) is kept.\n */\n begin(fresh: boolean): void {\n if (!fresh && this.startedAt != null) return;\n this.startedAt = this.timers.now();\n this.arm(CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS);\n }\n\n /** An attempt failed before any data loaded: once the budget is spent, that is reported at once. */\n noteFailedAttempt(): void {\n if (this.startedAt == null) return;\n if (this.timers.now() - this.startedAt >= CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS) this.onExhausted();\n }\n\n /**\n * The user asked to retry. The spent budget stays spent — a retry that fails is reported at\n * once — and a retry that hangs is reported a full budget from now.\n */\n manualRetry(): void {\n if (this.startedAt == null) this.startedAt = this.timers.now();\n this.arm(CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS);\n }\n\n /** The first data loaded — there is no first-open failure left to report. */\n loaded(): void {\n this.clearTimer();\n this.startedAt = null;\n }\n\n /** The lifecycle ended; nothing may fire for it. */\n dispose(): void {\n this.clearTimer();\n }\n\n private arm(ms: number): void {\n this.clearTimer();\n this.timer = this.timers.setTimeout(() => {\n this.timer = null;\n if (this.startedAt != null) this.onExhausted();\n }, ms);\n }\n\n private clearTimer(): void {\n if (this.timer != null) {\n this.timers.clearTimeout(this.timer);\n this.timer = null;\n }\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../src/realtime/map.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,
|
|
1
|
+
{"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../src/realtime/map.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAyB,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAChG,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAI9D,OAAO,EAAE,wBAAwB,EAAE,CAAC;AAEpC,sFAAsF;AACtF,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,YAAY,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,iBAAiB,GAAG,IAAI,GAAG,aAAa,CAc/G;AAED,4FAA4F;AAC5F,wBAAgB,cAAc,CAAC,GAAG,EAAE,cAAc,GAAG,iBAAiB,GAAG,IAAI,CAG5E;AAED,qFAAqF;AACrF,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,aAAa,CAcrF;AAED,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB,GAAG,aAAa,CAShB"}
|
package/dist/realtime/map.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Identity invariant: the DO stores uid references only (`senderUid`), never a
|
|
6
6
|
// display name — names resolve at render time through the ChatNameResolver
|
|
7
7
|
// context, identical to the Firestore path.
|
|
8
|
-
import { MODERATION_REDACTED_TEXT } from '@ttt-productions/chat-schemas';
|
|
8
|
+
import { CHAT_SYSTEM_SENDER_ID, MODERATION_REDACTED_TEXT } from '@ttt-productions/chat-schemas';
|
|
9
9
|
// The redacted-text constant lives in the wire contract; re-export to keep this
|
|
10
10
|
// module's public surface stable.
|
|
11
11
|
export { MODERATION_REDACTED_TEXT };
|
|
@@ -64,7 +64,8 @@ export function wireRowToMessage(row, threadId) {
|
|
|
64
64
|
createdAt: row.createdAt,
|
|
65
65
|
senderId: row.senderUid,
|
|
66
66
|
text: row.text,
|
|
67
|
-
isSystemMessage: row.senderUid ===
|
|
67
|
+
isSystemMessage: row.senderUid === CHAT_SYSTEM_SENDER_ID || undefined,
|
|
68
|
+
...(row.referencedUids && row.referencedUids.length > 0 ? { referencedUids: [...row.referencedUids] } : {}),
|
|
68
69
|
meta: {
|
|
69
70
|
seq: row.seq,
|
|
70
71
|
epoch: row.epoch,
|
package/dist/realtime/map.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"map.js","sourceRoot":"","sources":["../../src/realtime/map.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,+EAA+E;AAC/E,kDAAkD;AAClD,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,4CAA4C;AAG5C,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"map.js","sourceRoot":"","sources":["../../src/realtime/map.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,+EAA+E;AAC/E,kDAAkD;AAClD,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,4CAA4C;AAG5C,OAAO,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAGhG,gFAAgF;AAChF,kCAAkC;AAClC,OAAO,EAAE,wBAAwB,EAAE,CAAC;AAQpC;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CAAC,OAAsB,EAAE,OAAiC;IAC9F,IAAI,CAAC,OAAO;QAAE,OAAO,OAAO,CAAC;IAC7B,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,CAAC,IAAI,KAAK,UAAU,CAAC;IAC1E,OAAO;QACL,GAAG,OAAO;QACV,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,wBAAwB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,IAAI,EAAE;YACJ,GAAG,OAAO,CAAC,IAAI;YACf,cAAc,EAAE,OAAO,CAAC,IAAI;YAC5B,eAAe,EAAE,OAAO,CAAC,eAAe;YACxC,8EAA8E;YAC9E,SAAS,EAAE,OAAO,CAAC,IAAI,KAAK,SAAS;SACtC;KACF,CAAC;AACJ,CAAC;AAED,4FAA4F;AAC5F,MAAM,UAAU,cAAc,CAAC,GAAmB;IAChD,IAAI,GAAG,CAAC,cAAc,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAC5C,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,cAAc,EAAE,eAAe,EAAE,GAAG,CAAC,eAAe,IAAI,CAAC,EAAE,CAAC;AACjF,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC;AACrB,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAmB,EAAE,QAAgB;IACpE,OAAO;QACL,SAAS,EAAE,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC;QAClC,QAAQ;QACR,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,QAAQ,EAAE,GAAG,CAAC,SAAS;QACvB,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,eAAe,EAAE,GAAG,CAAC,SAAS,KAAK,qBAAqB,IAAI,SAAS;QACrE,GAAG,CAAC,GAAG,CAAC,cAAc,IAAI,GAAG,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,GAAG,GAAG,CAAC,cAAc,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3G,IAAI,EAAE;YACJ,GAAG,EAAE,GAAG,CAAC,GAAG;YACZ,KAAK,EAAE,GAAG,CAAC,KAAK;SACjB;KACF,CAAC;AACJ,CAAC;AAED,8FAA8F;AAC9F,MAAM,UAAU,iBAAiB,CAAC,IAMjC;IACC,OAAO;QACL,SAAS,EAAE,cAAc,IAAI,CAAC,eAAe,EAAE;QAC/C,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI,EAAE,EAAE,eAAe,EAAE,IAAI,CAAC,eAAe,EAAE,UAAU,EAAE,IAAI,EAAE;KAClE,CAAC;AACJ,CAAC","sourcesContent":["// Map the DO wire row (`WireMessageRow`) into the UI `ChatMessageV1` shape so the\n// UI stays transport-agnostic — the MessageList renders the same shape whether\n// the bytes came from Firestore or the DO socket.\n//\n// Identity invariant: the DO stores uid references only (`senderUid`), never a\n// display name — names resolve at render time through the ChatNameResolver\n// context, identical to the Firestore path.\n\nimport type { ChatMessageV1 } from '@ttt-productions/chat-core';\nimport { CHAT_SYSTEM_SENDER_ID, MODERATION_REDACTED_TEXT } from '@ttt-productions/chat-schemas';\nimport type { RevisionKind, WireMessageRow } from './wire.js';\n\n// The redacted-text constant lives in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { MODERATION_REDACTED_TEXT };\n\n/** The effective moderation overlay for a message: the max-`messageRevision` kind. */\nexport interface ModerationOverlay {\n kind: RevisionKind;\n messageRevision: number;\n}\n\n/**\n * Apply a moderation overlay to a mapped message so the ORIGINAL content never\n * renders for a hidden/deleted message. Pure — returns a new message; the input\n * is not mutated. The overlay state rides on `meta` (`meta` is opaque to\n * chat-core), so the renderer can branch on `meta.moderated` / `meta.moderationKind`.\n * - `delete` / `moderate` → blank/replace the text + mark moderated.\n * - `edit` → the row already carries the overlaid text (the DO rewrites `text`),\n * so only the marker is stamped.\n * - `restore` → original content (the DO row is the original); no redaction.\n */\nexport function applyModerationOverlay(message: ChatMessageV1, overlay: ModerationOverlay | null): ChatMessageV1 {\n if (!overlay) return message;\n const redacted = overlay.kind === 'delete' || overlay.kind === 'moderate';\n return {\n ...message,\n ...(redacted ? { text: MODERATION_REDACTED_TEXT } : {}),\n meta: {\n ...message.meta,\n moderationKind: overlay.kind,\n messageRevision: overlay.messageRevision,\n // `restore` is the absence of active moderation — it reverts to the original.\n moderated: overlay.kind !== 'restore',\n },\n };\n}\n\n/** The overlay carried inline on a DO row (the getHistory/getDeltaSince merge), or null. */\nexport function overlayFromRow(row: WireMessageRow): ModerationOverlay | null {\n if (row.moderationKind == null) return null;\n return { kind: row.moderationKind, messageRevision: row.messageRevision ?? 0 };\n}\n\n/** The DO `seq` is the stable per-channel message id (monotonic, epoch-spanning). */\nexport function seqToMessageId(seq: number): string {\n return String(seq);\n}\n\n/**\n * Convert a DO message row to the UI message. `threadId` is the caller's logical\n * thread id (the UI's thread key); the DO row carries no threadId of its own.\n * - `messageId` = the server seq (string) — stable + dedup key.\n *\n * A DO row carries TEXT only. Files are not part of the message timeline — they\n * live in the conversation's Conversation Files list, published through the\n * canonical media pipeline and read from Firestore, never mapped from a chat row.\n * A legacy row's stored reply pointer is likewise NOT mapped: chat has no\n * reply-authoring affordance, so the UI has no reply renderer to feed\n * (DJ ruling 2026-07-29).\n */\nexport function wireRowToMessage(row: WireMessageRow, threadId: string): ChatMessageV1 {\n return {\n messageId: seqToMessageId(row.seq),\n threadId,\n createdAt: row.createdAt,\n senderId: row.senderUid,\n text: row.text,\n isSystemMessage: row.senderUid === CHAT_SYSTEM_SENDER_ID || undefined,\n ...(row.referencedUids && row.referencedUids.length > 0 ? { referencedUids: [...row.referencedUids] } : {}),\n meta: {\n seq: row.seq,\n epoch: row.epoch,\n },\n };\n}\n\n/** The optimistic local echo a `send` produces before the server `ack` reconciles the seq. */\nexport function optimisticMessage(args: {\n clientMessageId: string;\n threadId: string;\n senderId: string;\n text: string;\n createdAt: number;\n}): ChatMessageV1 {\n return {\n messageId: `optimistic:${args.clientMessageId}`,\n threadId: args.threadId,\n createdAt: args.createdAt,\n senderId: args.senderId,\n text: args.text,\n meta: { clientMessageId: args.clientMessageId, optimistic: true },\n };\n}\n"]}
|
|
@@ -60,6 +60,14 @@ export declare const TERMINAL_ERROR_CODE: {
|
|
|
60
60
|
export type TerminalErrorCode = (typeof TERMINAL_ERROR_CODE)[keyof typeof TERMINAL_ERROR_CODE];
|
|
61
61
|
/** True when `code` is one of the terminal lifecycle verdicts above. */
|
|
62
62
|
export declare function isTerminalErrorCode(code: string | null | undefined): code is TerminalErrorCode;
|
|
63
|
+
/**
|
|
64
|
+
* How long a client keeps failing to open for the first time before it reports the
|
|
65
|
+
* first open as failed (`initialLoadFailed`) — so a chat or inbox that cannot connect
|
|
66
|
+
* shows a failure the user can retry instead of an opening state that never ends. The
|
|
67
|
+
* client keeps retrying in the background after it reports. Client policy, not a wire
|
|
68
|
+
* constant.
|
|
69
|
+
*/
|
|
70
|
+
export declare const CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS = 15000;
|
|
63
71
|
export { HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX } from '@ttt-productions/chat-schemas';
|
|
64
72
|
/**
|
|
65
73
|
* Max reconnect resends of one un-acked send before its optimistic row is flipped
|
|
@@ -90,4 +98,9 @@ export declare const SERVER_RETRYABLE_MAX_AGE_MS = 90000;
|
|
|
90
98
|
*/
|
|
91
99
|
export declare const SEND_RETRY_MIN_DELAY_MS = 250;
|
|
92
100
|
export declare const SEND_RETRY_MAX_DELAY_MS = 30000;
|
|
101
|
+
/**
|
|
102
|
+
* A fresh client-minted id for a command the client correlates with its answer (a send's
|
|
103
|
+
* `clientMessageId`, a mark-read's `requestId`). Random, so two tabs never collide.
|
|
104
|
+
*/
|
|
105
|
+
export declare function newCorrelationId(): string;
|
|
93
106
|
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,kFAAkF;IAClF,QAAQ,CAAC,kBAAkB,QAAQ;gBACvB,OAAO,SAAuB;CAI3C;AAED,8FAA8F;AAC9F,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,qBAAqB,CAOlF;AAED,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;IAC1E,YAAY,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,KAAK,IAAI,CAAC;IACzD,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,eAI3B,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAC;AAExF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB;IAC9B,yEAAyE;;IAEzE,iFAAiF;;CAEzE,CAAC;AAEX,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAE/F,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,iBAAiB,CAE9F;
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,kFAAkF;IAClF,QAAQ,CAAC,kBAAkB,QAAQ;gBACvB,OAAO,SAAuB;CAI3C;AAED,8FAA8F;AAC9F,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,qBAAqB,CAOlF;AAED,4FAA4F;AAC5F,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;IAC1E,YAAY,EAAE,CAAC,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,KAAK,IAAI,CAAC;IACzD,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,aAAa,EAAE,eAI3B,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,YAAY,GAAG,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAC;AAExF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB;IAC9B,yEAAyE;;IAEzE,iFAAiF;;CAEzE,CAAC;AAEX,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAE/F,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,iBAAiB,CAE9F;AAED;;;;;;GAMG;AACH,eAAO,MAAM,mCAAmC,QAAS,CAAC;AAI1D,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,IAAI,CAAC;AAE3C;;;GAGG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAIzC"}
|
package/dist/realtime/shared.js
CHANGED
|
@@ -65,6 +65,14 @@ export const TERMINAL_ERROR_CODE = {
|
|
|
65
65
|
export function isTerminalErrorCode(code) {
|
|
66
66
|
return code === TERMINAL_ERROR_CODE.REVOKED || code === TERMINAL_ERROR_CODE.ACCESS_DENIED;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* How long a client keeps failing to open for the first time before it reports the
|
|
70
|
+
* first open as failed (`initialLoadFailed`) — so a chat or inbox that cannot connect
|
|
71
|
+
* shows a failure the user can retry instead of an opening state that never ends. The
|
|
72
|
+
* client keeps retrying in the background after it reports. Client policy, not a wire
|
|
73
|
+
* constant.
|
|
74
|
+
*/
|
|
75
|
+
export const CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS = 15_000;
|
|
68
76
|
// The client-agreed limits live in the wire contract; re-export to keep this
|
|
69
77
|
// module's public surface stable.
|
|
70
78
|
export { HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX } from '@ttt-productions/chat-schemas';
|
|
@@ -97,4 +105,14 @@ export const SERVER_RETRYABLE_MAX_AGE_MS = 90_000;
|
|
|
97
105
|
*/
|
|
98
106
|
export const SEND_RETRY_MIN_DELAY_MS = 250;
|
|
99
107
|
export const SEND_RETRY_MAX_DELAY_MS = 30_000;
|
|
108
|
+
/**
|
|
109
|
+
* A fresh client-minted id for a command the client correlates with its answer (a send's
|
|
110
|
+
* `clientMessageId`, a mark-read's `requestId`). Random, so two tabs never collide.
|
|
111
|
+
*/
|
|
112
|
+
export function newCorrelationId() {
|
|
113
|
+
const c = globalThis.crypto;
|
|
114
|
+
if (c?.randomUUID)
|
|
115
|
+
return c.randomUUID();
|
|
116
|
+
return `cid-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
117
|
+
}
|
|
100
118
|
//# sourceMappingURL=shared.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,kEAAkE;AAClE,yDAAyD;AACzD,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,iFAAiF;AAWjF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAC9C,kFAAkF;IACzE,kBAAkB,GAAG,IAAI,CAAC;IACnC,YAAY,OAAO,GAAG,oBAAoB;QACxC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED,8FAA8F;AAC9F,MAAM,UAAU,uBAAuB,CAAC,GAAY;IAClD,OAAO,CACL,GAAG,YAAY,qBAAqB;QACpC,CAAC,OAAO,GAAG,KAAK,QAAQ;YACtB,GAAG,KAAK,IAAI;YACX,GAAwC,CAAC,kBAAkB,KAAK,IAAI,CAAC,CACzE,CAAC;AACJ,CAAC;AASD,MAAM,CAAC,MAAM,aAAa,GAAoB;IAC5C,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC;IAC1C,YAAY,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC;IACpC,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;CACtB,CAAC;AAKF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,yEAAyE;IACzE,OAAO,EAAE,SAAS;IAClB,iFAAiF;IACjF,aAAa,EAAE,eAAe;CACtB,CAAC;AAIX,wEAAwE;AACxE,MAAM,UAAU,mBAAmB,CAAC,IAA+B;IACjE,OAAO,IAAI,KAAK,mBAAmB,CAAC,OAAO,IAAI,IAAI,KAAK,mBAAmB,CAAC,aAAa,CAAC;AAC5F,CAAC;AAED,6EAA6E;AAC7E,kCAAkC;AAClC,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAE9C;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,MAAM,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAC3C,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC","sourcesContent":["// Shared injected contracts + tunable constants for the realtime transport.\n//\n// The client-agreed limits (`HEARTBEAT_MS`, `TYPING_COALESCE_MS`,\n// `HISTORY_PAGE_MAX`) come from the one wire contract in\n// `@ttt-productions/chat-schemas`; they are re-exported here so the transport's\n// existing import sites are unchanged. The worker runtime is the authority and\n// rejects anything out of bounds, but the client uses these to coalesce and\n// paginate politely. The remaining constants below are client-only retry policy.\n\n/**\n * Mints a short-lived chat grant token for the socket's scope. The app wires this\n * to its `mintChatGrant` callable (channel grant for a ChannelClient, inbox grant\n * for an InboxClient). Called on every connect AND once on a 4401 re-grant. The\n * app owns the Firestore permission check, the scope shape, caching to a React\n * Query staleTime < grant exp, and returning a FRESH token when re-invoked.\n */\nexport type GrantProvider = () => Promise<string>;\n\n/**\n * Package-owned, Firebase-FREE terminal access-denial signal. A grant provider\n * throws this (from `GrantProvider`) when the authoritative authorization answer\n * is a genuine, terminal \"no\" — as opposed to a transient/retryable mint failure.\n *\n * The transport treats it specially: instead of the normal reconnect backoff it\n * STOPS reconnecting, closes the lifecycle, fails any pending optimistic sends,\n * and surfaces the stable `access-denied` error code (which the React hook maps to\n * `allowed: false` so the shell renders its no-access surface instead of an eternal\n * loader). Every other thrown error stays transient and reconnects as before.\n *\n * This is deliberately generic: the consuming app translates its own backend\n * denial (e.g. a Firebase `functions/permission-denied`) into this class at the\n * grant-provider boundary. The package never imports or names Firebase. Consumers\n * that cannot subclass may instead throw any error carrying `isChatAccessDenied:\n * true` — `isChatAccessDeniedError` recognizes both (also cross-realm safe).\n */\nexport class ChatAccessDeniedError extends Error {\n /** Duck-typed marker so detection survives module-realm / re-throw boundaries. */\n readonly isChatAccessDenied = true;\n constructor(message = 'Chat access denied') {\n super(message);\n this.name = 'ChatAccessDeniedError';\n }\n}\n\n/** True when `err` is a terminal chat access denial (class instance or duck-typed marker). */\nexport function isChatAccessDeniedError(err: unknown): err is ChatAccessDeniedError {\n return (\n err instanceof ChatAccessDeniedError ||\n (typeof err === 'object' &&\n err !== null &&\n (err as { isChatAccessDenied?: unknown }).isChatAccessDenied === true)\n );\n}\n\n/** Injectable timers so the transport runs deterministically under fake timers in tests. */\nexport interface TransportTimers {\n setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;\n clearTimeout: (h: ReturnType<typeof setTimeout>) => void;\n now: () => number;\n}\n\nexport const defaultTimers: TransportTimers = {\n setTimeout: (fn, ms) => setTimeout(fn, ms),\n clearTimeout: (h) => clearTimeout(h),\n now: () => Date.now(),\n};\n\n/** Connection status surfaced to the UI. */\nexport type RealtimeStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';\n\n/**\n * The TERMINAL lifecycle verdicts both realtime clients surface on\n * `lastErrorCode` — the two codes that mean \"this lifecycle stopped on an\n * authoritative NO\", as opposed to a transient/protocol code (`send-failed`,\n * `blocked-word`, …) that leaves the socket running.\n *\n * ONE declaration for both clients: each sets and clears the same two strings,\n * and `useRealtimeChatMessages` maps `access-denied` to `allowed: false`.\n * A terminal verdict is terminal WITHIN its lifecycle; a later explicit\n * `connect()` is a fresh attempt the authority may legitimately re-deny, so the\n * stale code is cleared there (never carried into the new lifecycle's state).\n */\nexport const TERMINAL_ERROR_CODE = {\n /** A 4403 REVOKED close: access was pulled while the socket was open. */\n REVOKED: 'revoked',\n /** The grant provider threw `ChatAccessDeniedError` — a terminal policy \"no\". */\n ACCESS_DENIED: 'access-denied',\n} as const;\n\nexport type TerminalErrorCode = (typeof TERMINAL_ERROR_CODE)[keyof typeof TERMINAL_ERROR_CODE];\n\n/** True when `code` is one of the terminal lifecycle verdicts above. */\nexport function isTerminalErrorCode(code: string | null | undefined): code is TerminalErrorCode {\n return code === TERMINAL_ERROR_CODE.REVOKED || code === TERMINAL_ERROR_CODE.ACCESS_DENIED;\n}\n\n// The client-agreed limits live in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX } from '@ttt-productions/chat-schemas';\n\n/**\n * Max reconnect resends of one un-acked send before its optimistic row is flipped\n * to a visible failed state (retry affordance) instead of a permanent ghost. Each\n * reconnect that resumes with the pending send still un-acked counts one attempt.\n */\nexport const MAX_PENDING_SEND_ATTEMPTS = 5;\n\n/**\n * Max wall-clock age of an un-acked pending send before it is flipped to failed on\n * the next resume, even if the attempt cap hasn't been hit (a long offline window).\n */\nexport const PENDING_SEND_MAX_AGE_MS = 60_000;\n\n/**\n * Wall-clock budget (ms) for a SERVER-DECLARED retryable send. Once the Worker has\n * rejected a send as retryable — a correlated `send-rejected` frame OR the legacy\n * uncorrelated `error{code:'membership-pending'}` — that send keeps retrying on the\n * server's own hint until this budget elapses, and is NEVER failed by the generic\n * {@link MAX_PENDING_SEND_ATTEMPTS} cap (which governs only blind reconnect\n * resends). 90 s comfortably covers the backend projection/grant-bootstrap window\n * without stranding an optimistic bubble on \"Sending…\" the way the old ~10 s\n * five-attempt cap did.\n */\nexport const SERVER_RETRYABLE_MAX_AGE_MS = 90_000;\n\n/**\n * Lower/upper clamp (ms) applied to a server `retryAfterMs` hint before scheduling a\n * resend, so a pathological (but schema-valid) hint can neither hammer the socket\n * nor park a send for minutes.\n */\nexport const SEND_RETRY_MIN_DELAY_MS = 250;\nexport const SEND_RETRY_MAX_DELAY_MS = 30_000;\n"]}
|
|
1
|
+
{"version":3,"file":"shared.js","sourceRoot":"","sources":["../../src/realtime/shared.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,kEAAkE;AAClE,yDAAyD;AACzD,gFAAgF;AAChF,+EAA+E;AAC/E,4EAA4E;AAC5E,iFAAiF;AAWjF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAC9C,kFAAkF;IACzE,kBAAkB,GAAG,IAAI,CAAC;IACnC,YAAY,OAAO,GAAG,oBAAoB;QACxC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED,8FAA8F;AAC9F,MAAM,UAAU,uBAAuB,CAAC,GAAY;IAClD,OAAO,CACL,GAAG,YAAY,qBAAqB;QACpC,CAAC,OAAO,GAAG,KAAK,QAAQ;YACtB,GAAG,KAAK,IAAI;YACX,GAAwC,CAAC,kBAAkB,KAAK,IAAI,CAAC,CACzE,CAAC;AACJ,CAAC;AASD,MAAM,CAAC,MAAM,aAAa,GAAoB;IAC5C,UAAU,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC;IAC1C,YAAY,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC;IACpC,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;CACtB,CAAC;AAKF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,yEAAyE;IACzE,OAAO,EAAE,SAAS;IAClB,iFAAiF;IACjF,aAAa,EAAE,eAAe;CACtB,CAAC;AAIX,wEAAwE;AACxE,MAAM,UAAU,mBAAmB,CAAC,IAA+B;IACjE,OAAO,IAAI,KAAK,mBAAmB,CAAC,OAAO,IAAI,IAAI,KAAK,mBAAmB,CAAC,aAAa,CAAC;AAC5F,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mCAAmC,GAAG,MAAM,CAAC;AAE1D,6EAA6E;AAC7E,kCAAkC;AAClC,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEnG;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAE9C;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,MAAM,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAC3C,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAE9C;;;GAGG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,CAAC,GAAI,UAAyD,CAAC,MAAM,CAAC;IAC5E,IAAI,CAAC,EAAE,UAAU;QAAE,OAAO,CAAC,CAAC,UAAU,EAAE,CAAC;IACzC,OAAO,OAAO,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AACpE,CAAC","sourcesContent":["// Shared injected contracts + tunable constants for the realtime transport.\n//\n// The client-agreed limits (`HEARTBEAT_MS`, `TYPING_COALESCE_MS`,\n// `HISTORY_PAGE_MAX`) come from the one wire contract in\n// `@ttt-productions/chat-schemas`; they are re-exported here so the transport's\n// existing import sites are unchanged. The worker runtime is the authority and\n// rejects anything out of bounds, but the client uses these to coalesce and\n// paginate politely. The remaining constants below are client-only retry policy.\n\n/**\n * Mints a short-lived chat grant token for the socket's scope. The app wires this\n * to its `mintChatGrant` callable (channel grant for a ChannelClient, inbox grant\n * for an InboxClient). Called on every connect AND once on a 4401 re-grant. The\n * app owns the Firestore permission check, the scope shape, caching to a React\n * Query staleTime < grant exp, and returning a FRESH token when re-invoked.\n */\nexport type GrantProvider = () => Promise<string>;\n\n/**\n * Package-owned, Firebase-FREE terminal access-denial signal. A grant provider\n * throws this (from `GrantProvider`) when the authoritative authorization answer\n * is a genuine, terminal \"no\" — as opposed to a transient/retryable mint failure.\n *\n * The transport treats it specially: instead of the normal reconnect backoff it\n * STOPS reconnecting, closes the lifecycle, fails any pending optimistic sends,\n * and surfaces the stable `access-denied` error code (which the React hook maps to\n * `allowed: false` so the shell renders its no-access surface instead of an eternal\n * loader). Every other thrown error stays transient and reconnects as before.\n *\n * This is deliberately generic: the consuming app translates its own backend\n * denial (e.g. a Firebase `functions/permission-denied`) into this class at the\n * grant-provider boundary. The package never imports or names Firebase. Consumers\n * that cannot subclass may instead throw any error carrying `isChatAccessDenied:\n * true` — `isChatAccessDeniedError` recognizes both (also cross-realm safe).\n */\nexport class ChatAccessDeniedError extends Error {\n /** Duck-typed marker so detection survives module-realm / re-throw boundaries. */\n readonly isChatAccessDenied = true;\n constructor(message = 'Chat access denied') {\n super(message);\n this.name = 'ChatAccessDeniedError';\n }\n}\n\n/** True when `err` is a terminal chat access denial (class instance or duck-typed marker). */\nexport function isChatAccessDeniedError(err: unknown): err is ChatAccessDeniedError {\n return (\n err instanceof ChatAccessDeniedError ||\n (typeof err === 'object' &&\n err !== null &&\n (err as { isChatAccessDenied?: unknown }).isChatAccessDenied === true)\n );\n}\n\n/** Injectable timers so the transport runs deterministically under fake timers in tests. */\nexport interface TransportTimers {\n setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;\n clearTimeout: (h: ReturnType<typeof setTimeout>) => void;\n now: () => number;\n}\n\nexport const defaultTimers: TransportTimers = {\n setTimeout: (fn, ms) => setTimeout(fn, ms),\n clearTimeout: (h) => clearTimeout(h),\n now: () => Date.now(),\n};\n\n/** Connection status surfaced to the UI. */\nexport type RealtimeStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed';\n\n/**\n * The TERMINAL lifecycle verdicts both realtime clients surface on\n * `lastErrorCode` — the two codes that mean \"this lifecycle stopped on an\n * authoritative NO\", as opposed to a transient/protocol code (`send-failed`,\n * `blocked-word`, …) that leaves the socket running.\n *\n * ONE declaration for both clients: each sets and clears the same two strings,\n * and `useRealtimeChatMessages` maps `access-denied` to `allowed: false`.\n * A terminal verdict is terminal WITHIN its lifecycle; a later explicit\n * `connect()` is a fresh attempt the authority may legitimately re-deny, so the\n * stale code is cleared there (never carried into the new lifecycle's state).\n */\nexport const TERMINAL_ERROR_CODE = {\n /** A 4403 REVOKED close: access was pulled while the socket was open. */\n REVOKED: 'revoked',\n /** The grant provider threw `ChatAccessDeniedError` — a terminal policy \"no\". */\n ACCESS_DENIED: 'access-denied',\n} as const;\n\nexport type TerminalErrorCode = (typeof TERMINAL_ERROR_CODE)[keyof typeof TERMINAL_ERROR_CODE];\n\n/** True when `code` is one of the terminal lifecycle verdicts above. */\nexport function isTerminalErrorCode(code: string | null | undefined): code is TerminalErrorCode {\n return code === TERMINAL_ERROR_CODE.REVOKED || code === TERMINAL_ERROR_CODE.ACCESS_DENIED;\n}\n\n/**\n * How long a client keeps failing to open for the first time before it reports the\n * first open as failed (`initialLoadFailed`) — so a chat or inbox that cannot connect\n * shows a failure the user can retry instead of an opening state that never ends. The\n * client keeps retrying in the background after it reports. Client policy, not a wire\n * constant.\n */\nexport const CHAT_INITIAL_LOAD_FAILURE_BUDGET_MS = 15_000;\n\n// The client-agreed limits live in the wire contract; re-export to keep this\n// module's public surface stable.\nexport { HEARTBEAT_MS, TYPING_COALESCE_MS, HISTORY_PAGE_MAX } from '@ttt-productions/chat-schemas';\n\n/**\n * Max reconnect resends of one un-acked send before its optimistic row is flipped\n * to a visible failed state (retry affordance) instead of a permanent ghost. Each\n * reconnect that resumes with the pending send still un-acked counts one attempt.\n */\nexport const MAX_PENDING_SEND_ATTEMPTS = 5;\n\n/**\n * Max wall-clock age of an un-acked pending send before it is flipped to failed on\n * the next resume, even if the attempt cap hasn't been hit (a long offline window).\n */\nexport const PENDING_SEND_MAX_AGE_MS = 60_000;\n\n/**\n * Wall-clock budget (ms) for a SERVER-DECLARED retryable send. Once the Worker has\n * rejected a send as retryable — a correlated `send-rejected` frame OR the legacy\n * uncorrelated `error{code:'membership-pending'}` — that send keeps retrying on the\n * server's own hint until this budget elapses, and is NEVER failed by the generic\n * {@link MAX_PENDING_SEND_ATTEMPTS} cap (which governs only blind reconnect\n * resends). 90 s comfortably covers the backend projection/grant-bootstrap window\n * without stranding an optimistic bubble on \"Sending…\" the way the old ~10 s\n * five-attempt cap did.\n */\nexport const SERVER_RETRYABLE_MAX_AGE_MS = 90_000;\n\n/**\n * Lower/upper clamp (ms) applied to a server `retryAfterMs` hint before scheduling a\n * resend, so a pathological (but schema-valid) hint can neither hammer the socket\n * nor park a send for minutes.\n */\nexport const SEND_RETRY_MIN_DELAY_MS = 250;\nexport const SEND_RETRY_MAX_DELAY_MS = 30_000;\n\n/**\n * A fresh client-minted id for a command the client correlates with its answer (a send's\n * `clientMessageId`, a mark-read's `requestId`). Random, so two tabs never collide.\n */\nexport function newCorrelationId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) return c.randomUUID();\n return `cid-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n}\n"]}
|
|
@@ -3,7 +3,7 @@ import { InboxClient, type InboxClientConfig, type InboxClientState } from './in
|
|
|
3
3
|
import { type SocketFactory } from './socket.js';
|
|
4
4
|
import type { GrantProvider, TransportTimers } from './shared.js';
|
|
5
5
|
import type { ChatClientDiagnosticsOption } from './diagnostics.js';
|
|
6
|
-
import type {
|
|
6
|
+
import type { ChatConversationRef } from '@ttt-productions/chat-schemas';
|
|
7
7
|
/**
|
|
8
8
|
* Everything the channel transport needs. The app supplies `grantProvider`
|
|
9
9
|
* (its `mintChatGrant` callable, channel scope) + `endpoint`; `socketFactory`
|
|
@@ -11,7 +11,8 @@ import type { ChannelRefTuple } from './wire.js';
|
|
|
11
11
|
*/
|
|
12
12
|
export interface RealtimeTransportConfig {
|
|
13
13
|
endpoint: string;
|
|
14
|
-
|
|
14
|
+
/** The conversation the socket is for, as the app maps it onto the neutral reference. */
|
|
15
|
+
channelRef: ChatConversationRef;
|
|
15
16
|
threadId: string;
|
|
16
17
|
currentUserId: string;
|
|
17
18
|
grantProvider: GrantProvider;
|
|
@@ -34,9 +35,11 @@ export interface RealtimeTransportConfig {
|
|
|
34
35
|
*/
|
|
35
36
|
export interface RealtimeChatClient {
|
|
36
37
|
readonly channel: ChannelClient;
|
|
37
|
-
readonly channelRef:
|
|
38
|
+
readonly channelRef: ChatConversationRef;
|
|
38
39
|
readonly currentUserId: string;
|
|
39
40
|
connect(): Promise<void>;
|
|
41
|
+
/** Retry the connection now (the first-open failure's Retry) — see `ChannelClient.retryNow`. */
|
|
42
|
+
retry(): void;
|
|
40
43
|
/** Tear down every socket (auth-user switch / unmount). Idempotent. */
|
|
41
44
|
close(): void;
|
|
42
45
|
getState(): ChannelClientState;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/realtime/transport.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACvG,OAAO,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/realtime/transport.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACvG,OAAO,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAEzE;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,UAAU,EAAE,mBAAmB,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,SAAS,CAAC,EAAE,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAC7C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,2BAA2B,CAAC;CAC3C;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,UAAU,EAAE,mBAAmB,CAAC;IACzC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,gGAAgG;IAChG,KAAK,IAAI,IAAI,CAAC;IACd,uEAAuE;IACvE,KAAK,IAAI,IAAI,CAAC;IACd,QAAQ,IAAI,kBAAkB,CAAC;IAC/B,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,kBAAkB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;CAC5D;AAED,4FAA4F;AAC5F,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,uBAAuB,GAAG,kBAAkB,CAsB5F;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,aAAa,CAAC;IAC7B,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,SAAS,CAAC,EAAE,iBAAiB,CAAC,WAAW,CAAC,CAAC;IAC3C,iFAAiF;IACjF,WAAW,CAAC,EAAE,2BAA2B,CAAC;CAC3C,GAAG,WAAW,CAUd;AAED,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,CAAC"}
|
|
@@ -29,6 +29,7 @@ export function createRealtimeChatClient(config) {
|
|
|
29
29
|
channelRef: config.channelRef,
|
|
30
30
|
currentUserId: config.currentUserId,
|
|
31
31
|
connect: () => channel.connect(),
|
|
32
|
+
retry: () => channel.retryNow(),
|
|
32
33
|
close: () => channel.close(),
|
|
33
34
|
getState: () => channel.getState(),
|
|
34
35
|
subscribe: (fn) => channel.subscribe(fn),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/realtime/transport.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,6DAA6D;AAC7D,gFAAgF;AAChF,2EAA2E;AAC3E,oFAAoF;AACpF,EAAE;AACF,sEAAsE;AACtE,+EAA+E;AAC/E,kFAAkF;AAClF,mFAAmF;AACnF,sDAAsD;AAEtD,OAAO,EAAE,aAAa,EAAqD,MAAM,qBAAqB,CAAC;AACvG,OAAO,EAAE,WAAW,EAAiD,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EAAE,oBAAoB,EAAsB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/realtime/transport.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,6DAA6D;AAC7D,gFAAgF;AAChF,2EAA2E;AAC3E,oFAAoF;AACpF,EAAE;AACF,sEAAsE;AACtE,+EAA+E;AAC/E,kFAAkF;AAClF,mFAAmF;AACnF,sDAAsD;AAEtD,OAAO,EAAE,aAAa,EAAqD,MAAM,qBAAqB,CAAC;AACvG,OAAO,EAAE,WAAW,EAAiD,MAAM,mBAAmB,CAAC;AAC/F,OAAO,EAAE,oBAAoB,EAAsB,MAAM,aAAa,CAAC;AAgDvE,4FAA4F;AAC5F,MAAM,UAAU,wBAAwB,CAAC,MAA+B;IACtE,MAAM,OAAO,GAAG,IAAI,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,aAAa,EAAE,MAAM,CAAC,aAAa,IAAI,oBAAoB;QAC3D,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,WAAW,EAAE,MAAM,CAAC,WAAW;KAChC,CAAC,CAAC;IAEH,OAAO;QACL,OAAO;QACP,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE;QAChC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE;QAC/B,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE;QAC5B,QAAQ,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE;QAClC,SAAS,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;KACzC,CAAC;AACJ,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,iBAAiB,CAAC,MASjC;IACC,OAAO,IAAI,WAAW,CAAC;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,aAAa,EAAE,MAAM,CAAC,aAAa;QACnC,aAAa,EAAE,MAAM,CAAC,aAAa,IAAI,oBAAoB;QAC3D,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,WAAW,EAAE,MAAM,CAAC,WAAW;KAChC,CAAC,CAAC;AACL,CAAC","sourcesContent":["// The realtime transport HANDLE the chat-react config carries on the `realtime`\n// transport (`ChatRealtimeTransportConfig.client`). It owns:\n// - a ChannelClient for the active thread (send/read/typing/presence/history)\n// - the grant-mint wiring (channel scope) via the injected grantProvider\n// - auth-user-switch teardown (close every socket before connecting as a new uid)\n//\n// The INBOX socket is created SEPARATELY (one per user, app-wide) via\n// `createInboxClient` — it is not tied to a single thread, so the app owns its\n// lifetime (mount it once at the dock, not per ChatShell). This keeps the channel\n// transport (per-thread) and the inbox transport (per-user) cleanly split, exactly\n// as the worker splits Channel DOs from the Inbox DO.\n\nimport { ChannelClient, type ChannelClientConfig, type ChannelClientState } from './channel-client.js';\nimport { InboxClient, type InboxClientConfig, type InboxClientState } from './inbox-client.js';\nimport { browserSocketFactory, type SocketFactory } from './socket.js';\nimport type { GrantProvider, TransportTimers } from './shared.js';\nimport type { ChatClientDiagnosticsOption } from './diagnostics.js';\nimport type { ChatConversationRef } from '@ttt-productions/chat-schemas';\n\n/**\n * Everything the channel transport needs. The app supplies `grantProvider`\n * (its `mintChatGrant` callable, channel scope) + `endpoint`; `socketFactory`\n * defaults to the real browser WebSocket and is overridden in tests.\n */\nexport interface RealtimeTransportConfig {\n endpoint: string;\n /** The conversation the socket is for, as the app maps it onto the neutral reference. */\n channelRef: ChatConversationRef;\n threadId: string;\n currentUserId: string;\n grantProvider: GrantProvider;\n socketFactory?: SocketFactory;\n timers?: TransportTimers;\n reconnect?: ChannelClientConfig['reconnect'];\n /**\n * OPT-IN structured client diagnostics, forwarded to the ChannelClient. Absent\n * or `false` (the default) changes nothing and costs one nullish check; `true`\n * emits one single-line `console.debug` entry per client DECISION with a stable\n * `chat_client_*` event name; a function routes those entries into the app's own\n * logger. Content-free by construction (ids/seqs/states/counts/codes only).\n */\n diagnostics?: ChatClientDiagnosticsOption;\n}\n\n/**\n * The opaque realtime client handle stored on `ChatRealtimeTransportConfig.client`.\n * The UI hook (`useRealtimeChatMessages`) drives it; the UI never reaches into the\n * socket directly.\n */\nexport interface RealtimeChatClient {\n readonly channel: ChannelClient;\n readonly channelRef: ChatConversationRef;\n readonly currentUserId: string;\n connect(): Promise<void>;\n /** Retry the connection now (the first-open failure's Retry) — see `ChannelClient.retryNow`. */\n retry(): void;\n /** Tear down every socket (auth-user switch / unmount). Idempotent. */\n close(): void;\n getState(): ChannelClientState;\n subscribe(fn: (s: ChannelClientState) => void): () => void;\n}\n\n/** Create the per-thread realtime client (the value placed on `config.realtime.client`). */\nexport function createRealtimeChatClient(config: RealtimeTransportConfig): RealtimeChatClient {\n const channel = new ChannelClient({\n endpoint: config.endpoint,\n threadId: config.threadId,\n currentUserId: config.currentUserId,\n grantProvider: config.grantProvider,\n socketFactory: config.socketFactory ?? browserSocketFactory,\n timers: config.timers,\n reconnect: config.reconnect,\n diagnostics: config.diagnostics,\n });\n\n return {\n channel,\n channelRef: config.channelRef,\n currentUserId: config.currentUserId,\n connect: () => channel.connect(),\n retry: () => channel.retryNow(),\n close: () => channel.close(),\n getState: () => channel.getState(),\n subscribe: (fn) => channel.subscribe(fn),\n };\n}\n\n/** Create the per-USER inbox client (mount once at the dock; not per-thread). */\nexport function createInboxClient(config: {\n endpoint: string;\n currentUserId: string;\n grantProvider: GrantProvider;\n socketFactory?: SocketFactory;\n timers?: TransportTimers;\n reconnect?: InboxClientConfig['reconnect'];\n /** OPT-IN structured diagnostics, forwarded to the InboxClient (default OFF). */\n diagnostics?: ChatClientDiagnosticsOption;\n}): InboxClient {\n return new InboxClient({\n endpoint: config.endpoint,\n currentUserId: config.currentUserId,\n grantProvider: config.grantProvider,\n socketFactory: config.socketFactory ?? browserSocketFactory,\n timers: config.timers,\n reconnect: config.reconnect,\n diagnostics: config.diagnostics,\n });\n}\n\nexport type { ChannelClientState, InboxClientState };\n"]}
|
|
@@ -8,6 +8,10 @@ export type UseRealtimeChatMessagesResult = {
|
|
|
8
8
|
fetchOlder: () => Promise<void>;
|
|
9
9
|
hasOlder: boolean;
|
|
10
10
|
isFetchingOlder: boolean;
|
|
11
|
+
/** True once the first open has failed (see `ChannelClientState.initialLoadFailed`). */
|
|
12
|
+
initialLoadFailed: boolean;
|
|
13
|
+
/** Retry the connection now — the first-open failure's Retry. */
|
|
14
|
+
retry: () => void;
|
|
11
15
|
/** Realtime extras (not present on the firestore result) — optional for the UI. */
|
|
12
16
|
status: ChannelClientState["status"];
|
|
13
17
|
/** The last structured error code the DO sent (e.g. a close reason). Drives the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useRealtimeChatMessages.d.ts","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"useRealtimeChatMessages.d.ts","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAI9D,MAAM,MAAM,6BAA6B,GAAG;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,wFAAwF;IACxF,iBAAiB,EAAE,OAAO,CAAC;IAC3B,iEAAiE;IACjE,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,mFAAmF;IACnF,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACrC;4CACwC;IACxC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iHAAiH;IACjH,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IAChC;;;;;uCAKmC;IACnC,SAAS,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAO,CAAC;IAChD,sIAAsI;IACtI,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC;IACxD,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB;;yEAEqE;IACrE,iBAAiB,EAAE,MAAM,IAAI,CAAC;IAC9B,mBAAmB,EAAE,MAAM,IAAI,CAAC;CACjC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,kBAAkB,GAAG,6BAA6B,CAgFjG"}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// owning hook).
|
|
11
11
|
import * as React from "react";
|
|
12
12
|
import { useOptionalChatNameResolver } from "../context/ChatNameResolverContext.js";
|
|
13
|
+
import { newCorrelationId } from "./shared.js";
|
|
13
14
|
export function useRealtimeChatMessages(client) {
|
|
14
15
|
const [state, setState] = React.useState(() => client.getState());
|
|
15
16
|
// Subscribe only; the owning hook drives connect/close (single lifecycle owner, C-B7).
|
|
@@ -25,6 +26,8 @@ export function useRealtimeChatMessages(client) {
|
|
|
25
26
|
const set = new Set();
|
|
26
27
|
for (const m of state.messages) {
|
|
27
28
|
set.add(m.senderId);
|
|
29
|
+
for (const uid of m.referencedUids ?? [])
|
|
30
|
+
set.add(uid);
|
|
28
31
|
}
|
|
29
32
|
return Array.from(set);
|
|
30
33
|
}, [state.messages]);
|
|
@@ -35,10 +38,11 @@ export function useRealtimeChatMessages(client) {
|
|
|
35
38
|
const fetchOlder = React.useCallback(async () => {
|
|
36
39
|
client.channel.fetchOlder();
|
|
37
40
|
}, [client]);
|
|
38
|
-
const send = React.useCallback((text) => client.channel.send({ clientMessageId:
|
|
41
|
+
const send = React.useCallback((text) => client.channel.send({ clientMessageId: newCorrelationId(), text }), [client]);
|
|
39
42
|
const retrySend = React.useCallback((clientMessageId) => client.channel.retrySend(clientMessageId), [client]);
|
|
40
43
|
const readAck = React.useCallback((readSeq, focused) => client.channel.readAck(readSeq, focused), [client]);
|
|
41
44
|
const signalTyping = React.useCallback(() => client.channel.typing(), [client]);
|
|
45
|
+
const retry = React.useCallback(() => client.retry(), [client]);
|
|
42
46
|
const presenceSubscribe = React.useCallback(() => client.channel.presenceSubscribe(), [client]);
|
|
43
47
|
const presenceUnsubscribe = React.useCallback(() => client.channel.presenceUnsubscribe(), [client]);
|
|
44
48
|
return {
|
|
@@ -53,6 +57,8 @@ export function useRealtimeChatMessages(client) {
|
|
|
53
57
|
// a socket can be 'open' with no snapshot yet, and a post-load reconnect must not
|
|
54
58
|
// fall back into the opening state. `hasLoadedInitialData` encodes the full table.
|
|
55
59
|
isInitialLoading: !state.hasLoadedInitialData,
|
|
60
|
+
initialLoadFailed: state.initialLoadFailed,
|
|
61
|
+
retry,
|
|
56
62
|
messages: state.messages,
|
|
57
63
|
fetchOlder,
|
|
58
64
|
hasOlder: state.hasOlder,
|
|
@@ -69,11 +75,4 @@ export function useRealtimeChatMessages(client) {
|
|
|
69
75
|
presenceUnsubscribe,
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
|
-
/** A client message id for optimistic-send idempotency (SQLite unique (senderUid, clientMessageId)). */
|
|
73
|
-
function makeClientMessageId() {
|
|
74
|
-
const c = globalThis.crypto;
|
|
75
|
-
if (c?.randomUUID)
|
|
76
|
-
return c.randomUUID();
|
|
77
|
-
return `cmid-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
78
|
-
}
|
|
79
78
|
//# sourceMappingURL=useRealtimeChatMessages.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useRealtimeChatMessages.js","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,qEAAqE;AACrE,+EAA+E;AAC/E,kFAAkF;AAClF,2EAA2E;AAC3E,oFAAoF;AACpF,iFAAiF;AACjF,mFAAmF;AACnF,oFAAoF;AACpF,gBAAgB;AAEhB,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,OAAO,EAAE,2BAA2B,EAAE,MAAM,uCAAuC,CAAC;
|
|
1
|
+
{"version":3,"file":"useRealtimeChatMessages.js","sourceRoot":"","sources":["../../src/realtime/useRealtimeChatMessages.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\n// React hook for the REALTIME transport, returning the SAME shape as\n// `useChatMessages` (the firestore path) so `ChatShell` is transport-agnostic.\n// It SUBSCRIBES to a `RealtimeChatClient`'s observable state. It does NOT own the\n// socket lifecycle: the caller (the app hook that CREATES the client, e.g.\n// `useRealtimeChannelClient`) is the single connect/close owner (C-B7) — connecting\n// here too opened a second WebSocket. The client instance is owned by the caller\n// (passed via config) so its identity is the subscription key — a new client for a\n// new uid replaces the old one (Contract A auth-user-switch teardown happens in the\n// owning hook).\n\nimport * as React from \"react\";\nimport type { ChatMessageV1 } from \"@ttt-productions/chat-core\";\nimport type { RealtimeChatClient } from \"./transport.js\";\nimport type { ChannelClientState } from \"./channel-client.js\";\nimport { useOptionalChatNameResolver } from \"../context/ChatNameResolverContext.js\";\nimport { newCorrelationId } from \"./shared.js\";\n\nexport type UseRealtimeChatMessagesResult = {\n allowed: boolean;\n isInitialLoading: boolean;\n messages: ChatMessageV1[];\n fetchOlder: () => Promise<void>;\n hasOlder: boolean;\n isFetchingOlder: boolean;\n /** True once the first open has failed (see `ChannelClientState.initialLoadFailed`). */\n initialLoadFailed: boolean;\n /** Retry the connection now — the first-open failure's Retry. */\n retry: () => void;\n /** Realtime extras (not present on the firestore result) — optional for the UI. */\n status: ChannelClientState[\"status\"];\n /** The last structured error code the DO sent (e.g. a close reason). Drives the\n * disconnected-banner detail (R13). */\n lastErrorCode: string | null;\n typing: string[];\n presence: string[];\n /** Returns false when the socket was closed and nothing was sent (C-B8) — the caller keeps the composer text. */\n send: (text: string) => boolean;\n /** Retry a failed/un-acked send by its ORIGINAL clientMessageId (read from the\n * failed row's `meta.clientMessageId`) — the transport re-queues the tracked\n * pending send with the SAME id so the DO's send-idempotency dedups any copy\n * that already landed. Returns false for an unknown id (already acked/\n * reconciled). ChatShell wires this to MessageItemDefault's failed-bubble\n * retry affordance by default. */\n retrySend: (clientMessageId: string) => boolean;\n /** Returns whether the ack frame was sent (false on a closed socket) so the caller advances its local cursor only on success (M2). */\n readAck: (readSeq: number, focused: boolean) => boolean;\n signalTyping: () => void;\n /** Watched-only presence (R14): the UI subscribes ONLY while a member-list / online\n * indicator is open, and unsubscribes when it closes, so an unwatched channel runs\n * zero presence alarms. `presence` stays empty until subscribed. */\n presenceSubscribe: () => void;\n presenceUnsubscribe: () => void;\n};\n\nexport function useRealtimeChatMessages(client: RealtimeChatClient): UseRealtimeChatMessagesResult {\n const [state, setState] = React.useState<ChannelClientState>(() => client.getState());\n\n // Subscribe only; the owning hook drives connect/close (single lifecycle owner, C-B7).\n // Re-subscribes on client identity change (auth-user switch).\n React.useEffect(() => {\n const unsub = client.subscribe(setState);\n setState(client.getState());\n return unsub;\n }, [client]);\n\n // Pre-warm the name cache for visible senders (parity with the firestore hook).\n const resolverCtx = useOptionalChatNameResolver();\n const senderIds = React.useMemo(() => {\n const set = new Set<string>();\n for (const m of state.messages) {\n set.add(m.senderId);\n for (const uid of m.referencedUids ?? []) set.add(uid);\n }\n return Array.from(set);\n }, [state.messages]);\n\n React.useEffect(() => {\n if (resolverCtx?.prewarm && senderIds.length > 0) resolverCtx.prewarm(senderIds);\n }, [resolverCtx, senderIds]);\n\n const fetchOlder = React.useCallback(async () => {\n client.channel.fetchOlder();\n }, [client]);\n\n const send = React.useCallback(\n (text: string): boolean =>\n client.channel.send({ clientMessageId: newCorrelationId(), text }),\n [client],\n );\n\n const retrySend = React.useCallback(\n (clientMessageId: string): boolean => client.channel.retrySend(clientMessageId),\n [client],\n );\n\n const readAck = React.useCallback(\n (readSeq: number, focused: boolean) => client.channel.readAck(readSeq, focused),\n [client],\n );\n\n const signalTyping = React.useCallback(() => client.channel.typing(), [client]);\n const retry = React.useCallback(() => client.retry(), [client]);\n const presenceSubscribe = React.useCallback(() => client.channel.presenceSubscribe(), [client]);\n const presenceUnsubscribe = React.useCallback(() => client.channel.presenceUnsubscribe(), [client]);\n\n return {\n // On the realtime transport DATA ACCESS is enforced by the DO grant, so the hook\n // is \"allowed\" client-side UNLESS the grant provider surfaced a TERMINAL access\n // denial (ChatAccessDeniedError → stable 'access-denied' code). That single case\n // maps to allowed:false so ChatShell renders its no-access surface instead of an\n // eternal loader; a transient close / 4403-revoke keeps allowed:true and rides the\n // reconnect/disconnected banner. Otherwise accessMode is presentation-only here.\n allowed: state.lastErrorCode !== \"access-denied\",\n // Derive initial loading from authoritative-data completion, NOT connection status:\n // a socket can be 'open' with no snapshot yet, and a post-load reconnect must not\n // fall back into the opening state. `hasLoadedInitialData` encodes the full table.\n isInitialLoading: !state.hasLoadedInitialData,\n initialLoadFailed: state.initialLoadFailed,\n retry,\n messages: state.messages,\n fetchOlder,\n hasOlder: state.hasOlder,\n isFetchingOlder: state.isFetchingOlder,\n status: state.status,\n lastErrorCode: state.lastErrorCode,\n typing: state.typing,\n presence: state.presence,\n send,\n retrySend,\n readAck,\n signalTyping,\n presenceSubscribe,\n presenceUnsubscribe,\n };\n}\n\n"]}
|
package/dist/realtime/wire.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { CHAT_SUBPROTOCOL, CHAT_WIRE_VERSION, CHAT_CLOSE_CODES, CLIENT_KINDS as CLIENT_FRAME, SERVER_KINDS as SERVER_FRAME, ChatSendRejectedPayloadSchema, CHAT_SEND_REJECTION_CODES, CHAT_SEND_REJECTION_RETRYABLE, } from '@ttt-productions/chat-schemas';
|
|
2
|
-
export type { ChatCloseCode,
|
|
3
|
-
import type {
|
|
2
|
+
export type { ChatCloseCode, ChatSendRejectedPayload, ChatSendRejectionCode, } from '@ttt-productions/chat-schemas';
|
|
3
|
+
import type { ChatConversationRef, ChatMarkReadResultPayload, ChatSendRejectedPayload } from '@ttt-productions/chat-schemas';
|
|
4
4
|
/**
|
|
5
5
|
* The moderation revision `kind` (the worker runtime's revision input + the
|
|
6
6
|
* `revisions.kind` column). The EFFECTIVE state of a message is its
|
|
@@ -26,6 +26,8 @@ export interface WireMessageRow {
|
|
|
26
26
|
text: string;
|
|
27
27
|
createdAt: number;
|
|
28
28
|
epoch: number;
|
|
29
|
+
/** Account ids a server-written row's text refers to (absent on a member's own message). */
|
|
30
|
+
referencedUids?: string[];
|
|
29
31
|
/**
|
|
30
32
|
* The effective (max-`messageRevision`) moderation kind for this row, or null
|
|
31
33
|
* when never moderated. Populated by the DO's getHistory/getDeltaSince overlay
|
|
@@ -65,11 +67,11 @@ export interface WireRegistryEntry {
|
|
|
65
67
|
state: 'active' | 'tombstoned';
|
|
66
68
|
registryVersion: number;
|
|
67
69
|
/**
|
|
68
|
-
* The
|
|
69
|
-
* `channelRef` is an opaque
|
|
70
|
-
*
|
|
70
|
+
* The conversation this entry is — the OPENABLE identity for the inbox view (the
|
|
71
|
+
* `channelRef` is an opaque registry key). The app maps it back to its own
|
|
72
|
+
* conversation; absent, the entry offers no open action.
|
|
71
73
|
*/
|
|
72
|
-
ref?:
|
|
74
|
+
ref?: ChatConversationRef | null;
|
|
73
75
|
/**
|
|
74
76
|
* Per-entry unread flag (drives the per-row dot). Optional/absent until the inbox
|
|
75
77
|
* DO populates it; `deriveUnreadRefs` reads it as a typed field (no cast).
|
|
@@ -131,6 +133,9 @@ export type ServerFrame = {
|
|
|
131
133
|
code: string;
|
|
132
134
|
retryAfterMs?: number;
|
|
133
135
|
};
|
|
136
|
+
} | {
|
|
137
|
+
type: 'mark-read-result';
|
|
138
|
+
payload: ChatMarkReadResultPayload;
|
|
134
139
|
} | {
|
|
135
140
|
type: 'send-rejected';
|
|
136
141
|
payload: ChatSendRejectedPayload;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wire.d.ts","sourceRoot":"","sources":["../../src/realtime/wire.ts"],"names":[],"mappings":"AAiBA,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,IAAI,YAAY,EAC5B,YAAY,IAAI,YAAY,EAE5B,6BAA6B,EAC7B,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,+BAA+B,CAAC;AACvC,YAAY,EACV,aAAa,EACb,
|
|
1
|
+
{"version":3,"file":"wire.d.ts","sourceRoot":"","sources":["../../src/realtime/wire.ts"],"names":[],"mappings":"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"}
|
package/dist/realtime/wire.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Chat realtime WIRE PROTOCOL (Contract C) — the client side of the Channel +
|
|
2
2
|
// Inbox Durable Object protocol. The canonical protocol declarations (subprotocol,
|
|
3
|
-
// frame-kind maps, close codes,
|
|
4
|
-
// limits) are owned by `@ttt-productions/chat-schemas`; this file consumes them
|
|
3
|
+
// frame-kind maps, close codes, the neutral conversation reference, grant scope,
|
|
4
|
+
// the send and mark-read contracts, client-agreed limits) are owned by `@ttt-productions/chat-schemas`; this file consumes them
|
|
5
5
|
// and adds the client-side raw row/frame shapes the transport maps into the UI
|
|
6
6
|
// message shape. The client and the worker runtime agree on the wire by both
|
|
7
7
|
// importing that one contract; neither imports the other.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wire.js","sourceRoot":"","sources":["../../src/realtime/wire.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mFAAmF;AACnF,8EAA8E;AAC9E,gFAAgF;AAChF,+EAA+E;AAC/E,6EAA6E;AAC7E,0DAA0D;AAC1D,EAAE;AACF,2EAA2E;AAC3E,sEAAsE;AACtE,iFAAiF;AACjF,gFAAgF;AAChF,8EAA8E;AAE9E,4EAA4E;AAC5E,+EAA+E;AAC/E,2EAA2E;AAC3E,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,IAAI,YAAY,EAC5B,YAAY,IAAI,YAAY;AAC5B,2EAA2E;AAC3E,6BAA6B,EAC7B,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,+BAA+B,CAAC;AAQvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AA2HlE,qFAAqF;AACrF,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,OAAgC;IACvE,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;AACjE,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAY,CAAC;QAC1C,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QACvD,OAAO,MAA0E,CAAC;IACpF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,eAAe,CAAC,CAA0C;IACxE,OAAO,KAAK,CAAC,OAAO,CAAE,CAAuB,CAAC,QAAQ,CAAC,CAAC;AAC1D,CAAC","sourcesContent":["// Chat realtime WIRE PROTOCOL (Contract C) — the client side of the Channel +\n// Inbox Durable Object protocol. The canonical protocol declarations (subprotocol,\n// frame-kind maps, close codes, channel-ref tuple, grant scope, client-agreed\n// limits) are owned by `@ttt-productions/chat-schemas`; this file consumes them\n// and adds the client-side raw row/frame shapes the transport maps into the UI\n// message shape. The client and the worker runtime agree on the wire by both\n// importing that one contract; neither imports the other.\n//\n// Frame shape on the socket is `{ v, type, payload }` (NOT realtime-core's\n// `{ v, kind, payload }`): the worker runtime `send`/`broadcast` emit\n// `JSON.stringify({ v: 1, type, payload })` and read `env.type` + `env.payload`.\n// We keep that field name here to match; realtime-core's envelope schema is the\n// generic transport primitive, the chat protocol chose `type` for its frames.\n\n// Re-export the canonical wire contract so this package's public surface is\n// unchanged. `CLIENT_FRAME`/`SERVER_FRAME` are this package's historical names\n// for the frame-kind maps (`CLIENT_KINDS`/`SERVER_KINDS` in the contract).\nexport {\n CHAT_SUBPROTOCOL,\n CHAT_WIRE_VERSION,\n CHAT_CLOSE_CODES,\n CLIENT_KINDS as CLIENT_FRAME,\n SERVER_KINDS as SERVER_FRAME,\n // Correlated send-rejection contract (SERVER_FRAME.SEND_REJECTED payload).\n ChatSendRejectedPayloadSchema,\n CHAT_SEND_REJECTION_CODES,\n CHAT_SEND_REJECTION_RETRYABLE,\n} from '@ttt-productions/chat-schemas';\nexport type {\n ChatCloseCode,\n ChannelRefTuple,\n ChatSendRejectedPayload,\n ChatSendRejectionCode,\n} from '@ttt-productions/chat-schemas';\n\nimport { CHAT_WIRE_VERSION } from '@ttt-productions/chat-schemas';\nimport type { ChannelRefTuple, ChatSendRejectedPayload } from '@ttt-productions/chat-schemas';\n\n// ---- raw wire row shapes (what the DO serializes; opaque-but-typed here) ----\n\n/**\n * The moderation revision `kind` (the worker runtime's revision input + the\n * `revisions.kind` column). The EFFECTIVE state of a message is its\n * MAX-`messageRevision` row's kind, so a `restore` (a later revision) supersedes\n * an earlier `moderate`/`delete`. `moderate`/`delete` blank the original text;\n * `edit` overlays new text; `restore` reverts to the original.\n */\nexport type RevisionKind = 'delete' | 'moderate' | 'edit' | 'restore';\n\n/**\n * A message row as the Channel DO stores + broadcasts it (the worker runtime's\n * message row), declared as the fields the client actually CONSUMES. The adapter\n * mapping in `map.ts` turns it into the UI `ChatMessageV1` shape.\n *\n * The row is TEXT-only: there is no attachment lifecycle on a chat message, and\n * no reply pointer — chat has no reply-authoring affordance, so the client\n * neither sends nor reads one (DJ ruling 2026-07-29). A DO that still broadcasts\n * a legacy stringified `replyTo` key is simply ignored here.\n */\nexport interface WireMessageRow {\n seq: number;\n senderUid: string;\n clientMessageId: string;\n text: string;\n createdAt: number;\n epoch: number;\n /**\n * The effective (max-`messageRevision`) moderation kind for this row, or null\n * when never moderated. Populated by the DO's getHistory/getDeltaSince overlay\n * merge so a hidden/deleted/edited row arrives already-moderated and its\n * ORIGINAL text never reaches the renderer. Optional/absent on pre-overlay rows.\n */\n moderationKind?: RevisionKind | null;\n /**\n * The effective `messageRevision` that `moderationKind` reflects (the DO's\n * max-revision). Used to break ties against a live `revision` frame: a higher\n * `messageRevision` wins; a stale/older one is ignored. Optional/absent on\n * pre-overlay rows (treated as revision 0).\n */\n messageRevision?: number;\n}\n\n/**\n * The channel resume snapshot the DO returns for a `resume` frame (Contract C).\n * `resync` / `delta` are the durable gap-repair half of the contract:\n * - `resync: false` + `delta` = the contiguous ≤500-message backlog after the\n * client's cursor (oldest-first); the client APPLIES the delta and advances its\n * cursor to `lastMessageSeq` — a missed broadcast is never permanent.\n * - `resync: true` = the gap exceeds the backlog (or the client sent no cursor);\n * the client DROPS its local tail and re-pages history from the top.\n * Both are OPTIONAL/absent on a pre-contract DO (treated as `resync: true`, empty\n * delta — the safe re-page path) so a stale worker can't leave a client stuck.\n */\nexport interface WireChannelSnapshot {\n lastMessageSeq: number;\n readSeq: number;\n resync?: boolean;\n delta?: WireMessageRow[];\n}\n\n/** A registry entry in the inbox snapshot (the worker runtime's registry entry). */\nexport interface WireRegistryEntry {\n channelRef: string;\n kind: string;\n state: 'active' | 'tombstoned';\n registryVersion: number;\n /**\n * The typed channel-ref tuple — the OPENABLE identity for the inbox view (the\n * `channelRef` is an opaque DO-id dedup key). Null/absent on legacy or pre-tuple\n * rows; the consumer falls back to hiding the open action for those entries.\n */\n ref?: ChannelRefTuple | null;\n /**\n * Per-entry unread flag (drives the per-row dot). Optional/absent until the inbox\n * DO populates it; `deriveUnreadRefs` reads it as a typed field (no cast).\n */\n unread?: boolean;\n /**\n * Archived state — DISTINCT from `tombstoned`. An archived row is still an ACTIVE\n * membership (`state: 'active'`, history readable) but is grouped under the inbox\n * view's Archived toggle instead of the active list. The client must NOT count\n * archived rows in the unread roll-up (archive = done; the DO clears unread on\n * archive). Optional/absent on a pre-archive DO (treated as not-archived).\n */\n archived?: boolean;\n}\n\n/** The inbox snapshot payload pushed on connect / `resume` / live delta. */\nexport interface WireInboxSnapshot {\n registry: WireRegistryEntry[];\n hasUnread: boolean;\n}\n\n// ---- server → client frame payloads (discriminated on `type`) ----\n\nexport type ServerFrame =\n | { type: 'message'; payload: { message: WireMessageRow } }\n | { type: 'ack'; payload: { clientMessageId?: string; seq?: number; readSeq?: number } }\n | { type: 'history-page'; payload: { messages: WireMessageRow[] } }\n | { type: 'presence'; payload: { online: string[] } }\n | { type: 'typing'; payload: { uid: string; at: number } }\n | { type: 'snapshot'; payload: WireChannelSnapshot | WireInboxSnapshot }\n // A standalone unread push (inbox scope): either a full inbox snapshot or a\n // lightweight dock-dot patch. Represented in the union so the inbox client routes\n // it instead of silently dropping it (forward-compatible with a future DO push).\n | { type: 'unread'; payload: WireInboxSnapshot | { hasUnread: boolean } }\n | { type: 'error'; payload: { code: string; retryAfterMs?: number } }\n // A CORRELATED send rejection (SERVER_FRAME.SEND_REJECTED): names the SAME\n // clientMessageId as the rejected send + the canonical retry classification, so\n // exactly one optimistic bubble is failed/retried. See ChatSendRejectedPayload.\n | { type: 'send-rejected'; payload: ChatSendRejectedPayload }\n // A moderation revision was applied to one message. The Channel DO broadcasts\n // this on a NEWLY-applied revision (the worker runtime's moderation apply) so live\n // viewers hide/blank/edit/restore the message immediately with no extra fetch.\n // `messageSeq` is the target message id; `kind` is the applied revision kind;\n // `messageRevision` is its per-message revision number (max-revision wins).\n | { type: 'revision'; payload: { messageSeq: number; kind: RevisionKind; messageRevision: number } };\n\n/** Build an outbound `{ v, type, payload }` frame (the wire format the DO reads). */\nexport function buildFrame(type: string, payload: Record<string, unknown>): string {\n return JSON.stringify({ v: CHAT_WIRE_VERSION, type, payload });\n}\n\n/** Parse an inbound frame, tolerating malformed JSON. Returns null on garbage. */\nexport function parseFrame(raw: string): { v?: number; type?: string; payload?: Record<string, unknown> } | null {\n try {\n const parsed = JSON.parse(raw) as unknown;\n if (!parsed || typeof parsed !== 'object') return null;\n return parsed as { v?: number; type?: string; payload?: Record<string, unknown> };\n } catch {\n return null;\n }\n}\n\n/** A snapshot with `registry` is an inbox snapshot; one with `lastMessageSeq` is a channel snapshot. */\nexport function isInboxSnapshot(p: WireChannelSnapshot | WireInboxSnapshot): p is WireInboxSnapshot {\n return Array.isArray((p as WireInboxSnapshot).registry);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"wire.js","sourceRoot":"","sources":["../../src/realtime/wire.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mFAAmF;AACnF,iFAAiF;AACjF,gIAAgI;AAChI,+EAA+E;AAC/E,6EAA6E;AAC7E,0DAA0D;AAC1D,EAAE;AACF,2EAA2E;AAC3E,sEAAsE;AACtE,iFAAiF;AACjF,gFAAgF;AAChF,8EAA8E;AAE9E,4EAA4E;AAC5E,+EAA+E;AAC/E,2EAA2E;AAC3E,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,IAAI,YAAY,EAC5B,YAAY,IAAI,YAAY;AAC5B,2EAA2E;AAC3E,6BAA6B,EAC7B,yBAAyB,EACzB,6BAA6B,GAC9B,MAAM,+BAA+B,CAAC;AAOvC,OAAO,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAmIlE,qFAAqF;AACrF,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,OAAgC;IACvE,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC;AACjE,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAY,CAAC;QAC1C,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QACvD,OAAO,MAA0E,CAAC;IACpF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,eAAe,CAAC,CAA0C;IACxE,OAAO,KAAK,CAAC,OAAO,CAAE,CAAuB,CAAC,QAAQ,CAAC,CAAC;AAC1D,CAAC","sourcesContent":["// Chat realtime WIRE PROTOCOL (Contract C) — the client side of the Channel +\n// Inbox Durable Object protocol. The canonical protocol declarations (subprotocol,\n// frame-kind maps, close codes, the neutral conversation reference, grant scope,\n// the send and mark-read contracts, client-agreed limits) are owned by `@ttt-productions/chat-schemas`; this file consumes them\n// and adds the client-side raw row/frame shapes the transport maps into the UI\n// message shape. The client and the worker runtime agree on the wire by both\n// importing that one contract; neither imports the other.\n//\n// Frame shape on the socket is `{ v, type, payload }` (NOT realtime-core's\n// `{ v, kind, payload }`): the worker runtime `send`/`broadcast` emit\n// `JSON.stringify({ v: 1, type, payload })` and read `env.type` + `env.payload`.\n// We keep that field name here to match; realtime-core's envelope schema is the\n// generic transport primitive, the chat protocol chose `type` for its frames.\n\n// Re-export the canonical wire contract so this package's public surface is\n// unchanged. `CLIENT_FRAME`/`SERVER_FRAME` are this package's historical names\n// for the frame-kind maps (`CLIENT_KINDS`/`SERVER_KINDS` in the contract).\nexport {\n CHAT_SUBPROTOCOL,\n CHAT_WIRE_VERSION,\n CHAT_CLOSE_CODES,\n CLIENT_KINDS as CLIENT_FRAME,\n SERVER_KINDS as SERVER_FRAME,\n // Correlated send-rejection contract (SERVER_FRAME.SEND_REJECTED payload).\n ChatSendRejectedPayloadSchema,\n CHAT_SEND_REJECTION_CODES,\n CHAT_SEND_REJECTION_RETRYABLE,\n} from '@ttt-productions/chat-schemas';\nexport type {\n ChatCloseCode,\n ChatSendRejectedPayload,\n ChatSendRejectionCode,\n} from '@ttt-productions/chat-schemas';\n\nimport { CHAT_WIRE_VERSION } from '@ttt-productions/chat-schemas';\nimport type {\n ChatConversationRef,\n ChatMarkReadResultPayload,\n ChatSendRejectedPayload,\n} from '@ttt-productions/chat-schemas';\n\n// ---- raw wire row shapes (what the DO serializes; opaque-but-typed here) ----\n\n/**\n * The moderation revision `kind` (the worker runtime's revision input + the\n * `revisions.kind` column). The EFFECTIVE state of a message is its\n * MAX-`messageRevision` row's kind, so a `restore` (a later revision) supersedes\n * an earlier `moderate`/`delete`. `moderate`/`delete` blank the original text;\n * `edit` overlays new text; `restore` reverts to the original.\n */\nexport type RevisionKind = 'delete' | 'moderate' | 'edit' | 'restore';\n\n/**\n * A message row as the Channel DO stores + broadcasts it (the worker runtime's\n * message row), declared as the fields the client actually CONSUMES. The adapter\n * mapping in `map.ts` turns it into the UI `ChatMessageV1` shape.\n *\n * The row is TEXT-only: there is no attachment lifecycle on a chat message, and\n * no reply pointer — chat has no reply-authoring affordance, so the client\n * neither sends nor reads one (DJ ruling 2026-07-29). A DO that still broadcasts\n * a legacy stringified `replyTo` key is simply ignored here.\n */\nexport interface WireMessageRow {\n seq: number;\n senderUid: string;\n clientMessageId: string;\n text: string;\n createdAt: number;\n epoch: number;\n /** Account ids a server-written row's text refers to (absent on a member's own message). */\n referencedUids?: string[];\n /**\n * The effective (max-`messageRevision`) moderation kind for this row, or null\n * when never moderated. Populated by the DO's getHistory/getDeltaSince overlay\n * merge so a hidden/deleted/edited row arrives already-moderated and its\n * ORIGINAL text never reaches the renderer. Optional/absent on pre-overlay rows.\n */\n moderationKind?: RevisionKind | null;\n /**\n * The effective `messageRevision` that `moderationKind` reflects (the DO's\n * max-revision). Used to break ties against a live `revision` frame: a higher\n * `messageRevision` wins; a stale/older one is ignored. Optional/absent on\n * pre-overlay rows (treated as revision 0).\n */\n messageRevision?: number;\n}\n\n/**\n * The channel resume snapshot the DO returns for a `resume` frame (Contract C).\n * `resync` / `delta` are the durable gap-repair half of the contract:\n * - `resync: false` + `delta` = the contiguous ≤500-message backlog after the\n * client's cursor (oldest-first); the client APPLIES the delta and advances its\n * cursor to `lastMessageSeq` — a missed broadcast is never permanent.\n * - `resync: true` = the gap exceeds the backlog (or the client sent no cursor);\n * the client DROPS its local tail and re-pages history from the top.\n * Both are OPTIONAL/absent on a pre-contract DO (treated as `resync: true`, empty\n * delta — the safe re-page path) so a stale worker can't leave a client stuck.\n */\nexport interface WireChannelSnapshot {\n lastMessageSeq: number;\n readSeq: number;\n resync?: boolean;\n delta?: WireMessageRow[];\n}\n\n/** A registry entry in the inbox snapshot (the worker runtime's registry entry). */\nexport interface WireRegistryEntry {\n channelRef: string;\n kind: string;\n state: 'active' | 'tombstoned';\n registryVersion: number;\n /**\n * The conversation this entry is — the OPENABLE identity for the inbox view (the\n * `channelRef` is an opaque registry key). The app maps it back to its own\n * conversation; absent, the entry offers no open action.\n */\n ref?: ChatConversationRef | null;\n /**\n * Per-entry unread flag (drives the per-row dot). Optional/absent until the inbox\n * DO populates it; `deriveUnreadRefs` reads it as a typed field (no cast).\n */\n unread?: boolean;\n /**\n * Archived state — DISTINCT from `tombstoned`. An archived row is still an ACTIVE\n * membership (`state: 'active'`, history readable) but is grouped under the inbox\n * view's Archived toggle instead of the active list. The client must NOT count\n * archived rows in the unread roll-up (archive = done; the DO clears unread on\n * archive). Optional/absent on a pre-archive DO (treated as not-archived).\n */\n archived?: boolean;\n}\n\n/** The inbox snapshot payload pushed on connect / `resume` / live delta. */\nexport interface WireInboxSnapshot {\n registry: WireRegistryEntry[];\n hasUnread: boolean;\n}\n\n// ---- server → client frame payloads (discriminated on `type`) ----\n\nexport type ServerFrame =\n | { type: 'message'; payload: { message: WireMessageRow } }\n | { type: 'ack'; payload: { clientMessageId?: string; seq?: number; readSeq?: number } }\n | { type: 'history-page'; payload: { messages: WireMessageRow[] } }\n | { type: 'presence'; payload: { online: string[] } }\n | { type: 'typing'; payload: { uid: string; at: number } }\n | { type: 'snapshot'; payload: WireChannelSnapshot | WireInboxSnapshot }\n // A standalone unread push (inbox scope): either a full inbox snapshot or a\n // lightweight dock-dot patch. Represented in the union so the inbox client routes\n // it instead of silently dropping it (forward-compatible with a future DO push).\n | { type: 'unread'; payload: WireInboxSnapshot | { hasUnread: boolean } }\n | { type: 'error'; payload: { code: string; retryAfterMs?: number } }\n // The inbox runtime's correlated answer to one `mark-read`.\n | { type: 'mark-read-result'; payload: ChatMarkReadResultPayload }\n // A CORRELATED send rejection (SERVER_FRAME.SEND_REJECTED): names the SAME\n // clientMessageId as the rejected send + the canonical retry classification, so\n // exactly one optimistic bubble is failed/retried. See ChatSendRejectedPayload.\n | { type: 'send-rejected'; payload: ChatSendRejectedPayload }\n // A moderation revision was applied to one message. The Channel DO broadcasts\n // this on a NEWLY-applied revision (the worker runtime's moderation apply) so live\n // viewers hide/blank/edit/restore the message immediately with no extra fetch.\n // `messageSeq` is the target message id; `kind` is the applied revision kind;\n // `messageRevision` is its per-message revision number (max-revision wins).\n | { type: 'revision'; payload: { messageSeq: number; kind: RevisionKind; messageRevision: number } };\n\n/** Build an outbound `{ v, type, payload }` frame (the wire format the DO reads). */\nexport function buildFrame(type: string, payload: Record<string, unknown>): string {\n return JSON.stringify({ v: CHAT_WIRE_VERSION, type, payload });\n}\n\n/** Parse an inbound frame, tolerating malformed JSON. Returns null on garbage. */\nexport function parseFrame(raw: string): { v?: number; type?: string; payload?: Record<string, unknown> } | null {\n try {\n const parsed = JSON.parse(raw) as unknown;\n if (!parsed || typeof parsed !== 'object') return null;\n return parsed as { v?: number; type?: string; payload?: Record<string, unknown> };\n } catch {\n return null;\n }\n}\n\n/** A snapshot with `registry` is an inbox snapshot; one with `lastMessageSeq` is a channel snapshot. */\nexport function isInboxSnapshot(p: WireChannelSnapshot | WireInboxSnapshot): p is WireInboxSnapshot {\n return Array.isArray((p as WireInboxSnapshot).registry);\n}\n"]}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ChatMessageV1 } from "@ttt-productions/chat-core";
|
|
3
|
+
import type { ChatConversationRef } from "@ttt-productions/chat-schemas";
|
|
3
4
|
/**
|
|
4
5
|
* Which transport backs a chat thread.
|
|
5
|
-
* - `firestore` — the
|
|
6
|
-
* - `realtime` — the Cloudflare Durable Object socket
|
|
7
|
-
*
|
|
8
|
-
* becomes PRESENTATION-ONLY (UI affordances like read-only), never a
|
|
9
|
-
* data-access gate.
|
|
6
|
+
* - `firestore` — the Firestore newest-window + cursor path (default).
|
|
7
|
+
* - `realtime` — the Cloudflare Durable Object socket; the grant is the data-access
|
|
8
|
+
* authority there.
|
|
10
9
|
*/
|
|
11
10
|
export type ChatTransportMode = 'firestore' | 'realtime';
|
|
12
11
|
/**
|
|
@@ -14,13 +13,13 @@ export type ChatTransportMode = 'firestore' | 'realtime';
|
|
|
14
13
|
* in this package (`realtime/transport.ts`); chat-react consumes it through this
|
|
15
14
|
* config so the UI stays transport-agnostic. The app builds the `client` via
|
|
16
15
|
* `createRealtimeChatClient({ endpoint, channelRef, grantProvider, ... })` and
|
|
17
|
-
* passes it here. `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* passes it here. `client` stays structurally typed (`unknown` at the config seam)
|
|
17
|
+
* so `ChatCoreConfig` does not pull the realtime module's types into every firestore
|
|
18
|
+
* consumer — the realtime hook narrows it at the call site.
|
|
20
19
|
*/
|
|
21
20
|
export interface ChatRealtimeTransportConfig {
|
|
22
|
-
/**
|
|
23
|
-
channelRef:
|
|
21
|
+
/** The conversation the socket is scoped to, as the app maps it onto the neutral reference. */
|
|
22
|
+
channelRef: ChatConversationRef;
|
|
24
23
|
/**
|
|
25
24
|
* The realtime client handle from `createRealtimeChatClient(...)`. Drives the
|
|
26
25
|
* channel socket (subscribe / send / ack / typing / presence / history). The UI
|
|
@@ -43,15 +42,13 @@ export type ChatCoreConfig = {
|
|
|
43
42
|
/** Required iff `transport === 'realtime'`; ignored on the firestore transport. */
|
|
44
43
|
realtime?: ChatRealtimeTransportConfig;
|
|
45
44
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* Whether the current user may read this conversation — the app's ONE access fact,
|
|
46
|
+
* decided by the app's own rule for the conversation's kind (the package never decides
|
|
47
|
+
* access). False renders the no-access state and reads nothing. On the `realtime`
|
|
48
|
+
* transport the grant is the data authority, and a terminal denial from the grant
|
|
49
|
+
* closes the conversation even when this is true.
|
|
49
50
|
*/
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Required iff accessMode === "explicit-allowlist". Ignored otherwise.
|
|
53
|
-
*/
|
|
54
|
-
threadAllowedUserIds?: string[];
|
|
51
|
+
allowed: boolean;
|
|
55
52
|
createdAtField?: string;
|
|
56
53
|
pageSize?: number;
|
|
57
54
|
};
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAWzE;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG,UAAU,CAAC;AAEzD;;;;;;;;GAQG;AACH,MAAM,WAAW,2BAA2B;IAC1C,+FAA+F;IAC/F,UAAU,EAAE,mBAAmB,CAAC;IAChC;;;;OAIG;IACH,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,kBAAkB,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACtC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,EAAE,2BAA2B,CAAC;IACvC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAMF,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,EAAE,aAAa,KAAK,SAAS,CAAC;AAE9D,MAAM,MAAM,uBAAuB,GAAG,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC"}
|