@chromahq/react 1.0.46 → 1.0.48

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;
@@ -291,4 +341,4 @@ declare function useServiceWorkerHealthSimple(options?: ServiceWorkerHealthOptio
291
341
  };
292
342
 
293
343
  export { BridgeProvider, ServiceWorkerHealthProvider, getHealthStatus, subscribeToHealth, useBridge, useBridgeQuery, useConnectionStatus, useServiceWorkerHealth, useServiceWorkerHealthSimple };
294
- 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)
@@ -100,8 +100,17 @@ const CONFIG = {
100
100
  QUEUE_DRAIN_DELAY: 50,
101
101
  // Delay between processing queued requests
102
102
  // Optimistic health - don't surface unhealthy state immediately
103
- HEALTH_GRACE_PERIOD_MS: 3e3
104
- // Wait this long before showing unhealthy
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)}`;
105
114
  };
106
115
  const calculateBackoffDelay = (attempt, baseDelay, maxDelay) => Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
107
116
  const clearTimeoutSafe = (ref) => {
@@ -318,7 +327,9 @@ function createBridgeInstance(deps) {
318
327
  resolve,
319
328
  reject,
320
329
  timeout,
321
- timeoutDuration
330
+ timeoutDuration,
331
+ key,
332
+ payload
322
333
  });
323
334
  if (!isPortValid()) {
324
335
  if (isReconnectingRef.current) {
@@ -452,7 +463,67 @@ function createBridgeInstance(deps) {
452
463
  }
453
464
  return false;
454
465
  }
455
- }
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)
456
527
  };
457
528
  return bridge;
458
529
  }
@@ -613,22 +684,32 @@ const BridgeProvider = ({
613
684
  }
614
685
  portDisconnectedRef.current = false;
615
686
  if (preserveQueue) {
616
- pendingRequestsRef.current.forEach(({ resolve, reject, timeout, timeoutDuration }, id) => {
617
- clearTimeout(timeout);
618
- const key = id.split("_")[0] || "unknown";
619
- requestQueueRef.current.push({
620
- id,
621
- key,
622
- payload: void 0,
623
- // We don't have the payload anymore, but the request is already in flight
624
- timeoutDuration,
625
- resolve,
626
- reject,
627
- retryCount: 0,
628
- maxRetries: CONFIG.REQUEST_MAX_RETRIES,
629
- queuedAt: Date.now()
630
- });
631
- });
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
+ );
632
713
  pendingRequestsRef.current.clear();
633
714
  } else {
634
715
  pendingRequestsRef.current.forEach(({ reject, timeout }) => {
@@ -667,7 +748,14 @@ const BridgeProvider = ({
667
748
  return;
668
749
  }
669
750
  if (message.type === "broadcast" && message.key) {
670
- 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) => {
671
759
  try {
672
760
  handler(message.payload);
673
761
  } catch (err) {
@@ -725,6 +813,9 @@ const BridgeProvider = ({
725
813
  );
726
814
  }
727
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
+ }
728
819
  if (request.idempotencyKey) {
729
820
  activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
730
821
  }
@@ -750,6 +841,12 @@ const BridgeProvider = ({
750
841
  }, retryDelay);
751
842
  return;
752
843
  }
844
+ {
845
+ console.error(
846
+ `[Bridge] \u274C Queued request failed after ${request.maxRetries} retries: ${request.key}`,
847
+ error2
848
+ );
849
+ }
753
850
  if (request.idempotencyKey) {
754
851
  activeIdempotencyKeysRef.current.delete(request.idempotencyKey);
755
852
  }
@@ -948,11 +1045,145 @@ const BridgeProvider = ({
948
1045
  swRestartRetryCountRef.current = 0;
949
1046
  consecutiveTimeoutsRef.current = 0;
950
1047
  isConnectingRef.current = false;
951
- setTimeout(() => {
952
- if (isMountedRef.current && isConnectedRef.current) {
953
- drainRequestQueue();
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...");
954
1144
  }
955
- }, 200);
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);
956
1187
  reconnectionGracePeriodRef.current = true;
957
1188
  setTimeout(() => {
958
1189
  reconnectionGracePeriodRef.current = false;
@@ -960,15 +1191,6 @@ const BridgeProvider = ({
960
1191
  console.log("[Bridge] Grace period ended, timeout monitoring active");
961
1192
  }
962
1193
  }, 1e4);
963
- eventListenersRef.current.get("bridge:connected")?.forEach((handler) => {
964
- try {
965
- handler({ timestamp: Date.now() });
966
- } catch (err) {
967
- if (BRIDGE_ENABLE_LOGS) {
968
- console.warn("[Bridge] bridge:connected handler error:", err);
969
- }
970
- }
971
- });
972
1194
  const rejectAllPendingRequests = (message) => {
973
1195
  pendingRequestsRef.current.forEach(({ reject, timeout, timeoutDuration }, id) => {
974
1196
  if (timeoutDuration <= timeoutFailureThreshold) {
@@ -1259,7 +1481,7 @@ function broadcastHealthChange(status, isHealthy) {
1259
1481
  });
1260
1482
  }
1261
1483
  const ServiceWorkerHealthContext = createContext(null);
1262
- const HEALTH_GRACE_PERIOD_MS = 3e3;
1484
+ const HEALTH_GRACE_PERIOD_MS = 1e3;
1263
1485
  const ServiceWorkerHealthProvider = ({
1264
1486
  children,
1265
1487
  onHealthChange
@@ -1289,7 +1511,7 @@ const ServiceWorkerHealthProvider = ({
1289
1511
  clearTimeout(graceTimeoutRef.current);
1290
1512
  graceTimeoutRef.current = null;
1291
1513
  }
1292
- } else if (rawStatus !== "healthy" && !unhealthyStartedAt) {
1514
+ } else if (!unhealthyStartedAt) {
1293
1515
  setUnhealthyStartedAt(Date.now());
1294
1516
  graceTimeoutRef.current = setTimeout(() => {
1295
1517
  setGraceExpired(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromahq/react",
3
- "version": "1.0.46",
3
+ "version": "1.0.48",
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
  }