@classytic/arc-next 0.15.0 → 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/api.d.ts CHANGED
@@ -114,9 +114,33 @@ interface BaseApiConfig {
114
114
  }
115
115
  declare class BaseApi<TDoc = Record<string, unknown>, TCreate = Partial<TDoc>, TUpdate = Partial<TDoc>> {
116
116
  readonly entity: string;
117
- readonly config: Required<Omit<BaseApiConfig, "client">>;
118
- readonly baseUrl: string;
117
+ /**
118
+ * `basePath` stays OPTIONAL here — it holds the explicit per-instance
119
+ * override and nothing else. The resolved value is {@link basePath}, which is
120
+ * computed per read; baking it in would defeat the whole point (see below).
121
+ */
122
+ readonly config: Required<Omit<BaseApiConfig, "client" | "basePath">> & Pick<BaseApiConfig, "basePath">;
119
123
  private readonly requestFn;
124
+ /**
125
+ * Per-instance override → the deployment's declared prefix → `/api/v1`.
126
+ *
127
+ * The middle step is what lets a package construct its own API internally and
128
+ * still land on a host mounted elsewhere. Without it the only options were
129
+ * "every consumer passes `basePath`" — impossible for an instance a package
130
+ * owns — or "every host mounts at `/api/v1`".
131
+ *
132
+ * ## Why this is a GETTER and not resolved in the constructor
133
+ *
134
+ * `configureClient()` runs inside a `"use client"` provider, which is LATER
135
+ * than module evaluation. A package's API instance is created at import time,
136
+ * so a constructor-time read would capture `/api/v1` before the deployment
137
+ * ever declared `/api`, and the fallback would win permanently — producing a
138
+ * 404 that renders as an empty list, which is the failure this was meant to
139
+ * fix. An explicit `basePath` is unaffected either way.
140
+ */
141
+ get basePath(): string;
142
+ /** `{basePath}/{entity}` — the resource root every request is built from. */
143
+ get baseUrl(): string;
120
144
  constructor(entity: string, config?: BaseApiConfig);
121
145
  /** Merge per-instance headers into request options */
122
146
  private withHeaders;
package/dist/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createQueryString, handleApiRequest } from "./client.js";
1
+ import { createQueryString, getBasePath, handleApiRequest } from "./client.js";
2
2
  import { STANDARD_RESERVED_PARAMS } from "@classytic/repo-core/query-parser";
3
3
 
4
4
  //#region src/api.ts
@@ -9,15 +9,43 @@ for (const verb of [
9
9
  ]) if (!STANDARD_RESERVED_PARAMS.has(verb)) throw new Error(`[arc-next] dispatch verb '${verb}' is not in repo-core STANDARD_RESERVED_PARAMS`);
