@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.
- package/CHANGELOG.md +1 -0
- package/MODULE.md +118 -0
- package/README.md +126 -0
- package/dist/api/chatApi.d.ts +49 -0
- package/dist/api/chatApi.d.ts.map +1 -0
- package/dist/api/chatApi.js +46 -0
- package/dist/api/chatApi.js.map +1 -0
- package/dist/api/extensions.d.ts +19 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +19 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/generated/schema.d.ts +547 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +75 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ConversationListPanel.d.ts +22 -0
- package/dist/default/ConversationListPanel.d.ts.map +1 -0
- package/dist/default/ConversationListPanel.js +49 -0
- package/dist/default/ConversationListPanel.js.map +1 -0
- package/dist/default/ConversationThreadPanel.d.ts +21 -0
- package/dist/default/ConversationThreadPanel.d.ts.map +1 -0
- package/dist/default/ConversationThreadPanel.js +51 -0
- package/dist/default/ConversationThreadPanel.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +8 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +26 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/StartChatButton.d.ts +17 -0
- package/dist/default/StartChatButton.d.ts.map +1 -0
- package/dist/default/StartChatButton.js +18 -0
- package/dist/default/StartChatButton.js.map +1 -0
- package/dist/default/index.d.ts +21 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +18 -0
- package/dist/default/index.js.map +1 -0
- package/dist/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/freshness.d.ts +69 -0
- package/dist/flows/freshness.d.ts.map +1 -0
- package/dist/flows/freshness.js +227 -0
- package/dist/flows/freshness.js.map +1 -0
- package/dist/flows/registry.d.ts +26 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +24 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/ChatProvider.d.ts +22 -0
- package/dist/headless/ChatProvider.d.ts.map +1 -0
- package/dist/headless/ChatProvider.js +18 -0
- package/dist/headless/ChatProvider.js.map +1 -0
- package/dist/headless/ConversationList.d.ts +51 -0
- package/dist/headless/ConversationList.d.ts.map +1 -0
- package/dist/headless/ConversationList.js +47 -0
- package/dist/headless/ConversationList.js.map +1 -0
- package/dist/headless/ConversationThread.d.ts +50 -0
- package/dist/headless/ConversationThread.d.ts.map +1 -0
- package/dist/headless/ConversationThread.js +64 -0
- package/dist/headless/ConversationThread.js.map +1 -0
- package/dist/headless/MessageComposer.d.ts +39 -0
- package/dist/headless/MessageComposer.d.ts.map +1 -0
- package/dist/headless/MessageComposer.js +45 -0
- package/dist/headless/MessageComposer.js.map +1 -0
- package/dist/headless/StartDirectChat.d.ts +40 -0
- package/dist/headless/StartDirectChat.d.ts.map +1 -0
- package/dist/headless/StartDirectChat.js +46 -0
- package/dist/headless/StartDirectChat.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +23 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +75 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +58 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +359 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +183 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +58 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +78 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +130 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +28 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +80 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/limits.d.ts +12 -0
- package/dist/model/limits.d.ts.map +1 -0
- package/dist/model/limits.js +12 -0
- package/dist/model/limits.js.map +1 -0
- package/dist/model/mutations.d.ts +66 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +156 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +33 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +93 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +8 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +26 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/readMarker.d.ts +25 -0
- package/dist/model/readMarker.d.ts.map +1 -0
- package/dist/model/readMarker.js +31 -0
- package/dist/model/readMarker.js.map +1 -0
- package/dist/model/runtime.d.ts +50 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +27 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/model/threadWindow.d.ts +95 -0
- package/dist/model/threadWindow.d.ts.map +1 -0
- package/dist/model/threadWindow.js +100 -0
- package/dist/model/threadWindow.js.map +1 -0
- package/dist/nav/manifest.d.ts +23 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +15 -0
- package/dist/nav/manifest.js.map +1 -0
- package/dist/realtime/chatSocket.d.ts +93 -0
- package/dist/realtime/chatSocket.d.ts.map +1 -0
- package/dist/realtime/chatSocket.js +199 -0
- package/dist/realtime/chatSocket.js.map +1 -0
- package/dist/realtime/frames.d.ts +123 -0
- package/dist/realtime/frames.d.ts.map +1 -0
- package/dist/realtime/frames.js +123 -0
- package/dist/realtime/frames.js.map +1 -0
- package/dist/realtime/streams.d.ts +49 -0
- package/dist/realtime/streams.d.ts.map +1 -0
- package/dist/realtime/streams.js +52 -0
- package/dist/realtime/streams.js.map +1 -0
- package/llms.txt +89 -0
- package/manifest.json +733 -0
- package/nav-manifest.json +25 -0
- package/package.json +114 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/chatApi.ts +142 -0
- package/src/api/extensions.ts +18 -0
- package/src/api/generated/schema.ts +549 -0
- package/src/api/types.ts +87 -0
- package/src/default/ConversationListPanel.tsx +163 -0
- package/src/default/ConversationThreadPanel.tsx +245 -0
- package/src/default/ErrorAlert.tsx +43 -0
- package/src/default/StartChatButton.tsx +78 -0
- package/src/default/index.ts +20 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/freshness.ts +315 -0
- package/src/flows/registry.ts +36 -0
- package/src/headless/ChatProvider.tsx +23 -0
- package/src/headless/ConversationList.tsx +92 -0
- package/src/headless/ConversationThread.tsx +107 -0
- package/src/headless/MessageComposer.tsx +86 -0
- package/src/headless/StartDirectChat.tsx +75 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +95 -0
- package/src/i18n/generated/errors.es.gen.ts +64 -0
- package/src/i18n/generated/errors.gen.ts +214 -0
- package/src/i18n/generated/errors.json +408 -0
- package/src/i18n/generated/errors.ru.gen.ts +64 -0
- package/src/i18n/keys.ts +152 -0
- package/src/i18n/ru.ts +98 -0
- package/src/index.ts +189 -0
- package/src/model/context.tsx +25 -0
- package/src/model/limits.ts +11 -0
- package/src/model/mutations.ts +208 -0
- package/src/model/queries.ts +120 -0
- package/src/model/queryKeys.ts +32 -0
- package/src/model/readMarker.ts +32 -0
- package/src/model/runtime.ts +90 -0
- package/src/model/threadWindow.ts +173 -0
- package/src/nav/manifest.ts +36 -0
- package/src/realtime/chatSocket.ts +304 -0
- package/src/realtime/frames.ts +229 -0
- package/src/realtime/streams.ts +85 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stapel-chat WebSocket protocol, typed.
|
|
3
|
+
*
|
|
4
|
+
* This is a MIRROR of the module's own contract, not an invention:
|
|
5
|
+
* `stapel_chat/consumers.py` (`ChatConsumer`) and `stapel_chat/realtime.py`
|
|
6
|
+
* (`message_frame`) are the source, and `MODULE.md` §"Realtime protocol"
|
|
7
|
+
* states it in one line:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* client → server: hello{last_seq} / send{body,attachments,reply_to} / ack{seq} / ping
|
|
11
|
+
* server → client: welcome{server_seq} / message{…seq} / replay_done{up_to_seq} / error{code,message} / pong
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* The WS surface is invisible to `schema.json` (OpenAPI describes HTTP), so
|
|
15
|
+
* these types cannot be generated today — the realtime spec (§8) parks the
|
|
16
|
+
* frame contract in the `events.json` family for later. Until then this file
|
|
17
|
+
* is the pair's declared mirror and `test/frames.test.ts` pins it against the
|
|
18
|
+
* shapes the consumer actually sends.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here imports React or `@stapel/core`: it is plain protocol, so the
|
|
21
|
+
* future `@stapel/realtime` substrate can subsume it without dragging the UI.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Widest resume gap the server replays inline (`consumers.REPLAY_LIMIT`).
|
|
26
|
+
* Past it the server answers `error{resync}` instead, and the client
|
|
27
|
+
* re-hydrates from the REST history. Mirrored here so the client can SAY that
|
|
28
|
+
* before the server has to.
|
|
29
|
+
*/
|
|
30
|
+
export const CHAT_WS_REPLAY_LIMIT = 500;
|
|
31
|
+
|
|
32
|
+
/** `ChatConsumer.connect()` closes with this when `scope["user"]` is unset. */
|
|
33
|
+
export const CHAT_WS_CLOSE_UNAUTHENTICATED = 4401;
|
|
34
|
+
/** …and with this when the caller is not a participant of the conversation. */
|
|
35
|
+
export const CHAT_WS_CLOSE_NOT_PARTICIPANT = 4403;
|
|
36
|
+
|
|
37
|
+
// ── client → server ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Resume: replay everything after `last_seq`, then go live. */
|
|
40
|
+
export interface ChatHelloFrame {
|
|
41
|
+
readonly type: "hello";
|
|
42
|
+
readonly last_seq: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Append a message over the socket.
|
|
47
|
+
*
|
|
48
|
+
* TYPED BUT NEVER SENT BY THIS CLIENT, on purpose. The frame is part of the
|
|
49
|
+
* module's contract, so a mirror that omitted it would be an incomplete
|
|
50
|
+
* mirror; but this pair posts messages over REST (`ChatApi.sendMessage`),
|
|
51
|
+
* where the answer is the persisted row and a refusal is a real error
|
|
52
|
+
* envelope. Over the socket a refusal is an `error` frame with a
|
|
53
|
+
* socket-local code (`empty` / `too_long` / `attachments_disabled` /
|
|
54
|
+
* `invalid_reply`) that is NOT in the module's error registry, so it has no
|
|
55
|
+
* i18n key and no remediation — the UI could not tell the person what went
|
|
56
|
+
* wrong. `createChatSocket` exposes no method that emits this.
|
|
57
|
+
*/
|
|
58
|
+
export interface ChatSendFrame {
|
|
59
|
+
readonly type: "send";
|
|
60
|
+
readonly body?: string;
|
|
61
|
+
readonly attachments?: readonly string[];
|
|
62
|
+
readonly reply_to?: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Delivery acknowledgement — the server tracks it, nothing depends on it. */
|
|
66
|
+
export interface ChatAckFrame {
|
|
67
|
+
readonly type: "ack";
|
|
68
|
+
readonly seq: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Liveness probe; answered with `pong`. */
|
|
72
|
+
export interface ChatPingFrame {
|
|
73
|
+
readonly type: "ping";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ChatClientFrame =
|
|
77
|
+
| ChatHelloFrame
|
|
78
|
+
| ChatSendFrame
|
|
79
|
+
| ChatAckFrame
|
|
80
|
+
| ChatPingFrame;
|
|
81
|
+
|
|
82
|
+
// ── server → client ──────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** Answer to `hello`: the conversation's high-water mark right now. */
|
|
85
|
+
export interface ChatWelcomeFrame {
|
|
86
|
+
readonly type: "welcome";
|
|
87
|
+
readonly conversation_id: string;
|
|
88
|
+
readonly server_seq: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A message, replayed or live — the same frame either way (that is what makes
|
|
93
|
+
* the overlap after a resume harmless: it is seq-deduped on both ends).
|
|
94
|
+
* Field-for-field `stapel_chat.realtime.message_frame`.
|
|
95
|
+
*/
|
|
96
|
+
export interface ChatMessageFrame {
|
|
97
|
+
readonly type: "message";
|
|
98
|
+
readonly message_id: string;
|
|
99
|
+
readonly conversation_id: string;
|
|
100
|
+
readonly sender_id: string | null;
|
|
101
|
+
readonly seq: number;
|
|
102
|
+
readonly kind: string;
|
|
103
|
+
readonly body: string;
|
|
104
|
+
readonly reply_to: string | null;
|
|
105
|
+
readonly attachments: readonly string[];
|
|
106
|
+
readonly created_at: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** End of the replay window — everything up to `up_to_seq` has been sent. */
|
|
110
|
+
export interface ChatReplayDoneFrame {
|
|
111
|
+
readonly type: "replay_done";
|
|
112
|
+
readonly up_to_seq: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A protocol-level refusal. `code` is a SOCKET-LOCAL vocabulary
|
|
117
|
+
* (`resync` / `bad_type` / the send-path codes), NOT a `error.<status>.<code>`
|
|
118
|
+
* key from the module's error registry — which is exactly why the only one
|
|
119
|
+
* this pair acts on is `resync`.
|
|
120
|
+
*/
|
|
121
|
+
export interface ChatErrorFrame {
|
|
122
|
+
readonly type: "error";
|
|
123
|
+
readonly code: string;
|
|
124
|
+
readonly message: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Answer to `ping`. */
|
|
128
|
+
export interface ChatPongFrame {
|
|
129
|
+
readonly type: "pong";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type ChatServerFrame =
|
|
133
|
+
| ChatWelcomeFrame
|
|
134
|
+
| ChatMessageFrame
|
|
135
|
+
| ChatReplayDoneFrame
|
|
136
|
+
| ChatErrorFrame
|
|
137
|
+
| ChatPongFrame;
|
|
138
|
+
|
|
139
|
+
/** The one error code that means "stop replaying, re-read the journal". */
|
|
140
|
+
export const CHAT_WS_RESYNC = "resync";
|
|
141
|
+
|
|
142
|
+
// ── parsing (the wire is untrusted structured text) ──────────────────────────
|
|
143
|
+
|
|
144
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
145
|
+
return typeof value === "object" && value !== null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function str(value: unknown): string | null {
|
|
149
|
+
return typeof value === "string" ? value : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function num(value: unknown): number | null {
|
|
153
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Narrow one decoded server frame, or `null` for anything this build does not
|
|
158
|
+
* understand (an unknown `type`, a missing field, a `seq` that is not a
|
|
159
|
+
* number). Unknown is DROPPED, never coerced: a frame this client cannot read
|
|
160
|
+
* must not advance the seq cursor, or the gap it leaves is invisible.
|
|
161
|
+
*/
|
|
162
|
+
export function parseServerFrame(value: unknown): ChatServerFrame | null {
|
|
163
|
+
if (!isRecord(value)) return null;
|
|
164
|
+
switch (value.type) {
|
|
165
|
+
case "welcome": {
|
|
166
|
+
const conversationId = str(value.conversation_id);
|
|
167
|
+
const serverSeq = num(value.server_seq);
|
|
168
|
+
if (conversationId === null || serverSeq === null) return null;
|
|
169
|
+
return {
|
|
170
|
+
type: "welcome",
|
|
171
|
+
conversation_id: conversationId,
|
|
172
|
+
server_seq: serverSeq,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
case "message": {
|
|
176
|
+
const seq = num(value.seq);
|
|
177
|
+
const messageId = str(value.message_id);
|
|
178
|
+
const conversationId = str(value.conversation_id);
|
|
179
|
+
const createdAt = str(value.created_at);
|
|
180
|
+
if (
|
|
181
|
+
seq === null ||
|
|
182
|
+
messageId === null ||
|
|
183
|
+
conversationId === null ||
|
|
184
|
+
createdAt === null
|
|
185
|
+
) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const attachments = Array.isArray(value.attachments)
|
|
189
|
+
? value.attachments.filter((a): a is string => typeof a === "string")
|
|
190
|
+
: [];
|
|
191
|
+
return {
|
|
192
|
+
type: "message",
|
|
193
|
+
message_id: messageId,
|
|
194
|
+
conversation_id: conversationId,
|
|
195
|
+
sender_id: str(value.sender_id),
|
|
196
|
+
seq,
|
|
197
|
+
kind: str(value.kind) ?? "text",
|
|
198
|
+
body: str(value.body) ?? "",
|
|
199
|
+
reply_to: str(value.reply_to),
|
|
200
|
+
attachments,
|
|
201
|
+
created_at: createdAt,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
case "replay_done": {
|
|
205
|
+
const upTo = num(value.up_to_seq);
|
|
206
|
+
if (upTo === null) return null;
|
|
207
|
+
return { type: "replay_done", up_to_seq: upTo };
|
|
208
|
+
}
|
|
209
|
+
case "error": {
|
|
210
|
+
const code = str(value.code);
|
|
211
|
+
if (code === null) return null;
|
|
212
|
+
return { type: "error", code, message: str(value.message) ?? "" };
|
|
213
|
+
}
|
|
214
|
+
case "pong":
|
|
215
|
+
return { type: "pong" };
|
|
216
|
+
default:
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Decode a socket payload (JSON text) into a frame, or `null`. */
|
|
222
|
+
export function decodeServerFrame(data: unknown): ChatServerFrame | null {
|
|
223
|
+
if (typeof data !== "string") return null;
|
|
224
|
+
try {
|
|
225
|
+
return parseServerFrame(JSON.parse(data));
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this pair NAMES the things it can be fresh about (realtime spec §7,
|
|
3
|
+
* "how a pair declares its channels"): typed stream keys plus the one rule
|
|
4
|
+
* that turns a key into a socket URL. Everything that decides *whether* a stream
|
|
5
|
+
* has a socket at all lives here, so the transport seam above can stay a
|
|
6
|
+
* two-line question.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A conversation's own journal — the module mounts a socket per thread. */
|
|
10
|
+
export interface ChatConversationStream {
|
|
11
|
+
readonly kind: "conversation";
|
|
12
|
+
readonly conversationId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The caller's inbox (their conversation list).
|
|
17
|
+
*
|
|
18
|
+
* There is NO socket for this one, and that is a fact about the backend, not
|
|
19
|
+
* a gap in this pair: `stapel_chat.routing` mounts exactly one pattern,
|
|
20
|
+
* `ws/chat/<uuid:conversation_id>`, and the Channels group a message fans out
|
|
21
|
+
* to is `chat.conv.<id>` — per thread. Nothing broadcasts "your list moved".
|
|
22
|
+
* So the inbox is kept fresh by the polling half of the seam, and the seam is
|
|
23
|
+
* the reason no screen has to know that.
|
|
24
|
+
*/
|
|
25
|
+
export interface ChatInboxStream {
|
|
26
|
+
readonly kind: "inbox";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ChatStreamKey = ChatConversationStream | ChatInboxStream;
|
|
30
|
+
|
|
31
|
+
export function chatConversationStream(
|
|
32
|
+
conversationId: string
|
|
33
|
+
): ChatConversationStream {
|
|
34
|
+
return { kind: "conversation", conversationId };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function chatInboxStream(): ChatInboxStream {
|
|
38
|
+
return { kind: "inbox" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A stable string for a stream key — a React dependency, a log line. */
|
|
42
|
+
export function chatStreamId(stream: ChatStreamKey): string {
|
|
43
|
+
return stream.kind === "conversation"
|
|
44
|
+
? `conversation:${stream.conversationId}`
|
|
45
|
+
: "inbox";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Derive the socket base from the REST base URL.
|
|
50
|
+
*
|
|
51
|
+
* The mount point is canonical (`stapel_chat/routing.py`, and the fleet
|
|
52
|
+
* convention `/ws/<mod>/...` from the realtime spec §8): the sockets sit at
|
|
53
|
+
* the HOST ROOT, not under the module's API prefix, so only the origin of
|
|
54
|
+
* `baseUrl` is used. `http` → `ws`, `https` → `wss`.
|
|
55
|
+
*
|
|
56
|
+
* Returns `null` when no origin can be resolved — a relative `baseUrl` with
|
|
57
|
+
* no `origin` (server-side rendering, a node test). `null` is not a failure:
|
|
58
|
+
* it means "this build cannot open a socket", and the transport seam polls.
|
|
59
|
+
*/
|
|
60
|
+
export function deriveChatSocketBase(
|
|
61
|
+
baseUrl: string,
|
|
62
|
+
origin?: string | null
|
|
63
|
+
): string | null {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(baseUrl, origin ?? undefined);
|
|
66
|
+
const protocol =
|
|
67
|
+
url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
|
|
68
|
+
return `${protocol}//${url.host}/ws/chat/`;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The socket URL for a stream, or `null` when the stream has none (the
|
|
76
|
+
* inbox) or no base could be derived.
|
|
77
|
+
*/
|
|
78
|
+
export function chatSocketUrl(
|
|
79
|
+
base: string | null,
|
|
80
|
+
stream: ChatStreamKey
|
|
81
|
+
): string | null {
|
|
82
|
+
if (base === null || stream.kind !== "conversation") return null;
|
|
83
|
+
const prefix = base.endsWith("/") ? base : `${base}/`;
|
|
84
|
+
return `${prefix}${encodeURIComponent(stream.conversationId)}`;
|
|
85
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"target": "ES2022",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"jsx": "react-jsx",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"exactOptionalPropertyTypes": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"isolatedDeclarations": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"sourceMap": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"outDir": "dist",
|
|
23
|
+
"rootDir": "src"
|
|
24
|
+
},
|
|
25
|
+
"include": ["src"]
|
|
26
|
+
}
|