doover-js 0.12.1 → 0.13.1

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.
Files changed (43) hide show
  1. package/README.md +55 -0
  2. package/dist/client/data-client.d.ts +6 -0
  3. package/dist/client/doover-client.d.ts +4 -0
  4. package/dist/client/doover-client.js +6 -1
  5. package/dist/client/index.d.ts +3 -0
  6. package/dist/client/index.js +4 -1
  7. package/dist/client/network-status.d.ts +17 -0
  8. package/dist/client/network-status.js +70 -0
  9. package/dist/client/offline-cache.d.ts +9 -2
  10. package/dist/client/offline-cache.js +19 -6
  11. package/dist/client/service-reachability.d.ts +36 -0
  12. package/dist/client/service-reachability.js +107 -0
  13. package/dist/esm/client/data-client.d.ts +6 -0
  14. package/dist/esm/client/doover-client.d.ts +4 -0
  15. package/dist/esm/client/doover-client.js +6 -1
  16. package/dist/esm/client/index.d.ts +3 -0
  17. package/dist/esm/client/index.js +1 -0
  18. package/dist/esm/client/network-status.d.ts +17 -0
  19. package/dist/esm/client/network-status.js +66 -0
  20. package/dist/esm/client/offline-cache.d.ts +9 -2
  21. package/dist/esm/client/offline-cache.js +19 -6
  22. package/dist/esm/client/service-reachability.d.ts +36 -0
  23. package/dist/esm/client/service-reachability.js +103 -0
  24. package/dist/esm/http/rest-client.d.ts +7 -1
  25. package/dist/esm/http/rest-client.js +15 -7
  26. package/dist/esm/index.d.ts +3 -0
  27. package/dist/esm/index.js +1 -0
  28. package/dist/esm/package.json +1 -1
  29. package/dist/esm/react/index.d.ts +1 -0
  30. package/dist/esm/react/index.js +1 -0
  31. package/dist/esm/react/useOfflineStatus.js +30 -16
  32. package/dist/esm/react/useServiceReachability.d.ts +5 -0
  33. package/dist/esm/react/useServiceReachability.js +13 -0
  34. package/dist/http/rest-client.d.ts +7 -1
  35. package/dist/http/rest-client.js +15 -7
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.js +4 -1
  38. package/dist/react/index.d.ts +1 -0
  39. package/dist/react/index.js +3 -1
  40. package/dist/react/useOfflineStatus.js +29 -15
  41. package/dist/react/useServiceReachability.d.ts +5 -0
  42. package/dist/react/useServiceReachability.js +16 -0
  43. package/package.json +17 -2
package/README.md CHANGED
@@ -389,3 +389,58 @@ const statuses = useClientStatus();
389
389
  `*Api` resource methods accept either positional arguments or a `ChannelIdentifier` / `{ agentId }` object on every call form.
390
390
 
391
391
  `client.rpc.send` adds optional `signal: AbortSignal` and `timeoutMs: number` and rejects with `DooverRpcError` (with `.status` and `.request`) instead of the bare status string.
