@pmate/chat-sdk 0.12.0-next.12 → 0.12.0-next.14
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/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +17 -1
- package/lib/runtime/chatSession.js +114 -6
- package/lib/runtime/createChat.d.ts +2 -0
- package/lib/runtime/createChat.js +1 -0
- package/lib/runtime/messageClient.d.ts +1 -1
- package/lib/runtime/messageClient.js +2 -2
- package/lib/runtime/responseCache.d.ts +1 -0
- package/lib/runtime/responseCache.js +13 -8
- package/package.json +1 -1
|
@@ -62,6 +62,7 @@ export declare class ChatClient {
|
|
|
62
62
|
profileId: string | undefined;
|
|
63
63
|
accountId: string | undefined;
|
|
64
64
|
teamId: string | undefined;
|
|
65
|
+
clientInstanceId: string;
|
|
65
66
|
};
|
|
66
67
|
watchConnection(handler: (state: ChatConnectionState) => void): () => void;
|
|
67
68
|
private setConnection;
|
|
@@ -123,7 +123,7 @@ export class ChatClient {
|
|
|
123
123
|
/** Non-secret identity used to validate a shared browser transport. */
|
|
124
124
|
identityScope() {
|
|
125
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 };
|
|
126
|
+
return { appId: this.options.appId, profileId: this.profileIdValue ?? this.options.profileId, accountId: identity?.accountId, teamId: this.options.teamId, clientInstanceId: this.instanceId };
|
|
127
127
|
}
|
|
128
128
|
watchConnection(handler) {
|
|
129
129
|
this.connectionHandlers.add(handler);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ReadCountsResult } from "./readCounts.js";
|
|
2
|
+
import type { OnlineReachEvent } from "./reach.js";
|
|
1
3
|
import { type MessageReadersQuery } from "./messageReaders.js";
|
|
2
4
|
import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
|
|
3
5
|
import { DurableChatSender } from "./durableSender.js";
|
|
@@ -10,6 +12,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
|
|
|
10
12
|
messages: MessageClientOptions<T>;
|
|
11
13
|
connection: ChatClientOptions;
|
|
12
14
|
sharedConnection?: ChatClient;
|
|
15
|
+
activeThreadId?: string;
|
|
13
16
|
reading: ReadingClientOptions;
|
|
14
17
|
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
15
18
|
onError: (error: unknown) => void;
|
|
@@ -20,6 +23,7 @@ export type ChatThreadSnapshot<T> = {
|
|
|
20
23
|
hasEarlier: boolean;
|
|
21
24
|
resetVersion: number;
|
|
22
25
|
complete: boolean;
|
|
26
|
+
readCounts?: ReadCountsResult;
|
|
23
27
|
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
24
28
|
};
|
|
25
29
|
export type ChatSummarySnapshot<T> = {
|
|
@@ -40,6 +44,12 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
40
44
|
private connection;
|
|
41
45
|
private reading;
|
|
42
46
|
private readers;
|
|
47
|
+
private counts?;
|
|
48
|
+
private countsReceiver?;
|
|
49
|
+
private countsThread?;
|
|
50
|
+
private countSnapshots;
|
|
51
|
+
private countClosings;
|
|
52
|
+
private countTasks;
|
|
43
53
|
private sender;
|
|
44
54
|
private listeners;
|
|
45
55
|
private snapshots;
|
|
@@ -86,7 +96,13 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
86
96
|
}): Promise<ChatRuntimeMessage>;
|
|
87
97
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
88
98
|
retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
|
|
89
|
-
observeMessages(threadId: string, ids: string[], event: "visible" | "played"):
|
|
99
|
+
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): ReturnType<ReadingClient["recordVisible"]>;
|
|
100
|
+
observeMessages(threadId: string | undefined, ids: string[], event: "receipts", clientInstanceId?: string): Promise<void>;
|
|
101
|
+
/** Bridge transports can delegate social events without maintaining clients. */
|
|
102
|
+
handleReach(event: OnlineReachEvent): Promise<"handled" | undefined>;
|
|
103
|
+
private wakeCounts;
|
|
104
|
+
private publishCounts;
|
|
105
|
+
private watchCounts;
|
|
90
106
|
getSnapshot(): ChatSummarySnapshot<T>;
|
|
91
107
|
getSnapshot(threadId: string): ChatThreadSnapshot<T>;
|
|
92
108
|
subscribe(listener: () => void): () => void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ReadCountsClient } from "./readCounts.js";
|
|
1
2
|
import { WindowedTasks } from "./coalescer.js";
|
|
2
3
|
import { MessageReadersClient } from "./messageReaders.js";
|
|
3
4
|
import { MessageClient } from "./messageClient.js";
|
|
@@ -12,6 +13,12 @@ export class ChatSession {
|
|
|
12
13
|
connection;
|
|
13
14
|
reading;
|
|
14
15
|
readers;
|
|
16
|
+
counts;
|
|
17
|
+
countsReceiver;
|
|
18
|
+
countsThread;
|
|
19
|
+
countSnapshots = new Map();
|
|
20
|
+
countClosings = new Set();
|
|
21
|
+
countTasks;
|
|
15
22
|
sender;
|
|
16
23
|
listeners = new Map();
|
|
17
24
|
snapshots = new Map();
|
|
@@ -44,6 +51,14 @@ export class ChatSession {
|
|
|
44
51
|
this.sender = new DurableChatSender(this.connection, scope, options.onError);
|
|
45
52
|
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
46
53
|
options.onError(error); });
|
|
54
|
+
this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
|
|
55
|
+
options.onError(error); });
|
|
56
|
+
if (typeof window !== "undefined") {
|
|
57
|
+
const wake = () => this.wakeCounts();
|
|
58
|
+
window.addEventListener("online", wake);
|
|
59
|
+
window.addEventListener("focus", wake);
|
|
60
|
+
this.off.push(() => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); });
|
|
61
|
+
}
|
|
47
62
|
this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
|
|
48
63
|
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
|
|
49
64
|
this.off.push(this.connection.watchMessages(message => {
|
|
@@ -64,9 +79,10 @@ export class ChatSession {
|
|
|
64
79
|
this.publishGlobal();
|
|
65
80
|
if (state === "connected") {
|
|
66
81
|
const since = Date.now();
|
|
67
|
-
this.catchUp.schedule("scope", () => this.messages.restore(since));
|
|
82
|
+
this.catchUp.schedule("scope", () => this.messages.restore(since, options.activeThreadId));
|
|
68
83
|
if (this.readingStarted)
|
|
69
84
|
this.reading.refresh();
|
|
85
|
+
this.wakeCounts();
|
|
70
86
|
}
|
|
71
87
|
}));
|
|
72
88
|
}
|
|
@@ -188,15 +204,105 @@ export class ChatSession {
|
|
|
188
204
|
this.assertOpen();
|
|
189
205
|
return this.sender.enqueue(threadId, input);
|
|
190
206
|
}
|
|
191
|
-
getReaders(threadId, messageId, page = {}, signal) {
|
|
207
|
+
async getReaders(threadId, messageId, page = {}, signal) {
|
|
208
|
+
this.assertOpen();
|
|
209
|
+
const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
|
|
192
210
|
this.assertOpen();
|
|
193
|
-
|
|
211
|
+
if (!result.resetRequired) {
|
|
212
|
+
const previous = this.countSnapshots.get(threadId);
|
|
213
|
+
if (!previous || previous.revision <= result.revision) {
|
|
214
|
+
this.publishCounts({ appId: result.appId, threadId, revision: result.revision, counts: { ...previous?.counts, [messageId]: result.total } });
|
|
215
|
+
this.counts?.changed(threadId, result.revision);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
194
219
|
}
|
|
195
220
|
retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
|
|
196
|
-
observeMessages(threadId, ids, event) {
|
|
221
|
+
observeMessages(threadId, ids, event, clientInstanceId) {
|
|
222
|
+
if (this.closed && event === "receipts" && !threadId)
|
|
223
|
+
return Promise.resolve();
|
|
197
224
|
this.assertOpen();
|
|
225
|
+
if (event === "receipts") {
|
|
226
|
+
this.watchCounts(threadId, ids, clientInstanceId);
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
}
|
|
229
|
+
if (!threadId)
|
|
230
|
+
throw new Error("INVALID_READING_THREAD");
|
|
198
231
|
return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
|
|
199
232
|
}
|
|
233
|
+
/** Bridge transports can delegate social events without maintaining clients. */
|
|
234
|
+
async handleReach(event) {
|
|
235
|
+
this.assertOpen();
|
|
236
|
+
if (!["social.readCountsChanged", "social.readStateChanged"].includes(event.eventType))
|
|
237
|
+
return undefined;
|
|
238
|
+
if (event.appId !== this.options.messages.scope.appId)
|
|
239
|
+
throw new Error("UNEXPECTED_READING_APPLICATION");
|
|
240
|
+
if (event.eventType === "social.readCountsChanged") {
|
|
241
|
+
if (typeof event.payload.threadId !== "string" || !Number.isSafeInteger(event.payload.revision) || Number(event.payload.revision) < 0)
|
|
242
|
+
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
243
|
+
this.counts?.changed(event.payload.threadId, Number(event.payload.revision));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
if (typeof event.payload.syncCursor !== "string" || !/^v1:\d+$/.test(event.payload.syncCursor) || typeof event.payload.profileId !== "string")
|
|
247
|
+
throw new Error("INVALID_READING_EVENT");
|
|
248
|
+
if (event.payload.profileId !== this.options.messages.scope.profileId)
|
|
249
|
+
throw new Error("Reading reach identity mismatch");
|
|
250
|
+
await this.initialize();
|
|
251
|
+
await this.reading.synchronize(event.payload.syncCursor, event.payload.profileId);
|
|
252
|
+
this.assertOpen();
|
|
253
|
+
}
|
|
254
|
+
return "handled";
|
|
255
|
+
}
|
|
256
|
+
wakeCounts() {
|
|
257
|
+
if (this.counts && this.countsThread && !this.closed)
|
|
258
|
+
this.countTasks.schedule("counts", async () => { this.counts?.reconnect(); });
|
|
259
|
+
}
|
|
260
|
+
publishCounts(value) {
|
|
261
|
+
const previous = this.countSnapshots.get(value.threadId);
|
|
262
|
+
if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
|
|
263
|
+
return;
|
|
264
|
+
this.countSnapshots.delete(value.threadId);
|
|
265
|
+
this.countSnapshots.set(value.threadId, value);
|
|
266
|
+
if (this.countSnapshots.size > 128) {
|
|
267
|
+
const oldest = this.countSnapshots.keys().next().value;
|
|
268
|
+
this.countSnapshots.delete(oldest);
|
|
269
|
+
this.publish(oldest, false);
|
|
270
|
+
}
|
|
271
|
+
this.publish(value.threadId, false);
|
|
272
|
+
}
|
|
273
|
+
watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
|
|
274
|
+
if (!threadId || !ids.length) {
|
|
275
|
+
this.countsThread = undefined;
|
|
276
|
+
this.counts?.watch(undefined, []);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
|
|
280
|
+
throw new Error("INVALID_READ_COUNTS_RECEIVER");
|
|
281
|
+
if (!this.counts || this.countsReceiver !== receiver) {
|
|
282
|
+
if (this.counts) {
|
|
283
|
+
const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
|
|
284
|
+
this.countClosings.add(closing);
|
|
285
|
+
}
|
|
286
|
+
const scope = this.options.messages.scope;
|
|
287
|
+
const client = new ReadCountsClient({ appId: scope.appId, profileId: scope.profileId, scope, token: this.options.reading.sessionToken, clientInstanceId: receiver, hubHttpUrl: this.options.reading.hubHttpUrl, fetch: this.options.reading.fetch,
|
|
288
|
+
onChange: value => { if (!this.closed && this.counts === client)
|
|
289
|
+
this.publishCounts(value); },
|
|
290
|
+
onError: error => {
|
|
291
|
+
if (this.closed || this.counts !== client)
|
|
292
|
+
return;
|
|
293
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
294
|
+
const threads = [...this.countSnapshots.keys()];
|
|
295
|
+
this.countSnapshots.clear();
|
|
296
|
+
threads.forEach(thread => this.publish(thread, false));
|
|
297
|
+
}
|
|
298
|
+
this.options.onError(error);
|
|
299
|
+
} });
|
|
300
|
+
this.counts = client;
|
|
301
|
+
this.countsReceiver = receiver;
|
|
302
|
+
}
|
|
303
|
+
this.countsThread = threadId;
|
|
304
|
+
this.counts.watch(threadId, ids);
|
|
305
|
+
}
|
|
200
306
|
getSnapshot(threadId) {
|
|
201
307
|
if (threadId === undefined) {
|
|
202
308
|
if (!this.globalSnapshot)
|
|
@@ -207,7 +313,7 @@ export class ChatSession {
|
|
|
207
313
|
if (!snapshot) {
|
|
208
314
|
const messages = this.messages.getSnapshot(threadId);
|
|
209
315
|
const state = this.reading.getSnapshot();
|
|
210
|
-
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) : undefined };
|
|
316
|
+
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), readCounts: this.countSnapshots.get(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
|
|
211
317
|
this.snapshots.set(threadId, snapshot);
|
|
212
318
|
}
|
|
213
319
|
return snapshot;
|
|
@@ -277,13 +383,15 @@ export class ChatSession {
|
|
|
277
383
|
return this.closing ??= (async () => {
|
|
278
384
|
this.closed = true;
|
|
279
385
|
this.catchUp.close();
|
|
386
|
+
this.countTasks.close();
|
|
280
387
|
this.off.splice(0).forEach(off => off());
|
|
281
388
|
if (!this.options.sharedConnection)
|
|
282
389
|
this.connection.close();
|
|
283
|
-
await Promise.all([this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
|
|
390
|
+
await Promise.all([this.counts?.close(), ...this.countClosings, this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
|
|
284
391
|
this.listeners.clear();
|
|
285
392
|
this.globalListeners.clear();
|
|
286
393
|
this.snapshots.clear();
|
|
394
|
+
this.countSnapshots.clear();
|
|
287
395
|
this.historyEdges.clear();
|
|
288
396
|
this.earlierReads.clear();
|
|
289
397
|
this.globalSnapshot = undefined;
|
|
@@ -11,6 +11,8 @@ export type CreateChatOptions = {
|
|
|
11
11
|
profileId: string;
|
|
12
12
|
teamId: string;
|
|
13
13
|
token: string;
|
|
14
|
+
/** Limit reconnect reconciliation for a dedicated conversation document. */
|
|
15
|
+
threadId?: string;
|
|
14
16
|
endpoint?: string;
|
|
15
17
|
clientInstanceId?: string;
|
|
16
18
|
fetch?: typeof fetch;
|
|
@@ -16,6 +16,7 @@ export function createChat(options) {
|
|
|
16
16
|
};
|
|
17
17
|
return new ChatSession({
|
|
18
18
|
sharedConnection: options.connection,
|
|
19
|
+
activeThreadId: options.threadId,
|
|
19
20
|
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
21
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
21
22
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
|
|
@@ -59,7 +59,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
59
59
|
subscribe(thread: string, listener: () => void): () => void;
|
|
60
60
|
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
61
61
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
62
|
-
restore(since: number): Promise<void>;
|
|
62
|
+
restore(since: number, threadId?: string): Promise<void>;
|
|
63
63
|
read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
|
|
64
64
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
65
65
|
invalidate(thread: string): void;
|
|
@@ -71,8 +71,8 @@ export class MessageClient {
|
|
|
71
71
|
}
|
|
72
72
|
subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
|
|
73
73
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
74
|
-
async restore(since) {
|
|
75
|
-
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked);
|
|
74
|
+
async restore(since, threadId) {
|
|
75
|
+
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
|
|
76
76
|
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
77
77
|
while (queue.length && !this.closed && !this.closing) {
|
|
78
78
|
const window = queue.shift();
|
|
@@ -10,6 +10,7 @@ export class IndexedDbResponseCache {
|
|
|
10
10
|
pending = [];
|
|
11
11
|
tasks = new WindowedTasks(16, () => { });
|
|
12
12
|
running;
|
|
13
|
+
invalidations = new Set();
|
|
13
14
|
closed = false;
|
|
14
15
|
closing;
|
|
15
16
|
constructor(scope, options = {}) {
|
|
@@ -40,14 +41,18 @@ export class IndexedDbResponseCache {
|
|
|
40
41
|
this.tasks.schedule("write", () => this.flush());
|
|
41
42
|
return promise;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
invalidate() {
|
|
44
45
|
if (this.closed)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
|
|
47
|
+
const task = (async () => {
|
|
48
|
+
await this.db.transaction("rw", this.rows, this.epochs, async () => {
|
|
49
|
+
const epoch = ((await this.epochs.get(this.scope))?.epoch ?? 0) + 1;
|
|
50
|
+
await this.epochs.put({ scope: this.scope, epoch });
|
|
51
|
+
await this.rows.where("scope").equals(this.scope).delete();
|
|
52
|
+
});
|
|
53
|
+
})();
|
|
54
|
+
this.invalidations.add(task);
|
|
55
|
+
return task.finally(() => this.invalidations.delete(task));
|
|
51
56
|
}
|
|
52
57
|
async flush() {
|
|
53
58
|
if (this.running) {
|
|
@@ -96,7 +101,7 @@ export class IndexedDbResponseCache {
|
|
|
96
101
|
this.tasks.close();
|
|
97
102
|
return this.closing = (async () => {
|
|
98
103
|
try {
|
|
99
|
-
await this.flush();
|
|
104
|
+
await Promise.all([this.flush(), ...this.invalidations]);
|
|
100
105
|
}
|
|
101
106
|
finally {
|
|
102
107
|
this.db.close();
|
package/package.json
CHANGED