@pedi/chika-sdk 1.0.5 → 1.0.7

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/dist/index.js CHANGED
@@ -32,12 +32,22 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  ChannelClosedError: () => ChannelClosedError,
34
34
  ChatDisconnectedError: () => ChatDisconnectedError,
35
+ HttpError: () => HttpError,
36
+ QueueFullError: () => QueueFullError,
37
+ RetryExhaustedError: () => RetryExhaustedError,
38
+ calculateBackoff: () => calculateBackoff,
39
+ createAsyncStorageAdapter: () => createAsyncStorageAdapter,
35
40
  createChatSession: () => createChatSession,
36
41
  createManifest: () => createManifest,
42
+ createNetworkMonitor: () => createNetworkMonitor,
43
+ createQueueStorage: () => createQueueStorage,
37
44
  createSSEConnection: () => createSSEConnection,
45
+ isRetryableError: () => isRetryableError,
46
+ resolveRetryConfig: () => resolveRetryConfig,
38
47
  resolveServerUrl: () => resolveServerUrl,
39
48
  useChat: () => useChat,
40
- useUnread: () => useUnread
49
+ useUnread: () => useUnread,
50
+ withRetry: () => withRetry
41
51
  });
42
52
  module.exports = __toCommonJS(index_exports);
43
53
 
@@ -60,6 +70,104 @@ var ChannelClosedError = class extends Error {
60
70
  this.name = "ChannelClosedError";
61
71
  }
62
72
  };
73
+ var HttpError = class extends Error {
74
+ constructor(status, body, retryAfter) {
75
+ super(`HTTP ${status}: ${body}`);
76
+ this.status = status;
77
+ this.body = body;
78
+ this.retryAfter = retryAfter;
79
+ this.name = "HttpError";
80
+ }
81
+ };
82
+ var RetryExhaustedError = class extends Error {
83
+ constructor(operation, attempts, lastError) {
84
+ super(`${operation} failed after ${attempts} attempts: ${lastError.message}`);
85
+ this.operation = operation;
86
+ this.attempts = attempts;
87
+ this.lastError = lastError;
88
+ this.name = "RetryExhaustedError";
89
+ }
90
+ };
91
+ var QueueFullError = class extends Error {
92
+ constructor(maxSize) {
93
+ super(`Message queue full (max ${maxSize})`);
94
+ this.maxSize = maxSize;
95
+ this.name = "QueueFullError";
96
+ }
97
+ };
98
+
99
+ // src/retry.ts
100
+ var DEFAULT_RETRY_CONFIG = {
101
+ maxAttempts: 3,
102
+ baseDelayMs: 1e3,
103
+ maxDelayMs: 1e4,
104
+ jitterFactor: 0.3
105
+ };
106
+ function calculateBackoff(attempt, config) {
107
+ const delay = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
108
+ const jitter = 1 + (Math.random() * 2 - 1) * config.jitterFactor;
109
+ return Math.round(delay * jitter);
110
+ }
111
+ function isRetryableError(error) {
112
+ if (error instanceof ChannelClosedError) return false;
113
+ if (error instanceof ChatDisconnectedError) return false;
114
+ if (error instanceof QueueFullError) return false;
115
+ if (error instanceof DOMException && error.name === "AbortError") return false;
116
+ if (error instanceof HttpError) {
117
+ const { status } = error;
118
+ if (status === 408 || status === 429) return true;
119
+ if (status >= 500) return true;
120
+ return false;
121
+ }
122
+ if (error instanceof TypeError) return true;
123
+ return false;
124
+ }
125
+ function sleep(ms, signal) {
126
+ return new Promise((resolve, reject) => {
127
+ if (signal?.aborted) {
128
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
129
+ return;
130
+ }
131
+ const timer = setTimeout(resolve, ms);
132
+ signal?.addEventListener(
133
+ "abort",
134
+ () => {
135
+ clearTimeout(timer);
136
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
137
+ },
138
+ { once: true }
139
+ );
140
+ });
141
+ }
142
+ function resolveRetryConfig(resilience) {
143
+ if (resilience === false) return null;
144
+ if (!resilience || resilience.retry === void 0) return DEFAULT_RETRY_CONFIG;
145
+ if (resilience.retry === false) return null;
146
+ return { ...DEFAULT_RETRY_CONFIG, ...resilience.retry };
147
+ }
148
+ async function withRetry(fn, config = DEFAULT_RETRY_CONFIG, signal) {
149
+ let lastError;
150
+ for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
151
+ if (signal?.aborted) {
152
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
153
+ }
154
+ try {
155
+ return await fn();
156
+ } catch (err) {
157
+ lastError = err instanceof Error ? err : new Error(String(err));
158
+ if (!isRetryableError(err)) throw lastError;
159
+ if (attempt < config.maxAttempts - 1) {
160
+ const delayMs = err instanceof HttpError && err.retryAfter != null ? err.retryAfter * 1e3 : calculateBackoff(attempt, config);
161
+ await sleep(delayMs, signal);
162
+ }
163
+ }
164
+ }
165
+ throw new RetryExhaustedError(
166
+ "operation",
167
+ config.maxAttempts,
168
+ lastError
169
+ );
170
+ }
63
171
 
64
172
  // src/resolve-url.ts
