@pmate/chat-sdk 0.12.0-next.68 → 0.12.0-next.69

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,280 @@
1
+ import { createMessagePresenter } from "../messageView.js";
2
+ import { createChat as createSession } from "../runtime/createChat.js";
3
+ import { asChatError } from "../runtime/chatError.js";
4
+ import { ChatError } from "../chatError.js";
5
+ export { ChatError } from "../chatError.js";
6
+ const emptyMessages = Object.freeze([]);
7
+ /** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
8
+ export class ChatEngine {
9
+ advanced;
10
+ options;
11
+ groups = new WeakMap();
12
+ group(value) {
13
+ if (!value)
14
+ return undefined;
15
+ let projected = this.groups.get(value);
16
+ if (!projected) {
17
+ const { versionNumber: _, ...visible } = value;
18
+ projected = visible;
19
+ this.groups.set(value, projected);
20
+ }
21
+ return projected;
22
+ }
23
+ present = createMessagePresenter();
24
+ redactedQuotes = new WeakMap();
25
+ messageLists = new WeakMap();
26
+ messages(items) {
27
+ let values = this.messageLists.get(items);
28
+ if (!values) {
29
+ const recalled = new Set(items.filter(item => item.recalled).map(item => item.id));
30
+ values = items.map(item => {
31
+ const value = this.present(item);
32
+ if (!value.quote?.messageId || !recalled.has(value.quote.messageId))
33
+ return value;
34
+ let hidden = this.redactedQuotes.get(value);
35
+ if (!hidden) {
36
+ hidden = { ...value, quote: { ...value.quote, content: "", recalled: true } };
37
+ this.redactedQuotes.set(value, hidden);
38
+ }
39
+ return hidden;
40
+ });
41
+ this.messageLists.set(items, values);
42
+ }
43
+ return values;
44
+ }
45
+ views = new Map();
46
+ summary;
47
+ targets = new Map();
48
+ errors = new Map();
49
+ listeners = new Map();
50
+ closed = false;
51
+ constructor(advanced, options) {
52
+ this.advanced = advanced;
53
+ this.options = options;
54
+ }
55
+ async operation(run) { try {
56
+ return await run();
57
+ }
58
+ catch (error) {
59
+ throw asChatError(error);
60
+ } }
61
+ async deny(threadId, failure) {
62
+ this.errors.set(threadId, failure);
63
+ this.targets.delete(threadId);
64
+ const retiring = this.advanced.revokeAccess();
65
+ this.notify(threadId);
66
+ try {
67
+ await retiring;
68
+ }
69
+ catch (error) {
70
+ failure.cause = new AggregateError([failure.cause, error], "Access withdrawn; cache cleanup incomplete");
71
+ }
72
+ }
73
+ threadOperation(threadId, run) {
74
+ return this.operation(async () => {
75
+ const denied = threadId ? this.errors.get(threadId) : undefined;
76
+ if (denied?.code === "access-denied")
77
+ throw denied;
78
+ try {
79
+ return await run();
80
+ }
81
+ catch (error) {
82
+ const failure = asChatError(error);
83
+ if (threadId && failure.code === "access-denied")
84
+ await this.deny(threadId, failure);
85
+ throw failure;
86
+ }
87
+ });
88
+ }
89
+ start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
90
+ /** Resolve a scoped ID locally, registering its target when needed. No remote group is created. */
91
+ resolveThreadId(target) { return this.getConversation(target); }
92
+ /** A trusted recent window. Source selection and synchronization belong to the SDK. */
93
+ getThread(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId), options); }
94
+ loadEarlier(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId, { earlier: true }), options); }
95
+ refreshThread(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId, { refresh: true }), options); }
96
+ readForCaller(run, { signal }) {
97
+ if (signal?.aborted)
98
+ return Promise.reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError"));
99
+ if (!signal)
100
+ return run();
101
+ // A caller's cancellation never aborts another subscriber's shared read.
102
+ return new Promise((resolve, reject) => {
103
+ const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); };
104
+ signal.addEventListener("abort", abort, { once: true });
105
+ run().then(value => { signal.removeEventListener("abort", abort); resolve(value); }, error => { signal.removeEventListener("abort", abort); reject(error); });
106
+ });
107
+ }
108
+ threadSubscribers = new Map();
109
+ openQueue = new Set();
110
+ opening = 0;
111
+ drainOpens() {
112
+ while (!this.closed && this.opening < 4 && this.openQueue.size) {
113
+ const id = this.openQueue.values().next().value;
114
+ this.openQueue.delete(id);
115
+ if (!this.threadSubscribers.has(id))
116
+ continue;
117
+ this.opening++;
118
+ void this.getThread(id).catch(() => { }).finally(() => { this.opening--; this.drainOpens(); });
119
+ }
120
+ }
121
+ /** Listen first, then open the local window; concurrent consumers share the initial read. */
122
+ subscribeThread(threadId, listener, options = {}) {
123
+ const off = this.subscribe(threadId, listener, options), count = this.threadSubscribers.get(threadId) ?? 0;
124
+ this.threadSubscribers.set(threadId, count + 1);
125
+ if (!count) {
126
+ this.openQueue.add(threadId);
127
+ this.drainOpens();
128
+ }
129
+ let disposed = false;
130
+ return () => {
131
+ if (disposed)
132
+ return;
133
+ disposed = true;
134
+ off();
135
+ const remaining = (this.threadSubscribers.get(threadId) ?? 1) - 1;
136
+ if (remaining)
137
+ this.threadSubscribers.set(threadId, remaining);
138
+ else {
139
+ this.threadSubscribers.delete(threadId);
140
+ this.openQueue.delete(threadId);
141
+ }
142
+ };
143
+ }
144
+ /** @deprecated Use resolveThreadId. Retained only for the migration window. */
145
+ getConversation(target) {
146
+ return this.operation(async () => {
147
+ const id = await this.advanced.getConversation(target);
148
+ const value = target.groupId !== undefined ? { kind: "group", targetId: target.groupId } : { kind: "dm", targetId: target.peerId };
149
+ this.targets.delete(id);
150
+ this.targets.set(id, value);
151
+ if (this.targets.size > 256)
152
+ this.targets.delete(this.targets.keys().next().value);
153
+ this.notify(id);
154
+ return id;
155
+ });
156
+ }
157
+ getSnapshot(threadId) {
158
+ if (threadId === undefined) {
159
+ const source = this.advanced.getSnapshot(), previous = this.summary;
160
+ const threads = source.threads.filter(thread => this.errors.get(thread.threadId)?.code !== "access-denied").map(thread => ({ threadId: thread.threadId, lastMessage: this.messages(this.advanced.getSnapshot(thread.threadId).timeline).at(-1), group: this.group(thread.group), unread: thread.unread, outgoingCount: thread.outgoingCount }));
161
+ const unreadMessageCount = source.unreadMessageCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.unreadMessageCount ?? 0), 0);
162
+ const badgeCount = source.badgeCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.badgeCount ?? 0), 0);
163
+ if (previous && previous.connection === source.connection && previous.badgeCount === badgeCount && previous.unreadMessageCount === unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
164
+ return previous;
165
+ return this.summary = { threads, connection: source.connection, badgeCount, unreadMessageCount };
166
+ }
167
+ const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
168
+ const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
169
+ const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
170
+ const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
171
+ // Partial migrated rows never masquerade as a trusted recent window.
172
+ const next = { threadId, conversation, messages: !denied && source.complete ? this.messages(source.timeline) : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : this.group(source.group), readCounts: denied ? undefined : source.readCounts, error, status: !denied && source.complete ? "ready" : error ? "error" : "pending" };
173
+ if (previous && Object.keys(next).every(key => next[key] === previous[key]))
174
+ return previous;
175
+ this.views.set(threadId, next);
176
+ return next;
177
+ }
178
+ async getMessages(threadId, query = {}) {
179
+ try {
180
+ if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
181
+ throw new ChatError("invalid-input", "Unsupported message query");
182
+ const page = await this.advanced.getMessages(threadId, query);
183
+ if (!page.complete)
184
+ throw new ChatError("unavailable", "Messages are not ready; retry when connected");
185
+ this.errors.delete(threadId);
186
+ this.notify(threadId);
187
+ return this.getSnapshot(threadId);
188
+ }
189
+ catch (error) {
190
+ const failure = asChatError(error);
191
+ if (!this.closed) {
192
+ if (failure.code === "access-denied")
193
+ await this.deny(threadId, failure);
194
+ else {
195
+ this.errors.set(threadId, failure);
196
+ this.notify(threadId);
197
+ }
198
+ }
199
+ throw failure;
200
+ }
201
+ }
202
+ sendMessage(threadId, content, options = {}) {
203
+ return this.threadOperation(threadId, async () => {
204
+ if (content && ("groupCreate" in content || "groupUpdate" in content))
205
+ throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
206
+ const result = await this.advanced.sendMessage(threadId, content, options);
207
+ return { id: result.hash, timestamp: result.t };
208
+ });
209
+ }
210
+ retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
211
+ subscribe(target, callback, options = {}) {
212
+ const global = typeof target === "function", key = global ? "" : target;
213
+ const config = global ? callback : options;
214
+ const read = () => global ? this.getSnapshot() : this.getSnapshot(key);
215
+ let previous = read();
216
+ const emit = () => {
217
+ const value = read();
218
+ const fields = config?.fields;
219
+ const changed = fields ? fields.some(field => previous[field] !== value[field]) : value !== previous;
220
+ previous = value;
221
+ if (changed) {
222
+ try {
223
+ (global ? target : callback)(value);
224
+ }
225
+ catch (error) {
226
+ try {
227
+ this.options.onError(error);
228
+ }
229
+ catch { /* A consumer cannot break other consumers or cache publication. */ }
230
+ }
231
+ }
232
+ };
233
+ let listeners = this.listeners.get(key);
234
+ if (!listeners) {
235
+ listeners = new Set();
236
+ this.listeners.set(key, listeners);
237
+ }
238
+ listeners.add(emit);
239
+ const off = global ? this.advanced.subscribeChanges(emit) : this.advanced.subscribeChanges(key, emit, this.sessionFields(options.fields));
240
+ const dispose = () => { off(); listeners.delete(emit); if (!listeners.size)
241
+ this.listeners.delete(key); };
242
+ if (config?.emitCurrent !== false) {
243
+ try {
244
+ (global ? target : callback)(previous);
245
+ }
246
+ catch (error) {
247
+ dispose();
248
+ throw error;
249
+ }
250
+ }
251
+ return dispose;
252
+ }
253
+ sessionFields(fields) {
254
+ return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" || field === "status" ? ["complete"] : field === "conversation" ? ["messages", "group"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
255
+ }
256
+ notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
257
+ observeMessages(threadId, ids, event) { return this.threadOperation(threadId, async () => { await this.advanced.observeMessages(threadId, ids, event); }); }
258
+ markRead(threadId) { return this.threadOperation(threadId, async () => { await this.advanced.markRead(threadId); }); }
259
+ getReaders(threadId, messageId, page = {}, signal) {
260
+ return this.threadOperation(threadId, async () => {
261
+ if (Object.keys(page).some(key => !["afterProfileId", "revision"].includes(key)))
262
+ throw new ChatError("invalid-input", "Unsupported reader query");
263
+ return this.advanced.getReaders(threadId, messageId, page, signal);
264
+ });
265
+ }
266
+ watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
267
+ handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
268
+ getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
269
+ getGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.getGroup(threadId, options))); }
270
+ createGroup(input) {
271
+ return this.operation(async () => {
272
+ const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
273
+ const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
274
+ return { ...this.group(group), threadId };
275
+ });
276
+ }
277
+ updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
278
+ revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
279
+ close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.threadSubscribers.clear(); this.openQueue.clear(); this.views.clear(); this.summary = undefined; this.present = createMessagePresenter(); this.messageLists = new WeakMap(); this.groups = new WeakMap(); this.redactedQuotes = new WeakMap(); }); }
280
+ }
@@ -0,0 +1,4 @@
1
+ import type { Chat } from "../chatContract.js";
2
+ import type { ChatEngine } from "./chatEngine.js";
3
+ export declare function registerChat(chat: Chat, engine: ChatEngine): void;
4
+ export declare function chatEngine(chat: Chat): ChatEngine;
@@ -0,0 +1,4 @@
1
+ const engines = new WeakMap();
2
+ export function registerChat(chat, engine) { engines.set(chat, engine); }
3
+ export function chatEngine(chat) { const engine = engines.get(chat); if (!engine)
4
+ throw new TypeError("Unknown Chat client"); return engine; }
@@ -0,0 +1,4 @@
1
+ import type { Chat } from "../chatContract.js";
2
+ import { type CreateChatOptions } from "../runtime/createChat.js";
3
+ /** Build only the public capabilities; no aliases or engine fields on the returned object. */
4
+ export declare function createPublicChat(options: CreateChatOptions): Chat;
@@ -0,0 +1,52 @@
1
+ import { waitForCaller } from "../credentials.js";
2
+ import { ChatEngine } from "./chatEngine.js";
3
+ import { registerChat } from "./chatRegistry.js";
4
+ import { createChat as createSession } from "../runtime/createChat.js";
5
+ import { asChatError } from "../runtime/chatError.js";
6
+ import { ChatError } from "../chatError.js";
7
+ /** Build only the public capabilities; no aliases or engine fields on the returned object. */
8
+ export function createPublicChat(options) {
9
+ const configured = { ...options, onError: (error) => options.onError?.(asChatError(error)) };
10
+ let session;
11
+ try {
12
+ session = createSession(configured);
13
+ }
14
+ catch (error) {
15
+ throw asChatError(error);
16
+ }
17
+ const engine = new ChatEngine(session, configured);
18
+ const keys = (value, allowed) => { if (Object.keys(value).some(key => !allowed.includes(key)))
19
+ throw new ChatError("invalid-input", "Unsupported operation options"); };
20
+ const read = (run, options = {}, cleanup) => {
21
+ try {
22
+ keys(options, ["signal"]);
23
+ options.signal?.throwIfAborted();
24
+ return waitForCaller(run(), options.signal, cleanup);
25
+ }
26
+ catch (error) {
27
+ return Promise.reject(error);
28
+ }
29
+ };
30
+ const chat = Object.freeze({
31
+ start: () => engine.start(), close: () => engine.close(),
32
+ resolveThreadId: (target, options = {}) => read(() => engine.resolveThreadId(target), options),
33
+ getThread: (id, options) => read(() => engine.getThread(id), options), loadEarlier: (id, options) => read(() => engine.loadEarlier(id), options), refreshThread: (id, options) => read(() => engine.refreshThread(id), options),
34
+ subscribeThread: (id, listener, options = {}) => { keys(options, ["fields"]); return engine.subscribeThread(id, listener, options); }, subscribeChat: (listener, options = {}) => { keys(options, ["fields"]); return engine.subscribe(listener, options); },
35
+ sendMessage: (id, content, options) => engine.sendMessage(id, content, options), retryMessage: id => engine.retryMessage(id),
36
+ observeMessages: (id, ids, event) => engine.observeMessages(id, ids, event), markRead: id => engine.markRead(id), watchReaders: (id, ids, receiver) => engine.watchReaders(id, ids, receiver),
37
+ getReaders: (id, messageId, options = {}) => {
38
+ try {
39
+ keys(options, ["afterProfileId", "revision", "signal"]);
40
+ }
41
+ catch (error) {
42
+ return Promise.reject(error);
43
+ }
44
+ return read(() => engine.getReaders(id, messageId, { afterProfileId: options.afterProfileId, revision: options.revision }), { signal: options.signal });
45
+ },
46
+ getVoiceAttachment: (id, messageId, options = {}) => read(() => engine.getVoiceAttachment(id, messageId), options, lease => lease.release()),
47
+ getGroup: (id, options = {}) => read(() => engine.getGroup(id), options), refreshGroup: (id, options = {}) => read(() => engine.getGroup(id, { refresh: true }), options),
48
+ createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes),
49
+ });
50
+ registerChat(chat, engine);
51
+ return chat;
52
+ }
@@ -1,37 +1,6 @@
1
1
  import type { TimelineItem } from "./runtime/messageTimeline.js";
