@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 +26 -2
- package/dist/api.js +31 -4
- package/dist/client.d.ts +27 -1
- package/dist/client.js +12 -1
- 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/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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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"
|