cross-tab-worker-databus 0.20.86 → 0.20.87

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.
@@ -79,6 +79,7 @@ function useCrossTabStatus(bus) {
79
79
  }
80
80
  function useCrossTabHealth(bus, options) {
81
81
  const [health, setHealth] = (0, import_react.useState)(null);
82
+ const intervalMs = options?.intervalMs ?? 1e3;
82
83
  (0, import_react.useEffect)(() => {
83
84
  if (!bus) {
84
85
  setHealth(null);
@@ -88,14 +89,13 @@ function useCrossTabHealth(bus, options) {
88
89
  refresh();
89
90
  const unsubscribeStatus = bus.onStatus(refresh);
90
91
  const unsubscribeError = bus.onError(refresh);
91
- const intervalMs = options?.intervalMs ?? 1e3;
92
92
  const timer = intervalMs > 0 ? setInterval(refresh, intervalMs) : null;
93
93
  return () => {
94
94
  unsubscribeStatus();
95
95
  unsubscribeError();
96
96
  if (timer) clearInterval(timer);
97
97
  };
98
- }, [bus]);
98
+ }, [bus, intervalMs]);
99
99
  return health;
100
100
  }
101
101
  //# sourceMappingURL=hooks.cjs.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/hooks.ts", "../../src/utils/constants.ts"],