10
10
  var BaseApi = class {
11
11
  entity;
12
+ /**
13
+ * `basePath` stays OPTIONAL here — it holds the explicit per-instance
14
+ * override and nothing else. The resolved value is {@link basePath}, which is
15
+ * computed per read; baking it in would defeat the whole point (see below).
16
+ */
12
17
  config;
13
- baseUrl;
14
18
  requestFn;
19
+ /**
20
+ * Per-instance override → the deployment's declared prefix → `/api/v1`.
21
+ *
22
+ * The middle step is what lets a package construct its own API internally and
23
+ * still land on a host mounted elsewhere. Without it the only options were
24
+ * "every consumer passes `basePath`" — impossible for an instance a package
25
+ * owns — or "every host mounts at `/api/v1`".
26
+ *
27
+ * ## Why this is a GETTER and not resolved in the constructor
28
+ *
29
+ * `configureClient()` runs inside a `"use client"` provider, which is LATER
30
+ * than module evaluation. A package's API instance is created at import time,
31
+ * so a constructor-time read would capture `/api/v1` before the deployment
32
+ * ever declared `/api`, and the fallback would win permanently — producing a
33
+ * 404 that renders as an empty list, which is the failure this was meant to
34
+ * fix. An explicit `basePath` is unaffected either way.
35
+ */
36
+ get basePath() {
37
+ return this.config.basePath ?? getBasePath() ?? "/api/v1";
38
+ }
39
+ /** `{basePath}/{entity}` — the resource root every request is built from. */
40
+ get baseUrl() {
41
+ return `${this.basePath}/${this.entity}`;
42
+ }
15
43
  constructor(entity, config = {}) {
16
44
  this.entity = entity;
17
45
  const client = config.client;
18
46
  this.requestFn = typeof client === "function" ? (method, endpoint, options) => client().request(method, endpoint, options) : client?.request ?? handleApiRequest;
19
47
  this.config = {
20
- basePath: config.basePath ?? "/api/v1",
48
+ ...config.basePath !== void 0 ? { basePath: config.basePath } : {},
21
49
  defaultParams: {
22
50
  limit: 10,
23
51
  page: 1,
@@ -26,7 +54,6 @@ var BaseApi = class {
26
54
  cache: config.cache ?? "no-store",
27
55
  headers: { ...config.headers || {} }
28
56
  };
29
- this.baseUrl = `${this.config.basePath}/${this.entity}`;
30
57
  }
31
58
  /** Merge per-instance headers into request options */
32
59
  withHeaders(options) {
package/dist/client.d.ts CHANGED
@@ -278,6 +278,23 @@ interface ClientEncryptionConfig {
278
278
  }
279
279
  interface ClientConfig {
280
280
  baseUrl: string;
281
+ /**
282
+ * Route prefix every API is mounted under, when it is not `/api/v1`.
283
+ *
284
+ * `BaseApi` defaults each instance to `/api/v1` and takes a per-instance
285
+ * `basePath` override. That covers an app's OWN api classes and nothing else:
286
+ * a package that constructs its own API internally — erp-shell's permission
287
+ * `platformApi`, an SDK preset — has no seam to be told, so on a host mounted
288
+ * anywhere but `/api/v1` it silently requests a URL that does not exist and
289
+ * the feature reads as "no data" rather than as a misconfiguration.
290
+ *
291
+ * Setting it here makes the mount point a property of the DEPLOYMENT, stated
292
+ * once, which is what it actually is. A per-instance `basePath` still wins,
293
+ * so nothing that already passes one changes.
294
+ *
295
+ * @example configureClient({ baseUrl, basePath: '/api' }) // host mounts at /api
296
+ */
297
+ basePath?: string;
281
298
  internalApiKey?: string;
282
299
  defaultHeaders?: Record<string, string>;
283
300
  /**
@@ -457,6 +474,15 @@ declare function configureClient(config: ClientConfig): void;
457
474
  declare function getAuthMode(): "bearer" | "cookie" | "header";
458
475
  /** Get the configured base URL. Returns empty string if not configured. */
459
476
  declare function getBaseUrl(): string;
477
+ /**
478
+ * The deployment's route prefix, or `null` when it has not declared one.
479
+ *
480
+ * `null` rather than the `/api/v1` default on purpose: the default belongs to
481
+ * `BaseApi`, which is the one place that should own it. Returning it here would
482
+ * put the same literal in two files, and the next person to change one would
483
+ * have no way to know about the other.
484
+ */
485
+ declare function getBasePath(): string | null;
460
486
  /** Whether auto-idempotency is enabled on the global client. */
461
487
  declare function isAutoIdempotency(): boolean;
462
488
  /**
@@ -1016,4 +1042,4 @@ declare const arc: {
1016
1042
  delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
1017
1043
  };
1018
1044
  //#endregion
1019
- export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, QuotaDetails, RetryConfig, ServerClientConfig, StreamUrlProtocol, TextResponse, TierRequirement, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
1045
+ export { AfterResponseContext, AfterResponseInterceptor, ApiRequestOptions, ArcApiError, ArcApiErrorOptions, ArcClient, ArcClientConfig, ArcErrorCode, ArcFetchOptions, AuthConfig, AuthErrorContext, AuthErrorHandler, BeforeRequestContext, BeforeRequestInterceptor, BlobResponse, ClientConfig, ClientEncryptionConfig, HttpMethod, KNOWN_ARC_ERROR_CODES, NextFetchOptions, QuotaDetails, RetryConfig, ServerClientConfig, StreamUrlProtocol, TextResponse, TierRequirement, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBasePath, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
package/dist/client.js CHANGED
@@ -337,6 +337,17 @@ function getAuthMode() {
337
337
  function getBaseUrl() {
338
338
  return clientConfig?.baseUrl ?? "";
339
339
  }
340
+ /**
341
+ * The deployment's route prefix, or `null` when it has not declared one.
342
+ *
343
+ * `null` rather than the `/api/v1` default on purpose: the default belongs to
344
+ * `BaseApi`, which is the one place that should own it. Returning it here would
345
+ * put the same literal in two files, and the next person to change one would
346
+ * have no way to know about the other.
347
+ */
348
+ function getBasePath() {
349
+ return clientConfig?.basePath ?? null;
350
+ }
340
351
  /** Whether auto-idempotency is enabled on the global client. */
341
352
  function isAutoIdempotency() {
342
353
  return clientConfig?.autoIdempotency ?? false;
@@ -1276,4 +1287,4 @@ const arc = {
1276
1287
  };
1277
1288
 
1278
1289
  //#endregion
1279
- export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
1290
+ export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, createServerClient, getAuthContext, getAuthMode, getBasePath, getBaseUrl, getClientAuthContext, getQuotaDetails, getTierRequirement, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isTierRequiredError, isValidationError };
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.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"