@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 +16 -0
- package/dist/sse.js +138 -19
- package/dist/tab-leader.d.ts +14 -0
- package/dist/tab-leader.js +111 -0
- package/package.json +5 -1
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
|
-
|
|
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
|
|
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 (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
/**
|
|
167
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|
|
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"
|