4
- "sourcesContent": ["/**\n * React hooks adapter for cross-tab-worker-databus.\n *\n * A thin, transport-agnostic bridge between the imperative CrossTabDataBus\n * API and React component lifecycles. React is an optional peer dependency \u2014\n * this module is a separate entry point so consumers who don't use React\n * never load it.\n *\n * - `useCrossTabDataBus` owns the bus lifecycle: created on mount, stopped on\n * unmount. It is StrictMode-safe: the double-invoked effect exercises the\n * same stop/recreate path as BFCache suspend/resume.\n * - `useCrossTabSubscription` attaches a message handler with automatic\n * cleanup; the handler is read through a ref, so you can pass inline\n * closures without resubscribing on every render.\n * - `useCrossTabStatus` mirrors `bus.onStatus()` into React state.\n * - `useCrossTabHealth` polls `bus.getHealthSummary()` into React state,\n * with event-driven refreshes on status changes and errors.\n */\nimport { useEffect, useRef, useState } from 'react';\nimport type { DependencyList } from 'react';\nimport type { CrossTabDataBus, DataBusHealthSummary } from './core/data-bus';\nimport type { DataBusMessage, WorkerStatus } from './core/types';\nimport { WORKER_STATUS } from './utils/constants';\n\n/**\n * Create a CrossTabDataBus for the component's lifetime.\n *\n * @param create Factory invoked once per effect run. Return a fresh bus \u2014\n * do not share a bus instance between effects, or StrictMode's\n * mount \u2192 stop \u2192 mount cycle will stop the shared instance out from\n * under the second mount.\n * @param deps Re-create the bus when these change (default: create once).\n * @returns The active bus, or `null` before the first effect has run (SSR\n * and the initial render).\n */\nexport function useCrossTabDataBus<TConfig, TData>(\n create: () => CrossTabDataBus<TConfig, TData>,\n deps: DependencyList = []\n): CrossTabDataBus<TConfig, TData> | null {\n const [bus, setBus] = useState<CrossTabDataBus<TConfig, TData> | null>(null);\n const lifecycleGeneration = useRef(0);\n useEffect(() => {\n const generation = ++lifecycleGeneration.current;\n const instance = create();\n if (generation !== lifecycleGeneration.current) return;\n setBus(instance);\n void instance.ready().catch(() => {});\n return () => {\n if (generation === lifecycleGeneration.current) setBus(null);\n void instance.stop();\n };\n // The factory is intentionally not a dependency: callers pass an inline\n // closure and key recreation through `deps` instead.\n }, deps);\n return bus;\n}\n\n/**\n * Subscribe to `topic` for the component's lifetime. The handler is read\n * through a ref on each delivery, so inline closures are safe without\n * unsubscribing/resubscribing on re-renders.\n *\n * When `bus` is null (not yet created) the subscription is queued until the\n * bus appears \u2014 the bus itself queues it until the transport is ready.\n */\nexport function useCrossTabSubscription<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null,\n topic: string,\n handler: (message: DataBusMessage<TData>) => void\n): void {\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n if (!bus) return;\n return bus.subscribe(topic, message => handlerRef.current(message));\n }, [bus, topic]);\n}\n\n/**\n * Mirror the bus connection status into React state. Reports the live value\n * via `onStatus` and reads the current value synchronously whenever `bus`\n * changes identity.\n */\nexport function useCrossTabStatus<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null\n): WorkerStatus {\n const [status, setStatus] = useState<WorkerStatus>(WORKER_STATUS.CONNECTING);\n useEffect(() => {\n if (!bus) {\n setStatus(WORKER_STATUS.CONNECTING);\n return;\n }\n setStatus(bus.getStatus());\n return bus.onStatus(setStatus);\n }, [bus]);\n return status;\n}\n\n/**\n * Mirror the bus health summary into React state.\n *\n * `getHealthSummary()` is a snapshot, not an event stream, so the hook polls\n * it on an interval (default 1000 ms) and refreshes immediately on status\n * changes and errors. Pass `intervalMs: 0` to rely on event-driven refreshes\n * only. Returns `null` while the bus has not been created yet.\n */\nexport function useCrossTabHealth<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null,\n options?: { intervalMs?: number }\n): DataBusHealthSummary | null {\n const [health, setHealth] = useState<DataBusHealthSummary | null>(null);\n useEffect(() => {\n if (!bus) {\n setHealth(null);\n return;\n }\n const refresh = () => setHealth(bus.getHealthSummary());\n refresh();\n const unsubscribeStatus = bus.onStatus(refresh);\n const unsubscribeError = bus.onError(refresh);\n const intervalMs = options?.intervalMs ?? 1_000;\n const timer = intervalMs > 0 ? setInterval(refresh, intervalMs) : null;\n return () => {\n unsubscribeStatus();\n unsubscribeError();\n if (timer) clearInterval(timer);\n };\n // The options object is intentionally not a dependency: callers pass an\n // inline literal and the interval only affects polling cadence.\n }, [bus]);\n return health;\n}\n", "/**\n * \u516C\u5171\u5B57\u7B26\u4E32\u5E38\u91CF \u2014\u2014 \u751F\u547D\u5468\u671F\u72B6\u6001\u3001\u89D2\u8272\u3001\u63A7\u5236\u52A8\u4F5C\u3001\u534F\u8BAE\u6D88\u606F\u7C7B\u578B\u3001\u679A\u4E3E\u4E0E\n * \u547D\u540D\u7A7A\u95F4\u524D\u7F00\u3002\n *\n * \u6240\u6709\u8FD0\u884C\u65F6\u4F7F\u7528\u7684\u5B57\u7B26\u4E32\u5B57\u9762\u91CF\u96C6\u4E2D\u5728\u6B64\u4E00\u4EFD\u503C\uFF0C\u7C7B\u578B\u5B9A\u4E49\uFF08src/core/types.ts\n * \u7B49\uFF09\u901A\u8FC7 `(typeof X)[keyof typeof X]` \u4ECE\u8FD9\u4E9B\u5E38\u91CF\u6D3E\u751F\uFF0C\u6BD4\u8F83/switch \u5904\u5F15\u7528\n * \u540C\u4E00\u5BF9\u8C61\u6210\u5458\u3002\u8FD9\u6837\u65E2\u80FD\u6D88\u9664\u6563\u843D\u5728\u5404\u6587\u4EF6\u91CC\u7684\u91CD\u590D\u5B57\u7B26\u4E32\uFF08\u907F\u514D\u5927\u5C0F\u5199\u4E0D\u7EDF\u4E00\n * \u6216\u7B14\u8BEF\uFF09\uFF0C\u53C8\u4FDD\u8BC1\u7C7B\u578B\u4E0E\u503C\u6C38\u4E0D\u9519\u4F4D\u3002\n *\n * ## \u4F7F\u7528\u7EA6\u5B9A\n *\n * - **\u65B0\u589E\u6216\u4FEE\u6539\u5B57\u7B26\u4E32\u5B57\u9762\u91CF\u65F6\uFF0C\u5148\u5230\u8FD9\u91CC\u67E5\u627E/\u589E\u8865\uFF0C\u4E0D\u8981\u5728\u4E1A\u52A1\u6587\u4EF6\u91CC\u76F4\u63A5\u5199\n * \u9B54\u6CD5\u5B57\u7B26\u4E32\u3002** \u626B\u63CF\u6B8B\u7559\u5B57\u9762\u91CF\uFF1A`rg \"'(['a-z]+)'\" src/`\u3002\n * - **\u53EA\u7528\u4E8E\u7C7B\u578B\u4F4D\u7F6E\u7684\u5E38\u91CF**\uFF08\u5982 `PERSISTENCE_OPERATION`\uFF09\u5728\u8C03\u7528\u65B9\u7528\n * `import type`\uFF1B**\u5728\u8FD0\u884C\u65F6\u6BD4\u8F83/\u6784\u9020\u4E2D\u4F7F\u7528\u7684**\uFF08\u5982 `WORKER_STATUS.ERROR`\u3001\n * `TRACE_EVENT_TYPE.LIFECYCLE`\uFF09\u5FC5\u987B\u7528\u503C\u5BFC\u5165\uFF0C\u5426\u5219\u4F1A\u62A5 \"cannot be used as\n * a value because it was imported using 'import type'\"\u3002\n * - **\u503C\u6D3E\u751F\u7C7B\u578B**\uFF1A`export type WorkerStatus = (typeof WORKER_STATUS)[keyof\n * typeof WORKER_STATUS]`\u3002\u8FD9\u6837\u65B0\u589E\u679A\u4E3E\u5206\u652F\u65F6\u7C7B\u578B\u81EA\u52A8\u6536\u7A84\uFF0C\u7F16\u8BD1\u5668\u4F1A\u6307\u51FA\n * \u6BCF\u4E2A\u9057\u6F0F\u7684 switch \u5206\u652F\u3002\n * - \u5206\u7EC4\u7684\u987A\u5E8F\uFF08\u72B6\u6001 / \u89D2\u8272 / \u540E\u7AEF / \u6D88\u606F\u7C7B\u578B / \u679A\u4E3E / \u524D\u7F00\uFF09\u4FDD\u6301\u7A33\u5B9A\uFF0C\u65B9\u4FBF\n * \u7EF4\u62A4\u8005\u4E00\u773C\u5B9A\u4F4D\u3002\n */\n\n// ---- \u751F\u547D\u5468\u671F\u72B6\u6001 / \u89D2\u8272 / \u53EF\u89C1\u6027 ----\nexport const WORKER_STATUS = {\n CONNECTING: 'connecting',\n CONNECTED: 'connected',\n DISCONNECTED: 'disconnected',\n ERROR: 'error',\n} as const;\n\nexport const WORKER_ROLE = {\n ACTIVE: 'active',\n STANDBY: 'standby',\n} as const;\n\nexport const TAB_VISIBILITY = {\n VISIBLE: 'visible',\n HIDDEN: 'hidden',\n} as const;\n\n// ---- Worker \u540E\u7AEF\u6A21\u5F0F\u4E0E\u89E3\u6790\u7ED3\u679C\uFF08WorkerMode / WorkerBackend\uFF09----\nexport const WORKER_MODE = {\n DEDICATED: 'dedicated',\n SHARED: 'shared',\n AUTO: 'auto',\n} as const;\n\nexport const WORKER_BACKEND = {\n DEDICATED: 'dedicated',\n SHARED: 'shared',\n LOCAL: 'local',\n} as const;\n\n// ---- \u5B58\u50A8\u4E8B\u4EF6\u901A\u9053\u56DE\u9000 ----\nexport const CHANNEL_FALLBACK = {\n NONE: 'none',\n STORAGE_EVENT: 'storage-event',\n} as const;\n\n// ---- Worker / MessagePort / BroadcastChannel \u7684 EventTarget \u4E8B\u4EF6\u540D\u5224\u522B ----\n// MessageEvent.type\uFF08'message' / 'messageerror'\uFF09\u4E0E Worker \u4E0A\u7684 'error' \u4E8B\u4EF6\u540D\uFF0C\n// \u7528\u4E8E\u73AF\u5883\u9002\u914D\u5668\u63A5\u53E3\u7B7E\u540D\u4E0E\u6D4B\u8BD5\u66FF\u8EAB\u7684 addEventListener \u771F\u5047\u5206\u652F\u3002\nexport const EVENT_TYPE = {\n MESSAGE: 'message',\n MESSAGEERROR: 'messageerror',\n ERROR: 'error',\n} as const;\n\n// ---- \u63A7\u5236\u5E73\u9762\u52A8\u4F5C\uFF08WorkerControlAction\uFF09----\nexport const CONTROL_ACTION = {\n SUBSCRIBE: 'SUBSCRIBE',\n UNSUBSCRIBE: 'UNSUBSCRIBE',\n PUBLISH: 'PUBLISH',\n} as const;\n\n// ---- BroadcastChannel \u96C6\u7FA4\u6D88\u606F\u7C7B\u578B ----\nexport const CLUSTER_MESSAGE_TYPE = {\n CONTROL: 'CONTROL',\n EVENT: 'EVENT',\n REGISTRY: 'REGISTRY',\n ROUTE_RELEASED: 'ROUTE_RELEASED',\n} as const;\n\n// ---- \u547D\u540D\u7A7A\u95F4\u524D\u7F00\u4E0E\u4E8B\u4EF6\u540D ----\nexport const DEFAULT_STORAGE_PREFIX = 'cross-tab-worker-databus';\nexport const STORAGE_CHANNEL_PREFIX = `${DEFAULT_STORAGE_PREFIX}:channel:`;\nexport const TAB_ID_STORAGE_KEY = `${DEFAULT_STORAGE_PREFIX}:tab-id`;\nexport const PUBLICATION_EVENT = 'DATABUS_PUBLICATION';\n\n// ---- \u89C4\u8303 JSON \u4FE1\u5C01\u64CD\u4F5C\u7801 ----\nexport const PUBLICATION_ENVELOPE_OP = 'publication';\n\n// ---- trace \u4E8B\u4EF6\u7C7B\u578B ----\nexport const TRACE_EVENT_TYPE = {\n LIFECYCLE: 'lifecycle',\n STATUS: 'status',\n SUBSCRIPTION: 'subscription',\n COORDINATION: 'coordination',\n ERROR: 'error',\n RELIABILITY: 'reliability',\n MESSAGE_METRICS: 'message_metrics',\n} as const;\n\n// ---- trace \u751F\u547D\u5468\u671F\u52A8\u4F5C ----\nexport const TRACE_LIFECYCLE_ACTION = {\n START: 'start',\n STOP: 'stop',\n SUSPEND: 'suspend',\n RESUME: 'resume',\n} as const;\n\n// ---- \u5185\u90E8 handler \u8C03\u7528\u6807\u7B7E\uFF08\u9694\u79BB\u5F02\u5E38\u65F6\u7684\u5206\u7C7B\u6765\u6E90\uFF09----\nexport const INVOKE_LABEL = {\n DISPATCH: 'dispatch',\n STATUS: 'status',\n ERROR_HANDLER: 'error handler',\n} as const;\n\n// ---- trace \u6A21\u5F0F\uFF08DataBusTraceMode\uFF09----\nexport const TRACE_MODE = {\n EVENTS: 'events',\n METRICS: 'metrics',\n ALL: 'all',\n} as const;\n\n// ---- trace \u9519\u8BEF\u6765\u6E90 ----\nexport const TRACE_ERROR_SOURCE = {\n TRANSPORT: 'transport',\n OPERATION: 'operation',\n} as const;\n\n// ---- reliability \u8BCA\u65AD\u64CD\u4F5C ----\nexport const RELIABILITY_OPERATION = {\n TRANSPORT_RECOVERY: 'transport_recovery',\n ROUTE_ACK: 'route_ack',\n ROUTE_MIGRATION: 'route_migration',\n // A re-election that recovered a stranded unconfirmed handoff (previous\n // owner gone, ACK never arrived). Distinct from ROUTE_MIGRATION so trace\n // consumers can tell recoveries apart from routine graceful handoffs.\n ROUTE_MIGRATION_RECOVERY: 'route_migration_recovery',\n PERSISTENCE_CLEANUP: 'persistence_cleanup',\n PERSISTENCE_RETRY: 'persistence_retry',\n DEDUP_SUPPRESSED: 'dedup_suppressed',\n} as const;\n\n// ---- \u81EA\u52A8\u6062\u590D\u5C1D\u8BD5\u7ED3\u679C ----\nexport const RECOVERY_OUTCOME = {\n SCHEDULED: 'scheduled',\n SUCCEEDED: 'succeeded',\n FAILED: 'failed',\n EXHAUSTED: 'exhausted',\n} as const;\n\n// ---- \u8BA2\u9605\u52A8\u4F5C ----\nexport const SUBSCRIPTION_ACTION = {\n SUBSCRIBE: 'subscribe',\n UNSUBSCRIBE: 'unsubscribe',\n} as const;\n\n// ---- \u56DE\u653E\u4FEE\u526A\u7B56\u7565 ----\nexport const PRUNE_STRATEGY = {\n COUNT: 'count',\n AGE: 'age',\n BOTH: 'both',\n} as const;\n\n// ---- \u6301\u4E45\u5316\u64CD\u4F5C ----\nexport const PERSISTENCE_OPERATION = {\n LOAD: 'load',\n APPEND: 'append',\n CLEAR: 'clear',\n CLEAR_TOPIC: 'clearTopic',\n CLEAR_BEFORE: 'clearBefore',\n} as const;\n\n// ---- \u6545\u969C\u6765\u6E90\uFF08DataBusFailureSource\uFF09----\nexport const FAILURE_SOURCE = {\n TRANSPORT: 'transport',\n PERSISTENCE: 'persistence',\n DISPATCH: 'dispatch',\n} as const;\n\n// ---- \u5065\u5EB7\u5224\u5B9A\u72B6\u6001\uFF08DataBusHealthSummary['state']\uFF09----\nexport const HEALTH_STATE = {\n STOPPED: 'stopped',\n STARTING: 'starting',\n HEALTHY: 'healthy',\n RECOVERING: 'recovering',\n SUSPENDED: 'suspended',\n DEGRADED: 'degraded',\n} as const;\n\n// ---- WebSocket wire \u534F\u8BAE\u64CD\u4F5C\u7801 ----\nexport const WS_OP = {\n SUBSCRIBE: 'subscribe',\n UNSUBSCRIBE: 'unsubscribe',\n PUBLISH: 'publish',\n PUBLISH_BATCH: 'publishBatch',\n} as const;\n\n// ---- Centrifuge Worker \u8F93\u5165\u6D88\u606F\u7C7B\u578B ----\nexport const CENTRIFUGE_INPUT_TYPE = {\n INIT: 'INIT',\n SUBSCRIBE: 'SUBSCRIBE',\n UNSUBSCRIBE: 'UNSUBSCRIBE',\n PUBLISH: 'PUBLISH',\n PUBLISH_BIN: 'PUBLISH_BIN',\n PING: 'PING',\n STOP: 'STOP',\n TOKEN_RESPONSE: 'TOKEN_RESPONSE',\n TOKEN_ERROR: 'TOKEN_ERROR',\n} as const;\n\n// ---- Centrifuge Worker \u8F93\u51FA\u6D88\u606F\u7C7B\u578B ----\nexport const CENTRIFUGE_OUTPUT_TYPE = {\n STATUS: 'STATUS',\n MESSAGE: 'MESSAGE',\n MESSAGE_BIN: 'MESSAGE_BIN',\n ERROR: 'ERROR',\n TOKEN_REQUEST: 'TOKEN_REQUEST',\n} as const;\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBA,mBAA4C;;;ACOrC,IAAM,gBAAgB;AAAA,EAC3B,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,cAAc;AAAA,EACd,OAAO;AACT;AAwDO,IAAM,yBAAyB;AAC/B,IAAM,yBAAyB,GAAG,sBAAsB;AACxD,IAAM,qBAAqB,GAAG,sBAAsB;;;ADrDpD,SAAS,mBACd,QACA,OAAuB,CAAC,GACgB;AACxC,QAAM,CAAC,KAAK,MAAM,QAAI,uBAAiD,IAAI;AAC3E,QAAM,0BAAsB,qBAAO,CAAC;AACpC,8BAAU,MAAM;AACd,UAAM,aAAa,EAAE,oBAAoB;AACzC,UAAM,WAAW,OAAO;AACxB,QAAI,eAAe,oBAAoB,QAAS;AAChD,WAAO,QAAQ;AACf,SAAK,SAAS,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACpC,WAAO,MAAM;AACX,UAAI,eAAe,oBAAoB,QAAS,QAAO,IAAI;AAC3D,WAAK,SAAS,KAAK;AAAA,IACrB;AAAA,EAGF,GAAG,IAAI;AACP,SAAO;AACT;AAUO,SAAS,wBACd,KACA,OACA,SACM;AACN,QAAM,iBAAa,qBAAO,OAAO;AACjC,aAAW,UAAU;AACrB,8BAAU,MAAM;AACd,QAAI,CAAC,IAAK;AACV,WAAO,IAAI,UAAU,OAAO,aAAW,WAAW,QAAQ,OAAO,CAAC;AAAA,EACpE,GAAG,CAAC,KAAK,KAAK,CAAC;AACjB;AAOO,SAAS,kBACd,KACc;AACd,QAAM,CAAC,QAAQ,SAAS,QAAI,uBAAuB,cAAc,UAAU;AAC3E,8BAAU,MAAM;AACd,QAAI,CAAC,KAAK;AACR,gBAAU,cAAc,UAAU;AAClC;AAAA,IACF;AACA,cAAU,IAAI,UAAU,CAAC;AACzB,WAAO,IAAI,SAAS,SAAS;AAAA,EAC/B,GAAG,CAAC,GAAG,CAAC;AACR,SAAO;AACT;AAUO,SAAS,kBACd,KACA,SAC6B;AAC7B,QAAM,CAAC,QAAQ,SAAS,QAAI,uBAAsC,IAAI;AACtE,8BAAU,MAAM;AACd,QAAI,CAAC,KAAK;AACR,gBAAU,IAAI;AACd;AAAA,IACF;AACA,UAAM,UAAU,MAAM,UAAU,IAAI,iBAAiB,CAAC;AACtD,YAAQ;AACR,UAAM,oBAAoB,IAAI,SAAS,OAAO;AAC9C,UAAM,mBAAmB,IAAI,QAAQ,OAAO;AAC5C,UAAM,aAAa,SAAS,cAAc;AAC1C,UAAM,QAAQ,aAAa,IAAI,YAAY,SAAS,UAAU,IAAI;AAClE,WAAO,MAAM;AACX,wBAAkB;AAClB,uBAAiB;AACjB,UAAI,MAAO,eAAc,KAAK;AAAA,IAChC;AAAA,EAGF,GAAG,CAAC,GAAG,CAAC;AACR,SAAO;AACT;",
4
+ "sourcesContent": ["/**\n * React hooks adapter for cross-tab-worker-databus.\n *\n * A thin, transport-agnostic bridge between the imperative CrossTabDataBus\n * API and React component lifecycles. React is an optional peer dependency \u2014\n * this module is a separate entry point so consumers who don't use React\n * never load it.\n *\n * - `useCrossTabDataBus` owns the bus lifecycle: created on mount, stopped on\n * unmount. It is StrictMode-safe: the double-invoked effect exercises the\n * same stop/recreate path as BFCache suspend/resume.\n * - `useCrossTabSubscription` attaches a message handler with automatic\n * cleanup; the handler is read through a ref, so you can pass inline\n * closures without resubscribing on every render.\n * - `useCrossTabStatus` mirrors `bus.onStatus()` into React state.\n * - `useCrossTabHealth` polls `bus.getHealthSummary()` into React state,\n * with event-driven refreshes on status changes and errors.\n */\nimport { useEffect, useRef, useState } from 'react';\nimport type { DependencyList } from 'react';\nimport type { CrossTabDataBus, DataBusHealthSummary } from './core/data-bus';\nimport type { DataBusMessage, WorkerStatus } from './core/types';\nimport { WORKER_STATUS } from './utils/constants';\n\n/**\n * Create a CrossTabDataBus for the component's lifetime.\n *\n * @param create Factory invoked once per effect run. Return a fresh bus \u2014\n * do not share a bus instance between effects, or StrictMode's\n * mount \u2192 stop \u2192 mount cycle will stop the shared instance out from\n * under the second mount.\n * @param deps Re-create the bus when these change (default: create once).\n * @returns The active bus, or `null` before the first effect has run (SSR\n * and the initial render).\n */\nexport function useCrossTabDataBus<TConfig, TData>(\n create: () => CrossTabDataBus<TConfig, TData>,\n deps: DependencyList = []\n): CrossTabDataBus<TConfig, TData> | null {\n const [bus, setBus] = useState<CrossTabDataBus<TConfig, TData> | null>(null);\n const lifecycleGeneration = useRef(0);\n useEffect(() => {\n const generation = ++lifecycleGeneration.current;\n const instance = create();\n if (generation !== lifecycleGeneration.current) return;\n setBus(instance);\n void instance.ready().catch(() => {});\n return () => {\n if (generation === lifecycleGeneration.current) setBus(null);\n void instance.stop();\n };\n // The factory is intentionally not a dependency: callers pass an inline\n // closure and key recreation through `deps` instead.\n }, deps);\n return bus;\n}\n\n/**\n * Subscribe to `topic` for the component's lifetime. The handler is read\n * through a ref on each delivery, so inline closures are safe without\n * unsubscribing/resubscribing on re-renders.\n *\n * When `bus` is null (not yet created) the subscription is queued until the\n * bus appears \u2014 the bus itself queues it until the transport is ready.\n */\nexport function useCrossTabSubscription<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null,\n topic: string,\n handler: (message: DataBusMessage<TData>) => void\n): void {\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n if (!bus) return;\n return bus.subscribe(topic, message => handlerRef.current(message));\n }, [bus, topic]);\n}\n\n/**\n * Mirror the bus connection status into React state. Reports the live value\n * via `onStatus` and reads the current value synchronously whenever `bus`\n * changes identity.\n */\nexport function useCrossTabStatus<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null\n): WorkerStatus {\n const [status, setStatus] = useState<WorkerStatus>(WORKER_STATUS.CONNECTING);\n useEffect(() => {\n if (!bus) {\n setStatus(WORKER_STATUS.CONNECTING);\n return;\n }\n setStatus(bus.getStatus());\n return bus.onStatus(setStatus);\n }, [bus]);\n return status;\n}\n\n/**\n * Mirror the bus health summary into React state.\n *\n * `getHealthSummary()` is a snapshot, not an event stream, so the hook polls\n * it on an interval (default 1000 ms) and refreshes immediately on status\n * changes and errors. Pass `intervalMs: 0` to rely on event-driven refreshes\n * only; changing the interval replaces the timer without recreating the bus.\n * Returns `null` while the bus has not been created yet.\n */\nexport function useCrossTabHealth<TConfig, TData>(\n bus: CrossTabDataBus<TConfig, TData> | null,\n options?: { intervalMs?: number }\n): DataBusHealthSummary | null {\n const [health, setHealth] = useState<DataBusHealthSummary | null>(null);\n const intervalMs = options?.intervalMs ?? 1_000;\n useEffect(() => {\n if (!bus) {\n setHealth(null);\n return;\n }\n const refresh = () => setHealth(bus.getHealthSummary());\n refresh();\n const unsubscribeStatus = bus.onStatus(refresh);\n const unsubscribeError = bus.onError(refresh);\n const timer = intervalMs > 0 ? setInterval(refresh, intervalMs) : null;\n return () => {\n unsubscribeStatus();\n unsubscribeError();\n if (timer) clearInterval(timer);\n };\n // Depend on the normalized cadence rather than the options object so inline\n // option literals do not restart the effect on every render, while a real\n // intervalMs change still replaces the polling timer.\n }, [bus, intervalMs]);\n return health;\n}\n", "/**\n * \u516C\u5171\u5B57\u7B26\u4E32\u5E38\u91CF \u2014\u2014 \u751F\u547D\u5468\u671F\u72B6\u6001\u3001\u89D2\u8272\u3001\u63A7\u5236\u52A8\u4F5C\u3001\u534F\u8BAE\u6D88\u606F\u7C7B\u578B\u3001\u679A\u4E3E\u4E0E\n * \u547D\u540D\u7A7A\u95F4\u524D\u7F00\u3002\n *\n * \u6240\u6709\u8FD0\u884C\u65F6\u4F7F\u7528\u7684\u5B57\u7B26\u4E32\u5B57\u9762\u91CF\u96C6\u4E2D\u5728\u6B64\u4E00\u4EFD\u503C\uFF0C\u7C7B\u578B\u5B9A\u4E49\uFF08src/core/types.ts\n * \u7B49\uFF09\u901A\u8FC7 `(typeof X)[keyof typeof X]` \u4ECE\u8FD9\u4E9B\u5E38\u91CF\u6D3E\u751F\uFF0C\u6BD4\u8F83/switch \u5904\u5F15\u7528\n * \u540C\u4E00\u5BF9\u8C61\u6210\u5458\u3002\u8FD9\u6837\u65E2\u80FD\u6D88\u9664\u6563\u843D\u5728\u5404\u6587\u4EF6\u91CC\u7684\u91CD\u590D\u5B57\u7B26\u4E32\uFF08\u907F\u514D\u5927\u5C0F\u5199\u4E0D\u7EDF\u4E00\n * \u6216\u7B14\u8BEF\uFF09\uFF0C\u53C8\u4FDD\u8BC1\u7C7B\u578B\u4E0E\u503C\u6C38\u4E0D\u9519\u4F4D\u3002\n *\n * ## \u4F7F\u7528\u7EA6\u5B9A\n *\n * - **\u65B0\u589E\u6216\u4FEE\u6539\u5B57\u7B26\u4E32\u5B57\u9762\u91CF\u65F6\uFF0C\u5148\u5230\u8FD9\u91CC\u67E5\u627E/\u589E\u8865\uFF0C\u4E0D\u8981\u5728\u4E1A\u52A1\u6587\u4EF6\u91CC\u76F4\u63A5\u5199\n * \u9B54\u6CD5\u5B57\u7B26\u4E32\u3002** \u626B\u63CF\u6B8B\u7559\u5B57\u9762\u91CF\uFF1A`rg \"'(['a-z]+)'\" src/`\u3002\n * - **\u53EA\u7528\u4E8E\u7C7B\u578B\u4F4D\u7F6E\u7684\u5E38\u91CF**\uFF08\u5982 `PERSISTENCE_OPERATION`\uFF09\u5728\u8C03\u7528\u65B9\u7528\n * `import type`\uFF1B**\u5728\u8FD0\u884C\u65F6\u6BD4\u8F83/\u6784\u9020\u4E2D\u4F7F\u7528\u7684**\uFF08\u5982 `WORKER_STATUS.ERROR`\u3001\n * `TRACE_EVENT_TYPE.LIFECYCLE`\uFF09\u5FC5\u987B\u7528\u503C\u5BFC\u5165\uFF0C\u5426\u5219\u4F1A\u62A5 \"cannot be used as\n * a value because it was imported using 'import type'\"\u3002\n * - **\u503C\u6D3E\u751F\u7C7B\u578B**\uFF1A`export type WorkerStatus = (typeof WORKER_STATUS)[keyof\n * typeof WORKER_STATUS]`\u3002\u8FD9\u6837\u65B0\u589E\u679A\u4E3E\u5206\u652F\u65F6\u7C7B\u578B\u81EA\u52A8\u6536\u7A84\uFF0C\u7F16\u8BD1\u5668\u4F1A\u6307\u51FA\n * \u6BCF\u4E2A\u9057\u6F0F\u7684 switch \u5206\u652F\u3002\n * - \u5206\u7EC4\u7684\u987A\u5E8F\uFF08\u72B6\u6001 / \u89D2\u8272 / \u540E\u7AEF / \u6D88\u606F\u7C7B\u578B / \u679A\u4E3E / \u524D\u7F00\uFF09\u4FDD\u6301\u7A33\u5B9A\uFF0C\u65B9\u4FBF\n * \u7EF4\u62A4\u8005\u4E00\u773C\u5B9A\u4F4D\u3002\n */\n\n// ---- \u751F\u547D\u5468\u671F\u72B6\u6001 / \u89D2\u8272 / \u53EF\u89C1\u6027 ----\nexport const WORKER_STATUS = {\n CONNECTING: 'connecting',\n CONNECTED: 'connected',\n DISCONNECTED: 'disconnected',\n ERROR: 'error',\n} as const;\n\nexport const WORKER_ROLE = {\n ACTIVE: 'active',\n STANDBY: 'standby',\n} as const;\n\nexport const TAB_VISIBILITY = {\n VISIBLE: 'visible',\n HIDDEN: 'hidden',\n} as const;\n\n// ---- Worker \u540E\u7AEF\u6A21\u5F0F\u4E0E\u89E3\u6790\u7ED3\u679C\uFF08WorkerMode / WorkerBackend\uFF09----\nexport const WORKER_MODE = {\n DEDICATED: 'dedicated',\n SHARED: 'shared',\n AUTO: 'auto',\n} as const;\n\nexport const WORKER_BACKEND = {\n DEDICATED: 'dedicated',\n SHARED: 'shared',\n LOCAL: 'local',\n} as const;\n\n// ---- \u5B58\u50A8\u4E8B\u4EF6\u901A\u9053\u56DE\u9000 ----\nexport const CHANNEL_FALLBACK = {\n NONE: 'none',\n STORAGE_EVENT: 'storage-event',\n} as const;\n\n// ---- Worker / MessagePort / BroadcastChannel \u7684 EventTarget \u4E8B\u4EF6\u540D\u5224\u522B ----\n// MessageEvent.type\uFF08'message' / 'messageerror'\uFF09\u4E0E Worker \u4E0A\u7684 'error' \u4E8B\u4EF6\u540D\uFF0C\n// \u7528\u4E8E\u73AF\u5883\u9002\u914D\u5668\u63A5\u53E3\u7B7E\u540D\u4E0E\u6D4B\u8BD5\u66FF\u8EAB\u7684 addEventListener \u771F\u5047\u5206\u652F\u3002\nexport const EVENT_TYPE = {\n MESSAGE: 'message',\n MESSAGEERROR: 'messageerror',\n ERROR: 'error',\n} as const;\n\n// ---- \u63A7\u5236\u5E73\u9762\u52A8\u4F5C\uFF08WorkerControlAction\uFF09----\nexport const CONTROL_ACTION = {\n SUBSCRIBE: 'SUBSCRIBE',\n UNSUBSCRIBE: 'UNSUBSCRIBE',\n PUBLISH: 'PUBLISH',\n} as const;\n\n// ---- BroadcastChannel \u96C6\u7FA4\u6D88\u606F\u7C7B\u578B ----\nexport const CLUSTER_MESSAGE_TYPE = {\n CONTROL: 'CONTROL',\n EVENT: 'EVENT',\n REGISTRY: 'REGISTRY',\n ROUTE_RELEASED: 'ROUTE_RELEASED',\n} as const;\n\n// ---- \u547D\u540D\u7A7A\u95F4\u524D\u7F00\u4E0E\u4E8B\u4EF6\u540D ----\nexport const DEFAULT_STORAGE_PREFIX = 'cross-tab-worker-databus';\nexport const STORAGE_CHANNEL_PREFIX = `${DEFAULT_STORAGE_PREFIX}:channel:`;\nexport const TAB_ID_STORAGE_KEY = `${DEFAULT_STORAGE_PREFIX}:tab-id`;\nexport const PUBLICATION_EVENT = 'DATABUS_PUBLICATION';\n\n// ---- \u89C4\u8303 JSON \u4FE1\u5C01\u64CD\u4F5C\u7801 ----\nexport const PUBLICATION_ENVELOPE_OP = 'publication';\n\n// ---- trace \u4E8B\u4EF6\u7C7B\u578B ----\nexport const TRACE_EVENT_TYPE = {\n LIFECYCLE: 'lifecycle',\n STATUS: 'status',\n SUBSCRIPTION: 'subscription',\n COORDINATION: 'coordination',\n ERROR: 'error',\n RELIABILITY: 'reliability',\n MESSAGE_METRICS: 'message_metrics',\n} as const;\n\n// ---- trace \u751F\u547D\u5468\u671F\u52A8\u4F5C ----\nexport const TRACE_LIFECYCLE_ACTION = {\n START: 'start',\n STOP: 'stop',\n SUSPEND: 'suspend',\n RESUME: 'resume',\n} as const;\n\n// ---- \u5185\u90E8 handler \u8C03\u7528\u6807\u7B7E\uFF08\u9694\u79BB\u5F02\u5E38\u65F6\u7684\u5206\u7C7B\u6765\u6E90\uFF09----\nexport const INVOKE_LABEL = {\n DISPATCH: 'dispatch',\n STATUS: 'status',\n ERROR_HANDLER: 'error handler',\n} as const;\n\n// ---- trace \u6A21\u5F0F\uFF08DataBusTraceMode\uFF09----\nexport const TRACE_MODE = {\n EVENTS: 'events',\n METRICS: 'metrics',\n ALL: 'all',\n} as const;\n\n// ---- trace \u9519\u8BEF\u6765\u6E90 ----\nexport const TRACE_ERROR_SOURCE = {\n TRANSPORT: 'transport',\n OPERATION: 'operation',\n} as const;\n\n// ---- reliability \u8BCA\u65AD\u64CD\u4F5C ----\nexport const RELIABILITY_OPERATION = {\n TRANSPORT_RECOVERY: 'transport_recovery',\n ROUTE_ACK: 'route_ack',\n ROUTE_MIGRATION: 'route_migration',\n // A re-election that recovered a stranded unconfirmed handoff (previous\n // owner gone, ACK never arrived). Distinct from ROUTE_MIGRATION so trace\n // consumers can tell recoveries apart from routine graceful handoffs.\n ROUTE_MIGRATION_RECOVERY: 'route_migration_recovery',\n PERSISTENCE_CLEANUP: 'persistence_cleanup',\n PERSISTENCE_RETRY: 'persistence_retry',\n DEDUP_SUPPRESSED: 'dedup_suppressed',\n} as const;\n\n// ---- \u81EA\u52A8\u6062\u590D\u5C1D\u8BD5\u7ED3\u679C ----\nexport const RECOVERY_OUTCOME = {\n SCHEDULED: 'scheduled',\n SUCCEEDED: 'succeeded',\n FAILED: 'failed',\n EXHAUSTED: 'exhausted',\n} as const;\n\n// ---- \u8BA2\u9605\u52A8\u4F5C ----\nexport const SUBSCRIPTION_ACTION = {\n SUBSCRIBE: 'subscribe',\n UNSUBSCRIBE: 'unsubscribe',\n} as const;\n\n// ---- \u56DE\u653E\u4FEE\u526A\u7B56\u7565 ----\nexport const PRUNE_STRATEGY = {\n COUNT: 'count',\n AGE: 'age',\n BOTH: 'both',\n} as const;\n\n// ---- \u6301\u4E45\u5316\u64CD\u4F5C ----\nexport const PERSISTENCE_OPERATION = {\n LOAD: 'load',\n APPEND: 'append',\n CLEAR: 'clear',\n CLEAR_TOPIC: 'clearTopic',\n CLEAR_BEFORE: 'clearBefore',\n} as const;\n\n// ---- \u6545\u969C\u6765\u6E90\uFF08DataBusFailureSource\uFF09----\nexport const FAILURE_SOURCE = {\n TRANSPORT: 'transport',\n PERSISTENCE: 'persistence',\n DISPATCH: 'dispatch',\n} as const;\n\n// ---- \u5065\u5EB7\u5224\u5B9A\u72B6\u6001\uFF08DataBusHealthSummary['state']\uFF09----\nexport const HEALTH_STATE = {\n STOPPED: 'stopped',\n STARTING: 'starting',\n HEALTHY: 'healthy',\n RECOVERING: 'recovering',\n SUSPENDED: 'suspended',\n DEGRADED: 'degraded',\n} as const;\n\n// ---- WebSocket wire \u534F\u8BAE\u64CD\u4F5C\u7801 ----\nexport const WS_OP = {\n SUBSCRIBE: 'subscribe',\n UNSUBSCRIBE: 'unsubscribe',\n PUBLISH: 'publish',\n PUBLISH_BATCH: 'publishBatch',\n} as const;\n\n// ---- Centrifuge Worker \u8F93\u5165\u6D88\u606F\u7C7B\u578B ----\nexport const CENTRIFUGE_INPUT_TYPE = {\n INIT: 'INIT',\n SUBSCRIBE: 'SUBSCRIBE',\n UNSUBSCRIBE: 'UNSUBSCRIBE',\n PUBLISH: 'PUBLISH',\n PUBLISH_BIN: 'PUBLISH_BIN',\n PING: 'PING',\n STOP: 'STOP',\n TOKEN_RESPONSE: 'TOKEN_RESPONSE',\n TOKEN_ERROR: 'TOKEN_ERROR',\n} as const;\n\n// ---- Centrifuge Worker \u8F93\u51FA\u6D88\u606F\u7C7B\u578B ----\nexport const CENTRIFUGE_OUTPUT_TYPE = {\n STATUS: 'STATUS',\n MESSAGE: 'MESSAGE',\n MESSAGE_BIN: 'MESSAGE_BIN',\n ERROR: 'ERROR',\n TOKEN_REQUEST: 'TOKEN_REQUEST',\n} as const;\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBA,mBAA4C;;;ACOrC,IAAM,gBAAgB;AAAA,EAC3B,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,cAAc;AAAA,EACd,OAAO;AACT;AAwDO,IAAM,yBAAyB;AAC/B,IAAM,yBAAyB,GAAG,sBAAsB;AACxD,IAAM,qBAAqB,GAAG,sBAAsB;;;ADrDpD,SAAS,mBACd,QACA,OAAuB,CAAC,GACgB;AACxC,QAAM,CAAC,KAAK,MAAM,QAAI,uBAAiD,IAAI;AAC3E,QAAM,0BAAsB,qBAAO,CAAC;AACpC,8BAAU,MAAM;AACd,UAAM,aAAa,EAAE,oBAAoB;AACzC,UAAM,WAAW,OAAO;AACxB,QAAI,eAAe,oBAAoB,QAAS;AAChD,WAAO,QAAQ;AACf,SAAK,SAAS,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACpC,WAAO,MAAM;AACX,UAAI,eAAe,oBAAoB,QAAS,QAAO,IAAI;AAC3D,WAAK,SAAS,KAAK;AAAA,IACrB;AAAA,EAGF,GAAG,IAAI;AACP,SAAO;AACT;AAUO,SAAS,wBACd,KACA,OACA,SACM;AACN,QAAM,iBAAa,qBAAO,OAAO;AACjC,aAAW,UAAU;AACrB,8BAAU,MAAM;AACd,QAAI,CAAC,IAAK;AACV,WAAO,IAAI,UAAU,OAAO,aAAW,WAAW,QAAQ,OAAO,CAAC;AAAA,EACpE,GAAG,CAAC,KAAK,KAAK,CAAC;AACjB;AAOO,SAAS,kBACd,KACc;AACd,QAAM,CAAC,QAAQ,SAAS,QAAI,uBAAuB,cAAc,UAAU;AAC3E,8BAAU,MAAM;AACd,QAAI,CAAC,KAAK;AACR,gBAAU,cAAc,UAAU;AAClC;AAAA,IACF;AACA,cAAU,IAAI,UAAU,CAAC;AACzB,WAAO,IAAI,SAAS,SAAS;AAAA,EAC/B,GAAG,CAAC,GAAG,CAAC;AACR,SAAO;AACT;AAWO,SAAS,kBACd,KACA,SAC6B;AAC7B,QAAM,CAAC,QAAQ,SAAS,QAAI,uBAAsC,IAAI;AACtE,QAAM,aAAa,SAAS,cAAc;AAC1C,8BAAU,MAAM;AACd,QAAI,CAAC,KAAK;AACR,gBAAU,IAAI;AACd;AAAA,IACF;AACA,UAAM,UAAU,MAAM,UAAU,IAAI,iBAAiB,CAAC;AACtD,YAAQ;AACR,UAAM,oBAAoB,IAAI,SAAS,OAAO;AAC9C,UAAM,mBAAmB,IAAI,QAAQ,OAAO;AAC5C,UAAM,QAAQ,aAAa,IAAI,YAAY,SAAS,UAAU,IAAI;AAClE,WAAO,MAAM;AACX,wBAAkB;AAClB,uBAAiB;AACjB,UAAI,MAAO,eAAc,KAAK;AAAA,IAChC;AAAA,EAIF,GAAG,CAAC,KAAK,UAAU,CAAC;AACpB,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -2329,7 +2329,7 @@ var DedupManager = class {
2329
2329
  };
2330
2330
 
2331
2331
  // src/core/version.ts
2332
- var SDK_VERSION = true ? "0.20.86" : "";
2332
+ var SDK_VERSION = true ? "0.20.87" : "";
2333
2333
 
2334
2334
  // src/core/data-bus.ts
2335
2335
  var DEFAULT_REPLAY_MAX_PER_TOPIC = 100;
@@ -2354,6 +2354,11 @@ var CrossTabDataBus = class {
2354
2354
  started = false;
2355
2355
  stopping = false;
2356
2356
  transportReady = false;
2357
+ // Whether the installed transport has reported `connected` at least once
2358
+ // since the current open began. A clean `disconnected` after this point is
2359
+ // a lost working connection, not the pre-connect window of a worker-style
2360
+ // backend whose start() resolves before it reports the connection.
2361
+ transportHasConnected = false;
2357
2362
  // Last transport failure, retained so ready() can surface it to callers who
2358
2363
  // never awaited start() directly. Cleared on the next successful start.
2359
2364
  lastError = null;
@@ -2392,6 +2397,19 @@ var CrossTabDataBus = class {
2392
2397
  // a transport reopen succeeds so traces can correlate repeated failures.
2393
2398
  recoveryAttempt = 0;
2394
2399
  recoveryExhausted = false;
2400
+ // Gate that holds transport operations issued after a runtime `error` until
2401
+ // the scheduled recovery attempt has actually run. Without it, a dead
2402
+ // transport still has `transportReady === true` during the cooldown, so
2403
+ // publishes/subscribes would be written to the failed connection and lost.
2404
+ recoveryGate = null;
2405
+ recoveryGateRelease = null;
2406
+ recoveryTimer = null;
2407
+ recoveryTimerToken = 0;
2408
+ // Once an automatic attempt fails, an explicit transport operation may
2409
+ // recover immediately instead of waiting for the next paced attempt. The
2410
+ // gate still stays closed so the operation cannot reach the failed
2411
+ // transport; it is released by the successful on-demand reopen.
2412
+ recoveryDemandAllowed = false;
2395
2413
  /** Monotonic generation incremented on every successful transport open.
2396
2414
  * Stays in lockstep with `lastSuccessAt` so callers can detect that the
2397
2415
  * transport has been reopened even if the timestamp window is short. */
@@ -2610,8 +2628,38 @@ var CrossTabDataBus = class {
2610
2628
  this.queuedStart = queued;
2611
2629
  return queued;
2612
2630
  }
2631
+ /** Release every operation waiting on the scheduled recovery attempt. */
2632
+ releaseRecoveryGate() {
2633
+ const release = this.recoveryGateRelease;
2634
+ this.recoveryGate = null;
2635
+ this.recoveryGateRelease = null;
2636
+ this.recoveryDemandAllowed = false;
2637
+ release?.();
2638
+ }
2639
+ /** Cancel a pending automatic retry when an explicit lifecycle transition
2640
+ * supersedes it. The released gate re-enters runTransport(), which then
2641
+ * follows the newest start/stop/suspend intent. */
2642
+ cancelScheduledRecovery() {
2643
+ this.recoveryTimerToken += 1;
2644
+ if (this.recoveryTimer !== null) {
2645
+ clearTimeout(this.recoveryTimer);
2646
+ this.recoveryTimer = null;
2647
+ }
2648
+ this.releaseRecoveryGate();
2649
+ }
2650
+ /** Keep the recovery gate closed after a failed attempt while allowing the
2651
+ * next explicit transport operation to start an immediate on-demand reopen.
2652
+ * If no gate/successor retry remains, release any waiters. */
2653
+ allowDemandRecovery() {
2654
+ if (this.recoveryGate !== null && this.started && !this.stopping && !this.suspended && this.status === WORKER_STATUS.ERROR) {
2655
+ this.recoveryDemandAllowed = true;
2656
+ return;
2657
+ }
2658
+ this.releaseRecoveryGate();
2659
+ }
2613
2660
  /** Reset failure and recovery diagnostics for a new explicit start session. */
2614
2661
  resetFailureState() {
2662
+ this.cancelScheduledRecovery();
2615
2663
  this.lastError = null;
2616
2664
  this.lastErrorAt = null;
2617
2665
  this.lastFailure = null;
@@ -2634,6 +2682,7 @@ var CrossTabDataBus = class {
2634
2682
  return before.catch(() => void 0).then(() => {
2635
2683
  if (!isCurrentLifecycle() || this.stopping || this.suspended) return;
2636
2684
  if (this.pendingStop === chainedPendingStop) this.pendingStop = null;
2685
+ this.transportHasConnected = false;
2637
2686
  return Promise.resolve(
2638
2687
  this.transport.start(config, {
2639
2688
  onMessage: (message) => {
@@ -2655,6 +2704,7 @@ var CrossTabDataBus = class {
2655
2704
  this.recoveryGeneration += 1;
2656
2705
  this.lastSuccessAt = this.now();
2657
2706
  this.transportReady = true;
2707
+ this.releaseRecoveryGate();
2658
2708
  }
2659
2709
  });
2660
2710
  }).catch((error) => {
@@ -2663,11 +2713,9 @@ var CrossTabDataBus = class {
2663
2713
  if (!this.pendingStop) {
2664
2714
  this.pendingStop = this.createStopPromise();
2665
2715
  }
2716
+ this.transportReady = false;
2666
2717
  this.updateStatus(WORKER_STATUS.ERROR);
2667
2718
  this.reportError(error);
2668
- this.lastError = error;
2669
- this.lastErrorAt = this.now();
2670
- this.transportReady = false;
2671
2719
  if (stopClusterOnFailure) {
2672
2720
  this.stopping = true;
2673
2721
  this.cluster.stop();
@@ -2681,7 +2729,10 @@ var CrossTabDataBus = class {
2681
2729
  * Returns a rejected promise when the transport has failed and no start is in
2682
2730
  * flight — the caller can retry by calling start() or ready() again. While an
2683
2731
  * explicit stop() is settling, this rejects unless a restart is queued behind
2684
- * it; false readiness during teardown is never reported.
2732
+ * it; false readiness during teardown is never reported. While the tab is
2733
+ * BFCache-suspended (pagehide without a following pageshow), this also
2734
+ * rejects: the suspended start promise is the transport-stop gate, not a
2735
+ * readiness signal.
2685
2736
  */
2686
2737
  ready() {
2687
2738
  if (this.queuedStart) return this.getQueuedStartReady();
@@ -2690,6 +2741,11 @@ var CrossTabDataBus = class {
2690
2741
  "CrossTabDataBus is stopping; ready() cannot report readiness until stop() resolves. Wait for stop() to resolve, then call start() before awaiting ready()."
2691
2742
  ));
2692
2743
  }
2744
+ if (this.suspended) {
2745
+ return Promise.reject(new Error(
2746
+ "CrossTabDataBus is suspended; ready() cannot report readiness until pageshow resumes the transport."
2747
+ ));
2748
+ }
2693
2749
  if (!this.started && !this.hasInitialConfig && this.lastError !== null) {
2694
2750
  return Promise.reject(this.lastError);
2695
2751
  }
@@ -2825,11 +2881,15 @@ var CrossTabDataBus = class {
2825
2881
  getStatus() {
2826
2882
  return this.status;
2827
2883
  }
2828
- /** Return the current automatic transport recovery state. `hasError` means a transport error is currently retained. */
2829
2884
  /** Return the current automatic transport recovery state plus diagnostics.
2830
- * `generation` increments on every successful transport open (initial start
2831
- * and every recovery); `lastSuccessAt` is the timestamp of the most recent
2832
- * successful open, or `null` until the transport reaches `ready`. */
2885
+ * `hasError`/`errorMessage`/`errorAt` describe the most recent retained
2886
+ * *transport* failure from a transport open or a runtime `onError`. They
2887
+ * share the lifetime of the unified `lastFailure` ledger: a successful
2888
+ * recovery keeps the last failure visible, and only an explicit `start()`
2889
+ * clears it. `generation` increments on every successful transport open
2890
+ * (initial start and every recovery); `lastSuccessAt` is the timestamp of
2891
+ * the most recent successful open, or `null` until the transport reaches
2892
+ * `ready`. */
2833
2893
  getRecoveryStats() {
2834
2894
  const errorMessage = this.lastError instanceof Error ? this.lastError.message : this.lastError === null ? null : String(this.lastError);
2835
2895
  return {
@@ -2856,7 +2916,7 @@ var CrossTabDataBus = class {
2856
2916
  * unified failure ledger and recovery context that explains the verdict. */
2857
2917
  getHealthSummary() {
2858
2918
  const transport = this.transport;
2859
- const transportDown = !this.transportReady || this.status === WORKER_STATUS.ERROR || this.status === WORKER_STATUS.DISCONNECTED;
2919
+ const transportDown = this.status !== WORKER_STATUS.CONNECTED;
2860
2920
  const state = !this.started ? HEALTH_STATE.STOPPED : this.suspended ? HEALTH_STATE.SUSPENDED : transportDown ? this.recoveryExhausted ? HEALTH_STATE.DEGRADED : this.status === WORKER_STATUS.CONNECTING && this.recoveryAttempt === 0 ? HEALTH_STATE.STARTING : HEALTH_STATE.RECOVERING : HEALTH_STATE.HEALTHY;
2861
2921
  return {
2862
2922
  healthy: state === HEALTH_STATE.HEALTHY,
@@ -2947,6 +3007,7 @@ var CrossTabDataBus = class {
2947
3007
  async performStop() {
2948
3008
  this.lifecycleEpoch += 1;
2949
3009
  this.stopping = true;
3010
+ this.cancelScheduledRecovery();
2950
3011
  this.replayManager.suspend();
2951
3012
  this.trace.event({ type: TRACE_EVENT_TYPE.LIFECYCLE, action: TRACE_LIFECYCLE_ACTION.STOP });
2952
3013
  this.trace.stop();
@@ -2959,6 +3020,8 @@ var CrossTabDataBus = class {
2959
3020
  const pendingStop = this.pendingStop;
2960
3021
  if (pendingStop) await pendingStop.catch(() => void 0);
2961
3022
  else await this.transport.stop();
3023
+ } catch (error) {
3024
+ this.reportError(error);
2962
3025
  } finally {
2963
3026
  this.transportSubscribedTopics.clear();
2964
3027
  this.resetDedup();
@@ -3024,10 +3087,12 @@ var CrossTabDataBus = class {
3024
3087
  updateStatus(status) {
3025
3088
  const previousStatus = this.status;
3026
3089
  this.status = status;
3090
+ if (status === WORKER_STATUS.CONNECTED) this.transportHasConnected = true;
3027
3091
  if (previousStatus !== status) this.trace.event({ type: TRACE_EVENT_TYPE.STATUS, status });
3028
3092
  this.cluster.setStatus(status);
3029
3093
  if (status === WORKER_STATUS.DISCONNECTED || status === WORKER_STATUS.ERROR) this.transportSubscribedTopics.clear();
3030
3094
  if (status === WORKER_STATUS.CONNECTED && previousStatus !== WORKER_STATUS.CONNECTED) {
3095
+ if (this.transportReady) this.releaseRecoveryGate();
3031
3096
  for (const topic of this.cluster.getSnapshot().assignedTopics) this.subscribeTransport(topic);
3032
3097
  }
3033
3098
  if (status === WORKER_STATUS.ERROR && this.started && !this.stopping) {
@@ -3040,23 +3105,49 @@ var CrossTabDataBus = class {
3040
3105
  this.recoveryExhausted = true;
3041
3106
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt: this.recoveryMaxAttempts, outcome: RECOVERY_OUTCOME.EXHAUSTED });
3042
3107
  }
3108
+ this.releaseRecoveryGate();
3043
3109
  return;
3044
3110
  }
3045
3111
  this.trace.event({ type: TRACE_EVENT_TYPE.RELIABILITY, operation: RELIABILITY_OPERATION.TRANSPORT_RECOVERY, attempt, outcome: RECOVERY_OUTCOME.SCHEDULED });
3046
- setTimeout(() => {
3047
- if (this.stopping || !this.started || this.suspended) return;
3048
- if (this.status !== WORKER_STATUS.ERROR) return;
3049
- void this.reopenTransport(attempt);
3112
+ if (this.recoveryGate === null) {
3113
+ let release;
3114
+ this.recoveryGate = new Promise((resolve) => {
3115
+ release = resolve;
3116
+ });
3117
+ this.recoveryGateRelease = release;
3118
+ }
3119
+ this.recoveryDemandAllowed = false;
3120
+ const timerToken = ++this.recoveryTimerToken;
3121
+ this.recoveryTimer = setTimeout(() => {
3122
+ if (timerToken !== this.recoveryTimerToken) return;
3123
+ this.recoveryTimer = null;
3124
+ if (this.stopping || !this.started || this.suspended || this.status !== WORKER_STATUS.ERROR) {
3125
+ this.releaseRecoveryGate();
3126
+ return;
3127
+ }
3128
+ this.recoveryDemandAllowed = false;
3129
+ const opening = this.reopenTransport(attempt);
3130
+ void opening.then(
3131
+ () => this.releaseRecoveryGate(),
3132
+ () => this.allowDemandRecovery()
3133
+ );
3050
3134
  }, this.recoveryCooldownMs);
3051
3135
  }
3136
+ } else if (status === WORKER_STATUS.ERROR) {
3137
+ this.releaseRecoveryGate();
3052
3138
  }
3053
3139
  this.invokeHandlers(this.statusHandlers, (handler) => handler(status));
3054
3140
  }
3055
3141
  reportError(error, source = FAILURE_SOURCE.TRANSPORT) {
3142
+ const at = this.now();
3143
+ if (source === FAILURE_SOURCE.TRANSPORT) {
3144
+ this.lastError = error;
3145
+ this.lastErrorAt = at;
3146
+ }
3056
3147
  this.lastFailure = {
3057
3148
  source,
3058
3149
  message: error instanceof Error ? error.message : String(error),
3059
- at: this.now()
3150
+ at
3060
3151
  };
3061
3152
  if (source === FAILURE_SOURCE.PERSISTENCE) {
3062
3153
  this.persistenceFailureCount += 1;
@@ -3139,6 +3230,7 @@ var CrossTabDataBus = class {
3139
3230
  if (this.stopping) return;
3140
3231
  this.lifecycleEpoch += 1;
3141
3232
  this.suspended = true;
3233
+ this.cancelScheduledRecovery();
3142
3234
  this.transportReady = false;
3143
3235
  this.transportSubscribedTopics.clear();
3144
3236
  this.updateStatus(WORKER_STATUS.DISCONNECTED);
@@ -3208,7 +3300,24 @@ var CrossTabDataBus = class {
3208
3300
  */
3209
3301
  runTransport(operation) {
3210
3302
  if (this.suspended) return;
3211
- if (this.transportReady && !this.stopping) {
3303
+ if (this.recoveryGate && !this.stopping) {
3304
+ if (this.recoveryDemandAllowed && this.status === WORKER_STATUS.ERROR && !this.suspended) {
3305
+ this.recoveryDemandAllowed = false;
3306
+ const opening = this.reopenTransport();
3307
+ void opening.then(
3308
+ () => this.releaseRecoveryGate(),
3309
+ () => this.allowDemandRecovery()
3310
+ );
3311
+ }
3312
+ const gate = this.recoveryGate;
3313
+ void gate.then(() => {
3314
+ if (this.stopping || this.suspended) return;
3315
+ this.runTransport(operation);
3316
+ });
3317
+ return;
3318
+ }
3319
+ const droppedAfterConnect = this.transportHasConnected && this.status === WORKER_STATUS.DISCONNECTED;
3320
+ if (this.transportReady && this.status !== WORKER_STATUS.ERROR && !droppedAfterConnect && !this.stopping) {
3212
3321
  try {
3213
3322
  void Promise.resolve(operation()).catch((error) => this.reportError(error));
3214
3323
  } catch (error) {
@@ -3562,6 +3671,7 @@ function parseDataBusPublication(value, fallbackTopic) {
3562
3671
  }
3563
3672
 
3564
3673
  // src/websocket.ts
3674
+ var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
3565
3675
  var WS_OPEN = 1;
3566
3676
  var WebSocketTransport = class {
3567
3677
  constructor(connection) {
@@ -3571,12 +3681,27 @@ var WebSocketTransport = class {
3571
3681
  diagnosticsName = "websocket";
3572
3682
  diagnosticsBackend = "native-websocket";
3573
3683
  socket = null;
3684
+ socketActive = false;
3574
3685
  handlers = null;
3575
3686
  subscribedTopics = /* @__PURE__ */ new Set();
3576
- /** Open the WebSocket and wire lifecycle listeners. A factory failure is
3577
- * reported through `onStatus('error')` so the DataBus can recover. */
3687
+ // Handshake gate for the current start(). Resolves once the socket opens,
3688
+ // rejects when the attempt fails, so the DataBus start Promise — and every
3689
+ // operation parked behind it — settles at the real connection boundary.
3690
+ connectPromise = null;
3691
+ connectResolve = null;
3692
+ connectReject = null;
3693
+ connectTimer = null;
3694
+ /** Open the WebSocket and wire lifecycle listeners. Resolves once the
3695
+ * handshake completes and rejects when the attempt fails, matching the
3696
+ * `DataBusTransport.start` contract ("resolves on connect or rejects on
3697
+ * failure"). A factory failure is reported through `onStatus('error')` so
3698
+ * the DataBus can recover. */
3578
3699
  start(config, handlers) {
3579
- if (this.socket) return;
3700
+ if (this.socket && this.socketActive) {
3701
+ return this.connectPromise ?? void 0;
3702
+ }
3703
+ this.socket = null;
3704
+ this.socketActive = false;
3580
3705
  this.handlers = handlers;
3581
3706
  const factory = config.webSocketFactory ?? this.connection.webSocketFactory ?? defaultWebSocketFactory;
3582
3707
  const protocols = config.protocols ?? this.connection.protocols;
@@ -3588,23 +3713,66 @@ var WebSocketTransport = class {
3588
3713
  handlers.onError(error);
3589
3714
  return;
3590
3715
  }
3591
- socket.onopen = () => {
3592
- if (this.socket !== socket || this.handlers !== handlers) return;
3593
- for (const topic of this.subscribedTopics) {
3594
- this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
3716
+ const opening = new Promise((resolve, reject) => {
3717
+ this.connectResolve = resolve;
3718
+ this.connectReject = reject;
3719
+ let handshakeCompleted = false;
3720
+ let handshakeFailed = false;
3721
+ const timeoutMs = config.connectTimeoutMs ?? this.connection.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
3722
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
3723
+ this.connectTimer = setTimeout(() => {
3724
+ if (this.socket !== socket || this.handlers !== handlers || handshakeCompleted) return;
3725
+ handshakeFailed = true;
3726
+ this.connectTimer = null;
3727
+ this.socketActive = false;
3728
+ const error = new Error(`WebSocket did not open within ${timeoutMs}ms.`);
3729
+ handlers.onStatus(WORKER_STATUS.ERROR);
3730
+ handlers.onError(error);
3731
+ this.failConnect(error);
3732
+ socket.close();
3733
+ }, timeoutMs);
3595
3734
  }
3596
- handlers.onStatus(WORKER_STATUS.CONNECTED);
3597
- };
3598
- socket.onclose = () => {
3599
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.DISCONNECTED);
3600
- };
3601
- socket.onerror = () => {
3602
- if (this.socket === socket && this.handlers === handlers) handlers.onStatus(WORKER_STATUS.ERROR);
3603
- };
3604
- socket.onmessage = (event) => {
3605
- if (this.socket === socket && this.handlers === handlers) void this.handleMessage(event.data);
3606
- };
3607
- this.socket = socket;
3735
+ socket.onopen = () => {
3736
+ if (this.socket !== socket || this.handlers !== handlers || handshakeFailed) return;
3737
+ this.socketActive = true;
3738
+ this.clearConnectTimer();
3739
+ for (const topic of this.subscribedTopics) {
3740
+ this.sendFrame({ op: WS_OP.SUBSCRIBE, topic });
3741
+ }
3742
+ handlers.onStatus(WORKER_STATUS.CONNECTED);
3743
+ if (!handshakeCompleted) {
3744
+ handshakeCompleted = true;
3745
+ this.settleConnect();
3746
+ }
3747
+ };
3748
+ socket.onclose = () => {
3749
+ if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
3750
+ this.socketActive = false;
3751
+ handlers.onStatus(WORKER_STATUS.DISCONNECTED);
3752
+ if (!handshakeCompleted) {
3753
+ handshakeFailed = true;
3754
+ this.failConnect(new Error("WebSocket closed before the handshake completed."));
3755
+ }
3756
+ };
3757
+ socket.onerror = () => {
3758
+ if (this.socket !== socket || this.handlers !== handlers || !this.socketActive) return;
3759
+ this.socketActive = false;
3760
+ handlers.onStatus(WORKER_STATUS.ERROR);
3761
+ if (!handshakeCompleted) {
3762
+ handshakeFailed = true;
3763
+ this.failConnect(new Error("WebSocket failed to open."));
3764
+ }
3765
+ };
3766
+ socket.onmessage = (event) => {
3767
+ if (this.socket === socket && this.handlers === handlers && this.socketActive) {
3768
+ void this.handleMessage(event.data);
3769
+ }
3770
+ };
3771
+ this.socket = socket;
3772
+ this.socketActive = true;
3773
+ });
3774
+ this.connectPromise = opening;
3775
+ return opening;
3608
3776
  }
3609
3777
  /** Idempotent: re-subscribing an active topic re-sends the frame but does
3610
3778
  * not duplicate the local tracking entry. */
@@ -3660,10 +3828,38 @@ var WebSocketTransport = class {
3660
3828
  /** Close the socket and drop all state. Safe to call multiple times. */
3661
3829
  stop() {
3662
3830
  const socket = this.socket;
3831
+ const shouldClose = this.socketActive;
3663
3832
  this.socket = null;
3833
+ this.socketActive = false;
3664
3834
  this.handlers = null;
3665
3835
  this.subscribedTopics.clear();
3666
- socket?.close();
3836
+ this.settleConnect();
3837
+ this.connectPromise = null;
3838
+ if (shouldClose) socket?.close();
3839
+ }
3840
+ /** Resolve the in-flight handshake gate. Idempotent: once the socket has
3841
+ * opened (or a newer attempt replaced it) later calls are no-ops. */
3842
+ settleConnect() {
3843
+ this.clearConnectTimer();
3844
+ const resolve = this.connectResolve;
3845
+ this.connectResolve = null;
3846
+ this.connectReject = null;
3847
+ resolve?.();
3848
+ }
3849
+ /** Reject the in-flight handshake gate. Idempotent on the same terms as
3850
+ * {@link settleConnect}. */
3851
+ failConnect(error) {
3852
+ this.clearConnectTimer();
3853
+ const reject = this.connectReject;
3854
+ this.connectResolve = null;
3855
+ this.connectReject = null;
3856
+ reject?.(error);
3857
+ }
3858
+ clearConnectTimer() {
3859
+ if (this.connectTimer !== null) {
3860
+ clearTimeout(this.connectTimer);
3861
+ this.connectTimer = null;
3862
+ }
3667
3863
  }
3668
3864
  /** Send one JSON frame. Frames are dropped with an `onError` report when
3669
3865
  * the socket is not open — subscribe frames are re-sent on open, so the