@pmate/chat-sdk 0.12.0-next.42 → 0.12.0-next.43

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.
@@ -5,17 +5,18 @@ import { type ReadCountsResult } from "./readCounts.js";
5
5
  import type { OnlineReachEvent } from "./reach.js";
6
6
  import { type MessageReadersQuery } from "./messageReaders.js";
7
7
  import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
8
- import { DurableChatSender } from "./durableSender.js";
8
+ import { DurableChatSender, type SendMessageInput, type DurableSenderOptions } from "./durableSender.js";
9
9
  import { ReadingClient, type ReadingClientOptions } from "./readingClient.js";
10
10
  import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
11
11
  import { unreadInConversation } from "./readIndex.js";
12
12
  import type { StoredMessage } from "./messageStore.js";
13
- import type { ChatBody, ChatRuntimeMessage } from "./protocol.js";
13
+ import type { ChatRuntimeMessage } from "./protocol.js";
14
14
  export type ChatSessionOptions<T extends StoredMessage> = {
15
15
  messages: MessageClientOptions<T>;
16
16
  connection: ChatClientOptions;
17
17
  sharedConnection?: ChatClient;
18
18
  activeThreadId?: string;
19
+ uploadVoice?: DurableSenderOptions["uploadVoice"];
19
20
  reading: ReadingClientOptions;
20
21
  decode: (message: ChatRuntimeMessage) => T | undefined;
21
22
  readingCandidate?: (message: T) => ReadingCandidate | undefined;
@@ -116,11 +117,7 @@ export declare class ChatSession<T extends StoredMessage> {
116
117
  private rememberHistoryEdge;
117
118
  private readEarlier;
118
119
  private readAll;
119
- sendMessage(threadId: string, input: {
120
- to: string;
121
- body: ChatBody;
122
- recipients?: string[];
123
- }): Promise<ChatRuntimeMessage>;
120
+ sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
124
121
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
125
122
  retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
126
123
  observeMessages(threadId: string, ids: string[], event: "visible" | "played"): ReturnType<ReadingClient["recordVisible"]>;
@@ -69,7 +69,7 @@ export class ChatSession {
69
69
  } });
70
70
  this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
71
71
  this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
72
- this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess() });
72
+ this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice });
73
73
  this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
74
74
  options.onError(error); });
75
75
  this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