2
2
  import type { CachedChatMessage } from "./runtime/cachedMessage.js";
3
- import type { MessageQuote } from "./runtime/outgoingContent.js";
4
- import type { ChatMention } from "./mentions/model.js";
5
- export type ChatMessage = Readonly<Omit<TimelineItem<CachedChatMessage>, "message" | "reactionEvents"> & {
6
- kind: "text" | "image" | "voice" | "emoji" | "quote" | "invitation" | "system" | "attachment";
7
- text: string;
8
- senderName?: string;
9
- senderAvatar?: string;
10
- timeLabel?: string;
11
- image?: Readonly<{
12
- url: string;
13
- name?: string;
14
- }>;
15
- voice?: Readonly<{
16
- url: string;
17
- durationMs: number;
18
- transcript?: string;
19
- }>;
20
- quote?: Readonly<MessageQuote & {
21
- recalled?: boolean;
22
- }>;
23
- attachment?: Readonly<{
24
- name?: string;
25
- size?: string;
26
- }>;
27
- /** Explicit versioned content, interpreted with SDK readMessageContent. */
28
- richContent?: unknown;
29
- mentions?: readonly ChatMention[];
30
- recipientCount?: number;
31
- system?: Readonly<{
32
- type: "group-created" | "group-updated" | "contact-updated";
33
- title?: string;
34
- }>;
35
- }>;
3
+ import type { ChatMessage } from "./chatTypes.js";
4
+ export type { ChatMessage } from "./chatTypes.js";
36
5
  /** Decode a body once; reading and delivery projections reuse its normalized content. */
