@pmate/chat-sdk 0.12.0-next.23 → 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 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.messages.restore(since, options.activeThreadId));
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();
@@ -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) => options.onError?.(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, () => this.synchronize(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
- this.options.onError?.(error);
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 () => { await this.synchronize(); });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.23",
3
+ "version": "0.12.0-next.24",
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",