@@ -10,6 +10,7 @@ export type CreateChatOptions = {
10
10
  token: string;
11
11
  /** Limit reconnect reconciliation for a dedicated conversation document. */
12
12
  threadId?: string;
13
+ uploadEndpoint?: string;
13
14
  endpoint?: string;
14
15
  clientInstanceId?: string;
15
16
  fetch?: typeof fetch;
@@ -1,3 +1,4 @@
1
+ import { voiceUploader } from "./voiceUpload.js";
1
2
  import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
2
3
  import { ChatSession } from "./chatSession.js";
3
4
  import { SocialReadClient } from "./socialReadClient.js";
@@ -17,6 +18,7 @@ export function createChat(options) {
17
18
  };
18
19
  return new ChatSession({
19
20
  sharedConnection: options.connection,
21
+ uploadVoice: voiceUploader(profileId, options.uploadEndpoint, options.fetch),
20
22
  activeThreadId: options.threadId,
21
23
  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 },
22
24
  reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
@@ -9,6 +9,13 @@ export type OutgoingMessage = {
9
9
  status: "sending" | "failed" | "sent";
10
10
  confirmed?: boolean;
11
11
  accessVersion?: number;
12
+ voiceBlob?: Blob;
13
+ };
14
+ export type SendMessageInput = {
15
+ to: string;
16
+ body: ChatBody;
17
+ recipients?: string[];
18
+ voiceBlob?: Blob;
12
19
  };
13
20
  export type SenderScopeAccess = {
14
21
  version: number;
@@ -18,6 +25,7 @@ export type DurableSenderOptions = {
18
25
  indexedDB?: IDBFactory;
19
26
  name?: string;
20
27
  access?: () => Promise<SenderScopeAccess>;
28
+ uploadVoice?: (blob: Blob, signal: AbortSignal) => Promise<string>;
21
29
  };
22
30
  /** Persists the exact wire message before sending; retries preserve its hash. */
23
31
  export declare class DurableChatSender {
@@ -27,6 +35,11 @@ export declare class DurableChatSender {
27
35
  private db;
28
36
  private outbox;
29
37
  private epochs;
38
+ private localUrls;
39
+ private localViews;
40
+ private uploads;
41
+ private uploadTail;
42
+ private uploadWaiting;
30
43
  private version;
31
44
  private withdrawn;
32
45
  private revoking?;
@@ -59,20 +72,16 @@ export declare class DurableChatSender {
59
72
  snapshot(threadId?: string): readonly OutgoingMessage[];
60
73
  private emit;
61
74
  /** Resolves after the exact wire message is durable, independently of network delivery. */
62
- enqueue(threadId: string, input: {
63
- to: string;
64
- body: ChatBody;
65
- recipients?: string[];
66
- }): Promise<ChatRuntimeMessage>;
75
+ enqueue(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
67
76
  /** Compatibility API: resolves only after transport delivery. */
68
- send(threadId: string, input: {
69
- to: string;
70
- body: ChatBody;
71
- recipients?: string[];
72
- }): Promise<ChatRuntimeMessage>;
77
+ send(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
73
78
  private persistOutgoing;
74
79
  retry(hash: string): Promise<ChatRuntimeMessage>;
75
80
  private transmit;
81
+ private releaseUrl;
82
+ private releaseUrls;
83
+ private localView;
84
+ private prepareVoice;
76
85
  private save;
77
86
  /** Confirm only after the authoritative message has been durably merged.
78
87
  * A tombstone prevents another tab's late failure from recreating the outbox. */
@@ -9,6 +9,11 @@ export class DurableChatSender {
9
9
  db;
10
10
  outbox;
11
11
  epochs;
12
+ localUrls = new Map();
13
+ localViews = new WeakMap();
14
+ uploads = new Set();
15
+ uploadTail = Promise.resolve();
16
+ uploadWaiting = 0;
12
17
  version = 0;
13
18
  withdrawn = false;
14
19
  revoking;
@@ -132,6 +137,8 @@ export class DurableChatSender {
132
137
  /** Stop disclosure immediately; durable invalidation is coordinated separately. */
133
138
  withdraw() {
134
139
  this.withdrawn = true;
140
+ this.uploads.forEach(controller => controller.abort());
141
+ this.releaseUrls();
135
142
  this.values.clear();
136
143
  this.emit();
137
144
  }
@@ -165,7 +172,7 @@ export class DurableChatSender {
165
172
  return this.empty;
166
173
  let snapshot = this.snapshots.get(threadId);
167
174
  if (!snapshot) {
168
- snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
175
+ snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId).map(record => this.localView(record));
169
176
  this.snapshots.set(threadId, snapshot);
170
177
  }
171
178
  return snapshot;
@@ -193,7 +200,12 @@ export class DurableChatSender {
193
200
  const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
194
201
  if (message.from !== this.identity.profileId)
195
202
  throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
196
- const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
203
+ if (input.voiceBlob) {
204
+ const body = message.body;
205
+ if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
206
+ throw new Error("INVALID_VOICE_ATTACHMENT");
207
+ }
208
+ const record = { ...(input.voiceBlob ? { voiceBlob: input.voiceBlob } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
197
209
  await this.withEpoch("rw", async () => { await this.outbox.put(record); });
198
210
  await this.checkAccess();
199
211
  this.values.set(record.hash, record);
@@ -215,7 +227,7 @@ export class DurableChatSender {
215
227
  }
216
228
  async transmit(hash) {
217
229
  await this.ready;
218
- const record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
230
+ let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
219
231
  if (!record || this.closed || this.withdrawn)
220
232
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
221
233
  if (record.status === "sent" || record.confirmed)
@@ -232,6 +244,14 @@ export class DurableChatSender {
232
244
  this.emit();
233
245
  }
234
246
  try {
247
+ if (record.voiceBlob) {
248
+ record = await this.prepareVoice(record);
249
+ if (record.confirmed || record.status === "sent")
250
+ return record.message;
251
+ }
252
+ await this.checkAccess();
253
+ if (this.closing)
254
+ throw new Error("CHAT_SENDER_CLOSED");
235
255
  const message = await this.client.sendPrepared(record.message);
236
256
  const sent = { ...record, message, status: "sent" };
237
257
  const committed = await this.save(sent);
@@ -262,11 +282,92 @@ export class DurableChatSender {
262
282
  throw error;
263
283
  }
264
284
  }
285
+ releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
286
+ URL.revokeObjectURL(value.url);
287
+ this.localUrls.delete(hash);
288
+ } }
289
+ releaseUrls() { for (const hash of this.localUrls.keys())
290
+ this.releaseUrl(hash); }
291
+ localView(record) {
292
+ if (!record.voiceBlob || typeof URL.createObjectURL !== "function")
293
+ return record;
294
+ let cached = this.localViews.get(record);
295
+ if (cached)
296
+ return cached;
297
+ let local = this.localUrls.get(record.hash);
298
+ if (!local || local.blob !== record.voiceBlob) {
299
+ this.releaseUrl(record.hash);
300
+ local = { blob: record.voiceBlob, url: URL.createObjectURL(record.voiceBlob) };
301
+ this.localUrls.set(record.hash, local);
302
+ }
303
+ cached = { ...record, message: withVoiceUrl(record.message, local.url) };
304
+ this.localViews.set(record, cached);
305
+ return cached;
306
+ }
307
+ async prepareVoice(record) {
308
+ if (this.uploadWaiting >= 32)
309
+ throw new Error("VOICE_UPLOAD_BACKPRESSURE");
310
+ this.uploadWaiting++;
311
+ const previous = this.uploadTail;
312
+ let release;
313
+ this.uploadTail = new Promise(resolve => { release = resolve; });
314
+ try {
315
+ await previous;
316
+ await this.checkAccess();
317
+ if (this.closing)
318
+ throw new Error("CHAT_SENDER_CLOSED");
319
+ const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
320
+ if (!latest)
321
+ throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
322
+ if (!latest.voiceBlob || latest.confirmed || latest.status === "sent")
323
+ return latest;
324
+ if (!this.options.uploadVoice)
325
+ throw new Error("VOICE_UPLOAD_UNAVAILABLE");
326
+ const controller = new AbortController();
327
+ this.uploads.add(controller);
328
+ const timeout = setTimeout(() => controller.abort(), 30_000);
329
+ let url;
330
+ try {
331
+ url = await this.options.uploadVoice(latest.voiceBlob, controller.signal);
332
+ }
333
+ finally {
334
+ clearTimeout(timeout);
335
+ this.uploads.delete(controller);
336
+ }
337
+ const parsed = new URL(url);
338
+ if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
339
+ throw new Error("INVALID_VOICE_UPLOAD_URL");
340
+ await this.checkAccess();
341
+ if (this.closing)
342
+ throw new Error("CHAT_SENDER_CLOSED");
343
+ const { voiceBlob: _blob, ...uploaded } = latest;
344
+ const committed = await this.withEpoch("rw", async () => {
345
+ const current = await this.outbox.get([this.scope, record.hash]);
346
+ if (!current || current.confirmed || current.status === "sent" || !current.voiceBlob)
347
+ return current;
348
+ const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
349
+ await this.outbox.put(next);
350
+ return next;
351
+ });
352
+ if (!committed)
353
+ throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
354
+ this.values.set(record.hash, committed);
355
+ this.emit();
356
+ return committed;
357
+ }
358
+ finally {
359
+ this.uploadWaiting--;
360
+ release();
361
+ }
362
+ }
265
363
  async save(record) {
266
364
  return this.withEpoch("rw", async () => {
267
365
  const previous = await this.outbox.get([this.scope, record.hash]);
268
366
  if (!previous || previous.confirmed || previous.status === "sent")
269
367
  return previous;
368
+ // A second document may already have durably replaced the Blob with its URL.
369
+ if (record.voiceBlob && !previous.voiceBlob)
370
+ return previous;
270
371
  await this.outbox.put(record);
271
372
  return record;
272
373
  });
@@ -280,13 +381,15 @@ export class DurableChatSender {
280
381
  await this.ready;
281
382
  await this.withEpoch("rw", async () => {
282
383
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
283
- const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
384
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, status: "sent", confirmed: true }));
284
385
  if (confirmed.length)
285
386
  await this.outbox.bulkPut(confirmed);
286
387
  });
287
388
  let changed = false;
288
- for (const hash of hashes)
389
+ for (const hash of hashes) {
289
390
  changed = this.values.delete(hash) || changed;
391
+ this.releaseUrl(hash);
392
+ }
290
393
  if (changed)
291
394
  this.emit();
292
395
  }
@@ -309,6 +412,7 @@ export class DurableChatSender {
309
412
  }
310
413
  async close() {
311
414
  this.closing = true;
415
+ this.uploads.forEach(controller => controller.abort());
312
416
  this.offConnection();
313
417
  this.listeners.clear();
314
418
  this.channel?.close();
@@ -319,9 +423,15 @@ export class DurableChatSender {
319
423
  }
320
424
  finally {
321
425
  this.closed = true;
426
+ this.releaseUrls();
322
427
  this.values.clear();
323
428
  this.snapshots.clear();
324
429
  this.db.close();
325
430
  }
326
431
  }
327
432
  }
433
+ function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
434
+ function withVoiceUrl(message, url) {
435
+ const body = message.body;
436
+ return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
437
+ }
@@ -0,0 +1,2 @@
1
+ /** The SDK owns upload I/O; hosts configure only their service endpoint. */
2
+ export declare function voiceUploader(profileId: string, endpoint?: string, fetcher?: typeof fetch): (blob: Blob, signal: AbortSignal) => Promise<string>;
@@ -0,0 +1,20 @@
1
+ /** The SDK owns upload I/O; hosts configure only their service endpoint. */
2
+ export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.chat", fetcher = globalThis.fetch) {
3
+ return async (blob, signal) => {
4
+ const bytes = new Uint8Array(await blob.arrayBuffer());
5
+ if (signal.aborted)
6
+ throw new DOMException("Upload aborted", "AbortError");
7
+ let binary = "";
8
+ for (let offset = 0; offset < bytes.length; offset += 32768)
9
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
10
+ const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("mpeg") ? "mp3" : "webm";
11
+ const type = extension === "m4a" ? "audio/mp4" : extension === "mp3" ? "audio/mpeg" : "audio/webm";
12
+ const response = await fetcher(`${endpoint.replace(/\/+$/, "")}/file`, { method: "POST", signal, headers: { "Content-Type": "application/json", "x-file-content-type": type }, body: JSON.stringify({ user: profileId, base64: btoa(binary), filename: `voice-${Date.now()}.${extension}` }) });
13
+ if (!response.ok)
14
+ throw new Error("VOICE_UPLOAD_FAILED");
15
+ const result = await response.json();
16
+ if (typeof result.data !== "string" || !result.data)
17
+ throw new Error("VOICE_UPLOAD_FAILED");
18
+ return result.data;
19
+ };
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.42",
3
+ "version": "0.12.0-next.43",
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",