@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.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/use-chat.ts
2
9
  import { useEffect, useRef, useState, useCallback } from "react";
3
10
  import { AppState, Platform } from "react-native";
@@ -17,6 +24,104 @@ var ChannelClosedError = class extends Error {
17
24
  this.name = "ChannelClosedError";
18
25
  }
19
26
  };
27
+ var HttpError = class extends Error {
28
+ constructor(status, body, retryAfter) {
29
+ super(`HTTP ${status}: ${body}`);
30
+ this.status = status;
31
+ this.body = body;
32
+ this.retryAfter = retryAfter;
33
+ this.name = "HttpError";
34
+ }
35
+ };
36
+ var RetryExhaustedError = class extends Error {
37
+ constructor(operation, attempts, lastError) {
38
+ super(`${operation} failed after ${attempts} attempts: ${lastError.message}`);
39
+ this.operation = operation;
40
+ this.attempts = attempts;
41
+ this.lastError = lastError;
42
+ this.name = "RetryExhaustedError";
43
+ }
44
+ };
45
+ var QueueFullError = class extends Error {
46
+ constructor(maxSize) {
47
+ super(`Message queue full (max ${maxSize})`);
48
+ this.maxSize = maxSize;
49
+ this.name = "QueueFullError";
50
+ }
51
+ };
52
+
53
+ // src/retry.ts
54
+ var DEFAULT_RETRY_CONFIG = {
55
+ maxAttempts: 3,
56
+ baseDelayMs: 1e3,
57
+ maxDelayMs: 1e4,
58
+ jitterFactor: 0.3
59
+ };
60
+ function calculateBackoff(attempt, config) {
61
+ const delay = Math.min(config.baseDelayMs * 2 ** attempt, config.maxDelayMs);
62
+ const jitter = 1 + (Math.random() * 2 - 1) * config.jitterFactor;
63
+ return Math.round(delay * jitter);
64
+ }
65
+ function isRetryableError(error) {
66
+ if (error instanceof ChannelClosedError) return false;
67
+ if (error instanceof ChatDisconnectedError) return false;
68
+ if (error instanceof QueueFullError) return false;
69
+ if (error instanceof DOMException && error.name === "AbortError") return false;
70
+ if (error instanceof HttpError) {
71
+ const { status } = error;
72
+ if (status === 408 || status === 429) return true;
73
+ if (status >= 500) return true;
74
+ return false;
75
+ }
76
+ if (error instanceof TypeError) return true;
77
+ return false;
78
+ }
79
+ function sleep(ms, signal) {
80
+ return new Promise((resolve, reject) => {
81
+ if (signal?.aborted) {
82
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
83
+ return;
84
+ }
85
+ const timer = setTimeout(resolve, ms);
86
+ signal?.addEventListener(
87
+ "abort",
88
+ () => {
89
+ clearTimeout(timer);
90
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
91
+ },
92
+ { once: true }
93
+ );
94
+ });
95
+ }
96
+ function resolveRetryConfig(resilience) {
97
+ if (resilience === false) return null;
98
+ if (!resilience || resilience.retry === void 0) return DEFAULT_RETRY_CONFIG;
99
+ if (resilience.retry === false) return null;
100
+ return { ...DEFAULT_RETRY_CONFIG, ...resilience.retry };
101
+ }
102
+ async function withRetry(fn, config = DEFAULT_RETRY_CONFIG, signal) {
103
+ let lastError;
104
+ for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
105
+ if (signal?.aborted) {
106
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
107
+ }
108
+ try {
109
+ return await fn();
110
+ } catch (err) {
111
+ lastError = err instanceof Error ? err : new Error(String(err));
112
+ if (!isRetryableError(err)) throw lastError;
113
+ if (attempt < config.maxAttempts - 1) {
114
+ const delayMs = err instanceof HttpError && err.retryAfter != null ? err.retryAfter * 1e3 : calculateBackoff(attempt, config);
115
+ await sleep(delayMs, signal);
116
+ }
117
+ }
118
+ }
119
+ throw new RetryExhaustedError(
120
+ "operation",
121
+ config.maxAttempts,
122
+ lastError
123
+ );
124
+ }
20
125
 
21
126
  // src/resolve-url.ts
