@pmate/chat-sdk 0.12.0-next.0 → 0.12.0-next.2

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.
@@ -0,0 +1,59 @@
1
+ import { type MessageReadersQuery } from "./messageReaders.js";
2
+ import { type MessageClientOptions } from "./messageClient.js";
3
+ import { DurableChatSender } from "./durableSender.js";
4
+ import { type ReadingClientOptions } from "./readingClient.js";
5
+ import { type ChatClientOptions } 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
+ reading: ReadingClientOptions;
13
+ decode: (message: ChatRuntimeMessage) => T | undefined;
14
+ onError: (error: unknown) => void;
15
+ };
16
+ export type ChatThreadSnapshot<T> = {
17
+ messages: readonly T[];
18
+ outgoing: ReturnType<DurableChatSender["snapshot"]>;
19
+ hasEarlier: boolean;
20
+ unread: ReturnType<typeof unreadInConversation>;
21
+ };
22
+ /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
23
+ export declare class ChatSession<T extends StoredMessage> {
24
+ private options;
25
+ private messages;
26
+ private connection;
27
+ private reading;
28
+ private readers;
29
+ private sender;
30
+ private listeners;
31
+ private snapshots;
32
+ private off;
33
+ private closed;
34
+ private starting?;
35
+ private closing?;
36
+ constructor(options: ChatSessionOptions<T>);
37
+ start(): Promise<void>;
38
+ private assertOpen;
39
+ getMessages(threadId: string, options?: {
40
+ limit?: number;
41
+ before?: {
42
+ timestamp: number;
43
+ id: string;
44
+ };
45
+ }): Promise<import("./messageClient.js").MessageSnapshot<T>>;
46
+ sendMessage(threadId: string, input: {
47
+ to: string;
48
+ body: ChatBody;
49
+ recipients?: string[];
50
+ }): Promise<ChatRuntimeMessage>;
51
+ getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
52
+ retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
53
+ observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./readIndex.js").ReadIndexState>;
54
+ getSnapshot(threadId: string): ChatThreadSnapshot<T>;
55
+ subscribe(threadId: string, listener: () => void): () => void;
56
+ private publishAll;
57
+ private publish;
58
+ close(): Promise<void>;
59
+ }
@@ -0,0 +1,130 @@
1
+ import { MessageReadersClient } from "./messageReaders.js";
2
+ import { MessageClient } from "./messageClient.js";
3
+ import { DurableChatSender } from "./durableSender.js";
4
+ import { ReadingClient } from "./readingClient.js";
5
+ import { ChatClient } from "./chatClient.js";
6
+ import { unreadInConversation } from "./readIndex.js";
7
+ /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
8
+ export class ChatSession {
9
+ options;
10
+ messages;
11
+ connection;
12
+ reading;
13
+ readers;
14
+ sender;
15
+ listeners = new Map();
16
+ snapshots = new Map();
17
+ off = [];
18
+ closed = false;
19
+ starting;
20
+ closing;
21
+ constructor(options) {
22
+ this.options = options;
23
+ const scope = options.messages.scope;
24
+ 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)
25
+ throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
26
+ this.messages = new MessageClient(options.messages);
27
+ this.connection = new ChatClient(options.connection);
28
+ this.reading = new ReadingClient(options.reading);
29
+ this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
30
+ this.sender = new DurableChatSender(this.connection, scope, options.onError);
31
+ this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
32
+ this.off.push(this.connection.watchMessages(message => {
33
+ try {
34
+ const decoded = options.decode(message);
35
+ if (decoded)
36
+ void this.messages.receive(decoded.threadId, [decoded]).then(() => { this.publish(decoded.threadId); return this.sender.confirm([message.hash]); }).catch(options.onError);
37
+ }
38
+ catch (error) {
39
+ options.onError(error);
40
+ }
41
+ }));
42
+ this.off.push(this.connection.watchConnection(state => {
43
+ if (state === "connected") {
44
+ for (const thread of this.listeners.keys())
45
+ this.messages.invalidate(thread);
46
+ this.reading.refresh();
47
+ }
48
+ }));
49
+ }
50
+ start() {
51
+ this.assertOpen();
52
+ return this.starting ??= (async () => {
53
+ await this.sender.initialize();
54
+ await this.reading.start();
55
+ this.assertOpen();
56
+ await this.connection.connect();
57
+ })().catch(error => { this.starting = undefined; throw error; });
58
+ }
59
+ assertOpen() { if (this.closed)
60
+ throw new Error("CHAT_SESSION_CLOSED"); }
61
+ async getMessages(threadId, options = {}) {
62
+ this.assertOpen();
63
+ const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
64
+ if (!options.before)
65
+ this.publish(threadId);
66
+ return page;
67
+ }
68
+ sendMessage(threadId, input) {
69
+ this.assertOpen();
70
+ return this.sender.enqueue(threadId, input);
71
+ }
72
+ getReaders(threadId, messageId, page = {}, signal) {
73
+ this.assertOpen();
74
+ return this.readers.getReaders({ ...page, threadId, messageId }, signal);
75
+ }
76
+ retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
77
+ observeMessages(threadId, ids, event) {
78
+ this.assertOpen();
79
+ return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
80
+ }
81
+ getSnapshot(threadId) {
82
+ let snapshot = this.snapshots.get(threadId);
83
+ if (!snapshot) {
84
+ const messages = this.messages.getSnapshot(threadId);
85
+ const state = this.reading.getSnapshot();
86
+ snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? 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 } };
87
+ this.snapshots.set(threadId, snapshot);
88
+ }
89
+ return snapshot;
90
+ }
91
+ subscribe(threadId, listener) {
92
+ this.assertOpen();
93
+ let listeners = this.listeners.get(threadId);
94
+ if (!listeners) {
95
+ listeners = new Set();
96
+ this.listeners.set(threadId, listeners);
97
+ }
98
+ listeners.add(listener);
99
+ const off = this.messages.subscribe(threadId, () => this.publish(threadId));
100
+ return () => {
101
+ off();
102
+ listeners.delete(listener);
103
+ if (!listeners.size)
104
+ this.listeners.delete(threadId);
105
+ };
106
+ }
107
+ publishAll() { for (const threadId of [...this.snapshots.keys()])
108
+ this.publish(threadId); }
109
+ publish(threadId) {
110
+ const previous = this.snapshots.get(threadId);
111
+ this.snapshots.delete(threadId);
112
+ const next = this.getSnapshot(threadId);
113
+ if (previous && JSON.stringify(previous) === JSON.stringify(next)) {
114
+ this.snapshots.set(threadId, previous);
115
+ return;
116
+ }
117
+ this.listeners.get(threadId)?.forEach(listener => listener());
118
+ }
119
+ close() {
120
+ return this.closing ??= (async () => {
121
+ this.closed = true;
122
+ this.off.splice(0).forEach(off => off());
123
+ this.connection.close();
124
+ this.readers.close();
125
+ await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
126
+ this.listeners.clear();
127
+ this.snapshots.clear();
128
+ })();
129
+ }
130
+ }
@@ -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 this.retry(record.hash);
74
+ return message;
64
75
  }