65
173
  function createManifest(serverUrl) {
@@ -78,27 +186,55 @@ function resolveServerUrl(manifest, channelId) {
78
186
  var import_react_native_sse = __toESM(require("react-native-sse"));
79
187
  var DEFAULT_RECONNECT_DELAY_MS = 3e3;
80
188
  function createSSEConnection(config, callbacks) {
81
- const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
189
+ const baseDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
82
190
  const customEvents = config.customEvents ?? [];
191
+ const monitor = config.networkMonitor;
192
+ const backoffConfig = {
193
+ maxAttempts: Infinity,
194
+ baseDelayMs: baseDelay,
195
+ maxDelayMs: 3e4,
196
+ jitterFactor: 0.3
197
+ };
83
198
  let currentLastEventId = config.lastEventId;
84
199
  let es = null;
85
200
  let disposed = false;
86
201
  let reconnectTimer = null;
202
+ let attempt = 0;
203
+ let waitAbort = null;
87
204
  function cleanup() {
88
205
  if (es) {
89
206
  es.removeAllEventListeners();
90
207
  es.close();
91
208
  es = null;
92
209
  }
210
+ if (reconnectTimer) {
211
+ clearTimeout(reconnectTimer);
212
+ reconnectTimer = null;
213
+ }
214
+ if (waitAbort) {
215
+ waitAbort.abort();
216
+ waitAbort = null;
217
+ }
93
218
  }
94
- function scheduleReconnect() {
95
- if (disposed || reconnectTimer) return;
219
+ async function scheduleReconnect() {
220
+ if (disposed || reconnectTimer || waitAbort) return;
96
221
  callbacks.onReconnecting?.();
97
222
  cleanup();
223
+ if (monitor && !monitor.isConnected()) {
224
+ waitAbort = new AbortController();
225
+ try {
226
+ await monitor.waitForOnline(waitAbort.signal);
227
+ } catch {
228
+ return;
229
+ }
230
+ waitAbort = null;
231
+ if (disposed) return;
232
+ }
233
+ const delay = calculateBackoff(attempt++, backoffConfig);
98
234
  reconnectTimer = setTimeout(() => {
99
235
  reconnectTimer = null;
100
236
  connect();
101
- }, reconnectDelay);
237
+ }, delay);
102
238
  }
103
239
  function connect() {
104
240
  if (disposed) return;
@@ -111,6 +247,7 @@ function createSSEConnection(config, callbacks) {
111
247
  });
112
248
  es.addEventListener("open", () => {
113
249
  if (disposed) return;
250
+ attempt = 0;
114
251
  callbacks.onOpen?.();
115
252
  });
116
253
  es.addEventListener("message", (event) => {
@@ -147,11 +284,13 @@ function createSSEConnection(config, callbacks) {
147
284
  return {
148
285
  close: () => {
149
286
  disposed = true;
150
- if (reconnectTimer) {
151
- clearTimeout(reconnectTimer);
152
- reconnectTimer = null;
153
- }
154
287
  cleanup();
288
+ },
289
+ reconnectImmediate: () => {
290
+ if (disposed) return;
291
+ cleanup();
292
+ attempt = 0;
293
+ connect();
155
294
  }
156
295
  };
157
296
  }
@@ -159,23 +298,46 @@ function createSSEConnection(config, callbacks) {
159
298
  // src/session.ts
160
299
  var DEFAULT_RECONNECT_DELAY_MS2 = 3e3;
161
300
  var MAX_SEEN_IDS = 500;
162
- async function createChatSession(config, channelId, profile, callbacks) {
301
+ var MARK_READ_RETRY_CONFIG = {
302
+ maxAttempts: 2,
303
+ baseDelayMs: 500,
304
+ maxDelayMs: 2e3,
305
+ jitterFactor: 0.3
306
+ };
307
+ function parseRetryAfter(res) {
308
+ const header = res.headers.get("Retry-After");
309
+ if (!header) return void 0;
310
+ const seconds = Number(header);
311
+ return Number.isFinite(seconds) ? seconds : void 0;
312
+ }
313
+ async function throwHttpError(res) {
314
+ const body = await res.text().catch(() => "");
315
+ throw new HttpError(res.status, body, parseRetryAfter(res));
316
+ }
317
+ async function createChatSession(config, channelId, profile, callbacks, networkMonitor) {
163
318
  const serviceUrl = resolveServerUrl(config.manifest, channelId);
164
319
  const customHeaders = config.headers ?? {};
165
320
  const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS2;
321
+ const retryConfig = resolveRetryConfig(config.resilience);
322
+ const sessionAbort = new AbortController();
166
323
  callbacks.onStatusChange("connecting");
167
- const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
168
- method: "POST",
169
- headers: { "Content-Type": "application/json", ...customHeaders },
170
- body: JSON.stringify(profile)
171
- });
172
- if (joinRes.status === 410) {
173
- throw new ChannelClosedError(channelId);
174
- }
175
- if (!joinRes.ok) {
176
- throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);
177
- }
178
- const { messages, participants, joined_at } = await joinRes.json();
324
+ const joinFn = async () => {
325
+ const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/json", ...customHeaders },
328
+ body: JSON.stringify(profile),
329
+ signal: sessionAbort.signal
330
+ });
331
+ if (joinRes.status === 410) {
332
+ throw new ChannelClosedError(channelId);
333
+ }
334
+ if (!joinRes.ok) {
335
+ await throwHttpError(joinRes);
336
+ }
337
+ return joinRes.json();
338
+ };
339
+ const joinData = retryConfig ? await withRetry(joinFn, retryConfig, sessionAbort.signal) : await joinFn();
340
+ const { messages, participants, joined_at } = joinData;
179
341
  let lastEventId = messages.length > 0 ? messages[messages.length - 1].id : void 0;
180
342
  const joinedAt = joined_at;
181
343
  const seenMessageIds = new Set(messages.map((m) => m.id));
@@ -199,7 +361,8 @@ async function createChatSession(config, channelId, profile, callbacks) {
199
361
  headers: customHeaders,
200
362
  reconnectDelayMs: reconnectDelay,
201
363
  lastEventId,
202
- customEvents: ["resync"]
364
+ customEvents: ["resync"],
365
+ networkMonitor
203
366
  },
204
367
  {
205
368
  onOpen: () => {
@@ -223,8 +386,6 @@ async function createChatSession(config, channelId, profile, callbacks) {
223
386
  trimSeenIds();
224
387
  callbacks.onMessage(message);
225
388
  } else if (eventType === "resync") {
226
- sseConn?.close();
227
- sseConn = null;
228
389
  callbacks.onResync();
229
390
  }
230
391
  },
@@ -247,44 +408,65 @@ async function createChatSession(config, channelId, profile, callbacks) {
247
408
  channelId,
248
409
  initialParticipants: participants,
249
410
  initialMessages: messages,
250
- sendMessage: async (type, body, attributes) => {
411
+ networkMonitor: networkMonitor ?? null,
412
+ sendMessage: async (type, body, attributes, idempotencyKey) => {
251
413
  if (disposed) throw new ChatDisconnectedError("disconnected");
252
414
  const payload = {
253
415
  sender_id: profile.id,
254
416
  type,
255
417
  body,
256
- attributes
418
+ attributes,
419
+ ...idempotencyKey ? { idempotency_key: idempotencyKey } : {}
257
420
  };
258
- const res = await fetch(
259
- `${serviceUrl}/channels/${channelId}/messages`,
260
- {
261
- method: "POST",
262
- headers: { "Content-Type": "application/json", ...customHeaders },
263
- body: JSON.stringify(payload)
421
+ const sendFn = async () => {
422
+ const res = await fetch(
423
+ `${serviceUrl}/channels/${channelId}/messages`,
424
+ {
425
+ method: "POST",
426
+ headers: { "Content-Type": "application/json", ...customHeaders },
427
+ body: JSON.stringify(payload),
428
+ signal: sessionAbort.signal
429
+ }
430
+ );
431
+ if (!res.ok) {
432
+ await throwHttpError(res);
264
433
  }
265
- );
266
- if (!res.ok) {
267
- throw new Error(`Send failed: ${res.status} ${await res.text()}`);
268
- }
269
- const response = await res.json();
434
+ return res.json();
435
+ };
436
+ const response = retryConfig ? await withRetry(sendFn, retryConfig, sessionAbort.signal) : await sendFn();
270
437
  seenMessageIds.add(response.id);
271
438
  return response;
272
439
  },
273
440
  markAsRead: async (messageId) => {
274
- const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
275
- method: "POST",
276
- headers: { "Content-Type": "application/json", ...customHeaders },
277
- body: JSON.stringify({
278
- participant_id: profile.id,
279
- message_id: messageId
280
- })
281
- });
282
- if (!res.ok) {
283
- throw new Error(`markAsRead failed: ${res.status}`);
441
+ const readFn = async () => {
442
+ const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
443
+ method: "POST",
444
+ headers: { "Content-Type": "application/json", ...customHeaders },
445
+ body: JSON.stringify({
446
+ participant_id: profile.id,
447
+ message_id: messageId
448
+ }),
449
+ signal: sessionAbort.signal
450
+ });
451
+ if (!res.ok) {
452
+ await throwHttpError(res);
453
+ }
454
+ };
455
+ if (retryConfig) {
456
+ try {
457
+ await withRetry(readFn, MARK_READ_RETRY_CONFIG, sessionAbort.signal);
458
+ } catch (err) {
459
+ if (err instanceof HttpError) {
460
+ callbacks.onError(err);
461
+ }
462
+ }
463
+ } else {
464
+ await readFn();
284
465
  }
285
466
  },
286
467
  disconnect: () => {
287
468
  disposed = true;
469
+ sessionAbort.abort();
288
470
  sseConn?.close();
289
471
  sseConn = null;
290
472
  callbacks.onStatusChange("disconnected");
@@ -292,13 +474,342 @@ async function createChatSession(config, channelId, profile, callbacks) {
292
474
  };
293
475
  }
294
476
 
477
+ // src/network-monitor.ts
478
+ function createStubMonitor() {
479
+ return {
480
+ isConnected: () => true,
481
+ subscribe: () => () => {
482
+ },
483
+ waitForOnline: () => Promise.resolve(),
484
+ dispose: () => {
485
+ }
486
+ };
487
+ }
488
+ var resolvedNetInfo = void 0;
489
+ var netInfoResolved = false;
490
+ function getNetInfo() {
491
+ if (netInfoResolved) return resolvedNetInfo;
492
+ netInfoResolved = true;
493
+ try {
494
+ resolvedNetInfo = require("@react-native-community/netinfo");
495
+ } catch {
496
+ resolvedNetInfo = null;
497
+ }
498
+ return resolvedNetInfo;
499
+ }
500
+ function createNetworkMonitor() {
501
+ const NetInfo = getNetInfo();
502
+ if (!NetInfo) return createStubMonitor();
503
+ const netInfoModule = NetInfo.default ?? NetInfo;
504
+ let connected = true;
505
+ const listeners = /* @__PURE__ */ new Set();
506
+ let unsubscribeNetInfo = null;
507
+ unsubscribeNetInfo = netInfoModule.addEventListener(
508
+ (state) => {
509
+ const next = state.isConnected !== false;
510
+ if (next === connected) return;
511
+ connected = next;
512
+ for (const cb of listeners) {
513
+ cb(connected);
514
+ }
515
+ }
516
+ );
517
+ return {
518
+ isConnected: () => connected,
519
+ subscribe: (cb) => {
520
+ listeners.add(cb);
521
+ return () => {
522
+ listeners.delete(cb);
523
+ };
524
+ },
525
+ waitForOnline: (signal) => {
526
+ if (connected) return Promise.resolve();
527
+ return new Promise((resolve, reject) => {
528
+ if (signal?.aborted) {
529
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
530
+ return;
531
+ }
532
+ const unsub = () => {
533
+ listeners.delete(handler);
534
+ signal?.removeEventListener("abort", onAbort);
535
+ };
536
+ const handler = (isOnline) => {
537
+ if (isOnline) {
538
+ unsub();
539
+ resolve();
540
+ }
541
+ };
542
+ const onAbort = () => {
543
+ unsub();
544
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
545
+ };
546
+ listeners.add(handler);
547
+ signal?.addEventListener("abort", onAbort, { once: true });
548
+ });
549
+ },
550
+ dispose: () => {
551
+ listeners.clear();
552
+ unsubscribeNetInfo?.();
553
+ unsubscribeNetInfo = null;
554
+ }
555
+ };
556
+ }
557
+
558
+ // src/message-queue.ts
559
+ var resolvedStorage;
560
+ function tryRequire(name) {
561
+ try {
562
+ return require(name);
563
+ } catch {
564
+ return null;
565
+ }
566
+ }
567
+ function createMmkvAdapter(mod) {
568
+ const MMKV = mod.MMKV ?? mod.default?.MMKV ?? mod;
569
+ const instance = new MMKV({ id: "chika-queue" });
570
+ return {
571
+ getItem: (key) => Promise.resolve(instance.getString(key) ?? null),
572
+ setItem: (key, value) => {
573
+ instance.set(key, value);
574
+ return Promise.resolve();
575
+ },
576
+ removeItem: (key) => {
577
+ instance.delete(key);
578
+ return Promise.resolve();
579
+ }
580
+ };
581
+ }
582
+ function createAsyncStorageAdapterFrom(mod) {
583
+ const storage = mod.default ?? mod;
584
+ return {
585
+ getItem: (key) => storage.getItem(key),
586
+ setItem: (key, value) => storage.setItem(key, value),
587
+ removeItem: (key) => storage.removeItem(key)
588
+ };
589
+ }
590
+ function createQueueStorage() {
591
+ if (resolvedStorage !== void 0) return resolvedStorage?.adapter ?? null;
592
+ const mmkv = tryRequire("react-native-mmkv");
593
+ if (mmkv) {
594
+ try {
595
+ const adapter = createMmkvAdapter(mmkv);
596
+ resolvedStorage = { type: "mmkv", adapter };
597
+ return adapter;
598
+ } catch {
599
+ }
600
+ }
601
+ const asyncStorage = tryRequire("@react-native-async-storage/async-storage");
602
+ if (asyncStorage) {
603
+ const adapter = createAsyncStorageAdapterFrom(asyncStorage);
604
+ resolvedStorage = { type: "async-storage", adapter };
605
+ return adapter;
606
+ }
607
+ resolvedStorage = null;
608
+ return null;
609
+ }
610
+ function createAsyncStorageAdapter() {
611
+ const mod = tryRequire("@react-native-async-storage/async-storage");
612
+ if (!mod) return null;
613
+ return createAsyncStorageAdapterFrom(mod);
614
+ }
615
+ var MessageQueue = class {
616
+ constructor(config) {
617
+ this.config = config;
618
+ this.storageKey = `chika_queue_${config.channelId}`;
619
+ this.unsubNetwork = config.networkMonitor.subscribe((connected) => {
620
+ if (connected) this.flush();
621
+ });
622
+ }
623
+ entries = [];
624
+ flushing = false;
625
+ unsubNetwork = null;
626
+ storageKey;
627
+ /**
628
+ * Restore queued messages from persistent storage on cold start.
629
+ * Restored entries are fire-and-forget — there is no caller awaiting their
630
+ * promise (the original enqueue() caller is gone after app restart).
631
+ * Success/failure is reported via onStatusChange/onError callbacks.
632
+ */
633
+ async restore(rebuildSendFn) {
634
+ if (!this.config.storage) return;
635
+ try {
636
+ const raw = await this.config.storage.getItem(this.storageKey);
637
+ if (!raw) return;
638
+ const persisted = JSON.parse(raw);
639
+ for (const entry of persisted) {
640
+ const sendFn = rebuildSendFn(entry.optimisticId);
641
+ if (!sendFn) continue;
642
+ const abort = new AbortController();
643
+ this.entries.push({
644
+ optimisticId: entry.optimisticId,
645
+ sendFn,
646
+ status: "queued",
647
+ retryCount: entry.retryCount,
648
+ abort,
649
+ // No-op: restored entries have no caller awaiting the promise.
650
+ resolve: () => {
651
+ },
652
+ reject: () => {
653
+ }
654
+ });
655
+ }
656
+ if (this.entries.length > 0) {
657
+ this.config.onStatusChange?.();
658
+ this.flush();
659
+ }
660
+ } catch {
661
+ this.config.onError?.(new Error("Failed to restore message queue from storage"));
662
+ }
663
+ }
664
+ get pendingCount() {
665
+ return this.entries.length;
666
+ }
667
+ getAll() {
668
+ return this.entries.map((e) => ({
669
+ optimisticId: e.optimisticId,
670
+ status: e.status,
671
+ error: e.error,
672
+ retryCount: e.retryCount
673
+ }));
674
+ }
675
+ getStatus(optimisticId) {
676
+ const entry = this.entries.find((e) => e.optimisticId === optimisticId);
677
+ if (!entry) return void 0;
678
+ return {
679
+ optimisticId: entry.optimisticId,
680
+ status: entry.status,
681
+ error: entry.error,
682
+ retryCount: entry.retryCount
683
+ };
684
+ }
685
+ enqueue(sendFn, optimisticId) {
686
+ if (this.entries.length >= this.config.maxSize) {
687
+ throw new QueueFullError(this.config.maxSize);
688
+ }
689
+ const abort = new AbortController();
690
+ return new Promise((resolve, reject) => {
691
+ const entry = {
692
+ optimisticId,
693
+ sendFn,
694
+ status: "queued",
695
+ retryCount: 0,
696
+ abort,
697
+ resolve,
698
+ reject
699
+ };
700
+ this.entries.push(entry);
701
+ this.config.onStatusChange?.();
702
+ this.persist();
703
+ this.flush();
704
+ });
705
+ }
706
+ cancel(optimisticId) {
707
+ const idx = this.entries.findIndex((e) => e.optimisticId === optimisticId);
708
+ if (idx === -1) return;
709
+ const entry = this.entries[idx];
710
+ entry.abort.abort();
711
+ entry.reject(new DOMException("Cancelled", "AbortError"));
712
+ this.entries.splice(idx, 1);
713
+ this.config.onStatusChange?.();
714
+ this.persist();
715
+ }
716
+ retry(optimisticId) {
717
+ const entry = this.entries.find((e) => e.optimisticId === optimisticId);
718
+ if (!entry || entry.status !== "failed") return;
719
+ entry.status = "queued";
720
+ entry.error = void 0;
721
+ entry.abort = new AbortController();
722
+ this.config.onStatusChange?.();
723
+ this.flush();
724
+ }
725
+ dispose() {
726
+ this.unsubNetwork?.();
727
+ this.unsubNetwork = null;
728
+ for (const entry of this.entries) {
729
+ entry.abort.abort();
730
+ entry.reject(new DOMException("Queue disposed", "AbortError"));
731
+ }
732
+ this.entries = [];
733
+ }
734
+ /**
735
+ * Trigger a flush of queued messages. Returns immediately if a flush is
736
+ * already in progress or the network is offline. The in-progress flush
737
+ * will pick up any newly queued entries via its while loop.
738
+ */
739
+ async flush() {
740
+ if (this.flushing) return;
741
+ if (!this.config.networkMonitor.isConnected()) return;
742
+ this.flushing = true;
743
+ let awaitingSession = false;
744
+ try {
745
+ while (this.entries.length > 0) {
746
+ const entry = this.entries.find((e) => e.status === "queued");
747
+ if (!entry) break;
748
+ if (!this.config.networkMonitor.isConnected()) break;
749
+ entry.status = "sending";
750
+ this.config.onStatusChange?.();
751
+ try {
752
+ const result = await withRetry(
753
+ entry.sendFn,
754
+ this.config.retryConfig,
755
+ entry.abort.signal
756
+ );
757
+ entry.resolve(result);
758
+ const idx = this.entries.indexOf(entry);
759
+ if (idx !== -1) this.entries.splice(idx, 1);
760
+ this.config.onStatusChange?.();
761
+ this.persist();
762
+ } catch (err) {
763
+ if (entry.abort.signal.aborted) continue;
764
+ if (err instanceof ChatDisconnectedError) {
765
+ entry.status = "queued";
766
+ this.config.onStatusChange?.();
767
+ awaitingSession = true;
768
+ break;
769
+ }
770
+ entry.status = "failed";
771
+ entry.error = err instanceof Error ? err : new Error(String(err));
772
+ entry.retryCount++;
773
+ this.config.onStatusChange?.();
774
+ this.persist();
775
+ }
776
+ }
777
+ } finally {
778
+ this.flushing = false;
779
+ if (!awaitingSession && this.entries.some((e) => e.status === "queued") && this.config.networkMonitor.isConnected()) {
780
+ queueMicrotask(() => this.flush());
781
+ }
782
+ }
783
+ }
784
+ persist() {
785
+ if (!this.config.storage) return;
786
+ const data = this.entries.map((e) => ({
787
+ optimisticId: e.optimisticId,
788
+ retryCount: e.retryCount
789
+ }));
790
+ const onErr = (err) => {
791
+ this.config.onError?.(
792
+ err instanceof Error ? err : new Error("Queue storage write failed")
793
+ );
794
+ };
795
+ if (data.length === 0) {
796
+ this.config.storage.removeItem(this.storageKey).catch(onErr);
797
+ } else {
798
+ this.config.storage.setItem(this.storageKey, JSON.stringify(data)).catch(onErr);
799
+ }
800
+ }
801
+ };
802
+
295
803
  // src/use-chat.ts
296
804
  var DEFAULT_BACKGROUND_GRACE_MS = 2e3;
805
+ var DEFAULT_MAX_QUEUE_SIZE = 50;
806
+ var queueRegistry = /* @__PURE__ */ new Map();
297
807
  function useChat({ config, channelId, profile, onMessage }) {
298
808
  const [messages, setMessages] = (0, import_react.useState)([]);
299
809
  const [participants, setParticipants] = (0, import_react.useState)([]);
300
810
  const [status, setStatus] = (0, import_react.useState)("connecting");
301
811
  const [error, setError] = (0, import_react.useState)(null);
812
+ const [pendingMessages, setPendingMessages] = (0, import_react.useState)([]);
302
813
  const sessionRef = (0, import_react.useRef)(null);
303
814
  const disposedRef = (0, import_react.useRef)(false);
304
815
  const messagesRef = (0, import_react.useRef)(messages);
@@ -315,10 +826,36 @@ function useChat({ config, channelId, profile, onMessage }) {
315
826
  onMessageRef.current = onMessage;
316
827
  const startingRef = (0, import_react.useRef)(false);
317
828
  const pendingOptimisticIds = (0, import_react.useRef)(/* @__PURE__ */ new Set());
829
+ const [monitor, setMonitor] = (0, import_react.useState)(null);
830
+ const monitorRef = (0, import_react.useRef)(null);
831
+ monitorRef.current = monitor;
832
+ const queueRef = (0, import_react.useRef)(null);
833
+ const resilienceEnabled = config.resilience !== false;
834
+ const queueEnabled = resilienceEnabled && (typeof config.resilience === "object" ? config.resilience.offlineQueue !== false : true);
835
+ const retryConfig = resolveRetryConfig(config.resilience);
836
+ const maxQueueSize = (resilienceEnabled && config.resilience && typeof config.resilience === "object" ? config.resilience.maxQueueSize : void 0) ?? DEFAULT_MAX_QUEUE_SIZE;
318
837
  const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS : 0);
838
+ const injectedMonitor = typeof config.resilience === "object" ? config.resilience.networkMonitor : void 0;
839
+ (0, import_react.useEffect)(() => {
840
+ if (!resilienceEnabled) {
841
+ setMonitor(null);
842
+ return;
843
+ }
844
+ if (injectedMonitor) {
845
+ setMonitor(injectedMonitor);
846
+ return;
847
+ }
848
+ const m = createNetworkMonitor();
849
+ setMonitor(m);
850
+ return () => {
851
+ m.dispose();
852
+ setMonitor(null);
853
+ };
854
+ }, [resilienceEnabled, injectedMonitor]);
319
855
  const callbacks = {
320
856
  onMessage: (message) => {
321
857
  if (disposedRef.current) return;
858
+ let matchedOptimisticId = null;
322
859
  setMessages((prev) => {
323
860
  if (pendingOptimisticIds.current.size === 0) {
324
861
  return [...prev, message];
@@ -327,20 +864,28 @@ function useChat({ config, channelId, profile, onMessage }) {
327
864
  (m) => pendingOptimisticIds.current.has(m.id) && m.sender_id === message.sender_id && m.body === message.body && m.type === message.type
328
865
  );
329
866
  if (optimisticIdx !== -1) {
330
- const optimisticId = prev[optimisticIdx].id;
331
- pendingOptimisticIds.current.delete(optimisticId);
867
+ matchedOptimisticId = prev[optimisticIdx].id;
868
+ pendingOptimisticIds.current.delete(matchedOptimisticId);
332
869
  const next = [...prev];
333
870
  next[optimisticIdx] = message;
334
871
  return next;
335
872
  }
336
873
  return [...prev, message];
337
874
  });
875
+ if (matchedOptimisticId) {
876
+ const s = queueRef.current?.getStatus(matchedOptimisticId);
877
+ if (s && s.status !== "sending") {
878
+ queueRef.current?.cancel(matchedOptimisticId);
879
+ }
880
+ }
338
881
  onMessageRef.current?.(message);
339
882
  },
340
883
  onStatusChange: (nextStatus) => {
341
884
  if (disposedRef.current) return;
342
885
  setStatus(nextStatus);
343
- if (nextStatus === "connected") setError(null);
886
+ if (nextStatus === "connected") {
887
+ setError(null);
888
+ }
344
889
  },
345
890
  onError: (err) => {
346
891
  if (disposedRef.current) return;
@@ -360,14 +905,29 @@ function useChat({ config, channelId, profile, onMessage }) {
360
905
  sessionRef.current = null;
361
906
  }
362
907
  try {
363
- const session = await createChatSession(configRef.current, channelId, profileRef.current, callbacks);
908
+ const session = await createChatSession(
909
+ configRef.current,
910
+ channelId,
911
+ profileRef.current,
912
+ callbacks,
913
+ monitorRef.current ?? void 0
914
+ );
364
915
  if (disposedRef.current) {
365
916
  session.disconnect();
366
917
  return;
367
918
  }
368
919
  sessionRef.current = session;
369
920
  setParticipants(session.initialParticipants);
370
- setMessages(session.initialMessages);
921
+ queueRef.current?.flush();
922
+ if (pendingOptimisticIds.current.size > 0) {
923
+ const pendingIds = pendingOptimisticIds.current;
924
+ setMessages((prev) => {
925
+ const pendingMsgs = prev.filter((m) => pendingIds.has(m.id));
926
+ return [...session.initialMessages, ...pendingMsgs];
927
+ });
928
+ } else {
929
+ setMessages(session.initialMessages);
930
+ }
371
931
  } catch (err) {
372
932
  if (disposedRef.current) return;
373
933
  if (err instanceof ChannelClosedError) {
@@ -383,6 +943,7 @@ function useChat({ config, channelId, profile, onMessage }) {
383
943
  }
384
944
  (0, import_react.useEffect)(() => {
385
945
  disposedRef.current = false;
946
+ if (resilienceEnabled && !monitor) return;
386
947
  startSession();
387
948
  return () => {
388
949
  disposedRef.current = true;
@@ -400,7 +961,52 @@ function useChat({ config, channelId, profile, onMessage }) {
400
961
  sessionRef.current?.disconnect();
401
962
  sessionRef.current = null;
402
963
  };
403
- }, [channelId]);
964
+ }, [channelId, monitor]);
965
+ (0, import_react.useEffect)(() => {
966
+ if (!queueEnabled || !monitor || !retryConfig) return;
967
+ let entry = queueRegistry.get(channelId);
968
+ if (!entry) {
969
+ const queue = new MessageQueue({
970
+ channelId,
971
+ maxSize: maxQueueSize,
972
+ retryConfig,
973
+ networkMonitor: monitor,
974
+ storage: typeof config.resilience === "object" ? config.resilience.queueStorage : void 0,
975
+ onError: (err) => {
976
+ if (!disposedRef.current) setError(err);
977
+ },
978
+ onStatusChange: () => {
979
+ if (!disposedRef.current) {
980
+ setPendingMessages(queueRef.current?.getAll() ?? []);
981
+ }
982
+ }
983
+ });
984
+ entry = { queue, refCount: 0 };
985
+ queueRegistry.set(channelId, entry);
986
+ }
987
+ entry.refCount++;
988
+ queueRef.current = entry.queue;
989
+ return () => {
990
+ const e = queueRegistry.get(channelId);
991
+ if (e) {
992
+ e.refCount--;
993
+ if (e.refCount <= 0) {
994
+ e.queue.dispose();
995
+ queueRegistry.delete(channelId);
996
+ }
997
+ }
998
+ queueRef.current = null;
999
+ };
1000
+ }, [channelId, queueEnabled, monitor]);
1001
+ (0, import_react.useEffect)(() => {
1002
+ if (!monitor || !resilienceEnabled) return;
1003
+ const unsub = monitor.subscribe((connected) => {
1004
+ if (connected && statusRef.current === "error" && !startingRef.current) {
1005
+ startSession();
1006
+ }
1007
+ });
1008
+ return unsub;
1009
+ }, [channelId, resilienceEnabled, monitor]);
404
1010
  (0, import_react.useEffect)(() => {
405
1011
  function teardownSession() {
406
1012
  sessionRef.current?.disconnect();
@@ -446,12 +1052,11 @@ function useChat({ config, channelId, profile, onMessage }) {
446
1052
  const session = sessionRef.current;
447
1053
  if (!session) throw new ChatDisconnectedError(statusRef.current);
448
1054
  const optimistic = configRef.current.optimisticSend !== false;
449
- let optimisticId = null;
1055
+ const messageKey = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
450
1056
  if (optimistic) {
451
- optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
452
- pendingOptimisticIds.current.add(optimisticId);
1057
+ pendingOptimisticIds.current.add(messageKey);
453
1058
  const provisionalMsg = {
454
- id: optimisticId,
1059
+ id: messageKey,
455
1060
  channel_id: channelId,
456
1061
  sender_id: profileRef.current.id,
457
1062
  sender_role: profileRef.current.role,
@@ -462,35 +1067,91 @@ function useChat({ config, channelId, profile, onMessage }) {
462
1067
  };
463
1068
  setMessages((prev) => [...prev, provisionalMsg]);
464
1069
  }
465
- try {
466
- const response = await session.sendMessage(type, body, attributes);
467
- if (optimistic && optimisticId) {
468
- pendingOptimisticIds.current.delete(optimisticId);
1070
+ const doSend = () => {
1071
+ const s = sessionRef.current;
1072
+ if (!s) throw new ChatDisconnectedError(statusRef.current);
1073
+ return s.sendMessage(type, body, attributes, messageKey);
1074
+ };
1075
+ const handleSuccess = (response) => {
1076
+ if (optimistic) {
1077
+ pendingOptimisticIds.current.delete(messageKey);
469
1078
  setMessages((prev) => {
470
- const stillPending = prev.some((m) => m.id === optimisticId);
1079
+ const stillPending = prev.some((m) => m.id === messageKey);
471
1080
  if (!stillPending) return prev;
472
1081
  return prev.map(
473
- (m) => m.id === optimisticId ? { ...m, id: response.id, created_at: response.created_at } : m
1082
+ (m) => m.id === messageKey ? { ...m, id: response.id, created_at: response.created_at } : m
474
1083
  );
475
1084
  });
476
1085
  }
1086
+ };
1087
+ const handleError = (_err) => {
1088
+ if (optimistic) {
1089
+ pendingOptimisticIds.current.delete(messageKey);
1090
+ setMessages((prev) => prev.filter((m) => m.id !== messageKey));
1091
+ }
1092
+ };
1093
+ if (queueRef.current) {
1094
+ try {
1095
+ const response = await queueRef.current.enqueue(doSend, messageKey);
1096
+ handleSuccess(response);
1097
+ return response;
1098
+ } catch (err) {
1099
+ if (err instanceof QueueFullError) {
1100
+ handleError(err);
1101
+ throw err;
1102
+ }
1103
+ if (err instanceof RetryExhaustedError || !isRetryableError(err)) {
1104
+ if (optimistic && err instanceof RetryExhaustedError) {
1105
+ } else {
1106
+ handleError(err);
1107
+ }
1108
+ throw err;
1109
+ }
1110
+ handleError(err);
1111
+ throw err;
1112
+ }
1113
+ }
1114
+ try {
1115
+ const response = await doSend();
1116
+ handleSuccess(response);
477
1117
  return response;
478
1118
  } catch (err) {
479
- if (optimistic && optimisticId) {
480
- pendingOptimisticIds.current.delete(optimisticId);
481
- setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
482
- }
1119
+ handleError(err);
483
1120
  throw err;
484
1121
  }
485
1122
  },
486
1123
  [channelId]
487
1124
  );
1125
+ const cancelMessage = (0, import_react.useCallback)(
1126
+ (optimisticId) => {
1127
+ queueRef.current?.cancel(optimisticId);
1128
+ pendingOptimisticIds.current.delete(optimisticId);
1129
+ setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
1130
+ },
1131
+ []
1132
+ );
1133
+ const retryMessage = (0, import_react.useCallback)(
1134
+ (optimisticId) => {
1135
+ queueRef.current?.retry(optimisticId);
1136
+ },
1137
+ []
1138
+ );
488
1139
  const disconnect = (0, import_react.useCallback)(() => {
489
1140
  sessionRef.current?.disconnect();
490
1141
  sessionRef.current = null;
491
1142
  setStatus("disconnected");
492
1143
  }, []);
493
- return { messages, participants, status, error, sendMessage, disconnect };
1144
+ return {
1145
+ messages,
1146
+ participants,
1147
+ status,
1148
+ error,
1149
+ sendMessage,
1150
+ disconnect,
1151
+ pendingMessages,
1152
+ cancelMessage,
1153
+ retryMessage
1154
+ };
494
1155
  }
495
1156
 
496
1157
  // src/use-unread.ts
@@ -509,6 +1170,25 @@ function useUnread(options) {
509
1170
  const appStateRef = (0, import_react2.useRef)(import_react_native2.AppState.currentState);
510
1171
  const backgroundTimerRef = (0, import_react2.useRef)(null);
511
1172
  const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native2.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS2 : 0);
1173
+ const [monitor, setMonitor] = (0, import_react2.useState)(null);
1174
+ const resilienceEnabled = config.resilience !== false;
1175
+ const injectedMonitor = typeof config.resilience === "object" ? config.resilience.networkMonitor : void 0;
1176
+ (0, import_react2.useEffect)(() => {
1177
+ if (!resilienceEnabled) {
1178
+ setMonitor(null);
1179
+ return;
1180
+ }
1181
+ if (injectedMonitor) {
1182
+ setMonitor(injectedMonitor);
1183
+ return;
1184
+ }
1185
+ const m = createNetworkMonitor();
1186
+ setMonitor(m);
1187
+ return () => {
1188
+ m.dispose();
1189
+ setMonitor(null);
1190
+ };
1191
+ }, [resilienceEnabled, injectedMonitor]);
512
1192
  const connect = (0, import_react2.useCallback)(() => {
513
1193
  connRef.current?.close();
514
1194
  connRef.current = null;
@@ -520,7 +1200,8 @@ function useUnread(options) {
520
1200
  url,
521
1201
  headers: customHeaders,
522
1202
  reconnectDelayMs: configRef.current.reconnectDelayMs,
523
- customEvents: UNREAD_CUSTOM_EVENTS
1203
+ customEvents: UNREAD_CUSTOM_EVENTS,
1204
+ networkMonitor: monitor ?? void 0
524
1205
  },
525
1206
  {
526
1207
  onOpen: () => {
@@ -552,7 +1233,7 @@ function useUnread(options) {
552
1233
  }
553
1234
  }
554
1235
  );
555
- }, [channelId, participantId]);
1236
+ }, [channelId, participantId, monitor]);
556
1237
  const disconnect = (0, import_react2.useCallback)(() => {
557
1238
  connRef.current?.close();
558
1239
  connRef.current = null;
@@ -565,6 +1246,7 @@ function useUnread(options) {
565
1246
  disconnect();
566
1247
  return;
567
1248
  }
1249
+ if (resilienceEnabled && !monitor) return;
568
1250
  connect();
569
1251
  return () => {
570
1252
  disconnect();
@@ -616,11 +1298,21 @@ function useUnread(options) {
616
1298
  0 && (module.exports = {
617
1299
  ChannelClosedError,
618
1300
  ChatDisconnectedError,
1301
+ HttpError,
1302
+ QueueFullError,
1303
+ RetryExhaustedError,
1304
+ calculateBackoff,
1305
+ createAsyncStorageAdapter,
619
1306
  createChatSession,
620
1307
  createManifest,
1308
+ createNetworkMonitor,
1309
+ createQueueStorage,
621
1310
  createSSEConnection,
1311
+ isRetryableError,
1312
+ resolveRetryConfig,
622
1313
  resolveServerUrl,
623
1314
  useChat,
624
- useUnread
1315
+ useUnread,
1316
+ withRetry
625
1317
  });
626
1318
  //# sourceMappingURL=index.js.map