@pedi/chika-sdk 1.0.5 → 1.0.6

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,327 @@ 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: this.config.networkMonitor.isConnected() ? "sending" : "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
+ async flush() {
735
+ if (this.flushing) return;
736
+ if (!this.config.networkMonitor.isConnected()) return;
737
+ this.flushing = true;
738
+ try {
739
+ while (this.entries.length > 0) {
740
+ const entry = this.entries.find((e) => e.status === "queued");
741
+ if (!entry) break;
742
+ if (!this.config.networkMonitor.isConnected()) break;
743
+ entry.status = "sending";
744
+ this.config.onStatusChange?.();
745
+ try {
746
+ const result = await withRetry(
747
+ entry.sendFn,
748
+ this.config.retryConfig,
749
+ entry.abort.signal
750
+ );
751
+ entry.resolve(result);
752
+ const idx = this.entries.indexOf(entry);
753
+ if (idx !== -1) this.entries.splice(idx, 1);
754
+ this.config.onStatusChange?.();
755
+ this.persist();
756
+ } catch (err) {
757
+ if (entry.abort.signal.aborted) continue;
758
+ entry.status = "failed";
759
+ entry.error = err instanceof Error ? err : new Error(String(err));
760
+ entry.retryCount++;
761
+ this.config.onStatusChange?.();
762
+ this.persist();
763
+ }
764
+ }
765
+ } finally {
766
+ this.flushing = false;
767
+ }
768
+ }
769
+ persist() {
770
+ if (!this.config.storage) return;
771
+ const data = this.entries.map((e) => ({
772
+ optimisticId: e.optimisticId,
773
+ retryCount: e.retryCount
774
+ }));
775
+ const onErr = (err) => {
776
+ this.config.onError?.(
777
+ err instanceof Error ? err : new Error("Queue storage write failed")
778
+ );
779
+ };
780
+ if (data.length === 0) {
781
+ this.config.storage.removeItem(this.storageKey).catch(onErr);
782
+ } else {
783
+ this.config.storage.setItem(this.storageKey, JSON.stringify(data)).catch(onErr);
784
+ }
785
+ }
786
+ };
787
+
295
788
  // src/use-chat.ts
296
789
  var DEFAULT_BACKGROUND_GRACE_MS = 2e3;
790
+ var DEFAULT_MAX_QUEUE_SIZE = 50;
791
+ var queueRegistry = /* @__PURE__ */ new Map();
297
792
  function useChat({ config, channelId, profile, onMessage }) {
298
793
  const [messages, setMessages] = (0, import_react.useState)([]);
299
794
  const [participants, setParticipants] = (0, import_react.useState)([]);
300
795
  const [status, setStatus] = (0, import_react.useState)("connecting");
301
796
  const [error, setError] = (0, import_react.useState)(null);
797
+ const [pendingMessages, setPendingMessages] = (0, import_react.useState)([]);
302
798
  const sessionRef = (0, import_react.useRef)(null);
303
799
  const disposedRef = (0, import_react.useRef)(false);
304
800
  const messagesRef = (0, import_react.useRef)(messages);
@@ -315,7 +811,32 @@ function useChat({ config, channelId, profile, onMessage }) {
315
811
  onMessageRef.current = onMessage;
316
812
  const startingRef = (0, import_react.useRef)(false);
317
813
  const pendingOptimisticIds = (0, import_react.useRef)(/* @__PURE__ */ new Set());
814
+ const [monitor, setMonitor] = (0, import_react.useState)(null);
815
+ const monitorRef = (0, import_react.useRef)(null);
816
+ monitorRef.current = monitor;
817
+ const queueRef = (0, import_react.useRef)(null);
818
+ const resilienceEnabled = config.resilience !== false;
819
+ const queueEnabled = resilienceEnabled && (typeof config.resilience === "object" ? config.resilience.offlineQueue !== false : true);
820
+ const retryConfig = resolveRetryConfig(config.resilience);
821
+ const maxQueueSize = (resilienceEnabled && config.resilience && typeof config.resilience === "object" ? config.resilience.maxQueueSize : void 0) ?? DEFAULT_MAX_QUEUE_SIZE;
318
822
  const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS : 0);
823
+ const injectedMonitor = typeof config.resilience === "object" ? config.resilience.networkMonitor : void 0;
824
+ (0, import_react.useEffect)(() => {
825
+ if (!resilienceEnabled) {
826
+ setMonitor(null);
827
+ return;
828
+ }
829
+ if (injectedMonitor) {
830
+ setMonitor(injectedMonitor);
831
+ return;
832
+ }
833
+ const m = createNetworkMonitor();
834
+ setMonitor(m);
835
+ return () => {
836
+ m.dispose();
837
+ setMonitor(null);
838
+ };
839
+ }, [resilienceEnabled, injectedMonitor]);
319
840
  const callbacks = {
320
841
  onMessage: (message) => {
321
842
  if (disposedRef.current) return;
@@ -340,7 +861,9 @@ function useChat({ config, channelId, profile, onMessage }) {
340
861
  onStatusChange: (nextStatus) => {
341
862
  if (disposedRef.current) return;
342
863
  setStatus(nextStatus);
343
- if (nextStatus === "connected") setError(null);
864
+ if (nextStatus === "connected") {
865
+ setError(null);
866
+ }
344
867
  },
345
868
  onError: (err) => {
346
869
  if (disposedRef.current) return;
@@ -360,14 +883,28 @@ function useChat({ config, channelId, profile, onMessage }) {
360
883
  sessionRef.current = null;
361
884
  }
362
885
  try {
363
- const session = await createChatSession(configRef.current, channelId, profileRef.current, callbacks);
886
+ const session = await createChatSession(
887
+ configRef.current,
888
+ channelId,
889
+ profileRef.current,
890
+ callbacks,
891
+ monitorRef.current ?? void 0
892
+ );
364
893
  if (disposedRef.current) {
365
894
  session.disconnect();
366
895
  return;
367
896
  }
368
897
  sessionRef.current = session;
369
898
  setParticipants(session.initialParticipants);
370
- setMessages(session.initialMessages);
899
+ if (pendingOptimisticIds.current.size > 0) {
900
+ const pendingIds = pendingOptimisticIds.current;
901
+ setMessages((prev) => {
902
+ const pendingMsgs = prev.filter((m) => pendingIds.has(m.id));
903
+ return [...session.initialMessages, ...pendingMsgs];
904
+ });
905
+ } else {
906
+ setMessages(session.initialMessages);
907
+ }
371
908
  } catch (err) {
372
909
  if (disposedRef.current) return;
373
910
  if (err instanceof ChannelClosedError) {
@@ -383,6 +920,7 @@ function useChat({ config, channelId, profile, onMessage }) {
383
920
  }
384
921
  (0, import_react.useEffect)(() => {
385
922
  disposedRef.current = false;
923
+ if (resilienceEnabled && !monitor) return;
386
924
  startSession();
387
925
  return () => {
388
926
  disposedRef.current = true;
@@ -400,7 +938,52 @@ function useChat({ config, channelId, profile, onMessage }) {
400
938
  sessionRef.current?.disconnect();
401
939
  sessionRef.current = null;
402
940
  };
403
- }, [channelId]);
941
+ }, [channelId, monitor]);
942
+ (0, import_react.useEffect)(() => {
943
+ if (!queueEnabled || !monitor || !retryConfig) return;
944
+ let entry = queueRegistry.get(channelId);
945
+ if (!entry) {
946
+ const queue = new MessageQueue({
947
+ channelId,
948
+ maxSize: maxQueueSize,
949
+ retryConfig,
950
+ networkMonitor: monitor,
951
+ storage: typeof config.resilience === "object" ? config.resilience.queueStorage : void 0,
952
+ onError: (err) => {
953
+ if (!disposedRef.current) setError(err);
954
+ },
955
+ onStatusChange: () => {
956
+ if (!disposedRef.current) {
957
+ setPendingMessages(queueRef.current?.getAll() ?? []);
958
+ }
959
+ }
960
+ });
961
+ entry = { queue, refCount: 0 };
962
+ queueRegistry.set(channelId, entry);
963
+ }
964
+ entry.refCount++;
965
+ queueRef.current = entry.queue;
966
+ return () => {
967
+ const e = queueRegistry.get(channelId);
968
+ if (e) {
969
+ e.refCount--;
970
+ if (e.refCount <= 0) {
971
+ e.queue.dispose();
972
+ queueRegistry.delete(channelId);
973
+ }
974
+ }
975
+ queueRef.current = null;
976
+ };
977
+ }, [channelId, queueEnabled, monitor]);
978
+ (0, import_react.useEffect)(() => {
979
+ if (!monitor || !resilienceEnabled) return;
980
+ const unsub = monitor.subscribe((connected) => {
981
+ if (connected && statusRef.current === "error" && !startingRef.current) {
982
+ startSession();
983
+ }
984
+ });
985
+ return unsub;
986
+ }, [channelId, resilienceEnabled, monitor]);
404
987
  (0, import_react.useEffect)(() => {
405
988
  function teardownSession() {
406
989
  sessionRef.current?.disconnect();
@@ -446,12 +1029,11 @@ function useChat({ config, channelId, profile, onMessage }) {
446
1029
  const session = sessionRef.current;
447
1030
  if (!session) throw new ChatDisconnectedError(statusRef.current);
448
1031
  const optimistic = configRef.current.optimisticSend !== false;
449
- let optimisticId = null;
1032
+ const messageKey = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
450
1033
  if (optimistic) {
451
- optimisticId = `optimistic_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
452
- pendingOptimisticIds.current.add(optimisticId);
1034
+ pendingOptimisticIds.current.add(messageKey);
453
1035
  const provisionalMsg = {
454
- id: optimisticId,
1036
+ id: messageKey,
455
1037
  channel_id: channelId,
456
1038
  sender_id: profileRef.current.id,
457
1039
  sender_role: profileRef.current.role,
@@ -462,35 +1044,91 @@ function useChat({ config, channelId, profile, onMessage }) {
462
1044
  };
463
1045
  setMessages((prev) => [...prev, provisionalMsg]);
464
1046
  }
465
- try {
466
- const response = await session.sendMessage(type, body, attributes);
467
- if (optimistic && optimisticId) {
468
- pendingOptimisticIds.current.delete(optimisticId);
1047
+ const doSend = () => {
1048
+ const s = sessionRef.current;
1049
+ if (!s) throw new ChatDisconnectedError(statusRef.current);
1050
+ return s.sendMessage(type, body, attributes, messageKey);
1051
+ };
1052
+ const handleSuccess = (response) => {
1053
+ if (optimistic) {
1054
+ pendingOptimisticIds.current.delete(messageKey);
469
1055
  setMessages((prev) => {
470
- const stillPending = prev.some((m) => m.id === optimisticId);
1056
+ const stillPending = prev.some((m) => m.id === messageKey);
471
1057
  if (!stillPending) return prev;
472
1058
  return prev.map(
473
- (m) => m.id === optimisticId ? { ...m, id: response.id, created_at: response.created_at } : m
1059
+ (m) => m.id === messageKey ? { ...m, id: response.id, created_at: response.created_at } : m
474
1060
  );
475
1061
  });
476
1062
  }
1063
+ };
1064
+ const handleError = (_err) => {
1065
+ if (optimistic) {
1066
+ pendingOptimisticIds.current.delete(messageKey);
1067
+ setMessages((prev) => prev.filter((m) => m.id !== messageKey));
1068
+ }
1069
+ };
1070
+ if (queueRef.current) {
1071
+ try {
1072
+ const response = await queueRef.current.enqueue(doSend, messageKey);
1073
+ handleSuccess(response);
1074
+ return response;
1075
+ } catch (err) {
1076
+ if (err instanceof QueueFullError) {
1077
+ handleError(err);
1078
+ throw err;
1079
+ }
1080
+ if (err instanceof RetryExhaustedError || !isRetryableError(err)) {
1081
+ if (optimistic && err instanceof RetryExhaustedError) {
1082
+ } else {
1083
+ handleError(err);
1084
+ }
1085
+ throw err;
1086
+ }
1087
+ handleError(err);
1088
+ throw err;
1089
+ }
1090
+ }
1091
+ try {
1092
+ const response = await doSend();
1093
+ handleSuccess(response);
477
1094
  return response;
478
1095
  } catch (err) {
479
- if (optimistic && optimisticId) {
480
- pendingOptimisticIds.current.delete(optimisticId);
481
- setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
482
- }
1096
+ handleError(err);
483
1097
  throw err;
484
1098
  }
485
1099
  },
486
1100
  [channelId]
487
1101
  );
1102
+ const cancelMessage = (0, import_react.useCallback)(
1103
+ (optimisticId) => {
1104
+ queueRef.current?.cancel(optimisticId);
1105
+ pendingOptimisticIds.current.delete(optimisticId);
1106
+ setMessages((prev) => prev.filter((m) => m.id !== optimisticId));
1107
+ },
1108
+ []
1109
+ );
1110
+ const retryMessage = (0, import_react.useCallback)(
1111
+ (optimisticId) => {
1112
+ queueRef.current?.retry(optimisticId);
1113
+ },
1114
+ []
1115
+ );
488
1116
  const disconnect = (0, import_react.useCallback)(() => {
489
1117
  sessionRef.current?.disconnect();
490
1118
  sessionRef.current = null;
491
1119
  setStatus("disconnected");
492
1120
  }, []);
493
- return { messages, participants, status, error, sendMessage, disconnect };
1121
+ return {
1122
+ messages,
1123
+ participants,
1124
+ status,
1125
+ error,
1126
+ sendMessage,
1127
+ disconnect,
1128
+ pendingMessages,
1129
+ cancelMessage,
1130
+ retryMessage
1131
+ };
494
1132
  }
495
1133
 
496
1134
  // src/use-unread.ts
@@ -509,6 +1147,25 @@ function useUnread(options) {
509
1147
  const appStateRef = (0, import_react2.useRef)(import_react_native2.AppState.currentState);
510
1148
  const backgroundTimerRef = (0, import_react2.useRef)(null);
511
1149
  const backgroundGraceMs = config.backgroundGraceMs ?? (import_react_native2.Platform.OS === "android" ? DEFAULT_BACKGROUND_GRACE_MS2 : 0);
1150
+ const [monitor, setMonitor] = (0, import_react2.useState)(null);
1151
+ const resilienceEnabled = config.resilience !== false;
1152
+ const injectedMonitor = typeof config.resilience === "object" ? config.resilience.networkMonitor : void 0;
1153
+ (0, import_react2.useEffect)(() => {
1154
+ if (!resilienceEnabled) {
1155
+ setMonitor(null);
1156
+ return;
1157
+ }
1158
+ if (injectedMonitor) {
1159
+ setMonitor(injectedMonitor);
1160
+ return;
1161
+ }
1162
+ const m = createNetworkMonitor();
1163
+ setMonitor(m);
1164
+ return () => {
1165
+ m.dispose();
1166
+ setMonitor(null);
1167
+ };
1168
+ }, [resilienceEnabled, injectedMonitor]);
512
1169
  const connect = (0, import_react2.useCallback)(() => {
513
1170
  connRef.current?.close();
514
1171
  connRef.current = null;
@@ -520,7 +1177,8 @@ function useUnread(options) {
520
1177
  url,
521
1178
  headers: customHeaders,
522
1179
  reconnectDelayMs: configRef.current.reconnectDelayMs,
523
- customEvents: UNREAD_CUSTOM_EVENTS
1180
+ customEvents: UNREAD_CUSTOM_EVENTS,
1181
+ networkMonitor: monitor ?? void 0
524
1182
  },
525
1183
  {
526
1184
  onOpen: () => {
@@ -552,7 +1210,7 @@ function useUnread(options) {
552
1210
  }
553
1211
  }
554
1212
  );
555
- }, [channelId, participantId]);
1213
+ }, [channelId, participantId, monitor]);
556
1214
  const disconnect = (0, import_react2.useCallback)(() => {
557
1215
  connRef.current?.close();
558
1216
  connRef.current = null;
@@ -565,6 +1223,7 @@ function useUnread(options) {
565
1223
  disconnect();
566
1224
  return;
567
1225
  }
1226
+ if (resilienceEnabled && !monitor) return;
568
1227
  connect();
569
1228
  return () => {
570
1229
  disconnect();
@@ -616,11 +1275,21 @@ function useUnread(options) {
616
1275
  0 && (module.exports = {
617
1276
  ChannelClosedError,
618
1277
  ChatDisconnectedError,
1278
+ HttpError,
1279
+ QueueFullError,
1280
+ RetryExhaustedError,
1281
+ calculateBackoff,
1282
+ createAsyncStorageAdapter,
619
1283
  createChatSession,
620
1284
  createManifest,
1285
+ createNetworkMonitor,
1286
+ createQueueStorage,
621
1287
  createSSEConnection,
1288
+ isRetryableError,
1289
+ resolveRetryConfig,
622
1290
  resolveServerUrl,
623
1291
  useChat,
624
- useUnread
1292
+ useUnread,
1293
+ withRetry
625
1294
  });
626
1295
  //# sourceMappingURL=index.js.map