22
127
  function createManifest(serverUrl) {
@@ -35,27 +140,55 @@ function resolveServerUrl(manifest, channelId) {
35
140
  import EventSource from "react-native-sse";
36
141
  var DEFAULT_RECONNECT_DELAY_MS = 3e3;
37
142
  function createSSEConnection(config, callbacks) {
38
- const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
143
+ const baseDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
39
144
  const customEvents = config.customEvents ?? [];
145
+ const monitor = config.networkMonitor;
146
+ const backoffConfig = {
147
+ maxAttempts: Infinity,
148
+ baseDelayMs: baseDelay,
149
+ maxDelayMs: 3e4,
150
+ jitterFactor: 0.3
151
+ };
40
152
  let currentLastEventId = config.lastEventId;
41
153
  let es = null;
42
154
  let disposed = false;
43
155
  let reconnectTimer = null;
156
+ let attempt = 0;
157
+ let waitAbort = null;
44
158
  function cleanup() {
45
159
  if (es) {
46
160
  es.removeAllEventListeners();
47
161
  es.close();
48
162
  es = null;
49
163
  }
164
+ if (reconnectTimer) {
165
+ clearTimeout(reconnectTimer);
166
+ reconnectTimer = null;
167
+ }
168
+ if (waitAbort) {
169
+ waitAbort.abort();
170
+ waitAbort = null;
171
+ }
50
172
  }
51
- function scheduleReconnect() {
52
- if (disposed || reconnectTimer) return;
173
+ async function scheduleReconnect() {
174
+ if (disposed || reconnectTimer || waitAbort) return;
53
175
  callbacks.onReconnecting?.();
54
176
  cleanup();
177
+ if (monitor && !monitor.isConnected()) {
178
+ waitAbort = new AbortController();
179
+ try {
180
+ await monitor.waitForOnline(waitAbort.signal);
181
+ } catch {
182
+ return;
183
+ }
184
+ waitAbort = null;
185
+ if (disposed) return;
186
+ }
187
+ const delay = calculateBackoff(attempt++, backoffConfig);
55
188
  reconnectTimer = setTimeout(() => {
56
189
  reconnectTimer = null;
57
190
  connect();
58
- }, reconnectDelay);
191
+ }, delay);
59
192
  }
60
193
  function connect() {
61
194
  if (disposed) return;
@@ -68,6 +201,7 @@ function createSSEConnection(config, callbacks) {
68
201
  });
69
202
  es.addEventListener("open", () => {
70
203
  if (disposed) return;
204
+ attempt = 0;
71
205
  callbacks.onOpen?.();
72
206
  });
73
207
  es.addEventListener("message", (event) => {
@@ -104,11 +238,13 @@ function createSSEConnection(config, callbacks) {
104
238
  return {
105
239
  close: () => {
106
240
  disposed = true;
107
- if (reconnectTimer) {
108
- clearTimeout(reconnectTimer);
109
- reconnectTimer = null;
110
- }
111
241
  cleanup();
242
+ },
243
+ reconnectImmediate: () => {
244
+ if (disposed) return;
245
+ cleanup();
246
+ attempt = 0;
247
+ connect();
112
248
  }
113
249
  };
114
250
  }
@@ -116,23 +252,46 @@ function createSSEConnection(config, callbacks) {
116
252
  // src/session.ts
117
253
  var DEFAULT_RECONNECT_DELAY_MS2 = 3e3;
118
254
  var MAX_SEEN_IDS = 500;
119
- async function createChatSession(config, channelId, profile, callbacks) {
255
+ var MARK_READ_RETRY_CONFIG = {
256
+ maxAttempts: 2,
257
+ baseDelayMs: 500,
258
+ maxDelayMs: 2e3,
259
+ jitterFactor: 0.3
260
+ };
261
+ function parseRetryAfter(res) {
262
+ const header = res.headers.get("Retry-After");
263
+ if (!header) return void 0;
264
+ const seconds = Number(header);
265
+ return Number.isFinite(seconds) ? seconds : void 0;
266
+ }
267
+ async function throwHttpError(res) {
268
+ const body = await res.text().catch(() => "");
269
+ throw new HttpError(res.status, body, parseRetryAfter(res));
270
+ }
271
+ async function createChatSession(config, channelId, profile, callbacks, networkMonitor) {
120
272
  const serviceUrl = resolveServerUrl(config.manifest, channelId);
121
273
  const customHeaders = config.headers ?? {};
122
274
  const reconnectDelay = config.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS2;
275
+ const retryConfig = resolveRetryConfig(config.resilience);
276
+ const sessionAbort = new AbortController();
123
277
  callbacks.onStatusChange("connecting");
124
- const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
125
- method: "POST",
126
- headers: { "Content-Type": "application/json", ...customHeaders },
127
- body: JSON.stringify(profile)
128
- });
129
- if (joinRes.status === 410) {
130
- throw new ChannelClosedError(channelId);
131
- }
132
- if (!joinRes.ok) {
133
- throw new Error(`Join failed: ${joinRes.status} ${await joinRes.text()}`);
134
- }
135
- const { messages, participants, joined_at } = await joinRes.json();
278
+ const joinFn = async () => {
279
+ const joinRes = await fetch(`${serviceUrl}/channels/${channelId}/join`, {
280
+ method: "POST",
281
+ headers: { "Content-Type": "application/json", ...customHeaders },
282
+ body: JSON.stringify(profile),
283
+ signal: sessionAbort.signal
284
+ });
285
+ if (joinRes.status === 410) {
286
+ throw new ChannelClosedError(channelId);
287
+ }
288
+ if (!joinRes.ok) {
289
+ await throwHttpError(joinRes);
290
+ }
291
+ return joinRes.json();
292
+ };
293
+ const joinData = retryConfig ? await withRetry(joinFn, retryConfig, sessionAbort.signal) : await joinFn();
294
+ const { messages, participants, joined_at } = joinData;
136
295
  let lastEventId = messages.length > 0 ? messages[messages.length - 1].id : void 0;
137
296
  const joinedAt = joined_at;
138
297
  const seenMessageIds = new Set(messages.map((m) => m.id));
@@ -156,7 +315,8 @@ async function createChatSession(config, channelId, profile, callbacks) {
156
315
  headers: customHeaders,
157
316
  reconnectDelayMs: reconnectDelay,
158
317
  lastEventId,
159
- customEvents: ["resync"]
318
+ customEvents: ["resync"],
319
+ networkMonitor
160
320
  },
161
321
  {
162
322
  onOpen: () => {
@@ -180,8 +340,6 @@ async function createChatSession(config, channelId, profile, callbacks) {
180
340
  trimSeenIds();
181
341
  callbacks.onMessage(message);
182
342
  } else if (eventType === "resync") {
183
- sseConn?.close();
184
- sseConn = null;
185
343
  callbacks.onResync();
186
344
  }
187
345
  },
@@ -204,44 +362,65 @@ async function createChatSession(config, channelId, profile, callbacks) {
204
362
  channelId,
205
363
  initialParticipants: participants,
206
364
  initialMessages: messages,
207
- sendMessage: async (type, body, attributes) => {
365
+ networkMonitor: networkMonitor ?? null,
366
+ sendMessage: async (type, body, attributes, idempotencyKey) => {
208
367
  if (disposed) throw new ChatDisconnectedError("disconnected");
209
368
  const payload = {
210
369
  sender_id: profile.id,
211
370
  type,
212
371
  body,
213
- attributes
372
+ attributes,
373
+ ...idempotencyKey ? { idempotency_key: idempotencyKey } : {}
214
374
  };
215
- const res = await fetch(
216
- `${serviceUrl}/channels/${channelId}/messages`,
217
- {
218
- method: "POST",
219
- headers: { "Content-Type": "application/json", ...customHeaders },
220
- body: JSON.stringify(payload)
375
+ const sendFn = async () => {
376
+ const res = await fetch(
377
+ `${serviceUrl}/channels/${channelId}/messages`,
378
+ {
379
+ method: "POST",
380
+ headers: { "Content-Type": "application/json", ...customHeaders },
381
+ body: JSON.stringify(payload),
382
+ signal: sessionAbort.signal
383
+ }
384
+ );
385
+ if (!res.ok) {
386
+ await throwHttpError(res);
221
387
  }
222
- );
223
- if (!res.ok) {
224
- throw new Error(`Send failed: ${res.status} ${await res.text()}`);
225
- }
226
- const response = await res.json();
388
+ return res.json();
389
+ };
390
+ const response = retryConfig ? await withRetry(sendFn, retryConfig, sessionAbort.signal) : await sendFn();
227
391
  seenMessageIds.add(response.id);
228
392
  return response;
229
393
  },
230
394
  markAsRead: async (messageId) => {
231
- const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
232
- method: "POST",
233
- headers: { "Content-Type": "application/json", ...customHeaders },
234
- body: JSON.stringify({
235
- participant_id: profile.id,
236
- message_id: messageId
237
- })
238
- });
239
- if (!res.ok) {
240
- throw new Error(`markAsRead failed: ${res.status}`);
395
+ const readFn = async () => {
396
+ const res = await fetch(`${serviceUrl}/channels/${channelId}/read`, {
397
+ method: "POST",
398
+ headers: { "Content-Type": "application/json", ...customHeaders },
399
+ body: JSON.stringify({
400
+ participant_id: profile.id,
401
+ message_id: messageId
402
+ }),
403
+ signal: sessionAbort.signal
404
+ });
405
+ if (!res.ok) {
406
+ await throwHttpError(res);
407
+ }
408
+ };
409
+ if (retryConfig) {
410
+ try {
411
+ await withRetry(readFn, MARK_READ_RETRY_CONFIG, sessionAbort.signal);
412
+ } catch (err) {
413
+ if (err instanceof HttpError) {
414
+ callbacks.onError(err);
415
+ }
416
+ }
417
+ } else {
418
+ await readFn();
241
419
  }
242
420
  },
243
421
  disconnect: () => {
244
422
  disposed = true;
423
+ sessionAbort.abort();
245
424
  sseConn?.close();
246
425
  sseConn = null;
247
426
  callbacks.onStatusChange("disconnected");
@@ -249,13 +428,342 @@ async function createChatSession(config, channelId, profile, callbacks) {
249
428
  };
250
429
  }
251
430
 
431
+ // src/network-monitor.ts
432
+ function createStubMonitor() {
433
+ return {
434
+ isConnected: () => true,
435
+ subscribe: () => () => {
436
+ },
437
+ waitForOnline: () => Promise.resolve(),
438
+ dispose: () => {
439
+ }
440
+ };
441
+ }
442
+ var resolvedNetInfo = void 0;
443
+ var netInfoResolved = false;
444
+ function getNetInfo() {
445
+ if (netInfoResolved) return resolvedNetInfo;
446
+ netInfoResolved = true;
447
+ try {
448
+ resolvedNetInfo = __require("@react-native-community/netinfo");
449
+ } catch {
450
+ resolvedNetInfo = null;
451
+ }
452
+ return resolvedNetInfo;
453
+ }
454
+ function createNetworkMonitor() {
455
+ const NetInfo = getNetInfo();
456
+ if (!NetInfo) return createStubMonitor();
457
+ const netInfoModule = NetInfo.default ?? NetInfo;
458
+ let connected = true;
459
+ const listeners = /* @__PURE__ */ new Set();
460
+ let unsubscribeNetInfo = null;
461
+ unsubscribeNetInfo = netInfoModule.addEventListener(
462
+ (state) => {
463
+ const next = state.isConnected !== false;
464
+ if (next === connected) return;
465
+ connected = next;
466
+ for (const cb of listeners) {
467
+ cb(connected);
468
+ }
469
+ }
470
+ );
471
+ return {
472
+ isConnected: () => connected,
473
+ subscribe: (cb) => {
474
+ listeners.add(cb);
475
+ return () => {
476
+ listeners.delete(cb);
477
+ };
478
+ },
479
+ waitForOnline: (signal) => {
480
+ if (connected) return Promise.resolve();
481
+ return new Promise((resolve, reject) => {
482
+ if (signal?.aborted) {
483
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
484
+ return;
485
+ }
486
+ const unsub = () => {
487
+ listeners.delete(handler);
488
+ signal?.removeEventListener("abort", onAbort);
489
+ };
490
+ const handler = (isOnline) => {
491
+ if (isOnline) {
492
+ unsub();
493
+ resolve();
494
+ }
495
+ };
496
+ const onAbort = () => {
497
+ unsub();
498
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
499
+ };
500
+ listeners.add(handler);
501
+ signal?.addEventListener("abort", onAbort, { once: true });
502
+ });
503
+ },
504
+ dispose: () => {
505
+ listeners.clear();
506
+ unsubscribeNetInfo?.();
507
+ unsubscribeNetInfo = null;
508
+ }
509
+ };
510
+ }
511
+
512
+ // src/message-queue.ts
513
+ var resolvedStorage;
514
+ function tryRequire(name) {
515
+ try {
516
+ return __require(name);
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+ function createMmkvAdapter(mod) {
522
+ const MMKV = mod.MMKV ?? mod.default?.MMKV ?? mod;
523
+ const instance = new MMKV({ id: "chika-queue" });
524
+ return {
525
+ getItem: (key) => Promise.resolve(instance.getString(key) ?? null),
526
+ setItem: (key, value) => {
527
+ instance.set(key, value);
528
+ return Promise.resolve();
529
+ },
530
+ removeItem: (key) => {
531
+ instance.delete(key);
532
+ return Promise.resolve();
533
+ }
534
+ };
535
+ }
536
+ function createAsyncStorageAdapterFrom(mod) {
537
+ const storage = mod.default ?? mod;
538
+ return {
539
+ getItem: (key) => storage.getItem(key),
540
+ setItem: (key, value) => storage.setItem(key, value),
541
+ removeItem: (key) => storage.removeItem(key)
542
+ };
543
+ }
544
+ function createQueueStorage() {
545
+ if (resolvedStorage !== void 0) return resolvedStorage?.adapter ?? null;
546
+ const mmkv = tryRequire("react-native-mmkv");
547
+ if (mmkv) {
548
+ try {
549
+ const adapter = createMmkvAdapter(mmkv);
550
+ resolvedStorage = { type: "mmkv", adapter };
551
+ return adapter;
552
+ } catch {
553
+ }
554
+ }
555
+ const asyncStorage = tryRequire("@react-native-async-storage/async-storage");
556
+ if (asyncStorage) {
557
+ const adapter = createAsyncStorageAdapterFrom(asyncStorage);
558
+ resolvedStorage = { type: "async-storage", adapter };
559
+ return adapter;
560
+ }
561
+ resolvedStorage = null;
562
+ return null;
563
+ }
564
+ function createAsyncStorageAdapter() {
565
+ const mod = tryRequire("@react-native-async-storage/async-storage");
566
+ if (!mod) return null;
567
+ return createAsyncStorageAdapterFrom(mod);
568
+ }
569
+ var MessageQueue = class {
570
+ constructor(config) {
571
+ this.config = config;
572
+ this.storageKey = `chika_queue_${config.channelId}`;
573
+ this.unsubNetwork = config.networkMonitor.subscribe((connected) => {
574
+ if (connected) this.flush();
575
+ });
576
+ }
577
+ entries = [];
578
+ flushing = false;
579
+ unsubNetwork = null;
580
+ storageKey;
581
+ /**
582
+ * Restore queued messages from persistent storage on cold start.
583
+ * Restored entries are fire-and-forget — there is no caller awaiting their
584
+ * promise (the original enqueue() caller is gone after app restart).
585
+ * Success/failure is reported via onStatusChange/onError callbacks.
586
+ */
587
+ async restore(rebuildSendFn) {
588
+ if (!this.config.storage) return;
589
+ try {
590
+ const raw = await this.config.storage.getItem(this.storageKey);
591
+ if (!raw) return;
592
+ const persisted = JSON.parse(raw);
593
+ for (const entry of persisted) {
594
+ const sendFn = rebuildSendFn(entry.optimisticId);
595
+ if (!sendFn) continue;
596
+ const abort = new AbortController();
597
+ this.entries.push({
598
+ optimisticId: entry.optimisticId,
599
+ sendFn,
600
+ status: "queued",
601
+ retryCount: entry.retryCount,
602
+ abort,
603
+ // No-op: restored entries have no caller awaiting the promise.
604
+ resolve: () => {
605
+ },
606
+ reject: () => {
607
+ }
608
+ });
609
+ }
610
+ if (this.entries.length > 0) {
611
+ this.config.onStatusChange?.();
612
+ this.flush();
613
+ }
614
+ } catch {
615
+ this.config.onError?.(new Error("Failed to restore message queue from storage"));
616
+ }
617
+ }
618
+ get pendingCount() {
619
+ return this.entries.length;
620
+ }
621
+ getAll() {
622
+ return this.entries.map((e) => ({
623
+ optimisticId: e.optimisticId,
624
+ status: e.status,
625
+ error: e.error,
626
+ retryCount: e.retryCount
627
+ }));
628
+ }
629
+ getStatus(optimisticId) {
630
+ const entry = this.entries.find((e) => e.optimisticId === optimisticId);
631
+ if (!entry) return void 0;
632
+ return {
633
+ optimisticId: entry.optimisticId,
634
+ status: entry.status,
635
+ error: entry.error,
636
+ retryCount: entry.retryCount
637
+ };
638
+ }
639
+ enqueue(sendFn, optimisticId) {
640
+ if (this.entries.length >= this.config.maxSize) {
641
+ throw new QueueFullError(this.config.maxSize);
642
+ }
643
+ const abort = new AbortController();
644
+ return new Promise((resolve, reject) => {
645
+ const entry = {
646
+ optimisticId,
647
+ sendFn,
648
+ status: "queued",
649
+ retryCount: 0,
650
+ abort,
651
+ resolve,
652
+ reject
653
+ };
654
+ this.entries.push(entry);
655
+ this.config.onStatusChange?.();
656
+ this.persist();
657
+ this.flush();
658
+ });
659
+ }
660
+ cancel(optimisticId) {
661
+ const idx = this.entries.findIndex((e) => e.optimisticId === optimisticId);
662
+ if (idx === -1) return;
663
+ const entry = this.entries[idx];
664
+ entry.abort.abort();
665
+ entry.reject(new DOMException("Cancelled", "AbortError"));
666
+ this.entries.splice(idx, 1);
667
+ this.config.onStatusChange?.();
668
+ this.persist();
669
+ }
670
+ retry(optimisticId) {
671
+ const entry = this.entries.find((e) => e.optimisticId === optimisticId);
672
+ if (!entry || entry.status !== "failed") return;
673
+ entry.status = "queued";
674
+ entry.error = void 0;
675
+ entry.abort = new AbortController();
676
+ this.config.onStatusChange?.();
677
+ this.flush();
678
+ }
679
+ dispose() {
680
+ this.unsubNetwork?.();
681
+ this.unsubNetwork = null;
682
+ for (const entry of this.entries) {
683
+ entry.abort.abort();
684
+ entry.reject(new DOMException("Queue disposed", "AbortError"));
685
+ }
686
+ this.entries = [];
687
+ }
688
+ /**
689
+ * Trigger a flush of queued messages. Returns immediately if a flush is
690
+ * already in progress or the network is offline. The in-progress flush
691
+ * will pick up any newly queued entries via its while loop.
692
+ */
693
+ async flush() {
694
+ if (this.flushing) return;
695
+ if (!this.config.networkMonitor.isConnected()) return;
696
+ this.flushing = true;
697
+ let awaitingSession = false;
698
+ try {
699
+ while (this.entries.length > 0) {
700
+ const entry = this.entries.find((e) => e.status === "queued");
701
+ if (!entry) break;
702
+ if (!this.config.networkMonitor.isConnected()) break;
703
+ entry.status = "sending";
704
+ this.config.onStatusChange?.();
705
+ try {
706
+ const result = await withRetry(
707
+ entry.sendFn,
708
+ this.config.retryConfig,
709
+ entry.abort.signal
710
+ );
711
+ entry.resolve(result);
712
+ const idx = this.entries.indexOf(entry);
713
+ if (idx !== -1) this.entries.splice(idx, 1);
714
+ this.config.onStatusChange?.();
715
+ this.persist();
716
+ } catch (err) {
717
+ if (entry.abort.signal.aborted) continue;
718
+ if (err instanceof ChatDisconnectedError) {
719
+ entry.status = "queued";
720
+ this.config.onStatusChange?.();
721
+ awaitingSession = true;
722
+ break;
723
+ }
724
+ entry.status = "failed";
725
+ entry.error = err instanceof Error ? err : new Error(String(err));
726
+ entry.retryCount++;
727
+ this.config.onStatusChange?.();
728
+ this.persist();
729
+ }
730
+ }
731
+ } finally {
732
+ this.flushing = false;
733
+ if (!awaitingSession && this.entries.some((e) => e.status === "queued") && this.config.networkMonitor.isConnected()) {
734
+ queueMicrotask(() => this.flush());
735
+ }
736
+ }
737
+ }
738
+ persist() {
739
+ if (!this.config.storage) return;
740
+ const data = this.entries.map((e) => ({
741
+ optimisticId: e.optimisticId,
742
+ retryCount: e.retryCount
743
+ }));
744
+ const onErr = (err) => {
745
+ this.config.onError?.(
746
+ err instanceof Error ? err : new Error("Queue storage write failed")
747
+ );
748
+ };
749
+ if (data.length === 0) {
750
+ this.config.storage.removeItem(this.storageKey).catch(onErr);
751
+ } else {
752
+ this.config.storage.setItem(this.storageKey, JSON.stringify(data)).catch(onErr);
753
+ }
754
+ }
755
+ };
756
+
252
757
  // src/use-chat.ts
253
758
  var DEFAULT_BACKGROUND_GRACE_MS = 2e3;
759
+ var DEFAULT_MAX_QUEUE_SIZE = 50;
760
+ var queueRegistry = /* @__PURE__ */ new Map();
254
761
  function useChat({ config, channelId, profile, onMessage }) {
255
762
  const [messages, setMessages] = useState([]);
256
763
  const [participants, setParticipants] = useState([]);
257
764
  const [status, setStatus] = useState("connecting");
258
765
  const [error, setError] = useState(null);
766
+ const [pendingMessages, setPendingMessages] = useState([]);
259
767
  const sessionRef = useRef(null);
260
768
  const disposedRef = useRef(false);
261
769
  const messagesRef = useRef(messages);
@@ -272,10 +780,36 @@ function useChat({ config, channelId, profile, onMessage }) {
272
780
  onMessageRef.current = onMessage;
273
781
  const startingRef = useRef(false);
274
782
  const pendingOptimisticIds = useRef(/* @__PURE__ */ new Set());
783
+ const [monitor, setMonitor] = useState(null);
784
+ const monitorRef = useRef(null);
785
+ monitorRef.current = monitor;
786
+ const queueRef = useRef(null);
787
+ const resilienceEnabled = config.resilience !== false;
788
+ const queueEnabled = resilienceEnabled && (typeof config.resilience === "object" ? config.resilience.offlineQueue !== false : true);
789
+ const retryConfig = resolveRetryConfig(config.resilience);
790
+ const maxQueueSize = (resilienceEnabled && config.resilience && typeof config.resilience === "object" ? config.resilience.maxQueueSize : void 0) ?? DEFAULT_MAX_QUEUE_SIZE;
275
791
  const backgroundGraceMs = config.backgroundGraceMs ?? (Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS : 0);
792
+ const injectedMonitor = typeof config.resilience === "object" ? config.resilience.networkMonitor : void 0;
793
+ useEffect(() => {
794
+ if (!resilienceEnabled) {
795
+ setMonitor(null);
796
+ return;
797
+ }
798
+ if (injectedMonitor) {
799
+ setMonitor(injectedMonitor);
800
+ return;
801
+ }
802
+ const m = createNetworkMonitor();
803
+ setMonitor(m);
804
+ return () => {
805
+ m.dispose();
806
+ setMonitor(null);
807
+ };
808
+ }, [resilienceEnabled, injectedMonitor]);
276
809
  const callbacks = {
277
810
  onMessage: (message) => {
278
811
  if (disposedRef.current) return;
812
+ let matchedOptimisticId = null;
279
813
  setMessages((prev) => {
280
814
  if (pendingOptimisticIds.current.size === 0) {
281
815
  return [...prev, message];
@@ -284,20 +818,28 @@ function useChat({ config, channelId, profile, onMessage }) {
284
818
  (m) => pendingOptimisticIds.current.has(m.id) && m.sender_id === message.sender_id && m.body === message.body && m.type === message.type
285
819
  );
286
820
  if (optimisticIdx !== -1) {
287
- const optimisticId = prev[optimisticIdx].id;
288
- pendingOptimisticIds.current.delete(optimisticId);
821
+ matchedOptimisticId = prev[optimisticIdx].id;
822
+ pendingOptimisticIds.current.delete(matchedOptimisticId);
289
823
  const next = [...prev];
290
824
  next[optimisticIdx] = message;
291
825
  return next;
292
826
  }
293
827
  return [...prev, message];
294
828
  });
829
+ if (matchedOptimisticId) {
830
+ const s = queueRef.current?.getStatus(matchedOptimisticId);
831
+ if (s && s.status !== "sending") {
832
+ queueRef.current?.cancel(matchedOptimisticId);
833
+ }
834
+ }
295
835
  onMessageRef.current?.(message);
296
836
  },
297
837
  onStatusChange: (nextStatus) => {
298
838
  if (disposedRef.current) return;
299
839
  setStatus(nextStatus);
300
- if (nextStatus === "connected") setError(null);
840
+ if (nextStatus === "connected") {
841
+ setError(null);
842
+ }
301
843
  },
302
844
  onError: (err) => {
303
845
  if (disposedRef.current) return;
@@ -317,14 +859,29 @@ function useChat({ config, channelId, profile, onMessage }) {
317
859
  sessionRef.current = null;
318
860
  }
319
861
  try {
320
- const session = await createChatSession(configRef.current, channelId, profileRef.current, callbacks);
862
+ const session = await createChatSession(
863
+ configRef.current,
864
+ channelId,
865
+ profileRef.current,
866
+ callbacks,
867
+ monitorRef.current ?? void 0
868
+ );
321
869
  if (disposedRef.current) {
322
870
  session.disconnect();
323
871
  return;
324
872
  }
325
873
  sessionRef.current = session;
326
874
  setParticipants(session.initialParticipants);
327
- setMessages(session.initialMessages);
875
+ queueRef.current?.flush();
876
+ if (pendingOptimisticIds.current.size > 0) {
877
+ const pendingIds = pendingOptimisticIds.current;
878
+ setMessages((prev) => {
879
+ const pendingMsgs = prev.filter((m) => pendingIds.has(m.id));
880
+ return [...session.initialMessages, ...pendingMsgs];
881
+ });
882
+ } else {
883
+ setMessages(session.initialMessages);
884
+ }
328
885
  } catch (err) {
329
886
  if (disposedRef.current) return;
330
887
  if (err instanceof ChannelClosedError) {
@@ -340,6 +897,7 @@ function useChat({ config, channelId, profile, onMessage }) {
340
897
  }
341
898
  useEffect(() => {
342
899
  disposedRef.current = false;
900
+ if (resilienceEnabled && !monitor) return;
343
901
  startSession();
344
902
  return () => {
345
903
  disposedRef.current = true;
@@ -357,7 +915,52 @@ function useChat({ config, channelId, profile, onMessage }) {
357
915
  sessionRef.current?.disconnect();
358
916
  sessionRef.current = null;
359
917
  };
360
- }, [channelId]);
918
+ }, [channelId, monitor]);
919
+ useEffect(() => {
920
+ if (!queueEnabled || !monitor || !retryConfig) return;
921
+ let entry = queueRegistry.get(channelId);
922
+ if (!entry) {
923
+ const queue = new MessageQueue({
924
+ channelId,
925
+ maxSize: maxQueueSize,
926
+ retryConfig,
927
+ networkMonitor: monitor,
928
+ storage: typeof config.resilience === "object" ? config.resilience.queueStorage : void 0,
929
+ onError: (err) => {
930
+ if (!disposedRef.current) setError(err);
931
+ },
932
+ onStatusChange: () => {
933
+ if (!disposedRef.current) {
934
+ setPendingMessages(queueRef.current?.getAll() ?? []);
935
+ }
936
+ }
937
+ });
938
+ entry = { queue, refCount: 0 };
939
+ queueRegistry.set(channelId, entry);
940
+ }
941
+ entry.refCount++;
942
+ queueRef.current = entry.queue;
943
+ return () => {
944
+ const e = queueRegistry.get(channelId);
945
+ if (e) {
946
+ e.refCount--;
947
+ if (e.refCount <= 0) {
948
+ e.queue.dispose();
949
+ queueRegistry.delete(channelId);
950
+ }
951
+ }
952
+ queueRef.current = null;
953
+ };
954
+ }, [channelId, queueEnabled, monitor]);
955
+ useEffect(() => {
956
+ if (!monitor || !resilienceEnabled) return;
957
+ const unsub = monitor.subscribe((connected) => {
958
+ if (connected && statusRef.current === "error" && !startingRef.current) {
959
+ startSession();
960
+ }
961
+ });
962
+ return unsub;
963
+ }, [channelId, resilienceEnabled, monitor]);
361
964
  useEffect(() => {
362
965
  function teardownSession() {
363
966
  sessionRef.current?.disconnect();
@@ -403,12 +1006,11 @@ function useChat({ config, channelId, profile, onMessage }) {
403
1006
  const session = sessionRef.current;
404
1007
  if (!session) throw new ChatDisconnectedError(statusRef.current);
405
1008
  const optimistic = configRef.current.optimisticSend !== false;
406
- let optimisticId = null;
1009
+ const messageKey = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
407
1010
  if (optimistic) {
408
- optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
409
- pendingOptimisticIds.current.add(optimisticId);
1011
+ pendingOptimisticIds.current.add(messageKey);
410
1012
  const provisionalMsg = {
411
- id: optimisticId,
1013
+ id: messageKey,
412
1014
  channel_id: channelId,
413
1015
  sender_id: profileRef.current.id,
414
1016
  sender_role: profileRef.current.role,
@@ -419,35 +1021,91 @@ function useChat({ config, channelId, profile, onMessage }) {
419
1021
  };
420
1022
  setMessages((prev) => [...prev, provisionalMsg]);
421
1023
  }
422
- try {
423
- const response = await session.sendMessage(type, body, attributes);
424
- if (optimistic && optimisticId) {
425
- pendingOptimisticIds.current.delete(optimisticId);
1024
+ const doSend = () => {
1025
+ const s = sessionRef.current;
1026
+ if (!s) throw new ChatDisconnectedError(statusRef.current);
1027
+ return s.sendMessage(type, body, attributes, messageKey);
1028
+ };
1029
+ const handleSuccess = (response) => {
1030
+ if (optimistic) {
1031
+ pendingOptimisticIds.current.delete(messageKey);
426
1032
  setMessages((prev) => {
427
- const stillPending = prev.some((m) => m.id === optimisticId);
1033
+ const stillPending = prev.some((m) => m.id === messageKey);
428
1034
  if (!stillPending) return prev;
429
1035
  return prev.map(
430
- (m) => m.id === optimisticId ? { ...m, id: response.id, created_at: response.created_at } : m
1036
+ (m) => m.id === messageKey ? { ...m, id: response.id, created_at: response.created_at } : m
431
1037
  );
432
1038
  });
433
1039
  }
1040
+ };
1041
+ const handleError = (_err) => {
1042
+ if (optimistic) {
1043
+ pendingOptimisticIds.current.delete(messageKey);
1044
+ setMessages((prev) => prev.filter((m) => m.id !== messageKey));
1045
+ }
1046
+ };
1047
+ if (queueRef.current) {
1048
+ try {
1049
+ const response = await queueRef.current.enqueue(doSend, messageKey);
1050
+ handleSuccess(response);
1051
+ return response;
1052
+ } catch (err) {
1053
+ if (err instanceof QueueFullError) {
1054
+ handleError(err);
1055
+ throw err;
1056
+ }
1057
+ if (err instanceof RetryExhaustedError || !isRetryableError(err)) {
1058
+ if (optimistic && err instanceof RetryExhaustedError) {
1059
+ } else {
1060
+ handleError(err);
1061
+ }
1062
+ throw err;
1063
+ }
1064
+ handleError(err);
1065
+ throw err;
1066
+ }
1067
+ }
1068
+ try {
1069
+ const response = await doSend();
1070
+ handleSuccess(response);
434
1071
  return response;
435
1072
  } catch (err) {
436
- if (optimistic && optimisticId) {
437
- pendingOptimisticIds.current.delete(optimisticId);
438
- setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
439
- }
1073
+ handleError(err);
440
1074
  throw err;
441
1075
  }
442
1076
  },
443
1077
  [channelId]
444
1078
  );
1079
+ const cancelMessage = useCallback(
1080
+ (optimisticId) => {
1081
+ queueRef.current?.cancel(optimisticId);
1082
+ pendingOptimisticIds.current.delete(optimisticId);
1083
+ setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
1084
+ },
1085
+ []
1086
+ );
1087
+ const retryMessage = useCallback(
1088
+ (optimisticId) => {
1089
+ queueRef.current?.retry(optimisticId);
1090
+ },
1091
+ []
1092
+ );
445
1093
  const disconnect = useCallback(() => {
446
1094
  sessionRef.current?.disconnect();
447
1095
  sessionRef.current = null;
448
1096
  setStatus("disconnected");
449
1097
  }, []);
450
- return { messages, participants, status, error, sendMessage, disconnect };
1098
+ return {
1099
+ messages,
1100
+ participants,
1101
+ status,
1102
+ error,
1103
+ sendMessage,
1104
+ disconnect,
1105
+ pendingMessages,
1106
+ cancelMessage,
1107
+ retryMessage
1108
+ };
451
1109
  }
452
1110
 
453
1111
  // src/use-unread.ts
@@ -466,6 +1124,25 @@ function useUnread(options) {
466
1124
  const appStateRef = useRef2(AppState2.currentState);
467
1125
  const backgroundTimerRef = useRef2(null);
468
1126
  const backgroundGraceMs = config.backgroundGraceMs ?? (Platform2.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS2 : 0);
1127
+ const [monitor, setMonitor] = useState2(null);
1128
+ const resilienceEnabled = config.resilience !== false;
1129
+ const injectedMonitor = typeof config.resilience === "object" ? config.resilience.networkMonitor : void 0;
1130
+ useEffect2(() => {
1131
+ if (!resilienceEnabled) {
1132
+ setMonitor(null);
1133
+ return;
1134
+ }
1135
+ if (injectedMonitor) {
1136
+ setMonitor(injectedMonitor);
1137
+ return;
1138
+ }
1139
+ const m = createNetworkMonitor();
1140
+ setMonitor(m);
1141
+ return () => {
1142
+ m.dispose();
1143
+ setMonitor(null);
1144
+ };
1145
+ }, [resilienceEnabled, injectedMonitor]);
469
1146
  const connect = useCallback2(() => {
470
1147
  connRef.current?.close();
471
1148
  connRef.current = null;
@@ -477,7 +1154,8 @@ function useUnread(options) {
477
1154
  url,
478
1155
  headers: customHeaders,
479
1156
  reconnectDelayMs: configRef.current.reconnectDelayMs,
480
- customEvents: UNREAD_CUSTOM_EVENTS
1157
+ customEvents: UNREAD_CUSTOM_EVENTS,
1158
+ networkMonitor: monitor ?? void 0
481
1159
  },
482
1160
  {
483
1161
  onOpen: () => {
@@ -509,7 +1187,7 @@ function useUnread(options) {
509
1187
  }
510
1188
  }
511
1189
  );
512
- }, [channelId, participantId]);
1190
+ }, [channelId, participantId, monitor]);
513
1191
  const disconnect = useCallback2(() => {
514
1192
  connRef.current?.close();
515
1193
  connRef.current = null;
@@ -522,6 +1200,7 @@ function useUnread(options) {
522
1200
  disconnect();
523
1201
  return;
524
1202
  }
1203
+ if (resilienceEnabled && !monitor) return;
525
1204
  connect();
526
1205
  return () => {
527
1206
  disconnect();
@@ -572,11 +1251,21 @@ function useUnread(options) {
572
1251
  export {
573
1252
  ChannelClosedError,
574
1253
  ChatDisconnectedError,
1254
+ HttpError,
1255
+ QueueFullError,
1256
+ RetryExhaustedError,
1257
+ calculateBackoff,
1258
+ createAsyncStorageAdapter,
575
1259
  createChatSession,
576
1260
  createManifest,
1261
+ createNetworkMonitor,
1262
+ createQueueStorage,
577
1263
  createSSEConnection,
1264
+ isRetryableError,
1265
+ resolveRetryConfig,
578
1266
  resolveServerUrl,
579
1267
  useChat,
580
- useUnread
1268
+ useUnread,
1269
+ withRetry
581
1270
  };
582
1271
  //# sourceMappingURL=index.mjs.map