@pmate/chat-sdk 0.12.0-next.22 → 0.12.0-next.24
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/README.md +7 -0
- package/lib/runtime/chatSession.js +11 -6
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/messageClient.js +12 -5
- package/lib/runtime/messageReaders.js +3 -0
- package/lib/runtime/readCounts.js +11 -1
- package/lib/runtime/readingClient.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -228,3 +228,10 @@ Custom primitive integrations must initialize them with the same authenticated
|
|
|
228
228
|
scope, dispose them on identity changes, and wire their transport to an authorized
|
|
229
229
|
server. Standard browser integrations should use the facade instead. The Merry migration and production validation
|
|
230
230
|
are tracked in the unified message layer proposal.
|
|
231
|
+
|
|
232
|
+
When the browser reports offline, scheduled message restoration and reading
|
|
233
|
+
refreshes wait for the normal reconnect/wake path. Cached reads remain available;
|
|
234
|
+
unknown receipt counts stay unknown and an uncached reader detail page rejects
|
|
235
|
+
without attempting transport. A network failure arriving after the browser went
|
|
236
|
+
offline does not turn background synchronization into an operation error.
|
|
237
|
+
Authorization failures still revoke the corresponding cached data.
|
|
@@ -97,7 +97,8 @@ export class ChatSession {
|
|
|
97
97
|
this.publishGlobal();
|
|
98
98
|
if (state === "connected") {
|
|
99
99
|
const since = Date.now();
|
|
100
|
-
this.catchUp.schedule("scope", () => this.
|
|
100
|
+
this.catchUp.schedule("scope", async () => { if (this.connectionState === "connected" && !this.closed && !this.withdrawn)
|
|
101
|
+
await this.messages.restore(since, options.activeThreadId); });
|
|
101
102
|
if (this.readingStarted)
|
|
102
103
|
this.reading.refresh();
|
|
103
104
|
this.wakeCounts();
|
|
@@ -140,7 +141,8 @@ export class ChatSession {
|
|
|
140
141
|
/** One-time migration only: never establishes trusted history coverage. */
|
|
141
142
|
importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
|
|
142
143
|
getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
|
|
143
|
-
subscribeReading(listener) { this.
|
|
144
|
+
subscribeReading(listener) { if (this.closed || this.withdrawn)
|
|
145
|
+
return () => { }; return this.reading.subscribe(listener); }
|
|
144
146
|
refreshReading() { this.assertOpen(); this.reading.refresh(); }
|
|
145
147
|
synchronizeReading(cursor, profileId) { this.assertOpen(); return this.reading.synchronize(cursor, profileId); }
|
|
146
148
|
/** Explicit text-history acknowledgement; unheard audio stays unread. */
|
|
@@ -265,9 +267,11 @@ export class ChatSession {
|
|
|
265
267
|
}
|
|
266
268
|
retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
|
|
267
269
|
observeMessages(threadId, ids, event, clientInstanceId) {
|
|
268
|
-
if (this.closed
|
|
269
|
-
|
|
270
|
-
|
|
270
|
+
if (this.closed || this.withdrawn) {
|
|
271
|
+
if (event === "receipts" && !threadId)
|
|
272
|
+
return Promise.resolve();
|
|
273
|
+
return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
|
|
274
|
+
}
|
|
271
275
|
if (event === "receipts") {
|
|
272
276
|
this.watchCounts(threadId, ids, clientInstanceId);
|
|
273
277
|
return Promise.resolve();
|
|
@@ -382,7 +386,8 @@ export class ChatSession {
|
|
|
382
386
|
return snapshot;
|
|
383
387
|
}
|
|
384
388
|
subscribe(target, listener, fields) {
|
|
385
|
-
this.
|
|
389
|
+
if (this.closed || this.withdrawn)
|
|
390
|
+
return () => { };
|
|
386
391
|
if (typeof target === "function") {
|
|
387
392
|
this.globalListeners.add(target);
|
|
388
393
|
return () => { this.globalListeners.delete(target); };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Browser connectivity is advisory: only suppress network errors while the
|
|
2
|
+
* browser explicitly reports offline. Permission and data errors stay visible. */
|
|
3
|
+
export declare function isOffline(): boolean;
|
|
4
|
+
export declare function isOfflineNetworkFailure(error: unknown): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Browser connectivity is advisory: only suppress network errors while the
|
|
2
|
+
* browser explicitly reports offline. Permission and data errors stay visible. */
|
|
3
|
+
export function isOffline() {
|
|
4
|
+
return typeof navigator !== "undefined" && navigator.onLine === false;
|
|
5
|
+
}
|
|
6
|
+
export function isOfflineNetworkFailure(error) {
|
|
7
|
+
return isOffline() && (error instanceof TypeError || error?.name === "AbortError");
|
|
8
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
1
2
|
import { WindowedTasks } from "./coalescer.js";
|
|
2
3
|
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
3
4
|
/** Local-first message facade; hosts never select a cache or network source. */
|
|
@@ -48,7 +49,8 @@ export class MessageClient {
|
|
|
48
49
|
this.invalidate(thread);
|
|
49
50
|
else
|
|
50
51
|
options.onError?.(error, thread); });
|
|
51
|
-
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) =>
|
|
52
|
+
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => { if (!isOfflineNetworkFailure(error))
|
|
53
|
+
options.onError?.(error, thread); });
|
|
52
54
|
if (typeof BroadcastChannel !== "undefined") {
|
|
53
55
|
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
|
|
54
56
|
this.channel.onmessage = event => {
|
|
@@ -109,15 +111,17 @@ export class MessageClient {
|
|
|
109
111
|
subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
|
|
110
112
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
111
113
|
async restore(since, threadId) {
|
|
114
|
+
if (this.closed || this.closing || this.scopeDenied || isOffline())
|
|
115
|
+
return;
|
|
112
116
|
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
|
|
113
117
|
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
114
|
-
while (queue.length && !this.closed && !this.closing) {
|
|
118
|
+
while (queue.length && !this.closed && !this.closing && !this.scopeDenied && !isOffline()) {
|
|
115
119
|
const window = queue.shift();
|
|
116
120
|
try {
|
|
117
121
|
await this.synchronize(window.thread);
|
|
118
122
|
}
|
|
119
123
|
catch (error) {
|
|
120
|
-
if (!this.closed && !this.closing)
|
|
124
|
+
if (!this.closed && !this.closing && !isOfflineNetworkFailure(error))
|
|
121
125
|
this.options.onError?.(error, window.thread);
|
|
122
126
|
}
|
|
123
127
|
}
|
|
@@ -198,8 +202,11 @@ export class MessageClient {
|
|
|
198
202
|
}
|
|
199
203
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
200
204
|
invalidate(thread) {
|
|
201
|
-
if (!this.closed && !this.closing && !this.revoked.has(thread))
|
|
202
|
-
this.syncTasks.schedule(thread, () =>
|
|
205
|
+
if (!this.closed && !this.closing && !this.scopeDenied && !isOffline() && !this.revoked.has(thread))
|
|
206
|
+
this.syncTasks.schedule(thread, async () => {
|
|
207
|
+
if (!isOffline())
|
|
208
|
+
await this.synchronize(thread);
|
|
209
|
+
});
|
|
203
210
|
}
|
|
204
211
|
async fallback(thread, limit, before) {
|
|
205
212
|
await this.ensureScope();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline } from "./connectivity.js";
|
|
1
2
|
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
2
3
|
import { messageScopeKey } from "./messageStore.js";
|
|
3
4
|
export class MessageReadersError extends Error {
|
|
@@ -95,6 +96,8 @@ export class MessageReadersClient {
|
|
|
95
96
|
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
96
97
|
if (this.lifetime.signal.aborted)
|
|
97
98
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
99
|
+
if (isOffline())
|
|
100
|
+
throw new MessageReadersError(0, "MESSAGE_READERS_OFFLINE_UNAVAILABLE");
|
|
98
101
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
99
102
|
const controller = new AbortController();
|
|
100
103
|
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
1
2
|
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
2
3
|
import { messageScopeKey } from "./messageStore.js";
|
|
3
4
|
let subscriptionSequence = 0;
|
|
@@ -59,6 +60,8 @@ export class ReadCountsClient {
|
|
|
59
60
|
return envelope.data;
|
|
60
61
|
}
|
|
61
62
|
subscription(threadId, release) {
|
|
63
|
+
if (release && isOffline())
|
|
64
|
+
return Promise.resolve();
|
|
62
65
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
63
66
|
}
|
|
64
67
|
renew() {
|
|
@@ -98,7 +101,8 @@ export class ReadCountsClient {
|
|
|
98
101
|
clearInterval(this.heartbeat);
|
|
99
102
|
this.heartbeat = undefined;
|
|
100
103
|
}
|
|
101
|
-
|
|
104
|
+
if (!isOfflineNetworkFailure(error))
|
|
105
|
+
this.options.onError?.(error);
|
|
102
106
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
103
107
|
this.subscriptionPending = undefined; });
|
|
104
108
|
this.subscriptionPending = pending;
|
|
@@ -197,6 +201,8 @@ export class ReadCountsClient {
|
|
|
197
201
|
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
198
202
|
return;
|
|
199
203
|
}
|
|
204
|
+
if (isOffline())
|
|
205
|
+
return;
|
|
200
206
|
const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
|
|
201
207
|
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
202
208
|
return;
|
|
@@ -217,6 +223,10 @@ export class ReadCountsClient {
|
|
|
217
223
|
this.renew();
|
|
218
224
|
}
|
|
219
225
|
catch (error) {
|
|
226
|
+
if (isOfflineNetworkFailure(error)) {
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
220
230
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
221
231
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
222
232
|
this.denyCache();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
1
2
|
import { recordConversationRead } from "./conversationRead.js";
|
|
2
3
|
import { BatchedReadIndex } from "./batchedReadIndex.js";
|
|
3
4
|
import { WindowedTasks } from "./coalescer.js";
|
|
@@ -40,7 +41,7 @@ export class ReadingClient {
|
|
|
40
41
|
this.listeners.forEach(listener => listener());
|
|
41
42
|
}
|
|
42
43
|
failed(error) {
|
|
43
|
-
if (this.closed)
|
|
44
|
+
if (this.closed || isOfflineNetworkFailure(error))
|
|
44
45
|
return;
|
|
45
46
|
this.options.onError?.(error);
|
|
46
47
|
if (/HTTP_(400|401|403)/.test(String(error)) || (typeof navigator !== "undefined" && !navigator.onLine) || this.retry)
|
|
@@ -63,8 +64,9 @@ export class ReadingClient {
|
|
|
63
64
|
this.refresh();
|
|
64
65
|
}
|
|
65
66
|
refresh = () => {
|
|
66
|
-
if (!this.closed)
|
|
67
|
-
this.tasks.schedule("reading", async () => {
|
|
67
|
+
if (!this.closed && !isOffline())
|
|
68
|
+
this.tasks.schedule("reading", async () => { if (!isOffline())
|
|
69
|
+
await this.synchronize(); });
|
|
68
70
|
};
|
|
69
71
|
async synchronize(targetCursor, profileId) {
|
|
70
72
|
if (this.closed)
|
package/package.json
CHANGED