@pmate/chat-sdk 0.12.0-next.0 → 0.12.0-next.10
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/README.md +54 -3
- package/lib/runtime/chatClient.d.ts +7 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +90 -0
- package/lib/runtime/chatSession.js +274 -0
- package/lib/runtime/createChat.d.ts +24 -0
- package/lib/runtime/createChat.js +39 -0
- package/lib/runtime/durableSender.d.ts +8 -0
- package/lib/runtime/durableSender.js +16 -2
- package/lib/runtime/index.d.ts +3 -0
- package/lib/runtime/index.js +3 -0
- package/lib/runtime/messageClient.d.ts +21 -1
- package/lib/runtime/messageClient.js +257 -48
- package/lib/runtime/messageReaders.d.ts +46 -0
- package/lib/runtime/messageReaders.js +124 -0
- package/lib/runtime/messageStore.d.ts +11 -1
- package/lib/runtime/messageStore.js +21 -2
- package/lib/runtime/readCounts.d.ts +12 -1
- package/lib/runtime/readCounts.js +62 -5
- package/lib/runtime/readIndex.d.ts +3 -1
- package/lib/runtime/readIndex.js +9 -5
- package/lib/runtime/readingClient.d.ts +1 -0
- package/lib/runtime/readingClient.js +1 -1
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +65 -6
- package/lib/runtime/responseCache.d.ts +28 -0
- package/lib/runtime/responseCache.js +106 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,52 @@ sends and reading synchronization independently. The runtime now provides local
|
|
|
137
137
|
IndexedDB storage and fixed-window scheduling primitives so those operations can
|
|
138
138
|
share bounded I/O, durable confirmation and scoped subscriptions.
|
|
139
139
|
|
|
140
|
+
The browser entry point is `createChat` from `@pmate/chat-sdk/runtime`:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const chat = createChat({ appId, accountId, profileId, teamId, token, onError })
|
|
144
|
+
await chat.start()
|
|
145
|
+
const page = await chat.getMessages(threadId)
|
|
146
|
+
// SDK owns the page size and history boundary; hosts need not construct a cursor.
|
|
147
|
+
const earlier = await chat.getMessages(threadId, { earlier: true })
|
|
148
|
+
// After an authoritative mutation (e.g. history assignment), await reconciliation.
|
|
149
|
+
// Concurrent refreshes share transport; errors do not masquerade as fresh cache.
|
|
150
|
+
const reconciled = await chat.getMessages(threadId, { refresh: true })
|
|
151
|
+
// Explicit full-history queries reuse cached pages and internally rebase expired cursors.
|
|
152
|
+
const history = await chat.getMessages(threadId, { all: true })
|
|
153
|
+
const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
|
|
154
|
+
const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
|
|
155
|
+
await chat.observeMessages(threadId, visibleIds, "visible")
|
|
156
|
+
// Report only the actual audio `playing` event.
|
|
157
|
+
await chat.observeMessages(threadId, [voiceId], "played")
|
|
158
|
+
await chat.sendMessage(threadId, { to, body, recipients })
|
|
159
|
+
const readers = await chat.getReaders(threadId, messageId)
|
|
160
|
+
off(); offSummary()
|
|
161
|
+
await chat.close()
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The facade owns all clients below. Message reads transparently restore IndexedDB
|
|
165
|
+
windows and fetch missing ranges through the authenticated Hub. Its normalized
|
|
166
|
+
messages retain the protocol body; product rendering remains with the host.
|
|
167
|
+
`sendMessage` resolves after local persistence, while `outgoing` in the thread
|
|
168
|
+
snapshot reports transport progress and failure. Retry uses the original message
|
|
169
|
+
hash. The subscribed recent window remains 100 records regardless of individual
|
|
170
|
+
query limits; history requests return their own pages and do not replace it.
|
|
171
|
+
Global snapshots contain thread summaries and totals, not complete message arrays.
|
|
172
|
+
Message pages report `complete`; unavailable older pages return cached records with
|
|
173
|
+
`complete: false`. Authorization failures throw and never fall back to denied
|
|
174
|
+
records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
|
|
175
|
+
it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
|
|
176
|
+
pre-reset payload.
|
|
177
|
+
Voice visibility never acknowledges playback, including observations that arrive
|
|
178
|
+
before metadata. Create a new instance and close the previous owner when the
|
|
179
|
+
account or active Team changes.
|
|
180
|
+
|
|
181
|
+
This facade is still under integration. Reader details currently require network
|
|
182
|
+
access, global summaries do not yet replace the host's contact/thread metadata,
|
|
183
|
+
and Merry's message-list/send migration and production validation are unfinished.
|
|
184
|
+
The lower-level primitives remain available for custom transports:
|
|
185
|
+
|
|
140
186
|
`MessageClient` reads a trusted recent window locally, coalesces concurrent misses,
|
|
141
187
|
subscribes per conversation, and accepts incremental event batches. Its transport
|
|
142
188
|
supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
|
|
@@ -157,6 +203,11 @@ legacy cleanup happens after durable migration. Normal reads use memory and writ
|
|
|
157
203
|
are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
|
|
158
204
|
the host, and pending operations are not reported as saved before their promise
|
|
159
205
|
resolves.
|
|
206
|
+
Control-event batches are bounded by both 128 records and 1 MiB per transaction;
|
|
207
|
+
the queue includes in-flight writes and is limited to 4096 records / 8 MiB.
|
|
208
|
+
`transactionBytes` and `queueBytes` can lower these limits. Oversized operations
|
|
209
|
+
fail before changing the memory snapshot. Repeating an in-flight write waits for
|
|
210
|
+
that physical write, including its failure, rather than acknowledging it early.
|
|
160
211
|
|
|
161
212
|
`DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
|
|
162
213
|
before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
|
|
@@ -165,7 +216,7 @@ when the connection returns. Call `confirm(hashes)` only after authoritative
|
|
|
165
216
|
messages have been durably merged. Confirmation tombstones prevent another tab's
|
|
166
217
|
late transport failure from recreating an outgoing message.
|
|
167
218
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
219
|
+
Custom primitive integrations must initialize them with the same authenticated
|
|
220
|
+
scope, dispose them on identity changes, and wire their transport to an authorized
|
|
221
|
+
server. Standard browser integrations should use the facade instead. The Merry migration and production validation
|
|
171
222
|
are tracked in the unified message layer proposal.
|
|
@@ -56,6 +56,13 @@ export declare class ChatClient {
|
|
|
56
56
|
private initialize;
|
|
57
57
|
close(): void;
|
|
58
58
|
connectionState(): ChatConnectionState;
|
|
59
|
+
/** Non-secret identity used to validate a shared browser transport. */
|
|
60
|
+
identityScope(): {
|
|
61
|
+
appId: string;
|
|
62
|
+
profileId: string | undefined;
|
|
63
|
+
accountId: string | undefined;
|
|
64
|
+
teamId: string | undefined;
|
|
65
|
+
};
|
|
59
66
|
watchConnection(handler: (state: ChatConnectionState) => void): () => void;
|
|
60
67
|
private setConnection;
|
|
61
68
|
private rejectPending;
|
|
@@ -120,6 +120,11 @@ export class ChatClient {
|
|
|
120
120
|
this.rejectPending(new Error("ChatClient closed"));
|
|
121
121
|
}
|
|
122
122
|
connectionState() { return this.connection; }
|
|
123
|
+
/** Non-secret identity used to validate a shared browser transport. */
|
|
124
|
+
identityScope() {
|
|
125
|
+
const identity = this.session?.identity ?? this.session?.session?.identity;
|
|
126
|
+
return { appId: this.options.appId, profileId: this.profileIdValue ?? this.options.profileId, accountId: identity?.accountId, teamId: this.options.teamId };
|
|
127
|
+
}
|
|
123
128
|
watchConnection(handler) {
|
|
124
129
|
this.connectionHandlers.add(handler);
|
|
125
130
|
handler(this.connection);
|
|
@@ -423,10 +428,11 @@ export class ChatClient {
|
|
|
423
428
|
return profileId;
|
|
424
429
|
}
|
|
425
430
|
profileId() {
|
|
426
|
-
|
|
431
|
+
const profileId = this.profileIdValue ?? this.options.profileId;
|
|
432
|
+
if (!profileId) {
|
|
427
433
|
throw new Error("ChatClient is missing profile id; call connect() first");
|
|
428
434
|
}
|
|
429
|
-
return
|
|
435
|
+
return profileId;
|
|
430
436
|
}
|
|
431
437
|
async fetchProfiles(appId, accountId, token) {
|
|
432
438
|
const fetcher = this.options.fetchImpl ?? fetch;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type MessageReadersQuery } from "./messageReaders.js";
|
|
2
|
+
import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
|
|
3
|
+
import { DurableChatSender } from "./durableSender.js";
|
|
4
|
+
import { type ReadingClientOptions } from "./readingClient.js";
|
|
5
|
+
import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
|
|
6
|
+
import { unreadInConversation } from "./readIndex.js";
|
|
7
|
+
import type { StoredMessage } from "./messageStore.js";
|
|
8
|
+
import type { ChatBody, ChatRuntimeMessage } from "./protocol.js";
|
|
9
|
+
export type ChatSessionOptions<T extends StoredMessage> = {
|
|
10
|
+
messages: MessageClientOptions<T>;
|
|
11
|
+
connection: ChatClientOptions;
|
|
12
|
+
sharedConnection?: ChatClient;
|
|
13
|
+
reading: ReadingClientOptions;
|
|
14
|
+
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
15
|
+
onError: (error: unknown) => void;
|
|
16
|
+
};
|
|
17
|
+
export type ChatThreadSnapshot<T> = {
|
|
18
|
+
messages: readonly T[];
|
|
19
|
+
outgoing: ReturnType<DurableChatSender["snapshot"]>;
|
|
20
|
+
hasEarlier: boolean;
|
|
21
|
+
resetVersion: number;
|
|
22
|
+
complete: boolean;
|
|
23
|
+
unread: ReturnType<typeof unreadInConversation>;
|
|
24
|
+
};
|
|
25
|
+
export type ChatSummarySnapshot<T> = {
|
|
26
|
+
threads: readonly {
|
|
27
|
+
threadId: string;
|
|
28
|
+
lastMessage?: T;
|
|
29
|
+
unread: ChatThreadSnapshot<T>["unread"];
|
|
30
|
+
outgoingCount: number;
|
|
31
|
+
}[];
|
|
32
|
+
unreadMessageCount: number;
|
|
33
|
+
badgeCount: number;
|
|
34
|
+
connection: ChatConnectionState;
|
|
35
|
+
};
|
|
36
|
+
/** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
|
|
37
|
+
export declare class ChatSession<T extends StoredMessage> {
|
|
38
|
+
private options;
|
|
39
|
+
private messages;
|
|
40
|
+
private connection;
|
|
41
|
+
private reading;
|
|
42
|
+
private readers;
|
|
43
|
+
private sender;
|
|
44
|
+
private listeners;
|
|
45
|
+
private snapshots;
|
|
46
|
+
private historyEdges;
|
|
47
|
+
private earlierReads;
|
|
48
|
+
private off;
|
|
49
|
+
private globalListeners;
|
|
50
|
+
private globalSnapshot?;
|
|
51
|
+
private connectionState;
|
|
52
|
+
private catchUp;
|
|
53
|
+
private readingStarted;
|
|
54
|
+
private closed;
|
|
55
|
+
private starting?;
|
|
56
|
+
private closing?;
|
|
57
|
+
constructor(options: ChatSessionOptions<T>);
|
|
58
|
+
start(): Promise<void>;
|
|
59
|
+
private assertOpen;
|
|
60
|
+
getMessages(threadId: string, options?: {
|
|
61
|
+
limit?: number;
|
|
62
|
+
before?: {
|
|
63
|
+
timestamp: number;
|
|
64
|
+
id: string;
|
|
65
|
+
};
|
|
66
|
+
earlier?: boolean;
|
|
67
|
+
all?: boolean;
|
|
68
|
+
refresh?: boolean;
|
|
69
|
+
}): Promise<MessageSnapshot<T>>;
|
|
70
|
+
private rememberHistoryEdge;
|
|
71
|
+
private readEarlier;
|
|
72
|
+
private readAll;
|
|
73
|
+
sendMessage(threadId: string, input: {
|
|
74
|
+
to: string;
|
|
75
|
+
body: ChatBody;
|
|
76
|
+
recipients?: string[];
|
|
77
|
+
}): Promise<ChatRuntimeMessage>;
|
|
78
|
+
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
79
|
+
retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
|
|
80
|
+
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./readIndex.js").ReadIndexState>;
|
|
81
|
+
getSnapshot(): ChatSummarySnapshot<T>;
|
|
82
|
+
getSnapshot(threadId: string): ChatThreadSnapshot<T>;
|
|
83
|
+
subscribe(listener: () => void): () => void;
|
|
84
|
+
subscribe(threadId: string, listener: () => void): () => void;
|
|
85
|
+
private publishAll;
|
|
86
|
+
private buildGlobal;
|
|
87
|
+
private publishGlobal;
|
|
88
|
+
private publish;
|
|
89
|
+
close(): Promise<void>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
2
|
+
import { MessageReadersClient } from "./messageReaders.js";
|
|
3
|
+
import { MessageClient } from "./messageClient.js";
|
|
4
|
+
import { DurableChatSender } from "./durableSender.js";
|
|
5
|
+
import { ReadingClient } from "./readingClient.js";
|
|
6
|
+
import { ChatClient } from "./chatClient.js";
|
|
7
|
+
import { unreadInConversation } from "./readIndex.js";
|
|
8
|
+
/** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
|
|
9
|
+
export class ChatSession {
|
|
10
|
+
options;
|
|
11
|
+
messages;
|
|
12
|
+
connection;
|
|
13
|
+
reading;
|
|
14
|
+
readers;
|
|
15
|
+
sender;
|
|
16
|
+
listeners = new Map();
|
|
17
|
+
snapshots = new Map();
|
|
18
|
+
historyEdges = new Map();
|
|
19
|
+
earlierReads = new Map();
|
|
20
|
+
off = [];
|
|
21
|
+
globalListeners = new Set();
|
|
22
|
+
globalSnapshot;
|
|
23
|
+
connectionState = "closed";
|
|
24
|
+
catchUp;
|
|
25
|
+
readingStarted = false;
|
|
26
|
+
closed = false;
|
|
27
|
+
starting;
|
|
28
|
+
closing;
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
const scope = options.messages.scope;
|
|
32
|
+
if (options.connection.appId !== scope.appId || options.reading.appId !== scope.appId || options.reading.profileId !== scope.profileId || options.connection.profileId !== scope.profileId || options.connection.teamId !== scope.teamId)
|
|
33
|
+
throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
|
|
34
|
+
if (options.sharedConnection) {
|
|
35
|
+
const shared = options.sharedConnection.identityScope();
|
|
36
|
+
if (shared.appId !== scope.appId || shared.profileId !== scope.profileId || (shared.accountId !== undefined && shared.accountId !== scope.accountId) || (shared.teamId !== undefined && shared.teamId !== scope.teamId))
|
|
37
|
+
throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
|
|
38
|
+
}
|
|
39
|
+
this.messages = new MessageClient(options.messages);
|
|
40
|
+
this.connection = options.sharedConnection ?? new ChatClient(options.connection);
|
|
41
|
+
this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
|
|
42
|
+
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
43
|
+
this.sender = new DurableChatSender(this.connection, scope, options.onError);
|
|
44
|
+
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
45
|
+
options.onError(error); });
|
|
46
|
+
this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
|
|
47
|
+
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
|
|
48
|
+
this.off.push(this.connection.watchMessages(message => {
|
|
49
|
+
try {
|
|
50
|
+
const decoded = options.decode(message);
|
|
51
|
+
if (decoded)
|
|
52
|
+
void this.messages.receive(decoded.threadId, [decoded]).then(() => this.sender.confirm([message.hash])).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
53
|
+
this.messages.invalidate(decoded.threadId);
|
|
54
|
+
else if (!this.closed)
|
|
55
|
+
options.onError(error); });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
options.onError(error);
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
this.off.push(this.connection.watchConnection(state => {
|
|
62
|
+
this.connectionState = state;
|
|
63
|
+
this.publishGlobal();
|
|
64
|
+
if (state === "connected") {
|
|
65
|
+
const since = Date.now();
|
|
66
|
+
this.catchUp.schedule("scope", () => this.messages.restore(since));
|
|
67
|
+
if (this.readingStarted)
|
|
68
|
+
this.reading.refresh();
|
|
69
|
+
}
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
start() {
|
|
73
|
+
this.assertOpen();
|
|
74
|
+
return this.starting ??= (async () => {
|
|
75
|
+
await this.sender.initialize();
|
|
76
|
+
this.readingStarted = true;
|
|
77
|
+
await this.reading.start();
|
|
78
|
+
this.assertOpen();
|
|
79
|
+
await this.connection.connect();
|
|
80
|
+
})().catch(error => { this.starting = undefined; throw error; });
|
|
81
|
+
}
|
|
82
|
+
assertOpen() { if (this.closed)
|
|
83
|
+
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
84
|
+
async getMessages(threadId, options = {}) {
|
|
85
|
+
this.assertOpen();
|
|
86
|
+
if (options.earlier) {
|
|
87
|
+
if (options.before || options.all || options.limit !== undefined || options.refresh)
|
|
88
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
89
|
+
return this.readEarlier(threadId);
|
|
90
|
+
}
|
|
91
|
+
if (options.all && (options.before || options.limit !== undefined))
|
|
92
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
93
|
+
// Explicit authoritative changes (for example history assignment) can invalidate
|
|
94
|
+
// even a recently cached empty window. Keep this on the existing read surface.
|
|
95
|
+
if (options.refresh)
|
|
96
|
+
await this.messages.read(threadId, options.limit ?? 100, true);
|
|
97
|
+
if (options.all) {
|
|
98
|
+
return this.readAll(threadId);
|
|
99
|
+
}
|
|
100
|
+
const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
|
|
101
|
+
await this.sender.confirm(page.messages.map(message => message.id));
|
|
102
|
+
const edge = this.historyEdges.get(threadId);
|
|
103
|
+
if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
|
|
104
|
+
this.rememberHistoryEdge(threadId, page);
|
|
105
|
+
return page;
|
|
106
|
+
}
|
|
107
|
+
rememberHistoryEdge(threadId, page) {
|
|
108
|
+
const first = page.messages[0];
|
|
109
|
+
this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
|
|
110
|
+
}
|
|
111
|
+
readEarlier(threadId) {
|
|
112
|
+
const running = this.earlierReads.get(threadId);
|
|
113
|
+
if (running)
|
|
114
|
+
return running;
|
|
115
|
+
const task = (async () => {
|
|
116
|
+
const recent = await this.getMessages(threadId);
|
|
117
|
+
if (!recent.complete)
|
|
118
|
+
throw new Error("MESSAGE_WINDOW_INCOMPLETE");
|
|
119
|
+
const edge = this.historyEdges.get(threadId);
|
|
120
|
+
if (!edge.hasEarlier)
|
|
121
|
+
return { ...recent, messages: [], hasEarlier: false };
|
|
122
|
+
if (!edge.before)
|
|
123
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
124
|
+
const page = await this.getMessages(threadId, { before: edge.before });
|
|
125
|
+
this.assertOpen();
|
|
126
|
+
this.rememberHistoryEdge(threadId, page);
|
|
127
|
+
return page;
|
|
128
|
+
})().finally(() => { if (this.earlierReads.get(threadId) === task)
|
|
129
|
+
this.earlierReads.delete(threadId); });
|
|
130
|
+
this.earlierReads.set(threadId, task);
|
|
131
|
+
return task;
|
|
132
|
+
}
|
|
133
|
+
async readAll(threadId) {
|
|
134
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
135
|
+
const pages = [];
|
|
136
|
+
let version = 0;
|
|
137
|
+
try {
|
|
138
|
+
let page = await this.getMessages(threadId);
|
|
139
|
+
version = page.resetVersion;
|
|
140
|
+
pages.push(page.messages);
|
|
141
|
+
if (!page.complete)
|
|
142
|
+
return { ...page, hasEarlier: true };
|
|
143
|
+
let restart = false;
|
|
144
|
+
while (page.hasEarlier) {
|
|
145
|
+
const first = page.messages[0];
|
|
146
|
+
if (!first)
|
|
147
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
148
|
+
page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
|
|
149
|
+
if (page.resetVersion !== version) {
|
|
150
|
+
restart = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
pages.push(page.messages);
|
|
154
|
+
}
|
|
155
|
+
if (restart)
|
|
156
|
+
continue;
|
|
157
|
+
return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
161
|
+
throw error;
|
|
162
|
+
if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
163
|
+
continue;
|
|
164
|
+
if (pages.length)
|
|
165
|
+
return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
170
|
+
}
|
|
171
|
+
sendMessage(threadId, input) {
|
|
172
|
+
this.assertOpen();
|
|
173
|
+
return this.sender.enqueue(threadId, input);
|
|
174
|
+
}
|
|
175
|
+
getReaders(threadId, messageId, page = {}, signal) {
|
|
176
|
+
this.assertOpen();
|
|
177
|
+
return this.readers.getReaders({ ...page, threadId, messageId }, signal);
|
|
178
|
+
}
|
|
179
|
+
retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
|
|
180
|
+
observeMessages(threadId, ids, event) {
|
|
181
|
+
this.assertOpen();
|
|
182
|
+
return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
|
|
183
|
+
}
|
|
184
|
+
getSnapshot(threadId) {
|
|
185
|
+
if (threadId === undefined) {
|
|
186
|
+
if (!this.globalSnapshot)
|
|
187
|
+
this.globalSnapshot = this.buildGlobal();
|
|
188
|
+
return this.globalSnapshot;
|
|
189
|
+
}
|
|
190
|
+
let snapshot = this.snapshots.get(threadId);
|
|
191
|
+
if (!snapshot) {
|
|
192
|
+
const messages = this.messages.getSnapshot(threadId);
|
|
193
|
+
const state = this.reading.getSnapshot();
|
|
194
|
+
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
|
|
195
|
+
this.snapshots.set(threadId, snapshot);
|
|
196
|
+
}
|
|
197
|
+
return snapshot;
|
|
198
|
+
}
|
|
199
|
+
subscribe(target, listener) {
|
|
200
|
+
this.assertOpen();
|
|
201
|
+
if (typeof target === "function") {
|
|
202
|
+
this.globalListeners.add(target);
|
|
203
|
+
return () => { this.globalListeners.delete(target); };
|
|
204
|
+
}
|
|
205
|
+
const threadId = target;
|
|
206
|
+
const notify = listener;
|
|
207
|
+
let listeners = this.listeners.get(threadId);
|
|
208
|
+
if (!listeners) {
|
|
209
|
+
listeners = new Set();
|
|
210
|
+
this.listeners.set(threadId, listeners);
|
|
211
|
+
}
|
|
212
|
+
listeners.add(notify);
|
|
213
|
+
const off = this.messages.subscribe(threadId, () => { });
|
|
214
|
+
return () => {
|
|
215
|
+
off();
|
|
216
|
+
listeners.delete(notify);
|
|
217
|
+
if (!listeners.size)
|
|
218
|
+
this.listeners.delete(threadId);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
publishAll() {
|
|
222
|
+
for (const threadId of [...this.snapshots.keys()])
|
|
223
|
+
this.publish(threadId, false);
|
|
224
|
+
this.publishGlobal();
|
|
225
|
+
}
|
|
226
|
+
buildGlobal() {
|
|
227
|
+
const ids = new Set(this.snapshots.keys());
|
|
228
|
+
for (const thread of this.reading.getSnapshot()?.threads ?? [])
|
|
229
|
+
if (thread.teamId === this.options.messages.scope.teamId)
|
|
230
|
+
ids.add(thread.conversationId ?? thread.id);
|
|
231
|
+
for (const outgoing of this.sender.snapshot())
|
|
232
|
+
ids.add(outgoing.threadId);
|
|
233
|
+
const threads = [...ids].sort().map(threadId => {
|
|
234
|
+
const snapshot = this.getSnapshot(threadId);
|
|
235
|
+
return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
|
|
236
|
+
});
|
|
237
|
+
return { threads, unreadMessageCount: threads.reduce((sum, t) => sum + t.unread.unreadMessageCount, 0), badgeCount: threads.reduce((sum, t) => sum + t.unread.badgeCount, 0), connection: this.connectionState };
|
|
238
|
+
}
|
|
239
|
+
publishGlobal() {
|
|
240
|
+
const next = this.buildGlobal();
|
|
241
|
+
if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
|
|
242
|
+
return;
|
|
243
|
+
this.globalSnapshot = next;
|
|
244
|
+
this.globalListeners.forEach(listener => listener());
|
|
245
|
+
}
|
|
246
|
+
publish(threadId, notifyGlobal = true) {
|
|
247
|
+
const previous = this.snapshots.get(threadId);
|
|
248
|
+
this.snapshots.delete(threadId);
|
|
249
|
+
const next = this.getSnapshot(threadId);
|
|
250
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(next)) {
|
|
251
|
+
this.snapshots.set(threadId, previous);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.listeners.get(threadId)?.forEach(listener => listener());
|
|
255
|
+
if (notifyGlobal)
|
|
256
|
+
this.publishGlobal();
|
|
257
|
+
}
|
|
258
|
+
close() {
|
|
259
|
+
return this.closing ??= (async () => {
|
|
260
|
+
this.closed = true;
|
|
261
|
+
this.catchUp.close();
|
|
262
|
+
this.off.splice(0).forEach(off => off());
|
|
263
|
+
if (!this.options.sharedConnection)
|
|
264
|
+
this.connection.close();
|
|
265
|
+
await Promise.all([this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
|
|
266
|
+
this.listeners.clear();
|
|
267
|
+
this.globalListeners.clear();
|
|
268
|
+
this.snapshots.clear();
|
|
269
|
+
this.historyEdges.clear();
|
|
270
|
+
this.earlierReads.clear();
|
|
271
|
+
this.globalSnapshot = undefined;
|
|
272
|
+
})();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ChatSession } from "./chatSession.js";
|
|
2
|
+
import type { ChatReaderSocialMessage } from "./readerClient.js";
|
|
3
|
+
import type { ChatClient, ChatClientOptions } from "./chatClient.js";
|
|
4
|
+
export type CachedChatMessage = ChatReaderSocialMessage & {
|
|
5
|
+
id: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
};
|
|
8
|
+
export type CreateChatOptions = {
|
|
9
|
+
appId: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
profileId: string;
|
|
12
|
+
teamId: string;
|
|
13
|
+
token: string;
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
clientInstanceId?: string;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** Reuse an existing transport for call/presence integrations; its owner closes it. */
|
|
18
|
+
connection?: ChatClient;
|
|
19
|
+
onError: (error: unknown) => void;
|
|
20
|
+
onNotification?: ChatClientOptions["onNotification"];
|
|
21
|
+
onReach?: ChatClientOptions["onReach"];
|
|
22
|
+
};
|
|
23
|
+
/** Standard browser entry: storage, paging, realtime and reading share one identity. */
|
|
24
|
+
export declare function createChat(options: CreateChatOptions): ChatSession<CachedChatMessage>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ChatSession } from "./chatSession.js";
|
|
2
|
+
import { SocialReadClient } from "./socialReadClient.js";
|
|
3
|
+
import { chatTeamId } from "./teamScope.js";
|
|
4
|
+
import { dmThreadId, groupThreadId, isValidChatTeamId } from "./thread.js";
|
|
5
|
+
/** Standard browser entry: storage, paging, realtime and reading share one identity. */
|
|
6
|
+
export function createChat(options) {
|
|
7
|
+
const { appId, accountId, profileId, teamId, token, onError } = options;
|
|
8
|
+
if (!isValidChatTeamId(teamId))
|
|
9
|
+
throw new Error("INVALID_CHAT_TEAM");
|
|
10
|
+
const endpoint = (options.endpoint ?? "https://hub.pmate.chat").replace(/\/+$/, "");
|
|
11
|
+
const http = endpoint.replace(/^ws/, "http"), ws = endpoint.replace(/^http/, "ws");
|
|
12
|
+
const reader = new SocialReadClient({ appId, profileId, token, hubHttpUrl: http, fetch: options.fetch });
|
|
13
|
+
const page = async (threadId, signal, cursor = {}) => {
|
|
14
|
+
const result = await reader.messagePage({ threadId, teamId, limit: 100, ...cursor }, signal);
|
|
15
|
+
return { ...result, messages: result.messages.map(message => ({ ...message, id: message.messageId, timestamp: message.serverCreatedAt })) };
|
|
16
|
+
};
|
|
17
|
+
return new ChatSession({
|
|
18
|
+
sharedConnection: options.connection,
|
|
19
|
+
messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
20
|
+
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
21
|
+
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
|
|
22
|
+
session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
|
|
23
|
+
decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function decodeIncoming(message, scope) {
|
|
27
|
+
if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
|
|
28
|
+
return;
|
|
29
|
+
const body = message.body;
|
|
30
|
+
if (body.kind !== "message" || body.content?.appId !== scope.appId || chatTeamId(body) !== scope.teamId)
|
|
31
|
+
return;
|
|
32
|
+
if (body.threadKind !== "dm" && body.threadKind !== "group")
|
|
33
|
+
return;
|
|
34
|
+
if (body.threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
|
|
35
|
+
return;
|
|
36
|
+
const threadId = body.threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
|
|
37
|
+
return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind: body.threadKind, messageId: message.hash, id: message.hash,
|
|
38
|
+
timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
|
|
39
|
+
}
|
|
@@ -32,11 +32,19 @@ export declare class DurableChatSender {
|
|
|
32
32
|
subscribe(listener: () => void): () => void;
|
|
33
33
|
snapshot(threadId?: string): readonly OutgoingMessage[];
|
|
34
34
|
private emit;
|
|
35
|
+
/** Resolves after the exact wire message is durable, independently of network delivery. */
|
|
36
|
+
enqueue(threadId: string, input: {
|
|
37
|
+
to: string;
|
|
38
|
+
body: ChatBody;
|
|
39
|
+
recipients?: string[];
|
|
40
|
+
}): Promise<ChatRuntimeMessage>;
|
|
41
|
+
/** Compatibility API: resolves only after transport delivery. */
|
|
35
42
|
send(threadId: string, input: {
|
|
36
43
|
to: string;
|
|
37
44
|
body: ChatBody;
|
|
38
45
|
recipients?: string[];
|
|
39
46
|
}): Promise<ChatRuntimeMessage>;
|
|
47
|
+
private persistOutgoing;
|
|
40
48
|
retry(hash: string): Promise<ChatRuntimeMessage>;
|
|
41
49
|
private transmit;
|
|
42
50
|
private save;
|
|
@@ -47,7 +47,18 @@ export class DurableChatSender {
|
|
|
47
47
|
}
|
|
48
48
|
emit() { this.snapshots.clear(); if (!this.closed)
|
|
49
49
|
this.listeners.forEach(listener => listener()); }
|
|
50
|
+
/** Resolves after the exact wire message is durable, independently of network delivery. */
|
|
51
|
+
async enqueue(threadId, input) {
|
|
52
|
+
const message = await this.persistOutgoing(threadId, input);
|
|
53
|
+
void this.retry(message.hash).catch(error => this.onError(error));
|
|
54
|
+
return message;
|
|
55
|
+
}
|
|
56
|
+
/** Compatibility API: resolves only after transport delivery. */
|
|
50
57
|
async send(threadId, input) {
|
|
58
|
+
const message = await this.persistOutgoing(threadId, input);
|
|
59
|
+
return this.retry(message.hash);
|
|
60
|
+
}
|
|
61
|
+
async persistOutgoing(threadId, input) {
|
|
51
62
|
await this.ready;
|
|
52
63
|
if (this.closed)
|
|
53
64
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
@@ -60,7 +71,7 @@ export class DurableChatSender {
|
|
|
60
71
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
61
72
|
this.values.set(record.hash, record);
|
|
62
73
|
this.emit();
|
|
63
|
-
return
|
|
74
|
+
return message;
|
|
64
75
|
}
|
|
65
76
|
retry(hash) {
|
|
66
77
|
if (this.closed)
|
|
@@ -132,10 +143,13 @@ export class DurableChatSender {
|
|
|
132
143
|
/** Confirm only after the authoritative message has been durably merged.
|
|
133
144
|
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
134
145
|
async confirm(hashes) {
|
|
146
|
+
hashes = [...new Set(hashes)];
|
|
147
|
+
if (!hashes.length)
|
|
148
|
+
return;
|
|
135
149
|
await this.ready;
|
|
136
150
|
await this.db.transaction("rw", this.outbox, async () => {
|
|
137
151
|
const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
|
|
138
|
-
const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
|
|
152
|
+
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
|
|
139
153
|
if (confirmed.length)
|
|
140
154
|
await this.outbox.bulkPut(confirmed);
|
|
141
155
|
});
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED