@chromahq/react 1.0.45 → 1.0.47

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.d.ts CHANGED
@@ -23,8 +23,58 @@ declare global {
23
23
  }
24
24
  }
25
25
  type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting';
26
+ /**
27
+ * Options for critical operations that need acknowledgment and deduplication.
28
+ */
29
+ interface CriticalOperationOptions {
30
+ /**
31
+ * Client-generated nonce for idempotency. If not provided, one will be generated.
32
+ * The SW should store processed nonces and reject duplicates.
33
+ */
34
+ nonce?: string;
35
+ /**
36
+ * If true, the request will NOT be queued during disconnection - it will fail immediately.
37
+ * Use this for operations where you want explicit user retry rather than automatic retry.
38
+ * Default: false (requests are queued)
39
+ */
40
+ noQueue?: boolean;
41
+ /**
42
+ * Callback fired when SW acknowledges receipt of the request (before processing).
43
+ * Use this to update UI to show "processing" state.
44
+ */
45
+ onAcknowledged?: () => void;
46
+ }
47
+ /**
48
+ * Result of a critical operation, including metadata about the request.
49
+ */
50
+ interface CriticalOperationResult<T> {
51
+ data: T;
52
+ nonce: string;
53
+ acknowledged: boolean;
54
+ }
26
55
  interface Bridge {
27
56
  send: <Req = unknown, Res = unknown>(key: string, payload?: Req, timeoutDuration?: number) => Promise<Res>;
57
+ /**
58
+ * Send a critical operation that requires acknowledgment and idempotency.
59
+ * Use this for transfers, signing, and other non-idempotent operations.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const result = await bridge.sendCritical('transfer', {
64
+ * to: '0x...',
65
+ * amount: '1000000',
66
+ * }, {
67
+ * onAcknowledged: () => setStatus('processing'),
68
+ * noQueue: true, // Don't auto-retry transfers
69
+ * });
70
+ * ```
71
+ */
72
+ /** Alias: clearer naming for nonce/idempotency semantics */
73
+ sendWithNonce: <Req = unknown, Res = unknown>(key: string, payload?: Req, options?: CriticalOperationOptions, timeoutDuration?: number) => Promise<CriticalOperationResult<Res>>;
74
+ /** Alias for callers that think in idempotency terms */
75
+ sendIdempotent: <Req = unknown, Res = unknown>(key: string, payload?: Req, options?: CriticalOperationOptions, timeoutDuration?: number) => Promise<CriticalOperationResult<Res>>;
76
+ /** Back-compat name (kept) */
77
+ sendCritical: <Req = unknown, Res = unknown>(key: string, payload?: Req, options?: CriticalOperationOptions, timeoutDuration?: number) => Promise<CriticalOperationResult<Res>>;
28
78
  broadcast: (key: string, payload: unknown) => void;
29
79
  on: (key: string, handler: (payload: unknown) => void) => void;
30
80
  off: (key: string, handler: (payload: unknown) => void) => void;
@@ -36,6 +86,23 @@ interface Bridge {
36
86
  * @param durationMs - How long to pause health checks in milliseconds
37
87
  */
38
88
  pauseHealthChecks: (durationMs: number) => void;
89
+ /**
90
+ * Ensure the service worker is connected and responsive before performing a heavy operation.
91
+ * This performs a quick ping and returns true if successful.
92
+ * On Windows/Brave, use this before crypto operations to verify the SW hasn't silently restarted.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * const ready = await bridge.ensureConnected();
97
+ * if (!ready) {
98
+ * showToast('Connection lost. Please try again.');
99
+ * return;
100
+ * }
101
+ * bridge.pauseHealthChecks(30000);
102
+ * await bridge.send('unlock', { password });
103
+ * ```
104
+ */
105
+ ensureConnected: () => Promise<boolean>;
39
106
  }
40
107
  interface BridgeContextValue {
41
108
  bridge: Bridge | null;
@@ -274,4 +341,4 @@ declare function useServiceWorkerHealthSimple(options?: ServiceWorkerHealthOptio
274
341
  };
275
342
 
276
343
  export { BridgeProvider, ServiceWorkerHealthProvider, getHealthStatus, subscribeToHealth, useBridge, useBridgeQuery, useConnectionStatus, useServiceWorkerHealth, useServiceWorkerHealthSimple };
277
- export type { HealthStatus, ServiceWorkerHealthContextValue };
344
+ export type { CriticalOperationOptions, CriticalOperationResult, HealthStatus, ServiceWorkerHealthContextValue };
package/dist/index.js CHANGED
@@ -67,18 +67,18 @@ const recordDiagnostics = {
67
67
  const CONFIG = {
68
68
  RETRY_AFTER: 1e3,
69
69
  MAX_RETRIES: 10,
70
- PING_INTERVAL: 15e3,
71
- // Check every 15s (tolerant of long operations)
70
+ PING_INTERVAL: 5e3,
71
+ // Check every 5s for faster SW down detection
72
72
  MAX_RETRY_COOLDOWN: 3e4,
73
73
  DEFAULT_TIMEOUT: 6e4,
74
74
  // 60s default for slow operations
75
75
  MAX_RETRY_DELAY: 3e4,
76
- PING_TIMEOUT: 2e4,
77
- // Give SW 20s to respond to ping
76
+ PING_TIMEOUT: 8e3,
77
+ // Give SW 8s to respond to ping (reduced from 20s)
78
78
  ERROR_CHECK_INTERVAL: 100,
79
79
  MAX_ERROR_CHECKS: 10,
80
- CONSECUTIVE_FAILURE_THRESHOLD: 5,
81
- // Require 5 consecutive failures (75s total) before reconnecting
80
+ CONSECUTIVE_FAILURE_THRESHOLD: 2,
81
+ // Require 2 consecutive failures (~10s total) before reconnecting
82
82
  RECONNECT_DELAY: 100,
83
83
  PORT_NAME: "chroma-bridge",
84
84
  // Service worker restart retry settings (indefinite retries)
@@ -86,8 +86,31 @@ const CONFIG = {
86
86
  SW_RESTART_MAX_DELAY: 5e3,
87
87
  // Threshold for counting timeouts toward reconnection (only count fast timeouts as failures)
88
88
  // Requests with timeout > this value are considered intentional long operations
89
- TIMEOUT_FAILURE_THRESHOLD_MS: 3e4
89
+ TIMEOUT_FAILURE_THRESHOLD_MS: 3e4,
90
90
  // Only count timeouts < 30s as potential SW issues
91
+ // Request queue settings for transparent retry
92
+ REQUEST_QUEUE_MAX_SIZE: 50,
93
+ // Maximum queued requests during disconnection
94
+ REQUEST_MAX_RETRIES: 3,
95
+ // Max retries per request before giving up
96
+ REQUEST_RETRY_BASE_DELAY: 200,
97
+ // Base delay for request retry backoff
98
+ REQUEST_RETRY_MAX_DELAY: 2e3,
99
+ // Max delay for request retry backoff
100
+ QUEUE_DRAIN_DELAY: 50,
101
+ // Delay between processing queued requests
102
+ // Optimistic health - don't surface unhealthy state immediately
103
+ HEALTH_GRACE_PERIOD_MS: 1e3,
104
+ // Wait this long before showing unhealthy (reduced from 3s)
105
+ // Critical operation settings
106
+ CRITICAL_OP_TIMEOUT: 12e4
107
+ // 2 minutes for critical operations (transfers, signing)
108
+ };
109
+ const generateNonce = () => {
110
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
111
+ return crypto.randomUUID();
112
+ }
113
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
91
114
  };
92
115
  const calculateBackoffDelay = (attempt, baseDelay, maxDelay) => Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
93
116
  const clearTimeoutSafe = (ref) => {
@@ -111,17 +134,74 @@ const BridgeContext = createContext(null);
111
134
  function createBridgeInstance(deps) {
112
135
  const {
113
136
  portRef,
137
+ portDisconnectedRef,
114
138
  pendingRequestsRef,
139
+ requestQueueRef,
140
+ activeIdempotencyKeysRef,
115
141
  eventListenersRef,
116
142
  messageIdRef,
117
143
  isConnectedRef,
144
+ isReconnectingRef,
118
145
  consecutiveTimeoutsRef,
119
146
  reconnectionGracePeriodRef,
120
147
  healthPausedUntilRef,
121
148
  defaultTimeout,
122
149
  timeoutFailureThreshold,
123
- onReconnectNeeded
124
- } = deps;
150
+ onReconnectNeeded} = deps;
151
+ const isPortValid = () => {
152
+ if (!portRef.current) return false;
153
+ if (portDisconnectedRef.current) return false;
154
+ if (!isConnectedRef.current) return false;
155
+ return true;
156
+ };
157
+ const generateIdempotencyKey = (key, payload) => {
158
+ const isWriteOperation = !key.startsWith("get") && !key.startsWith("fetch") && key !== "__ping__";
159
+ if (!isWriteOperation) return "";
160
+ try {
161
+ return `${key}:${JSON.stringify(payload)}`;
162
+ } catch {
163
+ return `${key}:${Date.now()}`;
164
+ }
165
+ };
166
+ const queueRequest = (id, key, payload, timeoutDuration, resolve, reject, idempotencyKey) => {
167
+ if (key === "__ping__" || key === "__bridge_diagnostics__") {
168
+ return false;
169
+ }
170
+ if (requestQueueRef.current.length >= CONFIG.REQUEST_QUEUE_MAX_SIZE) {
171
+ {
172
+ console.warn("[Bridge] Request queue full, rejecting request");
173
+ }
174
+ return false;
175
+ }
176
+ if (idempotencyKey && activeIdempotencyKeysRef.current.has(idempotencyKey)) {
177
+ {
178
+ console.log(`[Bridge] Duplicate request detected, skipping: ${key}`);
179
+ }
180
+ return true;
181
+ }
182
+ if (idempotencyKey) {
183
+ activeIdempotencyKeysRef.current.add(idempotencyKey);
184
+ }
185
+ const queuedRequest = {
186
+ id,
187
+ key,
188
+ payload,
189
+ timeoutDuration,
190
+ resolve,
191
+ reject,
192
+ retryCount: 0,
193
+ maxRetries: CONFIG.REQUEST_MAX_RETRIES,
194
+ queuedAt: Date.now(),
195
+ idempotencyKey
196
+ };
197
+ requestQueueRef.current.push(queuedRequest);
198
+ {
199
+ console.log(
200
+ `[Bridge] Request queued: ${key} (queue size: ${requestQueueRef.current.length})`
201
+ );
202
+ }
203
+ return true;
204
+ };
125
205
  const rejectAllPending = (message) => {
126
206
  pendingRequestsRef.current.forEach(({ reject, timeout, timeoutDuration }, id) => {
127
207
  if (timeoutDuration <= timeoutFailureThreshold) {
@@ -134,19 +214,29 @@ function createBridgeInstance(deps) {
134
214
  const send = (key, payload, timeoutDuration = defaultTimeout) => {
135
215
  return new Promise((resolve, reject) => {
136
216
  const id = `msg_${++messageIdRef.current}`;
217
+ const idempotencyKey = generateIdempotencyKey(key, payload);
137
218
  const finalizePendingWithError = (error) => {
138
219
  const pending = pendingRequestsRef.current.get(id);
139
220
  if (!pending) return;
140
221
  clearTimeout(pending.timeout);
141
222
  pendingRequestsRef.current.delete(id);
223
+ if (idempotencyKey) {
224
+ activeIdempotencyKeysRef.current.delete(idempotencyKey);
225
+ }
142
226
  pending.reject(error instanceof Error ? error : new Error(error));
143
227
  };
144
- const triggerRuntimeFallback = (reason) => {
228
+ const triggerRuntimeFallback = (reason, retryCount = 0) => {
229
+ const MAX_FALLBACK_RETRIES = 2;
230
+ const FALLBACK_RETRY_DELAY = 300;
145
231
  {
146
- console.warn(`[Bridge] Falling back to runtime.sendMessage (${reason})`);
232
+ console.warn(
233
+ `[Bridge] Falling back to runtime.sendMessage (${reason})${retryCount > 0 ? ` [retry ${retryCount}]` : ""}`
234
+ );
147
235
  }
148
236
  recordDiagnostics.fallbackSent(reason);
149
- onReconnectNeeded();
237
+ if (retryCount === 0) {
238
+ onReconnectNeeded();
239
+ }
150
240
  if (!chrome?.runtime?.sendMessage) {
151
241
  const message = "chrome.runtime.sendMessage not available";
152
242
  recordDiagnostics.fallbackError(message);
@@ -159,13 +249,24 @@ function createBridgeInstance(deps) {
159
249
  id,
160
250
  key,
161
251
  payload,
162
- metadata: { transport: "direct", fallbackReason: reason },
252
+ metadata: { transport: "direct", fallbackReason: reason, retryCount },
163
253
  [DIRECT_MESSAGE_FLAG]: true
164
254
  },
165
255
  (response) => {
166
256
  const runtimeError = consumeRuntimeError();
167
257
  const pending = pendingRequestsRef.current.get(id);
168
258
  if (!pending) return;
259
+ if (runtimeError?.includes("Receiving end does not exist") && retryCount < MAX_FALLBACK_RETRIES) {
260
+ if (BRIDGE_ENABLE_LOGS) {
261
+ console.warn(
262
+ `[Bridge] SW not ready, retrying fallback in ${FALLBACK_RETRY_DELAY}ms...`
263
+ );
264
+ }
265
+ setTimeout(() => {
266
+ triggerRuntimeFallback(reason, retryCount + 1);
267
+ }, FALLBACK_RETRY_DELAY);
268
+ return;
269
+ }
169
270
  if (runtimeError) {
170
271
  recordDiagnostics.fallbackError(runtimeError);
171
272
  clearTimeout(pending.timeout);
@@ -226,10 +327,32 @@ function createBridgeInstance(deps) {
226
327
  resolve,
227
328
  reject,
228
329
  timeout,
229
- timeoutDuration
330
+ timeoutDuration,
331
+ key,
332
+ payload
230
333
  });
231
- if (!portRef.current) {
232
- triggerRuntimeFallback("port-unavailable");
334
+ if (!isPortValid()) {
335
+ if (isReconnectingRef.current) {
336
+ clearTimeout(timeout);
337
+ pendingRequestsRef.current.delete(id);
338
+ const queued = queueRequest(
339
+ id,
340
+ key,
341
+ payload,
342
+ timeoutDuration,
343
+ resolve,
344
+ reject,
345
+ idempotencyKey
346
+ );
347
+ if (queued) {
348
+ {
349
+ console.log(`[Bridge] Request queued during reconnection: ${key}`);
350
+ }
351
+ return;
352
+ }
353
+ }
354
+ const reason = !portRef.current ? "port-unavailable" : portDisconnectedRef.current ? "port-disconnected" : "port-not-connected";
355
+ triggerRuntimeFallback(reason);
233
356
  return;
234
357
  }
235
358
  try {
@@ -237,6 +360,23 @@ function createBridgeInstance(deps) {
237
360
  setTimeout(() => {
238
361
  const errorMessage = consumeRuntimeError();
239
362
  if (errorMessage && pendingRequestsRef.current.has(id)) {
363
+ if (isReconnectingRef.current) {
364
+ const pending = pendingRequestsRef.current.get(id);
365
+ if (pending) {
366
+ clearTimeout(pending.timeout);
367
+ pendingRequestsRef.current.delete(id);
368
+ const queued = queueRequest(
369
+ id,
370
+ key,
371
+ payload,
372
+ timeoutDuration,
373
+ pending.resolve,
374
+ pending.reject,
375
+ idempotencyKey
376
+ );
377
+ if (queued) return;
378
+ }
379
+ }
240
380
  finalizePendingWithError(errorMessage);
241
381
  }
242
382
  }, 0);
@@ -304,7 +444,86 @@ function createBridgeInstance(deps) {
304
444
  {
305
445
  console.log(`[Bridge] Health checks paused for ${Math.round(durationMs / 1e3)}s`);
306
446
  }
307
- }
447
+ },
448
+ ensureConnected: async () => {
449
+ if (!isPortValid()) {
450
+ {
451
+ console.warn("[Bridge] ensureConnected: Port invalid, triggering reconnect");
452
+ }
453
+ onReconnectNeeded();
454
+ return false;
455
+ }
456
+ const QUICK_PING_TIMEOUT = 5e3;
457
+ try {
458
+ await send("__ping__", void 0, QUICK_PING_TIMEOUT);
459
+ return true;
460
+ } catch {
461
+ {
462
+ console.warn("[Bridge] ensureConnected: Ping failed, SW may be unresponsive");
463
+ }
464
+ return false;
465
+ }
466
+ },
467
+ sendCritical: async (key, payload, options, timeoutDuration = CONFIG.CRITICAL_OP_TIMEOUT) => {
468
+ const nonce = options?.nonce || generateNonce();
469
+ const noQueue = options?.noQueue ?? false;
470
+ if (noQueue && !isPortValid()) {
471
+ throw new Error("Not connected. Please try again.");
472
+ }
473
+ const criticalPayload = {
474
+ __critical__: true,
475
+ __nonce__: nonce,
476
+ __timestamp__: Date.now(),
477
+ data: payload
478
+ };
479
+ let acknowledged = false;
480
+ const ackKey = `__ack__:${nonce}`;
481
+ const ackPromise = new Promise((resolveAck) => {
482
+ const ackHandler = () => {
483
+ acknowledged = true;
484
+ options?.onAcknowledged?.();
485
+ resolveAck();
486
+ off(ackKey, ackHandler);
487
+ };
488
+ on(ackKey, ackHandler);
489
+ setTimeout(() => {
490
+ off(ackKey, ackHandler);
491
+ resolveAck();
492
+ }, 5e3);
493
+ });
494
+ {
495
+ console.log(`[Bridge] Sending critical operation: ${key} (nonce: ${nonce})`);
496
+ }
497
+ try {
498
+ const [data] = await Promise.all([
499
+ send(key, criticalPayload, timeoutDuration),
500
+ ackPromise
501
+ ]);
502
+ if (BRIDGE_ENABLE_LOGS) {
503
+ console.log(
504
+ `[Bridge] Critical operation completed: ${key} (nonce: ${nonce}, acked: ${acknowledged})`
505
+ );
506
+ }
507
+ return {
508
+ data,
509
+ nonce,
510
+ acknowledged
511
+ };
512
+ } catch (error) {
513
+ {
514
+ console.error(
515
+ `[Bridge] Critical operation failed: ${key} (nonce: ${nonce}, acked: ${acknowledged})`,
516
+ error
517
+ );
518
+ }
519
+ const err = error instanceof Error ? error : new Error(String(error));
520
+ err.nonce = nonce;
521
+ err.acknowledged = acknowledged;
522
+ throw err;
523
+ }
524
+ },
525
+ sendWithNonce: async (key, payload, options, timeoutDuration) => bridge.sendCritical(key, payload, options, timeoutDuration),
526
+ sendIdempotent: async (key, payload, options, timeoutDuration) => bridge.sendCritical(key, payload, options, timeoutDuration)
308
527
  };
309
528
  return bridge;
310
529
  }
@@ -387,8 +606,13 @@ const BridgeProvider = ({
387
606
  const [error, setError] = useState(null);
388
607
  const [isServiceWorkerAlive, setIsServiceWorkerAlive] = useState(false);
389
608
  const portRef = useRef(null);
609
+ const portDisconnectedRef = useRef(false);
390
610
  const isConnectedRef = useRef(false);
391
611
  const isConnectingRef = useRef(false);
612
+ const isReconnectingRef = useRef(false);
613
+ const requestQueueRef = useRef([]);
614
+ const activeIdempotencyKeysRef = useRef(/* @__PURE__ */ new Set());
615
+ const queueDrainTimeoutRef = useRef(null);
392
616
  const retryCountRef = useRef(0);
393
617
  const reconnectTimeoutRef = useRef(null);
394
618
  const maxRetryCooldownRef = useRef(null);
@@ -433,7 +657,7 @@ const BridgeProvider = ({
433
657
  },
434
658
  [onError, updateStatus]
435
659
  );
436
- const cleanup = useCallback((emitDisconnect = true) => {
660
+ const cleanup = useCallback((emitDisconnect = true, preserveQueue = false) => {
437
661
  const wasConnected = isConnectedRef.current || portRef.current !== null;
438
662
  if (emitDisconnect && wasConnected) {
439
663
  eventListenersRef.current.get("bridge:disconnected")?.forEach((handler) => {
@@ -448,6 +672,7 @@ const BridgeProvider = ({
448
672
  }
449
673
  clearTimeoutSafe(reconnectTimeoutRef);
450
674
  clearTimeoutSafe(triggerReconnectTimeoutRef);
675
+ clearTimeoutSafe(queueDrainTimeoutRef);
451
676
  clearIntervalSafe(errorCheckIntervalRef);
452
677
  clearIntervalSafe(pingIntervalRef);
453
678
  if (portRef.current) {
@@ -457,11 +682,50 @@ const BridgeProvider = ({
457
682
  }
458
683
  portRef.current = null;
459
684
  }
460
- pendingRequestsRef.current.forEach(({ reject, timeout }) => {
461
- clearTimeout(timeout);
462
- reject(new Error("Bridge disconnected"));
463
- });
464
- pendingRequestsRef.current.clear();
685
+ portDisconnectedRef.current = false;
686
+ if (preserveQueue) {
687
+ const pendingCount = pendingRequestsRef.current.size;
688
+ if (pendingCount > 0 && BRIDGE_ENABLE_LOGS) {
689
+ console.log(
690
+ `[Bridge] Preserving ${pendingCount} pending requests for retry after reconnect`
691
+ );
692
+ }
693
+ pendingRequestsRef.current.forEach(
694
+ ({ resolve, reject, timeout, timeoutDuration, key, payload }, id) => {
695
+ clearTimeout(timeout);
696
+ requestQueueRef.current.push({
697
+ id,
698
+ key,
699
+ payload,
700
+ // Preserve the original payload for retry
701
+ timeoutDuration,
702
+ resolve,
703
+ reject,
704
+ retryCount: 0,
705
+ maxRetries: CONFIG.REQUEST_MAX_RETRIES,
706
+ queuedAt: Date.now()
707
+ });
708
+ {
709
+ console.log(`[Bridge] Queued pending request for retry: ${key}`);
710
+ }
711
+ }
712
+ );
713
+ pendingRequestsRef.current.clear();
714
+ } else {
715
+ pendingRequestsRef.current.forEach(({ reject, timeout }) => {
716
+ clearTimeout(timeout);
717
+ reject(new Error("Bridge disconnected"));
718
+ });
719
+ pendingRequestsRef.current.clear();
720
+ requestQueueRef.current.forEach(({ reject, idempotencyKey }) => {
721
+ if (idempotencyKey) {
722
+ activeIdempotencyKeysRef.current.delete(idempotencyKey);
723
+ }
724
+ reject(new Error("Bridge disconnected"));
725
+ });
726
+ requestQueueRef.current = [];
727
+ activeIdempotencyKeysRef.current.clear();
728
+ }
465
729
  if (!emitDisconnect) {
466
730
  eventListenersRef.current.clear();
467
731
  }
@@ -484,7 +748,14 @@ const BridgeProvider = ({
484
748
  return;
485
749
  }
486
750
  if (message.type === "broadcast" && message.key) {
487
- eventListenersRef.current.get(message.key)?.forEach((handler) => {
751
+ const listeners = eventListenersRef.current.get(message.key);
752
+ const listenerCount = listeners?.size ?? 0;
753
+ {
754
+ console.log(
755
+ `[Bridge] \u{1F4E1} Received broadcast: ${message.key}, dispatching to ${listenerCount} listeners`
756
+ );
757
+ }
758
+ listeners?.forEach((handler) => {
488
759
  try {
489
760
  handler(message.payload);
490
761
  } catch (err) {
@@ -499,6 +770,95 @@ const BridgeProvider = ({
499
770
  console.warn("[Bridge] Received response for unknown/expired request:", message.id);
500
771
  }
501
772
  }, []);
773
+ const drainRequestQueue = useCallback(() => {
774
+ if (requestQueueRef.current.length === 0) {
775
+ {
776
+ console.log("[Bridge] Request queue empty, nothing to drain");
777
+ }
778
+ return;
779
+ }
780
+ if (!bridgeRef.current || !isConnectedRef.current) {
781
+ {
782
+ console.log("[Bridge] Cannot drain queue - not connected");
783
+ }
784
+ return;
785
+ }
786
+ {
787
+ console.log(`[Bridge] Draining request queue (${requestQueueRef.current.length} requests)`);
788
+ }
789
+ const processNextRequest = () => {
790
+ if (!isMountedRef.current || !isConnectedRef.current) return;
791
+ const request = requestQueueRef.current.shift();
792
+ if (!request) {
793
+ {
794
+ console.log("[Bridge] Request queue drained successfully");
795
+ }
796
+ return;
797
+ }
798
+ const queuedDuration = Date.now() - request.queuedAt;
799
+ if (queuedDuration > request.timeoutDuration) {
800
+ {
801
+ console.warn(`[Bridge] Queued request expired: ${request.key}`);
802
+ }
803
+ if (request.idempotencyKey) {
804
+ activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
805
+ }
806
+ request.reject(new Error(`Request expired while queued: ${request.key}`));
807
+ queueDrainTimeoutRef.current = setTimeout(processNextRequest, CONFIG.QUEUE_DRAIN_DELAY);
808
+ return;
809
+ }
810
+ {
811
+ console.log(
812
+ `[Bridge] Re-sending queued request: ${request.key} (retry ${request.retryCount + 1}/${request.maxRetries})`
813
+ );
814
+ }
815
+ bridgeRef.current.send(request.key, request.payload, request.timeoutDuration - queuedDuration).then((data) => {
816
+ {
817
+ console.log(`[Bridge] \u2705 Queued request succeeded: ${request.key}`);
818
+ }
819
+ if (request.idempotencyKey) {
820
+ activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
821
+ }
822
+ request.resolve(data);
823
+ }).catch((error2) => {
824
+ request.retryCount++;
825
+ if (request.retryCount < request.maxRetries && isConnectedRef.current) {
826
+ const retryDelay = calculateBackoffDelay(
827
+ request.retryCount,
828
+ CONFIG.REQUEST_RETRY_BASE_DELAY,
829
+ CONFIG.REQUEST_RETRY_MAX_DELAY
830
+ );
831
+ {
832
+ console.log(
833
+ `[Bridge] Request failed, re-queuing: ${request.key} (retry in ${retryDelay}ms)`
834
+ );
835
+ }
836
+ setTimeout(() => {
837
+ if (isMountedRef.current) {
838
+ requestQueueRef.current.unshift(request);
839
+ processNextRequest();
840
+ }
841
+ }, retryDelay);
842
+ return;
843
+ }
844
+ {
845
+ console.error(
846
+ `[Bridge] \u274C Queued request failed after ${request.maxRetries} retries: ${request.key}`,
847
+ error2
848
+ );
849
+ }
850
+ if (request.idempotencyKey) {
851
+ activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
852
+ }
853
+ request.reject(error2);
854
+ }).finally(() => {
855
+ if (isMountedRef.current) {
856
+ queueDrainTimeoutRef.current = setTimeout(processNextRequest, CONFIG.QUEUE_DRAIN_DELAY);
857
+ }
858
+ });
859
+ };
860
+ processNextRequest();
861
+ }, []);
502
862
  const scheduleReconnect = useCallback(
503
863
  (connectFn) => {
504
864
  if (!isMountedRef.current) return;
@@ -546,6 +906,7 @@ const BridgeProvider = ({
546
906
  }
547
907
  return;
548
908
  }
909
+ isReconnectingRef.current = true;
549
910
  swRestartRetryCountRef.current++;
550
911
  const delay = calculateBackoffDelay(
551
912
  swRestartRetryCountRef.current,
@@ -578,7 +939,7 @@ const BridgeProvider = ({
578
939
  return;
579
940
  }
580
941
  isConnectingRef.current = true;
581
- cleanup(false);
942
+ cleanup(false, true);
582
943
  if (!chrome?.runtime?.connect) {
583
944
  handleError(new Error("Chrome runtime not available"));
584
945
  isConnectingRef.current = false;
@@ -620,6 +981,7 @@ const BridgeProvider = ({
620
981
  if (BRIDGE_ENABLE_LOGS) {
621
982
  console.warn("[Bridge] *** onDisconnect FIRED ***");
622
983
  }
984
+ portDisconnectedRef.current = true;
623
985
  isConnectingRef.current = false;
624
986
  const disconnectError = consumeRuntimeError();
625
987
  recordDiagnostics.portDisconnect(disconnectError);
@@ -628,7 +990,7 @@ const BridgeProvider = ({
628
990
  console.warn("[Bridge] isMounted:", isMountedRef.current);
629
991
  }
630
992
  updateStatus("disconnected");
631
- cleanup();
993
+ cleanup(true, true);
632
994
  if (isMountedRef.current) {
633
995
  if (BRIDGE_ENABLE_LOGS) {
634
996
  console.log("[Bridge] Scheduling SW restart reconnect...");
@@ -644,28 +1006,36 @@ const BridgeProvider = ({
644
1006
  if (!isMountedRef.current) return;
645
1007
  setIsServiceWorkerAlive(false);
646
1008
  updateStatus("reconnecting");
1009
+ isReconnectingRef.current = true;
647
1010
  retryCountRef.current = 0;
648
1011
  isConnectingRef.current = false;
649
1012
  clearTimeoutSafe(triggerReconnectTimeoutRef);
650
1013
  triggerReconnectTimeoutRef.current = setTimeout(() => {
651
1014
  if (!isMountedRef.current) return;
652
- cleanup(false);
1015
+ cleanup(false, true);
653
1016
  connect();
654
1017
  }, CONFIG.RECONNECT_DELAY);
655
1018
  };
656
1019
  const bridgeInstance = createBridgeInstance({
657
1020
  portRef,
1021
+ portDisconnectedRef,
658
1022
  pendingRequestsRef,
1023
+ requestQueueRef,
1024
+ activeIdempotencyKeysRef,
659
1025
  eventListenersRef,
660
1026
  messageIdRef,
661
1027
  isConnectedRef,
1028
+ isReconnectingRef,
662
1029
  consecutiveTimeoutsRef,
663
1030
  reconnectionGracePeriodRef,
664
1031
  healthPausedUntilRef,
665
1032
  defaultTimeout,
666
1033
  timeoutFailureThreshold,
667
- onReconnectNeeded: triggerReconnect
1034
+ onReconnectNeeded: triggerReconnect,
1035
+ drainRequestQueue
668
1036
  });
1037
+ portDisconnectedRef.current = false;
1038
+ isReconnectingRef.current = false;
669
1039
  setBridge(bridgeInstance);
670
1040
  isConnectedRef.current = true;
671
1041
  updateStatus("connected");
@@ -675,6 +1045,145 @@ const BridgeProvider = ({
675
1045
  swRestartRetryCountRef.current = 0;
676
1046
  consecutiveTimeoutsRef.current = 0;
677
1047
  isConnectingRef.current = false;
1048
+ if (BRIDGE_ENABLE_LOGS) {
1049
+ const queueSize = requestQueueRef.current.length;
1050
+ const pendingSize = pendingRequestsRef.current.size;
1051
+ console.log(`[Bridge] \u2705 PORT CONNECTED | Queued: ${queueSize} | Pending: ${pendingSize}`);
1052
+ }
1053
+ const verifyConnection = (targetPort) => {
1054
+ const VERIFY_PING_TIMEOUT = 8e3;
1055
+ const MAX_VERIFY_RETRIES = 3;
1056
+ const VERIFY_RETRY_DELAY = 2e3;
1057
+ const attemptVerify = (attempt) => {
1058
+ if (portRef.current !== targetPort) {
1059
+ if (BRIDGE_ENABLE_LOGS) {
1060
+ console.log(`[Bridge] Verification aborted - port changed (attempt ${attempt})`);
1061
+ }
1062
+ return Promise.resolve(false);
1063
+ }
1064
+ if (attempt > MAX_VERIFY_RETRIES) {
1065
+ return Promise.resolve(false);
1066
+ }
1067
+ if (BRIDGE_ENABLE_LOGS) {
1068
+ console.log(
1069
+ `[Bridge] Verifying connection (attempt ${attempt}/${MAX_VERIFY_RETRIES})...`
1070
+ );
1071
+ }
1072
+ const pingId = `verify_${Date.now()}_${attempt}`;
1073
+ return new Promise((resolve) => {
1074
+ if (portRef.current !== targetPort) {
1075
+ resolve(false);
1076
+ return;
1077
+ }
1078
+ const timeout = setTimeout(() => {
1079
+ pendingRequestsRef.current.delete(pingId);
1080
+ if (portRef.current !== targetPort) {
1081
+ resolve(false);
1082
+ return;
1083
+ }
1084
+ if (BRIDGE_ENABLE_LOGS) {
1085
+ console.warn(`[Bridge] Verification ping timeout (attempt ${attempt})`);
1086
+ }
1087
+ setTimeout(() => {
1088
+ attemptVerify(attempt + 1).then(resolve);
1089
+ }, VERIFY_RETRY_DELAY);
1090
+ }, VERIFY_PING_TIMEOUT);
1091
+ pendingRequestsRef.current.set(pingId, {
1092
+ resolve: () => {
1093
+ clearTimeout(timeout);
1094
+ if (portRef.current !== targetPort) {
1095
+ resolve(false);
1096
+ return;
1097
+ }
1098
+ if (BRIDGE_ENABLE_LOGS) {
1099
+ console.log("[Bridge] \u2705 VERIFIED - SW is responding");
1100
+ }
1101
+ resolve(true);
1102
+ },
1103
+ reject: () => {
1104
+ clearTimeout(timeout);
1105
+ if (portRef.current !== targetPort) {
1106
+ resolve(false);
1107
+ return;
1108
+ }
1109
+ if (BRIDGE_ENABLE_LOGS) {
1110
+ console.warn(`[Bridge] Verification ping error (attempt ${attempt})`);
1111
+ }
1112
+ setTimeout(() => {
1113
+ attemptVerify(attempt + 1).then(resolve);
1114
+ }, VERIFY_RETRY_DELAY);
1115
+ },
1116
+ timeout,
1117
+ timeoutDuration: VERIFY_PING_TIMEOUT,
1118
+ key: "__ping__",
1119
+ payload: void 0
1120
+ });
1121
+ try {
1122
+ targetPort.postMessage({ id: pingId, key: "__ping__", payload: void 0 });
1123
+ } catch (e) {
1124
+ clearTimeout(timeout);
1125
+ pendingRequestsRef.current.delete(pingId);
1126
+ if (portRef.current !== targetPort) {
1127
+ resolve(false);
1128
+ return;
1129
+ }
1130
+ if (BRIDGE_ENABLE_LOGS) {
1131
+ console.warn(`[Bridge] Verification postMessage error (attempt ${attempt}):`, e);
1132
+ }
1133
+ setTimeout(() => {
1134
+ attemptVerify(attempt + 1).then(resolve);
1135
+ }, VERIFY_RETRY_DELAY);
1136
+ }
1137
+ });
1138
+ };
1139
+ return attemptVerify(1);
1140
+ };
1141
+ const startVerification = () => {
1142
+ if (BRIDGE_ENABLE_LOGS) {
1143
+ console.log("[Bridge] Starting verification after initial delay...");
1144
+ }
1145
+ verifyConnection(port).then((verified) => {
1146
+ if (!isMountedRef.current || portRef.current !== port) {
1147
+ if (BRIDGE_ENABLE_LOGS) {
1148
+ console.log("[Bridge] Verification callback aborted - context changed");
1149
+ }
1150
+ return;
1151
+ }
1152
+ if (!verified) {
1153
+ if (BRIDGE_ENABLE_LOGS) {
1154
+ console.error("[Bridge] \u274C Connection verification failed - SW not responding");
1155
+ }
1156
+ isConnectingRef.current = false;
1157
+ isReconnectingRef.current = true;
1158
+ scheduleSwRestartReconnect(connect);
1159
+ return;
1160
+ }
1161
+ if (BRIDGE_ENABLE_LOGS) {
1162
+ const queueSize = requestQueueRef.current.length;
1163
+ console.log(
1164
+ `[Bridge] \u2705 RECONNECTED SUCCESSFULLY | Queue: ${queueSize} requests to drain`
1165
+ );
1166
+ }
1167
+ setTimeout(() => {
1168
+ if (isMountedRef.current && isConnectedRef.current) {
1169
+ drainRequestQueue();
1170
+ }
1171
+ }, 200);
1172
+ if (BRIDGE_ENABLE_LOGS) {
1173
+ console.log("[Bridge] Emitting bridge:connected event to stores");
1174
+ }
1175
+ eventListenersRef.current.get("bridge:connected")?.forEach((handler) => {
1176
+ try {
1177
+ handler({ timestamp: Date.now() });
1178
+ } catch (err) {
1179
+ if (BRIDGE_ENABLE_LOGS) {
1180
+ console.warn("[Bridge] bridge:connected handler error:", err);
1181
+ }
1182
+ }
1183
+ });
1184
+ });
1185
+ };
1186
+ setTimeout(startVerification, 2e3);
678
1187
  reconnectionGracePeriodRef.current = true;
679
1188
  setTimeout(() => {
680
1189
  reconnectionGracePeriodRef.current = false;
@@ -682,15 +1191,6 @@ const BridgeProvider = ({
682
1191
  console.log("[Bridge] Grace period ended, timeout monitoring active");
683
1192
  }
684
1193
  }, 1e4);
685
- eventListenersRef.current.get("bridge:connected")?.forEach((handler) => {
686
- try {
687
- handler({ timestamp: Date.now() });
688
- } catch (err) {
689
- if (BRIDGE_ENABLE_LOGS) {
690
- console.warn("[Bridge] bridge:connected handler error:", err);
691
- }
692
- }
693
- });
694
1194
  const rejectAllPendingRequests = (message) => {
695
1195
  pendingRequestsRef.current.forEach(({ reject, timeout, timeoutDuration }, id) => {
696
1196
  if (timeoutDuration <= timeoutFailureThreshold) {
@@ -723,6 +1223,7 @@ const BridgeProvider = ({
723
1223
  handleMessage,
724
1224
  scheduleReconnect,
725
1225
  scheduleSwRestartReconnect,
1226
+ drainRequestQueue,
726
1227
  defaultTimeout,
727
1228
  updateStatus,
728
1229
  pingInterval
@@ -980,15 +1481,19 @@ function broadcastHealthChange(status, isHealthy) {
980
1481
  });
981
1482
  }
982
1483
  const ServiceWorkerHealthContext = createContext(null);
1484
+ const HEALTH_GRACE_PERIOD_MS = 1e3;
983
1485
  const ServiceWorkerHealthProvider = ({
984
1486
  children,
985
1487
  onHealthChange
986
1488
  }) => {
987
1489
  const bridgeContext = useContext(BridgeContext);
988
1490
  const [lastHealthyAt, setLastHealthyAt] = useState(null);
1491
+ const [unhealthyStartedAt, setUnhealthyStartedAt] = useState(null);
1492
+ const [graceExpired, setGraceExpired] = useState(false);
1493
+ const graceTimeoutRef = useRef(null);
989
1494
  const bridgeStatus = bridgeContext?.status;
990
1495
  const isServiceWorkerAlive = bridgeContext?.isServiceWorkerAlive ?? false;
991
- const derivedStatus = useMemo(() => {
1496
+ const rawStatus = useMemo(() => {
992
1497
  if (!bridgeStatus) return "unknown";
993
1498
  if (bridgeStatus === "connected" && isServiceWorkerAlive) {
994
1499
  return "healthy";
@@ -998,14 +1503,41 @@ const ServiceWorkerHealthProvider = ({
998
1503
  }
999
1504
  return "unhealthy";
1000
1505
  }, [bridgeStatus, isServiceWorkerAlive]);
1506
+ useEffect(() => {
1507
+ if (rawStatus === "healthy") {
1508
+ setUnhealthyStartedAt(null);
1509
+ setGraceExpired(false);
1510
+ if (graceTimeoutRef.current) {
1511
+ clearTimeout(graceTimeoutRef.current);
1512
+ graceTimeoutRef.current = null;
1513
+ }
1514
+ } else if (!unhealthyStartedAt) {
1515
+ setUnhealthyStartedAt(Date.now());
1516
+ graceTimeoutRef.current = setTimeout(() => {
1517
+ setGraceExpired(true);
1518
+ }, HEALTH_GRACE_PERIOD_MS);
1519
+ }
1520
+ return () => {
1521
+ if (graceTimeoutRef.current) {
1522
+ clearTimeout(graceTimeoutRef.current);
1523
+ }
1524
+ };
1525
+ }, [rawStatus, unhealthyStartedAt]);
1526
+ const derivedStatus = useMemo(() => {
1527
+ if (rawStatus === "healthy") return "healthy";
1528
+ if (!graceExpired && lastHealthyAt) {
1529
+ return "healthy";
1530
+ }
1531
+ return rawStatus;
1532
+ }, [rawStatus, graceExpired, lastHealthyAt]);
1001
1533
  const isHealthy = derivedStatus === "healthy";
1002
1534
  const isRecovering = derivedStatus === "recovering";
1003
1535
  const isLoading = derivedStatus === "recovering" || derivedStatus === "unknown";
1004
1536
  useEffect(() => {
1005
- if (isHealthy) {
1537
+ if (rawStatus === "healthy") {
1006
1538
  setLastHealthyAt(Date.now());
1007
1539
  }
1008
- }, [isHealthy]);
1540
+ }, [rawStatus]);
1009
1541
  useEffect(() => {
1010
1542
  broadcastHealthChange(derivedStatus, isHealthy);
1011
1543
  onHealthChange?.(derivedStatus, isHealthy);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromahq/react",
3
- "version": "1.0.45",
3
+ "version": "1.0.47",
4
4
  "description": "React bindings for the Chroma Chrome extension framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -41,16 +41,20 @@
41
41
  "devDependencies": {
42
42
  "@rollup/plugin-node-resolve": "^15.2.3",
43
43
  "@rollup/plugin-typescript": "^12.1.3",
44
+ "@testing-library/react": "^16.0.0",
44
45
  "@types/react": "^18.2.7",
46
+ "jsdom": "^25.0.0",
45
47
  "rollup": "^4.8.0",
46
48
  "rollup-plugin-dts": "^6.1.0",
47
49
  "rollup-plugin-esbuild": "^6.1.0",
48
50
  "typescript": "^5.6.0",
49
51
  "react": "^19.1.0",
50
- "react-dom": "^19.1.0"
52
+ "react-dom": "^19.1.0",
53
+ "vitest": "^3.2.4"
51
54
  },
52
55
  "scripts": {
53
56
  "build": "rollup -c",
54
- "dev": "rollup -c --watch"
57
+ "dev": "rollup -c --watch",
58
+ "test": "vitest run"
55
59
  }
56
60
  }