@stapel/chat-react 0.1.0

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.
Files changed (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +118 -0
  3. package/README.md +126 -0
  4. package/dist/api/chatApi.d.ts +49 -0
  5. package/dist/api/chatApi.d.ts.map +1 -0
  6. package/dist/api/chatApi.js +46 -0
  7. package/dist/api/chatApi.js.map +1 -0
  8. package/dist/api/extensions.d.ts +19 -0
  9. package/dist/api/extensions.d.ts.map +1 -0
  10. package/dist/api/extensions.js +19 -0
  11. package/dist/api/extensions.js.map +1 -0
  12. package/dist/api/generated/schema.d.ts +547 -0
  13. package/dist/api/generated/schema.d.ts.map +1 -0
  14. package/dist/api/generated/schema.js +2 -0
  15. package/dist/api/generated/schema.js.map +1 -0
  16. package/dist/api/types.d.ts +75 -0
  17. package/dist/api/types.d.ts.map +1 -0
  18. package/dist/api/types.js +2 -0
  19. package/dist/api/types.js.map +1 -0
  20. package/dist/default/ConversationListPanel.d.ts +22 -0
  21. package/dist/default/ConversationListPanel.d.ts.map +1 -0
  22. package/dist/default/ConversationListPanel.js +49 -0
  23. package/dist/default/ConversationListPanel.js.map +1 -0
  24. package/dist/default/ConversationThreadPanel.d.ts +21 -0
  25. package/dist/default/ConversationThreadPanel.d.ts.map +1 -0
  26. package/dist/default/ConversationThreadPanel.js +51 -0
  27. package/dist/default/ConversationThreadPanel.js.map +1 -0
  28. package/dist/default/ErrorAlert.d.ts +8 -0
  29. package/dist/default/ErrorAlert.d.ts.map +1 -0
  30. package/dist/default/ErrorAlert.js +26 -0
  31. package/dist/default/ErrorAlert.js.map +1 -0
  32. package/dist/default/StartChatButton.d.ts +17 -0
  33. package/dist/default/StartChatButton.d.ts.map +1 -0
  34. package/dist/default/StartChatButton.js +18 -0
  35. package/dist/default/StartChatButton.js.map +1 -0
  36. package/dist/default/index.d.ts +21 -0
  37. package/dist/default/index.d.ts.map +1 -0
  38. package/dist/default/index.js +18 -0
  39. package/dist/default/index.js.map +1 -0
  40. package/dist/flows/errors.d.ts +12 -0
  41. package/dist/flows/errors.d.ts.map +1 -0
  42. package/dist/flows/errors.js +13 -0
  43. package/dist/flows/errors.js.map +1 -0
  44. package/dist/flows/freshness.d.ts +69 -0
  45. package/dist/flows/freshness.d.ts.map +1 -0
  46. package/dist/flows/freshness.js +227 -0
  47. package/dist/flows/freshness.js.map +1 -0
  48. package/dist/flows/registry.d.ts +26 -0
  49. package/dist/flows/registry.d.ts.map +1 -0
  50. package/dist/flows/registry.js +24 -0
  51. package/dist/flows/registry.js.map +1 -0
  52. package/dist/headless/ChatProvider.d.ts +22 -0
  53. package/dist/headless/ChatProvider.d.ts.map +1 -0
  54. package/dist/headless/ChatProvider.js +18 -0
  55. package/dist/headless/ChatProvider.js.map +1 -0
  56. package/dist/headless/ConversationList.d.ts +51 -0
  57. package/dist/headless/ConversationList.d.ts.map +1 -0
  58. package/dist/headless/ConversationList.js +47 -0
  59. package/dist/headless/ConversationList.js.map +1 -0
  60. package/dist/headless/ConversationThread.d.ts +50 -0
  61. package/dist/headless/ConversationThread.d.ts.map +1 -0
  62. package/dist/headless/ConversationThread.js +64 -0
  63. package/dist/headless/ConversationThread.js.map +1 -0
  64. package/dist/headless/MessageComposer.d.ts +39 -0
  65. package/dist/headless/MessageComposer.d.ts.map +1 -0
  66. package/dist/headless/MessageComposer.js +45 -0
  67. package/dist/headless/MessageComposer.js.map +1 -0
  68. package/dist/headless/StartDirectChat.d.ts +40 -0
  69. package/dist/headless/StartDirectChat.d.ts.map +1 -0
  70. package/dist/headless/StartDirectChat.js +46 -0
  71. package/dist/headless/StartDirectChat.js.map +1 -0
  72. package/dist/i18n/errorsMap.d.ts +10 -0
  73. package/dist/i18n/errorsMap.d.ts.map +1 -0
  74. package/dist/i18n/errorsMap.js +20 -0
  75. package/dist/i18n/errorsMap.js.map +1 -0
  76. package/dist/i18n/es.d.ts +23 -0
  77. package/dist/i18n/es.d.ts.map +1 -0
  78. package/dist/i18n/es.js +75 -0
  79. package/dist/i18n/es.js.map +1 -0
  80. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.es.gen.js +58 -0
  83. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  84. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  85. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  86. package/dist/i18n/generated/errors.gen.js +183 -0
  87. package/dist/i18n/generated/errors.gen.js.map +1 -0
  88. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  89. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  90. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  91. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  92. package/dist/i18n/keys.d.ts +78 -0
  93. package/dist/i18n/keys.d.ts.map +1 -0
  94. package/dist/i18n/keys.js +130 -0
  95. package/dist/i18n/keys.js.map +1 -0
  96. package/dist/i18n/ru.d.ts +28 -0
  97. package/dist/i18n/ru.d.ts.map +1 -0
  98. package/dist/i18n/ru.js +80 -0
  99. package/dist/i18n/ru.js.map +1 -0
  100. package/dist/index.d.ts +61 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +55 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/model/context.d.ts +11 -0
  105. package/dist/model/context.d.ts.map +1 -0
  106. package/dist/model/context.js +16 -0
  107. package/dist/model/context.js.map +1 -0
  108. package/dist/model/limits.d.ts +12 -0
  109. package/dist/model/limits.d.ts.map +1 -0
  110. package/dist/model/limits.js +12 -0
  111. package/dist/model/limits.js.map +1 -0
  112. package/dist/model/mutations.d.ts +66 -0
  113. package/dist/model/mutations.d.ts.map +1 -0
  114. package/dist/model/mutations.js +156 -0
  115. package/dist/model/mutations.js.map +1 -0
  116. package/dist/model/queries.d.ts +33 -0
  117. package/dist/model/queries.d.ts.map +1 -0
  118. package/dist/model/queries.js +93 -0
  119. package/dist/model/queries.js.map +1 -0
  120. package/dist/model/queryKeys.d.ts +8 -0
  121. package/dist/model/queryKeys.d.ts.map +1 -0
  122. package/dist/model/queryKeys.js +26 -0
  123. package/dist/model/queryKeys.js.map +1 -0
  124. package/dist/model/readMarker.d.ts +25 -0
  125. package/dist/model/readMarker.d.ts.map +1 -0
  126. package/dist/model/readMarker.js +31 -0
  127. package/dist/model/readMarker.js.map +1 -0
  128. package/dist/model/runtime.d.ts +50 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +27 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/model/threadWindow.d.ts +95 -0
  133. package/dist/model/threadWindow.d.ts.map +1 -0
  134. package/dist/model/threadWindow.js +100 -0
  135. package/dist/model/threadWindow.js.map +1 -0
  136. package/dist/nav/manifest.d.ts +23 -0
  137. package/dist/nav/manifest.d.ts.map +1 -0
  138. package/dist/nav/manifest.js +15 -0
  139. package/dist/nav/manifest.js.map +1 -0
  140. package/dist/realtime/chatSocket.d.ts +93 -0
  141. package/dist/realtime/chatSocket.d.ts.map +1 -0
  142. package/dist/realtime/chatSocket.js +199 -0
  143. package/dist/realtime/chatSocket.js.map +1 -0
  144. package/dist/realtime/frames.d.ts +123 -0
  145. package/dist/realtime/frames.d.ts.map +1 -0
  146. package/dist/realtime/frames.js +123 -0
  147. package/dist/realtime/frames.js.map +1 -0
  148. package/dist/realtime/streams.d.ts +49 -0
  149. package/dist/realtime/streams.d.ts.map +1 -0
  150. package/dist/realtime/streams.js +52 -0
  151. package/dist/realtime/streams.js.map +1 -0
  152. package/llms.txt +89 -0
  153. package/manifest.json +733 -0
  154. package/nav-manifest.json +25 -0
  155. package/package.json +114 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/chatApi.ts +142 -0
  158. package/src/api/extensions.ts +18 -0
  159. package/src/api/generated/schema.ts +549 -0
  160. package/src/api/types.ts +87 -0
  161. package/src/default/ConversationListPanel.tsx +163 -0
  162. package/src/default/ConversationThreadPanel.tsx +245 -0
  163. package/src/default/ErrorAlert.tsx +43 -0
  164. package/src/default/StartChatButton.tsx +78 -0
  165. package/src/default/index.ts +20 -0
  166. package/src/flows/errors.ts +16 -0
  167. package/src/flows/freshness.ts +315 -0
  168. package/src/flows/registry.ts +36 -0
  169. package/src/headless/ChatProvider.tsx +23 -0
  170. package/src/headless/ConversationList.tsx +92 -0
  171. package/src/headless/ConversationThread.tsx +107 -0
  172. package/src/headless/MessageComposer.tsx +86 -0
  173. package/src/headless/StartDirectChat.tsx +75 -0
  174. package/src/i18n/errorsMap.ts +31 -0
  175. package/src/i18n/es.ts +95 -0
  176. package/src/i18n/generated/errors.es.gen.ts +64 -0
  177. package/src/i18n/generated/errors.gen.ts +214 -0
  178. package/src/i18n/generated/errors.json +408 -0
  179. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  180. package/src/i18n/keys.ts +152 -0
  181. package/src/i18n/ru.ts +98 -0
  182. package/src/index.ts +189 -0
  183. package/src/model/context.tsx +25 -0
  184. package/src/model/limits.ts +11 -0
  185. package/src/model/mutations.ts +208 -0
  186. package/src/model/queries.ts +120 -0
  187. package/src/model/queryKeys.ts +32 -0
  188. package/src/model/readMarker.ts +32 -0
  189. package/src/model/runtime.ts +90 -0
  190. package/src/model/threadWindow.ts +173 -0
  191. package/src/nav/manifest.ts +36 -0
  192. package/src/realtime/chatSocket.ts +304 -0
  193. package/src/realtime/frames.ts +229 -0
  194. package/src/realtime/streams.ts +85 -0
  195. package/tsconfig.json +26 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The read marker, and the one rule that governs it: it only ever moves
3
+ * forward.
4
+ *
5
+ * The server already enforces that (`services.mark_read` never lowers
6
+ * `last_read_seq`), so a client that sends a smaller value is not corrupting
7
+ * anything — it is spending a request to be ignored, and, worse, telling the
8
+ * UI a story that the next list refresh will contradict. The guard is here so
9
+ * the client's own belief matches the server's.
10
+ *
11
+ * WHY THE CLIENT HAS TO REMEMBER AT ALL. `ConversationResponse.participants[]`
12
+ * carries `last_read_seq` — for every participant, with no marker for which
13
+ * one is the caller (the response has no "me"). `unread_count` is the only
14
+ * caller-relative number the endpoint returns. So the highest marker THIS
15
+ * client has reported cannot be read back off the wire; it is remembered in
16
+ * the query cache (`chatQueryKeys.readMarker`), which the session layer wipes
17
+ * at logout along with everything else.
18
+ */
19
+
20
+ /**
21
+ * The value to send, or `null` for "already reported — send nothing".
22
+ *
23
+ * `candidate` of 0 (an empty thread) is never sent: there is no such seq.
24
+ */
25
+ export function nextReadMarker(
26
+ known: number | undefined,
27
+ candidate: number
28
+ ): number | null {
29
+ if (!Number.isFinite(candidate) || candidate <= 0) return null;
30
+ if (known !== undefined && candidate <= known) return null;
31
+ return candidate;
32
+ }
@@ -0,0 +1,90 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
3
+ import { createChatApi } from "../api/chatApi.js";
4
+ import type { ChatApi } from "../api/chatApi.js";
5
+ import { canOpenWebSocket } from "../realtime/chatSocket.js";
6
+ import type {
7
+ ChatReconnectOptions,
8
+ ChatWebSocketFactory,
9
+ } from "../realtime/chatSocket.js";
10
+ import { deriveChatSocketBase } from "../realtime/streams.js";
11
+
12
+ /**
13
+ * Host-side realtime configuration. Every field is optional and every default
14
+ * degrades to polling rather than to a broken socket — a host that says
15
+ * nothing gets the right behaviour on a WSGI deployment (no sockets at all)
16
+ * and on an ASGI one (sockets, if the origin can be resolved).
17
+ */
18
+ export interface ChatRealtimeOptions {
19
+ /**
20
+ * Where this deployment mounts `stapel_chat.routing.websocket_urlpatterns`,
21
+ * e.g. `wss://shop.example/ws/chat/`. Omit to derive it from `baseUrl`
22
+ * (the canonical `/ws/chat/` at the API's own origin). Pass `null` to turn
23
+ * the socket transport OFF explicitly — a host that knows its backend runs
24
+ * under WSGI says so here instead of letting every tab fail a handshake
25
+ * six times first.
26
+ */
27
+ readonly socketUrl?: string | null;
28
+ /** Injectable socket transport (tests, instrumentation, React Native). */
29
+ readonly webSocket?: ChatWebSocketFactory;
30
+ /** Reconnect backoff knobs. */
31
+ readonly reconnect?: ChatReconnectOptions;
32
+ /**
33
+ * Origin used to resolve a relative `baseUrl`. Defaults to the browser's
34
+ * own; irrelevant when `baseUrl` is absolute.
35
+ */
36
+ readonly origin?: string | null;
37
+ }
38
+
39
+ /** The realtime configuration as the transport seam reads it. */
40
+ export interface ChatRealtimeConfig {
41
+ /** `null` — this build will not open sockets; the seam polls. */
42
+ readonly socketBase: string | null;
43
+ readonly webSocket: ChatWebSocketFactory | undefined;
44
+ readonly reconnect: ChatReconnectOptions | undefined;
45
+ }
46
+
47
+ /**
48
+ * The wired chat runtime — core's `ModuleRuntime` bound to this pair's API,
49
+ * PLUS the one thing chat has and a plain REST pair does not: where its
50
+ * socket lives. The socket URL is runtime configuration, not module state, so
51
+ * it rides here rather than through a second provider.
52
+ */
53
+ export type ChatRuntime = ModuleRuntime<ChatApi> & {
54
+ readonly realtime: ChatRealtimeConfig;
55
+ };
56
+
57
+ export interface CreateChatRuntimeOptions extends CreateModuleRuntimeOptions {
58
+ readonly realtime?: ChatRealtimeOptions;
59
+ }
60
+
61
+ function currentOrigin(): string | null {
62
+ return typeof location !== "undefined" ? location.origin : null;
63
+ }
64
+
65
+ function resolveRealtime(
66
+ baseUrl: string,
67
+ options: ChatRealtimeOptions | undefined
68
+ ): ChatRealtimeConfig {
69
+ const explicit = options?.socketUrl;
70
+ const socketBase =
71
+ explicit === null
72
+ ? null
73
+ : explicit !== undefined
74
+ ? explicit
75
+ : canOpenWebSocket()
76
+ ? deriveChatSocketBase(baseUrl, options?.origin ?? currentOrigin())
77
+ : null;
78
+ return {
79
+ socketBase,
80
+ webSocket: options?.webSocket,
81
+ reconnect: options?.reconnect,
82
+ };
83
+ }
84
+
85
+ export function createChatRuntime(
86
+ options: CreateChatRuntimeOptions
87
+ ): ChatRuntime {
88
+ const runtime = createModuleRuntime(createChatApi, options);
89
+ return { ...runtime, realtime: resolveRealtime(options.baseUrl, options.realtime) };
90
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The thread store: a merged, `seq`-ordered window over a conversation, and
3
+ * the four pure functions that move it.
4
+ *
5
+ * WHY A WINDOW AND NOT A PAGE LIST. A chat thread grows at BOTH ends: older
6
+ * history is paged backwards on scroll, new messages arrive at the tip while
7
+ * the reader is looking at it. TanStack's bidirectional infinite query cannot
8
+ * express the second half — `getPreviousPageParam` is answered from the page
9
+ * the server sent, and at the tip the server correctly says "no previous
10
+ * page", which would freeze the tail forever. So the thread is ONE cache
11
+ * entry holding a contiguous run of messages, and the two directions are two
12
+ * merges into it.
13
+ *
14
+ * THE ORDERING RULE IS THE BACKEND'S, NOT OURS. `seq` is a gapless, total
15
+ * per-conversation order (`stapel-chat/MODULE.md`: "seq is gapless and total
16
+ * — the canonical anchor for history and the resume cursor for realtime"), and
17
+ * ordering by timestamp is an explicit anti-pattern there: two messages in the
18
+ * same millisecond still have a definite order. Everything below therefore
19
+ * sorts, dedupes and detects gaps by `seq` alone.
20
+ *
21
+ * ONE MORE THING THE PAGINATOR MAKES US DO. Item order depends on the
22
+ * DIRECTION asked for: core's `AnchorPagination` returns newest-first for
23
+ * `next` and reverses to oldest-first for `prev` (`pagination.py`,
24
+ * `items[::-1]`). Rather than remember which call produced which order, every
25
+ * merge sorts by `seq` — the total order is cheaper to apply than to
26
+ * remember.
27
+ */
28
+ import type { ChatMessage, MessagePage } from "../api/types.js";
29
+
30
+ /**
31
+ * A contiguous run of messages, ascending by `seq`.
32
+ *
33
+ * "Contiguous" is load-bearing: because `seq` is gapless, a window whose seqs
34
+ * are consecutive integers provably has no hole in it. Every merge below
35
+ * either preserves that or refuses the merge and reports a gap — it never
36
+ * quietly stitches two runs with a hole between them, which is the defect
37
+ * that makes a chat show yesterday's message under today's.
38
+ */
39
+ export interface ChatThreadWindow {
40
+ /** Ascending by `seq`, no duplicates, no holes. */
41
+ readonly messages: readonly ChatMessage[];
42
+ /** Older history exists before `messages[0]`. */
43
+ readonly hasOlder: boolean;
44
+ /** Anchor for the next backfill page (`direction=next`), if any. */
45
+ readonly olderAnchor: string | null;
46
+ }
47
+
48
+ /** A merge that either advanced the window, or found a hole and refused. */
49
+ export interface ThreadMergeResult {
50
+ readonly window: ChatThreadWindow;
51
+ /**
52
+ * `true` when the incoming messages do not touch the window's tip: more
53
+ * arrived than one page holds. The caller re-hydrates (the REST twin of the
54
+ * socket's `error{resync}`) rather than rendering a thread with a hole.
55
+ */
56
+ readonly gap: boolean;
57
+ }
58
+
59
+ export const EMPTY_THREAD_WINDOW: ChatThreadWindow = {
60
+ messages: [],
61
+ hasOlder: false,
62
+ olderAnchor: null,
63
+ };
64
+
65
+ function ascending(items: readonly ChatMessage[]): ChatMessage[] {
66
+ return [...items].sort((a, b) => a.seq - b.seq);
67
+ }
68
+
69
+ /** The tip: the highest `seq` in the window, or 0 for an empty one. */
70
+ export function threadLastSeq(window: ChatThreadWindow): number {
71
+ const last = window.messages[window.messages.length - 1];
72
+ return last ? last.seq : 0;
73
+ }
74
+
75
+ /** The oldest `seq` in the window, or 0 for an empty one. */
76
+ export function threadFirstSeq(window: ChatThreadWindow): number {
77
+ const first = window.messages[0];
78
+ return first ? first.seq : 0;
79
+ }
80
+
81
+ /**
82
+ * A fresh window from the newest page (`GET …/messages` with no anchor).
83
+ * This is also the resync path: whatever was loaded before is dropped,
84
+ * because a window that cannot be proven contiguous is worse than a short
85
+ * one.
86
+ */
87
+ export function threadWindowFromPage(page: MessagePage): ChatThreadWindow {
88
+ return {
89
+ messages: ascending(page.items),
90
+ hasOlder: page.has_next,
91
+ olderAnchor: page.next_anchor ?? null,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Merge a backfill page (`direction=next` from the window's oldest seq) onto
97
+ * the FRONT. Anything at or above the current oldest is dropped as a
98
+ * duplicate — the anchor is exclusive, so this only fires on a retry.
99
+ */
100
+ export function mergeOlderPage(
101
+ window: ChatThreadWindow,
102
+ page: MessagePage
103
+ ): ChatThreadWindow {
104
+ const first = threadFirstSeq(window);
105
+ const older = ascending(page.items).filter(
106
+ (message) => first === 0 || message.seq < first
107
+ );
108
+ if (older.length === 0) {
109
+ return { ...window, hasOlder: page.has_next, olderAnchor: page.next_anchor ?? null };
110
+ }
111
+ return {
112
+ messages: [...older, ...window.messages],
113
+ hasOlder: page.has_next,
114
+ olderAnchor: page.next_anchor ?? null,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Merge a tail page (`direction=prev` anchored on the window's tip) onto the
120
+ * END — the poll-by-seq step.
121
+ *
122
+ * Two independent hole detectors, because either one alone can be fooled:
123
+ * - the seq test (`first new seq === tip + 1`), which is only meaningful
124
+ * because seq is gapless;
125
+ * - the paginator's own `has_prev`, which is how a window that was TRUNCATED
126
+ * (more messages above the anchor than `limit`) says so.
127
+ */
128
+ export function mergeNewerPage(
129
+ window: ChatThreadWindow,
130
+ page: MessagePage
131
+ ): ThreadMergeResult {
132
+ const tip = threadLastSeq(window);
133
+ const newer = ascending(page.items).filter((message) => message.seq > tip);
134
+ if (newer.length === 0) {
135
+ // Nothing new. `has_prev` cannot be trusted to mean anything here — there
136
+ // is no window to be truncated — so this is simply "still up to date".
137
+ return { window, gap: false };
138
+ }
139
+ const firstNew = newer[0];
140
+ // `tip + 1` also covers the empty window: the first message of a
141
+ // conversation is seq 1, so a page starting at 5 into an empty thread is a
142
+ // hole, not a beginning.
143
+ const contiguous = firstNew !== undefined && firstNew.seq === tip + 1;
144
+ if (!contiguous || page.has_prev) {
145
+ return { window, gap: true };
146
+ }
147
+ return {
148
+ window: { ...window, messages: [...window.messages, ...newer] },
149
+ gap: false,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Merge ONE message onto the end — a live socket frame, or the row a `send`
155
+ * just returned.
156
+ *
157
+ * Already-known seq is dropped (the socket dedupes too, but the sender's own
158
+ * REST answer and the frame it fans out to itself are two paths to the same
159
+ * row, and only this rule makes them idempotent). A seq beyond the tip + 1 is
160
+ * a hole: the window is left untouched and the caller re-hydrates.
161
+ */
162
+ export function mergeMessage(
163
+ window: ChatThreadWindow,
164
+ message: ChatMessage
165
+ ): ThreadMergeResult {
166
+ const tip = threadLastSeq(window);
167
+ if (message.seq <= tip) return { window, gap: false };
168
+ if (message.seq !== tip + 1) return { window, gap: true };
169
+ return {
170
+ window: { ...window, messages: [...window.messages, message] },
171
+ gap: false,
172
+ };
173
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * This pair's contribution to the scripted-fullstack nav contract
3
+ * (`@stapel/core`'s `NavEntry`/`PackageNavManifest`).
4
+ * `scripts/gen-nav-manifest.mjs` reads `navEntries` below, stamps
5
+ * `package`/`version` from THIS package's own `package.json`, and emits
6
+ * `packages/chat-react/nav-manifest.json` plus this package's slice of the
7
+ * root aggregate.
8
+ *
9
+ * ONE ENTRY, MEMBER-ONLY. Chat is a member surface end to end: every endpoint
10
+ * is `IsAuthenticated`, and the socket closes 4401 without a session. The
11
+ * public storefront links INTO it ("message the seller"), but the link lands
12
+ * on a member route — which is why `surface` is stated explicitly here rather
13
+ * than left to be derived: a public container that forgot the audience filter
14
+ * would otherwise mount an inbox for visitors who have none.
15
+ *
16
+ * The thread route (`/…/chat/:id`) is deliberately NOT an entry: it is not a
17
+ * menu destination, and the nav contract addresses routes by static path. A
18
+ * container mounts `<ConversationThreadPanel conversationId={…}/>` under its
19
+ * own `:id` child route.
20
+ */
21
+ import type { NavEntry } from "@stapel/core";
22
+
23
+ export const navEntries: readonly NavEntry[] = [
24
+ {
25
+ id: "chat.conversations",
26
+ labelKey: "chat.nav.conversations",
27
+ icon: "MessageOutlined",
28
+ route: { path: "chat" },
29
+ component: { export: "ConversationListPanel", subpath: "default" },
30
+ placement: { level: "top" },
31
+ menuVisibleDefault: true,
32
+ requiresAuth: true,
33
+ surface: "member",
34
+ order: 25,
35
+ },
36
+ ];
@@ -0,0 +1,304 @@
1
+ /**
2
+ * A resumable client for stapel-chat's own socket protocol.
3
+ *
4
+ * The module is store-first and transport-thin: the socket never owns state,
5
+ * it relays the durable `seq`-ordered journal (`consumers.py` header). This
6
+ * client is the mirror image of that discipline —
7
+ *
8
+ * - **resume, not subscribe.** Every (re)connect opens with
9
+ * `hello{last_seq}` carrying the seq the CONSUMER already holds, so the
10
+ * server replays exactly what was missed and then goes live. A reconnect
11
+ * after five seconds or five minutes is the same operation.
12
+ * - **idempotent by seq.** The server drops frames it has already sent; this
13
+ * client drops frames whose `seq` it has already surfaced. The
14
+ * replay/live overlap after a resume is therefore invisible to the
15
+ * consumer, on both ends of the wire.
16
+ * - **a gap is not a stall.** Past `REPLAY_LIMIT` the server answers
17
+ * `error{resync}`; this client forwards it verbatim and stops pretending
18
+ * to be up to date. Re-hydration is the consumer's job (it owns the REST
19
+ * history), which is what keeps correctness independent of delivery.
20
+ * - **a refusal is not a retry.** Close codes 4401 (unauthenticated) and
21
+ * 4403 (not a participant) are answers, not faults: reconnecting would
22
+ * hammer the host with a question it already declined. Everything else is
23
+ * a fault and reconnects with exponential backoff + jitter.
24
+ *
25
+ * No React, no `@stapel/core`, no DOM assumptions beyond an injectable socket
26
+ * factory — the future `@stapel/realtime` substrate is meant to replace this
27
+ * file wholesale, so nothing above it may reach into it.
28
+ */
29
+ import { decodeServerFrame } from "./frames.js";
30
+ import type { ChatServerFrame } from "./frames.js";
31
+ import {
32
+ CHAT_WS_CLOSE_NOT_PARTICIPANT,
33
+ CHAT_WS_CLOSE_UNAUTHENTICATED,
34
+ } from "./frames.js";
35
+
36
+ /**
37
+ * Connection state, named as the realtime spec (§7) names it, so the
38
+ * substrate migration is a rename of nothing.
39
+ *
40
+ * - `connecting` — a socket is being opened (including a reconnect wait),
41
+ * - `open` — connected; frames are arriving,
42
+ * - `degraded` — the socket dropped and a reconnect is pending,
43
+ * - `closed` — it will not come back on its own (deliberate close, a
44
+ * refusal, or the retry budget spent).
45
+ */
46
+ export type ChatConnectionState = "connecting" | "open" | "degraded" | "closed";
47
+
48
+ /** Why the socket will not come back. `undefined` while it still might. */
49
+ export type ChatSocketRefusal =
50
+ | "unauthenticated"
51
+ | "not_participant"
52
+ | "unreachable";
53
+
54
+ export interface ChatSocketStatus {
55
+ readonly state: ChatConnectionState;
56
+ /** Set only in `closed`, and only when the reason is known. */
57
+ readonly refusal: ChatSocketRefusal | undefined;
58
+ /** Consecutive failed connects since the last `open`. */
59
+ readonly attempt: number;
60
+ }
61
+
62
+ /** The callbacks the transport hands back to this client. */
63
+ export interface ChatSocketHandlers {
64
+ readonly onOpen: () => void;
65
+ /** One decoded payload (the `data` of a message event). */
66
+ readonly onData: (data: unknown) => void;
67
+ readonly onClose: (code: number) => void;
68
+ readonly onError: () => void;
69
+ }
70
+
71
+ /** What a transport must offer: send text, close. */
72
+ export interface ChatSocketConnection {
73
+ send(payload: string): void;
74
+ close(): void;
75
+ }
76
+
77
+ /**
78
+ * Opens one socket. Injectable so tests drive the protocol without a network
79
+ * and a host can wrap the socket (instrumentation, a proxy, React Native).
80
+ */
81
+ export type ChatWebSocketFactory = (
82
+ url: string,
83
+ handlers: ChatSocketHandlers
84
+ ) => ChatSocketConnection;
85
+
86
+ /** Reconnect backoff knobs (exponential, jittered, capped). */
87
+ export interface ChatReconnectOptions {
88
+ readonly baseDelayMs?: number;
89
+ readonly maxDelayMs?: number;
90
+ /** Consecutive failures after which the socket gives up (and the caller
91
+ * falls back to polling). Default 6. */
92
+ readonly maxAttempts?: number;
93
+ }
94
+
95
+ /** Cancel handle for a scheduled callback. */
96
+ export type ChatCancel = () => void;
97
+
98
+ export interface ChatSocketOptions {
99
+ /** Fully-qualified socket URL, e.g. `wss://host/ws/chat/<uuid>`. */
100
+ readonly url: string;
101
+ /**
102
+ * The highest seq the CONSUMER holds, read afresh at every connect. A
103
+ * function, not a value, because the consumer keeps advancing while the
104
+ * socket is down — which is the whole point of resuming by seq.
105
+ */
106
+ readonly lastSeq: () => number;
107
+ readonly onFrame: (frame: ChatServerFrame) => void;
108
+ readonly onStatus: (status: ChatSocketStatus) => void;
109
+ readonly webSocket?: ChatWebSocketFactory;
110
+ readonly reconnect?: ChatReconnectOptions;
111
+ /** Injectable timer (tests). Returns its own cancel. */
112
+ readonly schedule?: (fn: () => void, ms: number) => ChatCancel;
113
+ /** Injectable jitter source (tests). */
114
+ readonly random?: () => number;
115
+ }
116
+
117
+ export interface ChatSocket {
118
+ /** Current status (also pushed through `onStatus`). */
119
+ status(): ChatSocketStatus;
120
+ /** Acknowledge delivery up to `seq` (the server tracks it; nothing
121
+ * depends on it — a dropped ack costs nothing). */
122
+ ack(seq: number): void;
123
+ /** Liveness probe. */
124
+ ping(): void;
125
+ /** Close deliberately: no reconnect, no further callbacks. */
126
+ close(): void;
127
+ }
128
+
129
+ const DEFAULT_BASE_DELAY_MS = 500;
130
+ const DEFAULT_MAX_DELAY_MS = 15_000;
131
+ const DEFAULT_MAX_ATTEMPTS = 6;
132
+
133
+ function defaultSchedule(fn: () => void, ms: number): ChatCancel {
134
+ const handle = setTimeout(fn, ms);
135
+ return () => {
136
+ clearTimeout(handle);
137
+ };
138
+ }
139
+
140
+ /**
141
+ * The browser transport. Uses `addEventListener` rather than the `on*`
142
+ * properties so the structural contract above stays independent of the DOM
143
+ * `WebSocket` type (and so a host wrapper can be a plain object).
144
+ */
145
+ export function browserWebSocketFactory(
146
+ url: string,
147
+ handlers: ChatSocketHandlers
148
+ ): ChatSocketConnection {
149
+ const socket = new WebSocket(url);
150
+ socket.addEventListener("open", () => {
151
+ handlers.onOpen();
152
+ });
153
+ socket.addEventListener("message", (event: MessageEvent<unknown>) => {
154
+ handlers.onData(event.data);
155
+ });
156
+ socket.addEventListener("close", (event: CloseEvent) => {
157
+ handlers.onClose(event.code);
158
+ });
159
+ socket.addEventListener("error", () => {
160
+ handlers.onError();
161
+ });
162
+ return {
163
+ send: (payload) => {
164
+ socket.send(payload);
165
+ },
166
+ close: () => {
167
+ socket.close();
168
+ },
169
+ };
170
+ }
171
+
172
+ /** True when the environment can open a socket at all (SSR / node cannot). */
173
+ export function canOpenWebSocket(): boolean {
174
+ return typeof WebSocket !== "undefined";
175
+ }
176
+
177
+ function refusalFor(code: number): ChatSocketRefusal | undefined {
178
+ if (code === CHAT_WS_CLOSE_UNAUTHENTICATED) return "unauthenticated";
179
+ if (code === CHAT_WS_CLOSE_NOT_PARTICIPANT) return "not_participant";
180
+ return undefined;
181
+ }
182
+
183
+ /**
184
+ * Open a resumable conversation socket. Connects immediately; returns the
185
+ * handle that closes it. Every state change is pushed through `onStatus`, so
186
+ * a React layer can render the transport without polling this object.
187
+ */
188
+ export function createChatSocket(options: ChatSocketOptions): ChatSocket {
189
+ const factory = options.webSocket ?? browserWebSocketFactory;
190
+ const schedule = options.schedule ?? defaultSchedule;
191
+ const random = options.random ?? Math.random;
192
+ const baseDelay = options.reconnect?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
193
+ const maxDelay = options.reconnect?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
194
+ const maxAttempts = options.reconnect?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
195
+
196
+ let connection: ChatSocketConnection | null = null;
197
+ let cancelRetry: ChatCancel | null = null;
198
+ let disposed = false;
199
+ let attempt = 0;
200
+ /** Highest seq handed to the consumer — the dedup cursor. */
201
+ let maxSeqSeen = 0;
202
+ let status: ChatSocketStatus = {
203
+ state: "connecting",
204
+ refusal: undefined,
205
+ attempt: 0,
206
+ };
207
+
208
+ function setStatus(
209
+ state: ChatConnectionState,
210
+ refusal?: ChatSocketRefusal
211
+ ): void {
212
+ status = { state, refusal, attempt };
213
+ options.onStatus(status);
214
+ }
215
+
216
+ function send(frame: unknown): void {
217
+ if (!connection) return;
218
+ try {
219
+ connection.send(JSON.stringify(frame));
220
+ } catch {
221
+ // A send on a socket that just died is not an error worth surfacing:
222
+ // the close handler is already on its way and will reconnect.
223
+ }
224
+ }
225
+
226
+ function backoffDelay(): number {
227
+ const exponential = baseDelay * 2 ** Math.max(0, attempt - 1);
228
+ const capped = Math.min(exponential, maxDelay);
229
+ // Full jitter — a fleet of tabs reconnecting after one server blip must
230
+ // not arrive as one wave.
231
+ return Math.round(capped * (0.5 + random() * 0.5));
232
+ }
233
+
234
+ function connect(): void {
235
+ if (disposed) return;
236
+ setStatus("connecting");
237
+ // The consumer may have advanced by REST while we were down; never hand
238
+ // it a frame it already has.
239
+ maxSeqSeen = Math.max(maxSeqSeen, options.lastSeq());
240
+ connection = factory(options.url, {
241
+ onOpen: () => {
242
+ if (disposed) return;
243
+ attempt = 0;
244
+ send({ type: "hello", last_seq: options.lastSeq() });
245
+ setStatus("open");
246
+ },
247
+ onData: (data) => {
248
+ if (disposed) return;
249
+ const frame = decodeServerFrame(data);
250
+ // An unreadable frame must NOT advance the cursor — the gap it would
251
+ // hide is exactly what resume-by-seq exists to close.
252
+ if (!frame) return;
253
+ if (frame.type === "message") {
254
+ if (frame.seq <= maxSeqSeen) return;
255
+ maxSeqSeen = frame.seq;
256
+ }
257
+ options.onFrame(frame);
258
+ },
259
+ onError: () => {
260
+ // `error` always precedes `close` on a browser socket; the close
261
+ // handler owns the reconnect so the two cannot both schedule one.
262
+ },
263
+ onClose: (code) => {
264
+ if (disposed) return;
265
+ connection = null;
266
+ const refusal = refusalFor(code);
267
+ if (refusal) {
268
+ // The host answered the question. Asking again, faster, is the
269
+ // classic way to turn a 4403 into an outage.
270
+ setStatus("closed", refusal);
271
+ return;
272
+ }
273
+ attempt += 1;
274
+ if (attempt >= maxAttempts) {
275
+ setStatus("closed", "unreachable");
276
+ return;
277
+ }
278
+ setStatus("degraded");
279
+ cancelRetry = schedule(connect, backoffDelay());
280
+ },
281
+ });
282
+ }
283
+
284
+ connect();
285
+
286
+ return {
287
+ status: () => status,
288
+ ack: (seq) => {
289
+ send({ type: "ack", seq });
290
+ },
291
+ ping: () => {
292
+ send({ type: "ping" });
293
+ },
294
+ close: () => {
295
+ disposed = true;
296
+ cancelRetry?.();
297
+ cancelRetry = null;
298
+ const open = connection;
299
+ connection = null;
300
+ open?.close();
301
+ status = { state: "closed", refusal: undefined, attempt };
302
+ },
303
+ };
304
+ }