392
+
393
+ ## Network availability and offline cache status
394
+
395
+ `useOfflineStatus()` reports browser network changes for every client, including
396
+ `DooverClient` without an offline cache. It updates on initial offline load and
397
+ online/offline events even when no requests run. `online` describes the platform's
398
+ network signal; it does not prove that the Doover server is reachable.
399
+ `useClientStatus()` separately reports the gateway connection.
400
+
401
+ `OfflineDataClient` adds cache metadata such as `isOfflineFallback` and `cachedAt`.
402
+ These fields describe the last cache operation, not every item on the page. Its
403
+ snapshots retain object identity between changes for React external-store hooks.
404
+ Network transitions preserve cache metadata until another operation updates it.
405
+
406
+ Both clients accept `networkStatus`, a `NetworkStatusSource` with `getSnapshot()`
407
+ and `subscribe(listener)`. The offline wrapper inherits its underlying client's
408
+ source unless overridden. Sources return a stable `{ online, at }` snapshot and
409
+ notify subscribers when it changes. The exported `browserNetworkStatus` is the
410
+ default; it attaches browser listeners while subscribed and releases them after
411
+ the last unsubscribe. Outside a browser it assumes online when no platform
412
+ signal exists.
413
+
414
+ Native applications can pass `createNetworkStatusStore(initialOnline)` and call
415
+ its `setOnline(online)` method from platform network callbacks. Gateway failures
416
+ must not update this source. The legacy `OfflineDataClient.isOnline` callback
417
+ remains supported for request-time checks; a reactive source also updates the UI
418
+ between requests.
419
+
420
+ ### Data service reachability
421
+
422
+ `useServiceReachability()` returns `"unknown"`, `"reachable"`, or `"unreachable"`.
423
+ It observes `client.reachability`, independently of `navigator.onLine` and the
424
+ WebSocket's open state. `OfflineDataClient` forwards the wrapped client's source.
425
+ Clients without this source return `"unknown"`; multiplex consumers can observe
426
+ the relevant member client rather than assuming all services share connectivity.
427
+
428
+ Checks run only while this hook or a direct `client.reachability.subscribe(...)`
429
+ subscription is mounted. Multiple observers share one loop. Existing
430
+ `onStatusChange` subscriptions do not start checks. The final unsubscribe cancels
431
+ the timer and probe. Each probe has a five-second deadline. Healthy clients check
432
+ every 30 seconds; unreachable clients retry every five seconds. Browser offline
433
+ events suspend probes; online events trigger a fresh check.
434
+
435
+ A REST transport failure also schedules a check, coalescing simultaneous failures
436
+ and limiting probes to one per second. It does not retry or queue the failed
437
+ request, including commands. HTTP error responses and response parsing failures
438
+ do not trigger these checks.
439
+
440
+ The probe sends a credential-free `HEAD` request to `dataRestUrl` with
441
+ `cache: "no-store"`. Any HTTP response, including 401, 404, or 500, proves network
442
+ reachability; this is not an application health check. DNS, CORS, transport errors,
443
+ and timeouts report unreachable. A deployment must allow CORS on its probe URL.
444
+ Set `reachability: { probeUrl: "https://data.example.com/health" }` to use a different
445
+ read-only endpoint, or `reachability: false` to disable the source. The configured
446
+ `fetchImpl` also handles probes, including in native applications.
@@ -1,3 +1,5 @@
1
+ import type { ServiceReachabilitySource } from "./service-reachability.js";
2
+ import type { NetworkStatusSource } from "./network-status.js";
1
3
  import type { AgentsApi } from "../apis/agents-api.js";
2
4
  import type { AggregatesApi } from "../apis/aggregates-api.js";
3
5
  import type { AlarmsApi } from "../apis/alarms-api.js";
