@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.
- package/lib/runtime/chatSession.d.ts +4 -7
- package/lib/runtime/chatSession.js +1 -1
- package/lib/runtime/createChat.d.ts +1 -0
- package/lib/runtime/createChat.js +2 -0
- package/lib/runtime/durableSender.d.ts +19 -10
- package/lib/runtime/durableSender.js +115 -5
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +20 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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