37
6
  export declare function createMessagePresenter(): (item: TimelineItem<CachedChatMessage>) => ChatMessage;
@@ -16,6 +16,7 @@ export type ChatClientOptions = {
16
16
  onNotification?: OnlineNotificationHandler;
17
17
  profileId?: string;
18
18
  botKey?: PmateBotKeyFile | string | unknown;
19
+ getToken?: () => Promise<string>;
19
20
  session?: BotLoginSession;
20
21
  hubUrl?: string;
21
22
  authBaseUrl?: string;
@@ -161,7 +161,7 @@ export class ChatClient {
161
161
  catch (error) {
162
162
  if (generation !== this.generation)
163
163
  return;
164
- if (error instanceof ChatAckError) {
164
+ if (error instanceof ChatAckError || ["authentication-required", "access-denied"].includes(error?.code ?? "")) {
165
165
  this.close();
166
166
  this.setConnection("authentication_required");
167
167
  }
@@ -295,7 +295,12 @@ export class ChatClient {
295
295
  });
296
296
  }
297
297
  async sendAuth() {
298
- const token = this.session?.token;
298
+ const generation = this.generation;
299
+ const token = this.options.getToken ? await this.options.getToken() : this.session?.token;
300
+ if (generation !== this.generation)
301
+ throw new Error("ChatClient closed");
302
+ if (this.session && token)
303
+ this.session = { ...this.session, token };
299
304
  if (!token) {
300
305
  throw new Error("Missing PMate session token");
301
306
  }
@@ -1,12 +1,3 @@
1
- export type ChatErrorCode = "unavailable" | "access-denied" | "storage-unavailable" | "invalid-input" | "conflict" | "closed" | "busy";
2
- /** Stable recovery categories. The original failure remains available as cause. */
3
- export declare class ChatError extends Error {
4
- readonly code: ChatErrorCode;
5
- readonly retryable: boolean;
6
- readonly status?: number;
7
- constructor(code: ChatErrorCode, message: string, options?: {
8
- cause?: unknown;
9
- status?: number;
10
- });
11
- }
1
+ import { ChatError } from "../chatError.js";
2
+ export { ChatError, type ChatErrorCode } from "../chatError.js";
12
3
  export declare function asChatError(error: unknown): ChatError;
@@ -1,22 +1,11 @@
1
- /** Stable recovery categories. The original failure remains available as cause. */
2
- export class ChatError extends Error {
3
- code;
4
- retryable;
5
- status;
6
- constructor(code, message, options = {}) {
7
- super(message, { cause: options.cause });
8
- this.code = code;
9
- this.name = "ChatError";
10
- this.status = options.status;
11
- this.retryable = ["unavailable", "storage-unavailable", "busy"].includes(code);
12
- }
13
- }
1
+ import { ChatError } from "../chatError.js";
2
+ export { ChatError } from "../chatError.js";
14
3
  export function asChatError(error) {
15
4
  if (error instanceof ChatError)
16
5
  return error;
17
6
  const value = error;
18
7
  const status = value?.status, message = value?.message ?? String(error);
19
- const code = status === 401 || status === 403 || /ACCESS_REVOKED|ACCESS_DENIED|SCOPE_CHANGED/.test(message) ? "access-denied"
8
+ const code = status === 401 ? "authentication-required" : status === 403 || /ACCESS_REVOKED|ACCESS_DENIED|SCOPE_CHANGED/.test(message) ? "access-denied"
20
9
  : /CLOSED/.test(message) ? "closed"
21
10
  : status === 409 || /REVISION_CONFLICT|PROJECTION_CHANGED/.test(message) ? "conflict"
22
11
  : /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
@@ -16,6 +16,7 @@ import { unreadInConversation } from "./readIndex.js";
16
16
  import type { StoredMessage } from "./messageStore.js";
17
17
  import type { ChatRuntimeMessage } from "./protocol.js";
18
18
  export type ChatSessionOptions<T extends StoredMessage> = {
19
+ onDispose?: () => void;
19
20
  messages: MessageClientOptions<T>;
20
21
  connection: ChatClientOptions;
21
22
  sharedConnection?: ChatClient;
@@ -907,6 +907,7 @@ export class ChatSession {
907
907
  throw failed.reason;
908
908
  }
909
909
  finally {
910
+ this.options.onDispose?.();
910
911
  this.listeners.clear();
911
912
  this.globalListeners.clear();
912
913
  this.readingListeners.clear();
@@ -1,3 +1,4 @@
1
+ import { type TokenProvider } from "../credentials.js";
1
2
  import { ChatSession } from "./chatSession.js";
2
3
  import type { ChatClient, ChatClientOptions } from "./chatClient.js";
3
4
  import { type CachedChatMessage } from "./cachedMessage.js";
@@ -7,7 +8,8 @@ export type CreateChatOptions = {
7
8
  accountId: string;
8
9
  profileId: string;
9
10
  teamId: string;
10
- token: string;
11
+ token?: string;
12
+ getToken?: TokenProvider;
11
13
  /** Limit reconnect reconciliation for a dedicated conversation document. */
12
14
  threadId?: string;
13
15
  uploadEndpoint?: string;
@@ -21,4 +23,6 @@ export type CreateChatOptions = {
21
23
  onReach?: ChatClientOptions["onReach"];
22
24
  };
23
25
  /** Standard browser entry: storage, paging, realtime and reading share one identity. */
24
- export declare function createChat(options: CreateChatOptions): ChatSession<CachedChatMessage>;
26
+ export declare function createChatSession(options: CreateChatOptions): ChatSession<CachedChatMessage>;
27
+ /** @deprecated Advanced compatibility factory. Use /chat createChat, or createChatSession for raw sessions. */
28
+ export declare const createChat: typeof createChatSession;
@@ -1,3 +1,4 @@
1
+ import { ChatCredentials } from "../credentials.js";
1
2
  import { voiceUploader } from "./voiceUpload.js";
2
3
  import { isOffline } from "./connectivity.js";
3
4
  import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
@@ -6,20 +7,26 @@ import { SocialReadClient } from "./socialReadClient.js";
6
7
  import { isValidChatTeamId } from "./thread.js";
7
8
  import { decodeCachedChatMessage } from "./cachedMessage.js";
8
9
  /** Standard browser entry: storage, paging, realtime and reading share one identity. */
9
- export function createChat(options) {
10
- const { appId, accountId, profileId, teamId, token, onError } = options;
10
+ export function createChatSession(options) {
11
+ const { appId, accountId, profileId, teamId, onError } = options;
12
+ const token = options.token ?? "";
13
+ if (!token && !options.getToken)
14
+ throw new Error("INVALID_CHAT_CREDENTIALS");
11
15
  if (!isValidChatTeamId(teamId))
12
16
  throw new Error("INVALID_CHAT_TEAM");
13
17
  const endpoint = (options.endpoint ?? "https://hub.pmate.chat").replace(/\/+$/, "");
14
18
  const http = endpoint.replace(/^ws/, "http"), ws = endpoint.replace(/^http/, "ws");
15
- const reader = new SocialReadClient({ appId, profileId, token, hubHttpUrl: http, fetch: options.fetch });
19
+ const credentials = options.getToken ? new ChatCredentials(options.getToken, new URL(http).origin, options.fetch) : undefined;
20
+ const fetcher = credentials?.fetch ?? options.fetch;
21
+ const reader = new SocialReadClient({ appId, profileId, token, hubHttpUrl: http, fetch: fetcher });
16
22
  const page = async (threadId, signal, cursor = {}) => {
17
23
  const result = await reader.messagePage({ threadId, teamId, limit: 100, ...cursor }, signal);
18
24
  return { ...result, messages: result.messages.map(message => ({ ...message, id: message.messageId, timestamp: message.serverCreatedAt })) };
19
25
  };
20
26
  return new ChatSession({
21
27
  sharedConnection: options.connection,
22
- uploadVoice: voiceUploader(profileId, options.uploadEndpoint, options.fetch),
28
+ onDispose: () => credentials?.close(),
29
+ uploadVoice: voiceUploader(profileId, options.uploadEndpoint, fetcher),
23
30
  reconcileOutgoing: async (record, signal) => {
24
31
  if (isOffline())
25
32
  throw new Error("OUTBOX_RECONCILIATION_OFFLINE");
@@ -35,8 +42,9 @@ export function createChat(options) {
35
42
  loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
36
43
  activeThreadId: options.threadId,
37
44
  messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
38
- reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
39
- connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
45
+ reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: fetcher, onError },
46
+ connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: fetcher,
47
+ getToken: credentials?.getToken,
40
48
  session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
41
49
  readingCandidate: message => {
42
50
  if (isReadingControl(message.body, appId))
@@ -50,3 +58,5 @@ export function createChat(options) {
50
58
  decode: message => decodeCachedChatMessage(message, { appId, profileId, teamId }), onError,
51
59
  });
52
60
  }
61
+ /** @deprecated Advanced compatibility factory. Use /chat createChat, or createChatSession for raw sessions. */
62
+ export const createChat = createChatSession;
@@ -51,7 +51,7 @@ export class ReadCountsClient {
51
51
  const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
52
52
  const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
53
53
  if (!response.ok)
54
- throw new Error(`READ_COUNTS_HTTP_${response.status}`);
54
+ throw Object.assign(new Error(`READ_COUNTS_HTTP_${response.status}`), { status: response.status });
55
55
  const envelope = await response.json();
56
56
  if (envelope.success !== true)
57
57
  throw new Error("INVALID_READ_COUNTS_RESPONSE");
@@ -40,7 +40,7 @@ export class ReadSyncClient {
40
40
  ...init, signal: timeout.signal, headers: { ...init.headers, authorization: `Bearer ${this.options.sessionToken}` },
41
41
  });
42
42
  if (!response.ok)
43
- throw new Error(`READ_SYNC_HTTP_${response.status}`);
43
+ throw Object.assign(new Error(`READ_SYNC_HTTP_${response.status}`), { status: response.status });
44
44
  const envelope = await response.json();
45
45
  if (envelope.success !== true || envelope.data === undefined)
46
46
  throw new Error("INVALID_READ_SYNC_RESPONSE");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.68",
3
+ "version": "0.12.0-next.69",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,6 +33,21 @@
33
33
  "types": "./lib/chat.d.ts",
34
34
  "import": "./lib/chat.js",
35
35
  "default": "./lib/chat.js"
36
+ },
37
+ "./advanced": {
38
+ "types": "./lib/advanced.d.ts",
39
+ "import": "./lib/advanced.js",
40
+ "default": "./lib/advanced.js"
41
+ },
42
+ "./framework": {
43
+ "types": "./lib/framework.d.ts",
44
+ "import": "./lib/framework.js",
45
+ "default": "./lib/framework.js"
46
+ },
47
+ "./history": {
48
+ "types": "./lib/history.d.ts",
49
+ "import": "./lib/history.js",
50
+ "default": "./lib/history.js"
36
51
  }
37
52
  },
38
53
  "publishConfig": {