@@ -76,6 +78,10 @@ export interface DataClientStatus {
76
78
  * later; the invariant is that `DooverClient` always satisfies it.
77
79
  */
78
80
  export interface DataClient {
81
+ /** Optional platform source; React hooks otherwise use browser network status. */
82
+ readonly networkStatus?: NetworkStatusSource;
83
+ /** Optional data API reachability. Polls only while explicitly subscribed. */
84
+ readonly reachability?: ServiceReachabilitySource;
79
85
  readonly agents: AgentsApiLike;
80
86
  readonly channels: ChannelsApiLike;
81
87
  readonly messages: MessagesApiLike;
@@ -1,3 +1,5 @@
1
+ import { type ServiceReachabilitySource } from "./service-reachability.js";
2
+ import { type NetworkStatusSource } from "./network-status.js";
1
3
  import { OrganisationsApi } from "../apis/organisations-api.js";
2
4
  import type { DooverAuth } from "../auth/doover-auth.js";
3
5
  import { RestClient, type DooverClientConfig } from "../http/rest-client.js";
@@ -6,6 +8,8 @@ import { type Capability } from "./capabilities.js";
6
8
  import type { AgentsApiLike, AggregatesApiLike, AlarmsApiLike, ChannelsApiLike, ConnectionsApiLike, DataClient, DataClientStatus, AgentScope, GatewayClientLike, MessagesApiLike, NotificationsApiLike, PermissionsApiLike, ProcessorsApiLike, RpcDispatcherLike, TurnApiLike, UsersApiLike } from "./data-client.js";
7
9
  import { DooverStatsCollector, type DooverStatsSnapshot } from "./stats.js";
8
10
  export declare class DooverClient implements DataClient {
11
+ readonly networkStatus: NetworkStatusSource;
12
+ readonly reachability: ServiceReachabilitySource | undefined;
9
13
  readonly auth: DooverAuth;
10
14
  readonly rest: RestClient;
11
15
  readonly viewer: DooverDataProvider;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DooverClient = void 0;
4
+ const service_reachability_js_1 = require("./service-reachability.js");
5
+ const network_status_js_1 = require("./network-status.js");
4
6
  const agents_api_js_1 = require("../apis/agents-api.js");
5
7
  const aggregates_api_js_1 = require("../apis/aggregates-api.js");
6
8
  const alarms_api_js_1 = require("../apis/alarms-api.js");
@@ -25,6 +27,7 @@ const stats_js_1 = require("./stats.js");
25
27
  const ALL_CAPS_SET = new Set(capabilities_js_1.ALL_CAPABILITIES);
26
28
  class DooverClient {
27
29
  constructor(config) {
30
+ this.networkStatus = config.networkStatus ?? network_status_js_1.browserNetworkStatus;
28
31
  this.auth = (0, build_auth_js_1.buildAuth)({
29
32
  auth: config.auth,
30
33
  profile: config.profile,
@@ -48,7 +51,9 @@ class DooverClient {
48
51
  },
49
52
  };
50
53
  const stamper = new provenance_js_1.ProvenanceStamper(this.identity);
51
- this.rest = new rest_client_js_1.RestClient(config, this.auth);
54
+ const reachability = config.reachability === false ? undefined : new service_reachability_js_1.ServiceReachabilityMonitor(config.reachability?.probeUrl ?? config.dataRestUrl, this.networkStatus, config.fetchImpl ?? ((...args) => fetch(...args)));
55
+ this.reachability = reachability;
56
+ this.rest = new rest_client_js_1.RestClient(config, this.auth, () => reachability?.requestFailed());
52
57
  this.gatewayImpl = new gateway_client_js_1.GatewayClient(config, this.auth);
53
58
  this.gatewayImpl.setProvenanceHook((value, ctx) => stamper.stampGatewayEvent(value, ctx));
54
59
  this.gateway = this.gatewayImpl;
@@ -2,3 +2,6 @@ export { DooverClient } from "./doover-client.js";
2
2
  export { getDooverClient, peekDooverClient, resetDooverClient } from "./singleton.js";
3
3
  export type { DooverClientConfig } from "../http/rest-client.js";
4
4
  export type * from "./data-client.js";
5
+ export { browserNetworkStatus, createNetworkStatusStore } from "./network-status.js";
6
+ export type { NetworkStatusSource, NetworkStatusSnapshot } from "./network-status.js";
7
+ export type { ServiceReachability, ServiceReachabilitySource, ReachabilityOptions } from "./service-reachability.js";
@@ -1,9 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resetDooverClient = exports.peekDooverClient = exports.getDooverClient = exports.DooverClient = void 0;
3
+ exports.createNetworkStatusStore = exports.browserNetworkStatus = exports.resetDooverClient = exports.peekDooverClient = exports.getDooverClient = exports.DooverClient = void 0;
4
4
  var doover_client_js_1 = require("./doover-client.js");
5
5
  Object.defineProperty(exports, "DooverClient", { enumerable: true, get: function () { return doover_client_js_1.DooverClient; } });
6
6
  var singleton_js_1 = require("./singleton.js");
7
7
  Object.defineProperty(exports, "getDooverClient", { enumerable: true, get: function () { return singleton_js_1.getDooverClient; } });
8
8
  Object.defineProperty(exports, "peekDooverClient", { enumerable: true, get: function () { return singleton_js_1.peekDooverClient; } });
9
9
  Object.defineProperty(exports, "resetDooverClient", { enumerable: true, get: function () { return singleton_js_1.resetDooverClient; } });
10
+ var network_status_js_1 = require("./network-status.js");
11
+ Object.defineProperty(exports, "browserNetworkStatus", { enumerable: true, get: function () { return network_status_js_1.browserNetworkStatus; } });
12
+ Object.defineProperty(exports, "createNetworkStatusStore", { enumerable: true, get: function () { return network_status_js_1.createNetworkStatusStore; } });
@@ -0,0 +1,17 @@
1
+ /** Network availability is independent of the Doover gateway connection. */
2
+ export interface NetworkStatusSnapshot {
3
+ readonly online: boolean;
4
+ readonly at: number;
5
+ }
6
+ /** Snapshots must retain their identity until the status changes. */
7
+ export interface NetworkStatusSource {
8
+ getSnapshot(): NetworkStatusSnapshot;
9
+ subscribe(listener: () => void): () => void;
10
+ }
11
+ /** A source that native network callbacks can update. */
12
+ export declare function createNetworkStatusStore(initialOnline: boolean): {
13
+ getSnapshot: () => NetworkStatusSnapshot;
14
+ subscribe(listener: () => void): () => void;
15
+ setOnline(online: boolean): void;
16
+ };
17
+ export declare const browserNetworkStatus: NetworkStatusSource;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.browserNetworkStatus = void 0;
4
+ exports.createNetworkStatusStore = createNetworkStatusStore;
5
+ /** A source that native network callbacks can update. */
6
+ function createNetworkStatusStore(initialOnline) {
7
+ let snapshot = { online: initialOnline, at: Date.now() };
8
+ const listeners = new Set();
9
+ return {
10
+ getSnapshot: () => snapshot,
11
+ subscribe(listener) {
12
+ listeners.add(listener);
13
+ return () => { listeners.delete(listener); };
14
+ },
15
+ setOnline(online) {
16
+ if (online === snapshot.online)
17
+ return;
18
+ snapshot = { online, at: Date.now() };
19
+ listeners.forEach((listener) => listener());
20
+ },
21
+ };
22
+ }
23
+ function readBrowserOnline() {
24
+ const nav = typeof window !== "undefined" ? window.navigator
25
+ : typeof navigator !== "undefined" ? navigator : undefined;
26
+ return typeof nav?.onLine === "boolean" ? nav.onLine : true;
27
+ }
28
+ // Listen only while observed. getSnapshot also refreshes after periods with
29
+ // no subscribers, including initial offline loads. No browser globals at import.
30
+ function createBrowserNetworkStatus() {
31
+ let snapshot = { online: true, at: 0 };
32
+ const listeners = new Set();
33
+ let detach;
34
+ const getSnapshot = () => {
35
+ const online = readBrowserOnline();
36
+ if (online !== snapshot.online)
37
+ snapshot = { online, at: Date.now() };
38
+ return snapshot;
39
+ };
40
+ const refresh = () => {
41
+ getSnapshot();
42
+ // A request may already have read the new snapshot before this event.
43
+ // Still notify mounted consumers; React ignores an unchanged snapshot.
44
+ listeners.forEach((listener) => listener());
45
+ };
46
+ return {
47
+ getSnapshot,
48
+ subscribe(listener) {
49
+ listeners.add(listener);
50
+ if (!detach && typeof window !== "undefined") {
51
+ const target = window;
52
+ target.addEventListener("online", refresh);
53
+ target.addEventListener("offline", refresh);
54
+ detach = () => {
55
+ target.removeEventListener("online", refresh);
56
+ target.removeEventListener("offline", refresh);
57
+ };
58
+ }
59
+ refresh();
60
+ return () => {
61
+ listeners.delete(listener);
62
+ if (!listeners.size) {
63
+ detach?.();
64
+ detach = undefined;
65
+ }
66
+ };
67
+ },
68
+ };
69
+ }
70
+ exports.browserNetworkStatus = createBrowserNetworkStatus();
@@ -1,3 +1,5 @@
1
+ import type { ServiceReachabilitySource } from "./service-reachability.js";
2
+ import { type NetworkStatusSource } from "./network-status.js";
1
3
  import type { AgentScope, AgentsApiLike, AggregatesApiLike, AlarmsApiLike, ChannelsApiLike, ConnectionsApiLike, DataClient, DataClientStatus, GatewayClientLike, MessagesApiLike, NotificationsApiLike, PermissionsApiLike, ProcessorsApiLike, RpcDispatcherLike, TurnApiLike, UsersApiLike } from "./data-client.js";
2
4
  import type { Capability } from "./capabilities.js";
3
5
  export declare const DEFAULT_OFFLINE_RETENTION_MS: number;
@@ -83,18 +85,23 @@ export declare class MemoryOfflineStorageAdapter implements OfflineStorageAdapte
83
85
  deleteBlob(key: string): Promise<void>;
84
86
  }
85
87
  export interface OfflineDataClientOptions {
88
+ /** Reactive platform signal; defaults to the wrapped client's source. */
89
+ networkStatus?: NetworkStatusSource;
86
90
  client: DataClient;
87
91
  storage: OfflineStorageAdapter;
88
92
  scope: OfflineCacheScope;
89
93
  policies?: OfflineChannelPolicy[];
90
94
  defaultRetentionMs?: number;
91
95
  /**
92
- * Apps may provide their own reachability signal. When omitted, the wrapper
93
- * uses `navigator.onLine` when present and otherwise assumes online.
96
+ * Legacy pull-only override. Prefer networkStatus for reactive updates
97
+ * between requests. When omitted, the wrapper reads its network source.
94
98
  */
95
99
  isOnline?: () => boolean;
96
100
  }
97
101
  export declare class OfflineDataClient implements DataClient {
102
+ readonly networkStatus: NetworkStatusSource;
103
+ readonly reachability: ServiceReachabilitySource | undefined;
104
+ private unsubscribeNetwork?;
98
105
  readonly agents: AgentsApiLike;
99
106
  readonly channels: ChannelsApiLike;
100
107
  readonly messages: MessagesApiLike;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OfflineDataClient = exports.MemoryOfflineStorageAdapter = exports.DEFAULT_OFFLINE_RETENTION_MS = void 0;
4
+ const network_status_js_1 = require("./network-status.js");
4
5
  const errors_js_1 = require("./errors.js");
5
6
  const request_options_js_1 = require("./request-options.js");
6
7
  exports.DEFAULT_OFFLINE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@@ -57,6 +58,8 @@ class OfflineDataClient {
57
58
  this.offlineStatus = INITIAL_OFFLINE_STATUS;
58
59
  this.persistQueue = Promise.resolve();
59
60
  this.client = options.client;
61
+ this.reachability = options.client.reachability;
62
+ this.networkStatus = options.networkStatus ?? options.client.networkStatus ?? network_status_js_1.browserNetworkStatus;
60
63
  this.storage = options.storage;
61
64
  this.scope = options.scope;
62
65
  this.defaultRetentionMs = options.defaultRetentionMs ?? exports.DEFAULT_OFFLINE_RETENTION_MS;
@@ -90,19 +93,30 @@ class OfflineDataClient {
90
93
  return this.policies;
91
94
  }
92
95
  getOfflineStatus() {
93
- return {
94
- ...this.offlineStatus,
95
- online: this.isOnline(),
96
- };
96
+ const online = this.isOnline();
97
+ if (online !== this.offlineStatus.online) {
98
+ this.offlineStatus = { ...this.offlineStatus, online, at: Date.now() };
99
+ }
100
+ return this.offlineStatus;
97
101
  }
98
102
  onOfflineStatusChange(listener) {
99
103
  this.offlineStatusListeners.add(listener);
104
+ if (!this.unsubscribeNetwork) {
105
+ this.unsubscribeNetwork = this.networkStatus.subscribe(() => {
106
+ const status = this.getOfflineStatus();
107
+ this.offlineStatusListeners.forEach((callback) => callback(status));
108
+ });
109
+ }
100
110
  let active = true;
101
111
  return () => {
102
112
  if (!active)
103
113
  return;
104
114
  active = false;
105
115
  this.offlineStatusListeners.delete(listener);
116
+ if (!this.offlineStatusListeners.size) {
117
+ this.unsubscribeNetwork?.();
118
+ this.unsubscribeNetwork = undefined;
119
+ }
106
120
  };
107
121
  }
108
122
  async clearScope(scope = this.scope) {
@@ -682,8 +696,7 @@ class OfflineDataClient {
682
696
  isOnline() {
683
697
  if (this.isOnlineFn)
684
698
  return this.isOnlineFn();
685
- const nav = globalThis.navigator;
686
- return nav?.onLine ?? true;
699
+ return this.networkStatus.getSnapshot().online;
687
700
  }
688
701
  setOfflineStatus(status) {
689
702
  this.offlineStatus = {
@@ -0,0 +1,36 @@
1
+ import type { NetworkStatusSource } from "./network-status.js";
2
+ /** Reachability of the data API, independent of browser and gateway status. */
3
+ export type ServiceReachability = "unknown" | "reachable" | "unreachable";
4
+ export interface ServiceReachabilitySource {
5
+ getSnapshot(): ServiceReachability;
6
+ subscribe(listener: () => void): () => void;
7
+ }
8
+ export interface ReachabilityOptions {
9
+ /** A CORS-enabled, read-only URL on the data service. Defaults to dataRestUrl.
10
+ * Any HTTP response proves reachability, including authentication/server errors.
11
+ * HEAD requests omit credentials and bypass the browser cache. */
12
+ probeUrl?: string;
13
+ }
14
+ /** One polling loop per observed client. No requests or timers until subscribed. */
15
+ export declare class ServiceReachabilityMonitor implements ServiceReachabilitySource {
16
+ private readonly url;
17
+ private readonly network;
18
+ private readonly fetchImpl;
19
+ private status;
20
+ private listeners;
21
+ private timer;
22
+ private controller;
23
+ private unsubscribeNetwork;
24
+ private lastCheck;
25
+ constructor(url: string, network: NetworkStatusSource, fetchImpl: typeof fetch);
26
+ getSnapshot(): ServiceReachability;
27
+ subscribe(listener: () => void): () => void;
28
+ /** Transport failures trigger a probe; they never retry the failed request.
29
+ * Coalesce failures and cap probes at one per second during request bursts. */
30
+ requestFailed(): void;
31
+ private setStatus;
32
+ private cancel;
33
+ private networkChanged;
34
+ private schedule;
35
+ private check;
36
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ServiceReachabilityMonitor = void 0;
4
+ /** One polling loop per observed client. No requests or timers until subscribed. */
5
+ class ServiceReachabilityMonitor {
6
+ constructor(url, network, fetchImpl) {
7
+ this.url = url;
8
+ this.network = network;
9
+ this.fetchImpl = fetchImpl;
10
+ this.status = "unknown";
11
+ this.listeners = new Set();
12
+ this.lastCheck = -Infinity;
13
+ }
14
+ getSnapshot() { return this.status; }
15
+ subscribe(listener) {
16
+ const notify = () => listener();
17
+ this.listeners.add(notify);
18
+ if (this.listeners.size === 1) {
19
+ this.unsubscribeNetwork = this.network.subscribe(() => this.networkChanged());
20
+ this.networkChanged();
21
+ }
22
+ return () => {
23
+ this.listeners.delete(notify);
24
+ if (!this.listeners.size) {
25
+ this.unsubscribeNetwork?.();
26
+ this.unsubscribeNetwork = undefined;
27
+ this.cancel();
28
+ }
29
+ };
30
+ }
31
+ /** Transport failures trigger a probe; they never retry the failed request.
32
+ * Coalesce failures and cap probes at one per second during request bursts. */
33
+ requestFailed() {
34
+ if (!this.listeners.size || this.controller || !this.network.getSnapshot().online)
35
+ return;
36
+ this.schedule(Math.max(0, 1000 - (Date.now() - this.lastCheck)));
37
+ }
38
+ setStatus(status) {
39
+ if (status === this.status)
40
+ return;
41
+ this.status = status;
42
+ this.listeners.forEach((listener) => listener());
43
+ }
44
+ cancel() {
45
+ clearTimeout(this.timer);
46
+ this.timer = undefined;
47
+ const previous = this.controller;
48
+ this.controller = undefined;
49
+ previous?.abort();
50
+ }
51
+ networkChanged() {
52
+ if (!this.network.getSnapshot().online) {
53
+ this.cancel();
54
+ this.setStatus("unreachable");
55
+ }
56
+ else if (!this.controller) {
57
+ void this.check();
58
+ }
59
+ }
60
+ schedule(delay) {
61
+ clearTimeout(this.timer);
62
+ this.timer = setTimeout(() => { void this.check(); }, delay);
63
+ }
64
+ async check() {
65
+ if (!this.listeners.size || this.controller || !this.network.getSnapshot().online)
66
+ return;
67
+ clearTimeout(this.timer);
68
+ this.timer = undefined;
69
+ const controller = new AbortController();
70
+ this.controller = controller;
71
+ this.lastCheck = Date.now();
72
+ // The explicit race also bounds native/custom fetch implementations that
73
+ // ignore AbortSignal. Late responses cannot overwrite a newer observation.
74
+ let rejectAbort = () => { };
75
+ const aborted = new Promise((_resolve, reject) => {
76
+ rejectAbort = () => reject(new Error("Reachability check cancelled or timed out"));
77
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
78
+ });
79
+ const timeout = setTimeout(() => controller.abort(), 5000);
80
+ try {
81
+ const response = await Promise.race([
82
+ Promise.resolve().then(() => this.fetchImpl(this.url, {
83
+ method: "HEAD", cache: "no-store", credentials: "omit",
84
+ signal: controller.signal,
85
+ })),
86
+ aborted,
87
+ ]);
88
+ if (this.controller === controller) {
89
+ this.setStatus(response.status > 0 ? "reachable" : "unreachable");
90
+ }
91
+ }
92
+ catch {
93
+ if (this.controller === controller)
94
+ this.setStatus("unreachable");
95
+ }
96
+ finally {
97
+ clearTimeout(timeout);
98
+ controller.signal.removeEventListener("abort", rejectAbort);
99
+ if (this.controller === controller) {
100
+ this.controller = undefined;
101
+ if (this.listeners.size)
102
+ this.schedule(this.status === "reachable" ? 30000 : 5000);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ exports.ServiceReachabilityMonitor = ServiceReachabilityMonitor;
@@ -1,3 +1,5 @@
1
+ import type { ServiceReachabilitySource } from "./service-reachability.js";
2
+ import type { NetworkStatusSource } from "./network-status.js";
1
3
  import type { AgentsApi } from "../apis/agents-api.js";
2
4
  import type { AggregatesApi } from "../apis/aggregates-api.js";
3
5
  import type { AlarmsApi } from "../apis/alarms-api.js";
@@ -76,6 +78,10 @@ export interface DataClientStatus {
76
78
  * later; the invariant is that `DooverClient` always satisfies it.
77
79
  */
78
80
  export interface DataClient {
81
+ /** Optional platform source; React hooks otherwise use browser network status. */
82
+ readonly networkStatus?: NetworkStatusSource;
83
+ /** Optional data API reachability. Polls only while explicitly subscribed. */
84
+ readonly reachability?: ServiceReachabilitySource;
79
85
  readonly agents: AgentsApiLike;
80
86
  readonly channels: ChannelsApiLike;
81
87
  readonly messages: MessagesApiLike;
@@ -1,3 +1,5 @@
1
+ import { type ServiceReachabilitySource } from "./service-reachability.js";
2
+ import { type NetworkStatusSource } from "./network-status.js";
1
3
  import { OrganisationsApi } from "../apis/organisations-api.js";
2
4
  import type { DooverAuth } from "../auth/doover-auth.js";
3
5
  import { RestClient, type DooverClientConfig } from "../http/rest-client.js";
@@ -6,6 +8,8 @@ import { type Capability } from "./capabilities.js";
6
8
  import type { AgentsApiLike, AggregatesApiLike, AlarmsApiLike, ChannelsApiLike, ConnectionsApiLike, DataClient, DataClientStatus, AgentScope, GatewayClientLike, MessagesApiLike, NotificationsApiLike, PermissionsApiLike, ProcessorsApiLike, RpcDispatcherLike, TurnApiLike, UsersApiLike } from "./data-client.js";
7
9
  import { DooverStatsCollector, type DooverStatsSnapshot } from "./stats.js";
8
10
  export declare class DooverClient implements DataClient {
11
+ readonly networkStatus: NetworkStatusSource;
12
+ readonly reachability: ServiceReachabilitySource | undefined;
9
13
  readonly auth: DooverAuth;
10
14
  readonly rest: RestClient;
11
15
  readonly viewer: DooverDataProvider;
@@ -1,3 +1,5 @@
1
+ import { ServiceReachabilityMonitor } from "./service-reachability.js";
2
+ import { browserNetworkStatus } from "./network-status.js";
1
3
  import { AgentsApi } from "../apis/agents-api.js";
2
4
  import { AggregatesApi } from "../apis/aggregates-api.js";
3
5
  import { AlarmsApi } from "../apis/alarms-api.js";
@@ -22,6 +24,7 @@ import { DooverStatsCollector } from "./stats.js";
22
24
  const ALL_CAPS_SET = new Set(ALL_CAPABILITIES);
23
25
  export class DooverClient {
24
26
  constructor(config) {
27
+ this.networkStatus = config.networkStatus ?? browserNetworkStatus;
25
28
  this.auth = buildAuth({
26
29
  auth: config.auth,
27
30
  profile: config.profile,
@@ -45,7 +48,9 @@ export class DooverClient {
45
48
  },
46
49
  };
47
50
  const stamper = new ProvenanceStamper(this.identity);
48
- this.rest = new RestClient(config, this.auth);
51
+ const reachability = config.reachability === false ? undefined : new ServiceReachabilityMonitor(config.reachability?.probeUrl ?? config.dataRestUrl, this.networkStatus, config.fetchImpl ?? ((...args) => fetch(...args)));
52
+ this.reachability = reachability;
53
+ this.rest = new RestClient(config, this.auth, () => reachability?.requestFailed());
49
54
  this.gatewayImpl = new GatewayClient(config, this.auth);
50
55
  this.gatewayImpl.setProvenanceHook((value, ctx) => stamper.stampGatewayEvent(value, ctx));
51
56
  this.gateway = this.gatewayImpl;
@@ -2,3 +2,6 @@ export { DooverClient } from "./doover-client.js";
2
2
  export { getDooverClient, peekDooverClient, resetDooverClient } from "./singleton.js";
3
3
  export type { DooverClientConfig } from "../http/rest-client.js";
4
4
  export type * from "./data-client.js";
5
+ export { browserNetworkStatus, createNetworkStatusStore } from "./network-status.js";
6
+ export type { NetworkStatusSource, NetworkStatusSnapshot } from "./network-status.js";
7
+ export type { ServiceReachability, ServiceReachabilitySource, ReachabilityOptions } from "./service-reachability.js";
@@ -1,2 +1,3 @@
1
1
  export { DooverClient } from "./doover-client.js";
2
2
  export { getDooverClient, peekDooverClient, resetDooverClient } from "./singleton.js";
3
+ export { browserNetworkStatus, createNetworkStatusStore } from "./network-status.js";
@@ -0,0 +1,17 @@
1
+ /** Network availability is independent of the Doover gateway connection. */
2
+ export interface NetworkStatusSnapshot {
3
+ readonly online: boolean;
4
+ readonly at: number;
5
+ }
6
+ /** Snapshots must retain their identity until the status changes. */
7
+ export interface NetworkStatusSource {
8
+ getSnapshot(): NetworkStatusSnapshot;
9
+ subscribe(listener: () => void): () => void;
10
+ }
11
+ /** A source that native network callbacks can update. */
12
+ export declare function createNetworkStatusStore(initialOnline: boolean): {
13
+ getSnapshot: () => NetworkStatusSnapshot;
14
+ subscribe(listener: () => void): () => void;
15
+ setOnline(online: boolean): void;
16
+ };
17
+ export declare const browserNetworkStatus: NetworkStatusSource;
@@ -0,0 +1,66 @@
1
+ /** A source that native network callbacks can update. */
2
+ export function createNetworkStatusStore(initialOnline) {
3
+ let snapshot = { online: initialOnline, at: Date.now() };
4
+ const listeners = new Set();
5
+ return {
6
+ getSnapshot: () => snapshot,
7
+ subscribe(listener) {
8
+ listeners.add(listener);
9
+ return () => { listeners.delete(listener); };
10
+ },
11
+ setOnline(online) {
12
+ if (online === snapshot.online)
13
+ return;
14
+ snapshot = { online, at: Date.now() };
15
+ listeners.forEach((listener) => listener());
16
+ },
17
+ };
18
+ }
19
+ function readBrowserOnline() {
20
+ const nav = typeof window !== "undefined" ? window.navigator
21
+ : typeof navigator !== "undefined" ? navigator : undefined;
22
+ return typeof nav?.onLine === "boolean" ? nav.onLine : true;
23
+ }
24
+ // Listen only while observed. getSnapshot also refreshes after periods with
25
+ // no subscribers, including initial offline loads. No browser globals at import.
26
+ function createBrowserNetworkStatus() {
27
+ let snapshot = { online: true, at: 0 };
28
+ const listeners = new Set();
29
+ let detach;
30
+ const getSnapshot = () => {
31
+ const online = readBrowserOnline();
32
+ if (online !== snapshot.online)
33
+ snapshot = { online, at: Date.now() };
34
+ return snapshot;
35
+ };
36
+ const refresh = () => {
37
+ getSnapshot();
38
+ // A request may already have read the new snapshot before this event.
39
+ // Still notify mounted consumers; React ignores an unchanged snapshot.
40
+ listeners.forEach((listener) => listener());
41
+ };
42
+ return {
43
+ getSnapshot,
44
+ subscribe(listener) {
45
+ listeners.add(listener);
46
+ if (!detach && typeof window !== "undefined") {
47
+ const target = window;
48
+ target.addEventListener("online", refresh);
49
+ target.addEventListener("offline", refresh);
50
+ detach = () => {
51
+ target.removeEventListener("online", refresh);
52
+ target.removeEventListener("offline", refresh);
53
+ };
54
+ }
55
+ refresh();
56
+ return () => {
57
+ listeners.delete(listener);
58
+ if (!listeners.size) {
59
+ detach?.();
60
+ detach = undefined;
61
+ }
62
+ };
63
+ },
64
+ };
65
+ }
66
+ export const browserNetworkStatus = createBrowserNetworkStatus();