@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,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
|
+
}
|