65
76
  retry(hash) {
66
77
  if (this.closed)
@@ -26,3 +26,5 @@ export * from "./messageStore.js";
26
26
  export * from "./messageClient.js";
27
27
  export * from "./recordCache.js";
28
28
  export * from "./durableSender.js";
29
+ export * from "./chatSession.js";
30
+ export * from "./messageReaders.js";
@@ -26,3 +26,5 @@ export * from "./messageStore.js";
26
26
  export * from "./messageClient.js";
27
27
  export * from "./recordCache.js";
28
28
  export * from "./durableSender.js";
29
+ export * from "./chatSession.js";
30
+ export * from "./messageReaders.js";
@@ -33,6 +33,7 @@ export declare class MessageClient<T extends StoredMessage> {
33
33
  private watches;
34
34
  private reads;
35
35
  private syncing;
36
+ private paging;
36
37
  private writes;
37
38
  private activeWrites;
38
39
  private writeTasks;
@@ -63,6 +64,7 @@ export declare class MessageClient<T extends StoredMessage> {
63
64
  messages: T[];
64
65
  hasEarlier: boolean;
65
66
  }>;
67
+ private fillWindow;
66
68
  revoke(thread: string): Promise<void>;
67
69
  close(): Promise<void>;
68
70
  }
@@ -7,6 +7,7 @@ export class MessageClient {
7
7
  watches = new Map();
8
8
  reads = new Map();
9
9
  syncing = new Map();
10
+ paging = new Map();
10
11
  writes = new Map();
11
12
  activeWrites = new Set();
12
13
  writeTasks;
@@ -55,6 +56,7 @@ export class MessageClient {
55
56
  await this.synchronize(thread);
56
57
  if (this.closed)
57
58
  throw new Error("MESSAGE_CLIENT_CLOSED");
59
+ await this.fillWindow(thread, limit);
58
60
  return this.reload(thread, limit);
59
61
  })().finally(() => { if (this.reads.get(key) === task)
60
62
  this.reads.delete(key); });
@@ -183,19 +185,33 @@ export class MessageClient {
183
185
  throw new Error("MESSAGE_ACCESS_REVOKED");
184
186
  if (!(await this.storage.read(thread, 1)).coverage?.complete)
185
187
  await this.synchronize(thread);
188
+ return this.fillWindow(thread, limit, before);
189
+ }
190
+ async fillWindow(thread, limit, before) {
186
191
  let page = await this.storage.read(thread, limit, before);
187
192
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
188
- const token = page.coverage.nextBefore;
189
- const remote = await this.options.transport.older(thread, token, this.abort.signal);
190
- if (this.closed || this.revoked.has(thread))
191
- throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
192
- if (remote.resetRequired) {
193
- await this.storage.removeThread(thread);
194
- throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
193
+ const pending = this.paging.get(thread);
194
+ if (pending) {
195
+ await pending;
196
+ page = await this.storage.read(thread, limit, before);
197
+ continue;
195
198
  }
196
- if (remote.nextBefore === token)
197
- throw new Error("MESSAGE_SYNC_NO_PROGRESS");
198
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
199
+ const token = page.coverage.nextBefore;
200
+ const task = (async () => {
201
+ const remote = await this.options.transport.older(thread, token, this.abort.signal);
202
+ if (this.closed || this.revoked.has(thread))
203
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
204
+ if (remote.resetRequired) {
205
+ await this.storage.removeThread(thread);
206
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
207
+ }
208
+ if (remote.nextBefore === token)
209
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
210
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
211
+ })().finally(() => { if (this.paging.get(thread) === task)
212
+ this.paging.delete(thread); });
213
+ this.paging.set(thread, task);
214
+ await task;
199
215
  page = await this.storage.read(thread, limit, before);
200
216
  }
201
217
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
@@ -0,0 +1,39 @@
1
+ export type MessageReadersPage = {
2
+ appId: string;
3
+ threadId: string;
4
+ messageId: string;
5
+ revision: number;
6
+ total: number;
7
+ profileIds: string[];
8
+ nextAfter: string | null;
9
+ resetRequired: boolean;
10
+ };
11
+ export type MessageReadersQuery = {
12
+ threadId: string;
13
+ messageId: string;
14
+ afterProfileId?: string;
15
+ revision?: number;
16
+ limit?: number;
17
+ };
18
+ export type MessageReadersOptions = {
19
+ appId: string;
20
+ profileId: string;
21
+ token: string;
22
+ hubHttpUrl: string;
23
+ fetch?: typeof fetch;
24
+ };
25
+ export declare class MessageReadersError extends Error {
26
+ readonly status: number;
27
+ readonly forbidden: boolean;
28
+ constructor(status: number, message: string);
29
+ }
30
+ /** Authenticated detail reads; concurrent identical requests share one transport call. */
31
+ export declare class MessageReadersClient {
32
+ private options;
33
+ private pending;
34
+ private lifetime;
35
+ constructor(options: MessageReadersOptions);
36
+ getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
37
+ private request;
38
+ close(): void;
39
+ }
@@ -0,0 +1,71 @@
1
+ export class MessageReadersError extends Error {
2
+ status;
3
+ forbidden;
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.forbidden = status === 401 || status === 403;
8
+ }
9
+ }
10
+ /** Authenticated detail reads; concurrent identical requests share one transport call. */
11
+ export class MessageReadersClient {
12
+ options;
13
+ pending = new Map();
14
+ lifetime = new AbortController();
15
+ constructor(options) { this.options = { ...options }; }
16
+ getReaders(query, signal) {
17
+ if (this.lifetime.signal.aborted)
18
+ return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
19
+ if (signal?.aborted)
20
+ return Promise.reject(signal.reason);
21
+ const input = { ...query, limit: query.limit ?? 50 };
22
+ if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100 || (input.revision !== undefined && (!Number.isSafeInteger(input.revision) || input.revision < 0)))
23
+ return Promise.reject(new Error("INVALID_MESSAGE_READERS_QUERY"));
24
+ const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
25
+ let task = this.pending.get(key);
26
+ if (!task) {
27
+ task = this.request(input).finally(() => { if (this.pending.get(key) === task)
28
+ this.pending.delete(key); });
29
+ this.pending.set(key, task);
30
+ }
31
+ if (!signal)
32
+ return task;
33
+ // Cancelling one view must not cancel another view's shared request.
34
+ return new Promise((resolve, reject) => {
35
+ const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
36
+ signal.addEventListener("abort", abort, { once: true });
37
+ task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
38
+ });
39
+ }
40
+ async request(input) {
41
+ const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
42
+ const controller = new AbortController();
43
+ const cancel = () => controller.abort(this.lifetime.signal.reason);
44
+ this.lifetime.signal.addEventListener("abort", cancel, { once: true });
45
+ const timeout = setTimeout(() => controller.abort(), 8000);
46
+ try {
47
+ const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
48
+ method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
49
+ signal: controller.signal, redirect: "error", cache: "no-store",
50
+ });
51
+ if (!response.ok)
52
+ throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
53
+ const envelope = await response.json(), data = envelope.data;
54
+ if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
55
+ || !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
56
+ || !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
57
+ || data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
58
+ || typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
59
+ || (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
60
+ throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
61
+ if (this.lifetime.signal.aborted)
62
+ throw new Error("MESSAGE_READERS_CLOSED");
63
+ return data;
64
+ }
65
+ finally {
66
+ clearTimeout(timeout);
67
+ this.lifetime.signal.removeEventListener("abort", cancel);
68
+ }
69
+ }
70
+ close() { this.lifetime.abort(); this.pending.clear(); }
71
+ }
@@ -102,8 +102,9 @@ function resolveObservedReads(current) {
102
102
  }
103
103
  if (message.recalled || message.from === next.profileId || !message.recipients.includes(next.profileId))
104
104
  continue;
105
+ // Visibility may include voice rows; incompatible observations never acknowledge them.
105
106
  if (message.readPolicy !== intent.readPolicy)
106
- throw new Error("READ_POLICY_MISMATCH");
107
+ continue;
107
108
  next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
108
109
  : recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
109
110
  }
@@ -47,6 +47,7 @@ export class IndexedDbRecordCache {
47
47
  }
48
48
  await this.reload();
49
49
  this.initialized = true;
50
+ this.listeners.forEach(listener => listener());
50
51
  })().catch(error => { this.ready = undefined; throw error; });
51
52
  return this.ready;
52
53
  }
@@ -70,7 +71,8 @@ export class IndexedDbRecordCache {
70
71
  }
71
72
  if (changed) {
72
73
  this.snapshotValue = [...this.values.values()];
73
- this.listeners.forEach(listener => listener());
74
+ if (this.initialized)
75
+ this.listeners.forEach(listener => listener());
74
76
  }
75
77
  }
76
78
  async upsert(incoming) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.0",
3
+ "version": "0.12.0-next.2",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",