@classytic/arc-next 0.15.1 → 0.16.0

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/sse.d.ts CHANGED
@@ -101,6 +101,22 @@ interface EventStreamOptions<TData = unknown> extends SubscribeToEventsOptions<T
101
101
  * high-volume streams that don't read it.
102
102
  */
103
103
  trackEventCount?: boolean;
104
+ /**
105
+ * Hold ONE connection per browser rather than one per tab. Default: true.
106
+ *
107
+ * Browsers allow ~6 concurrent connections per origin on HTTP/1.1, so a
108
+ * per-tab stream lets a user starve their own app with a handful of tabs, and
109
+ * multiplies every reconnect against one server-side limit. The elected tab
110
+ * connects and relays events and connection state over `BroadcastChannel`;
111
+ * followers apply both without a socket.
112
+ *
113
+ * React-only — {@link subscribeToEvents} stays a plain per-caller connection,
114
+ * because tab coordination is a browser lifecycle concern and that function is
115
+ * the Node-capable core.
116
+ *
117
+ * Set false for a stream that must be per-tab.
118
+ */
119
+ shareAcrossTabs?: boolean;
104
120
  }
105
121
  interface EventStreamResult<TData = unknown> {
106
122
  isConnected: boolean;
package/dist/sse.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { ArcApiError, _getAuthErrorHandler, _runAuthRecovery, buildStreamUrl, getAuthMode } from "./client.js";
4
+ import { useTabLeader } from "./tab-leader.js";
4
5
  import { useQueryClient } from "@tanstack/react-query";
5
- import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
7
 
7
8
  //#region src/sse.ts
8
9
  /**
@@ -31,7 +32,24 @@ function buildSseUrl(path, params = {}) {
31
32
  * `Range: bytes=0-0` for servers that 405 on HEAD. Either way, the body
32
33
  * is never read — only the status code matters.
33
34
  */
34
- async function probeForAuthFailure(url, retryOn403) {
35
+ /** `Retry-After` is delta-seconds or an HTTP-date. Unparseable ⇒ no opinion. */
36
+ function parseRetryAfter(value) {
37
+ if (!value) return void 0;
38
+ const seconds = Number(value);
39
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
40
+ const at = Date.parse(value);
41
+ return Number.isNaN(at) ? void 0 : Math.max(0, at - Date.now());
42
+ }
43
+ /**
44
+ * Learn WHY the stream failed, since `EventSource` will not say.
45
+ *
46
+ * 429 matters as much as 401 and fails worse: a rate-limited stream that
47
+ * reconnects on the backoff schedule spends a token per attempt, so the window
48
+ * never drains and the client locks itself out indefinitely. The probe is a
49
+ * plain `fetch`, so unlike the stream it can read both the status and
50
+ * `Retry-After`.
51
+ */
52
+ async function probeConnectionFailure(url, retryOn403) {
35
53
  try {
36
54
  let res = await fetch(url, {
37
55
  method: "HEAD",
@@ -42,11 +60,15 @@ async function probeForAuthFailure(url, retryOn403) {
42
60
  credentials: "include",
43
61
  headers: { Range: "bytes=0-0" }
44
62
  });
45
- if (res.status === 401) return "auth-failure";
46
- if (retryOn403 && res.status === 403) return "auth-failure";
47
- return "not-auth";
63
+ if (res.status === 401) return { kind: "auth-failure" };
64
+ if (retryOn403 && res.status === 403) return { kind: "auth-failure" };
65
+ if (res.status === 429) return {
66
+ kind: "rate-limited",
67
+ retryAfterMs: parseRetryAfter(res.headers.get("retry-after"))
68
+ };
69
+ return { kind: "not-auth" };
48
70
  } catch {
49
- return "not-auth";
71
+ return { kind: "not-auth" };
50
72
  }
51
73
  }
52
74
  /**
@@ -129,11 +151,29 @@ function subscribeToEvents(options) {
129
151
  connected = false;
130
152
  options.onConnectionChange?.(false);
131
153
  if (manualClose) return;
154
+ /**
155
+ * The probe runs whether or not an auth handler is registered.
156
+ *
157
+ * It was gated on `handler`, so a deployment with no `onAuthError` never
158
+ * learned WHY the stream failed and fell straight to backoff — including
159
+ * for 429, the one status where backing off on the wrong schedule is
160
+ * self-defeating rather than merely slow.
161
+ */
132
162
  const { handler, retryOn403, maxAuthRetries } = _getAuthErrorHandler();
133
- if (handler && sseAuthRetries < maxAuthRetries) {
134
- sseAuthRetries += 1;
135
- probeForAuthFailure(buildUrl(), retryOn403).then(async (status) => {
136
- if (status === "auth-failure") {
163
+ if (sseProbes < maxAuthRetries) {
164
+ sseProbes += 1;
165
+ if (handler) sseAuthRetries += 1;
166
+ probeConnectionFailure(buildUrl(), retryOn403).then(async ({ kind, retryAfterMs }) => {
167
+ if (kind === "rate-limited") {
168
+ /**
169
+ * Honour the server's own number. Falling back to a full window
170
+ * rather than the 3s-based curve: retrying inside the window
171
+ * cannot succeed and each attempt refills the bucket.
172
+ */
173
+ scheduleReconnect(retryAfterMs ?? 6e4);
174
+ return;
175
+ }
176
+ if (kind === "auth-failure" && handler && sseAuthRetries <= maxAuthRetries) {
137
177
  const { decision } = await _runAuthRecovery(handler, {
138
178
  error: new ArcApiError("SSE pre-flight auth failure", {
139
179
  status: 401,
@@ -163,15 +203,21 @@ function subscribeToEvents(options) {
163
203
  scheduleReconnect();
164
204
  };
165
205
  };
166
- /** Standard backoff-reconnect — shared between non-auth errors and skipped recoveries. */
167
- const scheduleReconnect = () => {
206
+ /**
207
+ * Standard backoff-reconnect shared between non-auth errors and skipped
208
+ * recoveries. `explicitDelayMs` overrides the curve when the SERVER stated a
209
+ * wait (`Retry-After`); its number beats any local guess.
210
+ */
211
+ const scheduleReconnect = (explicitDelayMs) => {
168
212
  if (reconnectAttempts < maxReconnectAttempts) {
169
213
  reconnectAttempts += 1;
170
- const delay = Math.min(reconnectDelay * 1.5 ** (reconnectAttempts - 1), 3e4);
214
+ const delay = explicitDelayMs ?? Math.min(reconnectDelay * 1.5 ** (reconnectAttempts - 1), 3e4);
171
215
  reconnectTimer = setTimeout(connect, delay);
172
216
  }
173
217
  };
174
218
  let sseAuthRetries = 0;
219
+ /** Probes attempted for THIS subscription — bounds the extra fetch per failure. */
220
+ let sseProbes = 0;
175
221
  connect();
176
222
  return {
177
223
  close: () => {
@@ -214,7 +260,7 @@ function subscribeToEvents(options) {
214
260
  * });
215
261
  */
216
262
  function useEventStream(options) {
217
- const { url, resource, path, enabled = true, trackLastEvent = true, trackEventCount = true } = options;
263
+ const { url, resource, path, enabled = true, trackLastEvent = true, trackEventCount = true, shareAcrossTabs = true } = options;
218
264
  const queryClient = useQueryClient();
219
265
  const [isConnected, setIsConnected] = useState(false);
220
266
  const [lastEvent, setLastEvent] = useState(null);
@@ -230,12 +276,72 @@ function useEventStream(options) {
230
276
  const eventTypesKey = JSON.stringify(options.eventTypes ?? null);
231
277
  const patterns = useMemo(() => options.patterns, [patternsKey]);
232
278
  const eventTypes = useMemo(() => options.eventTypes, [eventTypesKey]);
279
+ /**
280
+ * The single place an event is applied, whether it arrived over this tab's
281
+ * socket or was relayed by the leader. Two copies would drift.
282
+ */
283
+ const applyEvent = useCallback((event) => {
284
+ if (trackLastEvent) setLastEvent(event);
285
+ if (trackEventCount) setEventCount((n) => n + 1);
286
+ onEventRef.current?.(event);
287
+ for (const key of invalidateKeysRef.current) queryClient.invalidateQueries({ queryKey: key });
288
+ }, [
289
+ queryClient,
290
+ trackLastEvent,
291
+ trackEventCount
292
+ ]);
293
+ /**
294
+ * Stream identity shared by every tab pointing at it — the election key and
295
+ * the channel name. Derived from the ENDPOINT, not the built URL, so a
296
+ * per-tab token or org param cannot split one stream into several elections.
297
+ */
298
+ const channelName = `arc-next.sse.${resource ?? path ?? url ?? "/events/stream"}`;
299
+ const isLeaderTab = useTabLeader({
300
+ key: channelName,
301
+ enabled: enabled && shareAcrossTabs
302
+ });
303
+ const connectedRef = useRef(false);
233
304
  useEffect(() => {
234
305
  if (!enabled) {
235
306
  handleRef.current?.close();
236
307
  handleRef.current = null;
237
308
  return;
238
309
  }
310
+ /**
311
+ * FOLLOWER — no socket. It mirrors the leader's events and connection
312
+ * state, so its cache, badge and polling decision stay correct at zero
313
+ * connection cost.
314
+ */
315
+ if (shareAcrossTabs && !isLeaderTab) {
316
+ handleRef.current?.close();
317
+ handleRef.current = null;
318
+ if (typeof BroadcastChannel === "undefined") return;
319
+ const channel = new BroadcastChannel(channelName);
320
+ channel.onmessage = (ev) => {
321
+ const msg = ev.data;
322
+ if (msg?.kind === "event") applyEvent(msg.event);
323
+ else if (msg?.kind === "state") {
324
+ setIsConnected(msg.connected);
325
+ onConnectionChangeRef.current?.(msg.connected);
326
+ }
327
+ };
328
+ channel.postMessage({ kind: "hello" });
329
+ return () => {
330
+ channel.close();
331
+ setIsConnected(false);
332
+ };
333
+ }
334
+ let channel = null;
335
+ if (shareAcrossTabs && typeof BroadcastChannel !== "undefined") {
336
+ channel = new BroadcastChannel(channelName);
337
+ channel.onmessage = (ev) => {
338
+ if (ev.data?.kind !== "hello") return;
339
+ channel?.postMessage({
340
+ kind: "state",
341
+ connected: connectedRef.current
342
+ });
343
+ };
344
+ }
239
345
  const handle = subscribeToEvents({
240
346
  url,
241
347
  resource,
@@ -246,20 +352,29 @@ function useEventStream(options) {
246
352
  maxReconnectAttempts: options.maxReconnectAttempts,
247
353
  withCredentials: options.withCredentials,
248
354
  onConnectionChange: (c) => {
355
+ connectedRef.current = c;
249
356
  setIsConnected(c);
250
357
  onConnectionChangeRef.current?.(c);
358
+ channel?.postMessage({
359
+ kind: "state",
360
+ connected: c
361
+ });
251
362
  },
252
363
  onEvent: (event) => {
253
- if (trackLastEvent) setLastEvent(event);
254
- if (trackEventCount) setEventCount((n) => n + 1);
255
- onEventRef.current?.(event);
256
- for (const key of invalidateKeysRef.current) queryClient.invalidateQueries({ queryKey: key });
364
+ channel?.postMessage({
365
+ kind: "event",
366
+ event
367
+ });
368
+ applyEvent(event);
257
369
  }
258
370
  });
259
371
  handleRef.current = handle;
260
372
  return () => {
261
373
  handle.close();
262
374
  handleRef.current = null;
375
+ channel?.close();
376
+ channel = null;
377
+ connectedRef.current = false;
263
378
  };
264
379
  }, [
265
380
  enabled,
@@ -273,7 +388,11 @@ function useEventStream(options) {
273
388
  options.withCredentials,
274
389
  trackLastEvent,
275
390
  trackEventCount,
276
- queryClient
391
+ queryClient,
392
+ shareAcrossTabs,
393
+ isLeaderTab,
394
+ channelName,
395
+ applyEvent
277
396
  ]);
278
397
  return {
279
398
  isConnected,
@@ -0,0 +1,14 @@
1
+ //#region src/tab-leader.d.ts
2
+ /**
3
+ * `true` in exactly one tab at a time (per `key`).
4
+ *
5
+ * When storage is unavailable every tab reports leader. That is deliberate: a
6
+ * degraded browser must not lose the feature entirely, and the server-side
7
+ * concurrency cap is the backstop for the duplicate connections it allows.
8
+ */
9
+ declare function useTabLeader(options: {
10
+ key: string;
11
+ enabled?: boolean;
12
+ }): boolean;
13
+ //#endregion
14
+ export { useTabLeader };
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ //#region src/tab-leader.ts
6
+ /**
7
+ * Elect ONE leader tab per browser, so a shared resource is held once.
8
+ *
9
+ * A browser allows only ~6 concurrent connections per origin on HTTP/1.1, so a
10
+ * per-tab stream lets a user with several tabs starve their own app — and it
11
+ * multiplies every reconnect against one server-side limit.
12
+ *
13
+ * Modelled on Odoo's `multi_tab_fallback_service` (addons/bus): the leader
14
+ * writes a heartbeat on an interval, any tab may claim leadership once that
15
+ * heartbeat goes stale, and a closing tab releases it immediately. localStorage
16
+ * is the coordination channel because it is synchronous, origin-scoped and
17
+ * present everywhere — a SharedWorker is tidier but absent in several browsers,
18
+ * which is why Odoo keeps this path too.
19
+ */
20
+ /** Leader refresh interval. Comfortably under STALE_MS so a live leader is never displaced. */
21
+ const HEARTBEAT_MS = 1500;
22
+ /** A heartbeat older than this means the holder is gone (crashed tab, killed process). */
23
+ const STALE_MS = 5e3;
24
+ /** How often a follower checks whether leadership is up for grabs. */
25
+ const CHECK_MS = 2e3;
26
+ function readRecord(key) {
27
+ try {
28
+ const raw = localStorage.getItem(key);
29
+ if (!raw) return null;
30
+ const parsed = JSON.parse(raw);
31
+ return typeof parsed?.id === "string" && typeof parsed?.ts === "number" ? parsed : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ function writeRecord(key, record) {
37
+ try {
38
+ localStorage.setItem(key, JSON.stringify(record));
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * `true` in exactly one tab at a time (per `key`).
46
+ *
47
+ * When storage is unavailable every tab reports leader. That is deliberate: a
48
+ * degraded browser must not lose the feature entirely, and the server-side
49
+ * concurrency cap is the backstop for the duplicate connections it allows.
50
+ */
51
+ function useTabLeader(options) {
52
+ const { key, enabled = true } = options;
53
+ const [isLeader, setIsLeader] = useState(false);
54
+ useEffect(() => {
55
+ if (!enabled || typeof window === "undefined" || typeof localStorage === "undefined") {
56
+ setIsLeader(false);
57
+ return;
58
+ }
59
+ const storageKey = `arc-next.leader.${key}`;
60
+ const tabId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
61
+ let leading = false;
62
+ let timer = null;
63
+ const claimOrRenew = () => {
64
+ const now = Date.now();
65
+ const held = readRecord(storageKey);
66
+ const vacant = !held || now - held.ts > STALE_MS;
67
+ if (held?.id === tabId || vacant) {
68
+ /**
69
+ * Last write wins. Two tabs can claim a vacant slot in the same tick;
70
+ * the loser observes a foreign id on its next pass and steps down, so
71
+ * the overlap is bounded by one interval rather than persisting.
72
+ */
73
+ if (writeRecord(storageKey, {
74
+ id: tabId,
75
+ ts: now
76
+ })) {
77
+ const won = readRecord(storageKey)?.id === tabId;
78
+ if (won !== leading) {
79
+ leading = won;
80
+ setIsLeader(won);
81
+ }
82
+ } else if (!leading) {
83
+ leading = true;
84
+ setIsLeader(true);
85
+ }
86
+ } else if (leading) {
87
+ leading = false;
88
+ setIsLeader(false);
89
+ }
90
+ timer = setTimeout(claimOrRenew, leading ? HEARTBEAT_MS : CHECK_MS);
91
+ };
92
+ /** Release immediately so a sibling promotes now rather than after STALE_MS. */
93
+ const release = () => {
94
+ if (!leading) return;
95
+ if (readRecord(storageKey)?.id === tabId) try {
96
+ localStorage.removeItem(storageKey);
97
+ } catch {}
98
+ };
99
+ claimOrRenew();
100
+ window.addEventListener("pagehide", release);
101
+ return () => {
102
+ if (timer) clearTimeout(timer);
103
+ release();
104
+ window.removeEventListener("pagehide", release);
105
+ };
106
+ }, [key, enabled]);
107
+ return isLeader;
108
+ }
109
+
110
+ //#endregion
111
+ export { useTabLeader };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-next",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "React + TanStack Query SDK for Arc resources",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -78,6 +78,10 @@
78
78
  "types": "./dist/sse.d.ts",
79
79
  "default": "./dist/sse.js"
80
80
  },
81
+ "./tab-leader": {
82
+ "types": "./dist/tab-leader.d.ts",
83
+ "default": "./dist/tab-leader.js"
84
+ },
81
85
  "./ws": {
82
86
  "types": "./dist/ws.d.ts",
83
87
  "default": "./dist/ws.js"