@classytic/arc-next 0.6.0 → 0.7.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/README.md +141 -4
- package/dist/cache.d.ts +28 -1
- package/dist/cache.js +101 -9
- package/dist/client.d.ts +277 -4
- package/dist/client.js +385 -9
- package/dist/hooks.js +56 -26
- package/dist/query.d.ts +67 -12
- package/dist/query.js +60 -24
- package/dist/sse.js +71 -5
- package/dist/upload.js +33 -2
- package/dist/ws.js +42 -5
- package/package.json +8 -3
package/dist/client.js
CHANGED
|
@@ -275,6 +275,23 @@ function getBaseUrl() {
|
|
|
275
275
|
function isAutoIdempotency() {
|
|
276
276
|
return clientConfig?.autoIdempotency ?? false;
|
|
277
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Whether the globally-configured client carries enough auth to satisfy a
|
|
280
|
+
* protected endpoint without a per-request token. True when any of
|
|
281
|
+
* `internalApiKey`, `defaultHeaders`, or `authMode: 'cookie'` is configured.
|
|
282
|
+
*
|
|
283
|
+
* Read by `createCrudHooks` to decide whether queries should be enabled when
|
|
284
|
+
* `getToken()` returns null — without this, an app that authenticates via a
|
|
285
|
+
* global `internalApiKey` or static headers would see every query stuck in
|
|
286
|
+
* a permanently-disabled state, looking like a clean empty success.
|
|
287
|
+
*/
|
|
288
|
+
function hasGlobalStaticAuth() {
|
|
289
|
+
if (!clientConfig) return false;
|
|
290
|
+
if (clientConfig.authMode === "cookie") return true;
|
|
291
|
+
if (clientConfig.internalApiKey) return true;
|
|
292
|
+
if (clientConfig.defaultHeaders && Object.keys(clientConfig.defaultHeaders).length > 0) return true;
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
278
295
|
let authConfig = null;
|
|
279
296
|
let hasWarnedAsyncToken = false;
|
|
280
297
|
/**
|
|
@@ -308,7 +325,7 @@ function readToken(getToken) {
|
|
|
308
325
|
if (result && typeof result.then === "function") {
|
|
309
326
|
if (!hasWarnedAsyncToken) {
|
|
310
327
|
hasWarnedAsyncToken = true;
|
|
311
|
-
console.
|
|
328
|
+
console.error(/* @__PURE__ */ new Error("[arc-next] configureAuth({ getToken }) returned a Promise. Tokens MUST resolve synchronously — async returns are dropped and every authenticated query will be silently disabled (no GET fires, isLoading:false, item:null). Fix: cache the token outside getToken (localStorage, memory, signal, useState) and have getToken() return the cached value. See README → 'Authentication'."));
|
|
312
329
|
}
|
|
313
330
|
return null;
|
|
314
331
|
}
|
|
@@ -328,6 +345,138 @@ function _resetAuthWarnings() {
|
|
|
328
345
|
hasWarnedAsyncToken = false;
|
|
329
346
|
}
|
|
330
347
|
/**
|
|
348
|
+
* Shared in-flight refresh. Multiple concurrent 401s collapse onto one
|
|
349
|
+
* `onAuthError` invocation — the dedup happens here. Cleared in `.finally`
|
|
350
|
+
* so the NEXT 401 (after settlement) triggers a fresh recovery cycle.
|
|
351
|
+
*/
|
|
352
|
+
let pendingAuthRecovery = null;
|
|
353
|
+
/** @internal — exposed for tests; clears the dedup so they don't bleed. */
|
|
354
|
+
function _resetAuthRecovery() {
|
|
355
|
+
pendingAuthRecovery = null;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* @internal
|
|
359
|
+
* Cross-transport access to the configured auth-recovery handler.
|
|
360
|
+
* Upload (XHR) and WebSocket / SSE plumbing share the same dedup as the
|
|
361
|
+
* fetch path — they read the handler here and call {@link _runAuthRecovery}
|
|
362
|
+
* when they detect a transport-specific auth failure (XHR 401, WS close
|
|
363
|
+
* code 1008/4401, SSE pre-flight probe 401).
|
|
364
|
+
*/
|
|
365
|
+
function _getAuthErrorHandler() {
|
|
366
|
+
return {
|
|
367
|
+
handler: authConfig?.onAuthError,
|
|
368
|
+
retryOn403: authConfig?.retryOn403 ?? false,
|
|
369
|
+
maxAuthRetries: Math.max(0, authConfig?.maxAuthRetries ?? 1)
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* @internal
|
|
374
|
+
* Drive the shared recovery cycle from a non-fetch transport. Same dedup
|
|
375
|
+
* as the fetch path — concurrent callers (XHR upload + WebSocket reconnect
|
|
376
|
+
* + SSE probe firing at once) collapse to one refresh.
|
|
377
|
+
*/
|
|
378
|
+
function _runAuthRecovery(handler, ctx) {
|
|
379
|
+
return runAuthRecovery(handler, ctx);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* @internal
|
|
383
|
+
* Resolve the next-attempt token. Mirrors the priority in `executeRequest`'s
|
|
384
|
+
* auth loop — `setToken` override beats re-reading `getToken()`. Exported so
|
|
385
|
+
* transports outside the fetch path apply the same precedence.
|
|
386
|
+
*/
|
|
387
|
+
function _resolveRefreshedToken(overrideToken) {
|
|
388
|
+
if (overrideToken !== void 0) return overrideToken;
|
|
389
|
+
return readToken(authConfig?.getToken);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* @internal
|
|
393
|
+
* True for any error a transport should run through `onAuthError`. Matches
|
|
394
|
+
* the fetch path's predicate so XHR / WS / SSE failures classify the same
|
|
395
|
+
* way (401, or 403 when `retryOn403`).
|
|
396
|
+
*/
|
|
397
|
+
function _isAuthRecoverable(error, retryOn403) {
|
|
398
|
+
return isAuthRecoverable(error, retryOn403);
|
|
399
|
+
}
|
|
400
|
+
/** True for status codes that should trigger {@link AuthConfig.onAuthError}. */
|
|
401
|
+
function isAuthRecoverable(error, retryOn403) {
|
|
402
|
+
if (!isArcApiError(error)) return false;
|
|
403
|
+
if (error.status === 401) return true;
|
|
404
|
+
if (retryOn403 && error.status === 403) return true;
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Run the recovery handler with concurrent-request dedup. Every concurrent
|
|
409
|
+
* 401 awaits the same promise and gets the same decision + override token.
|
|
410
|
+
* Cleared on settlement so a later (post-settlement) 401 starts a new cycle.
|
|
411
|
+
*/
|
|
412
|
+
function runAuthRecovery(handler, ctx) {
|
|
413
|
+
if (pendingAuthRecovery) return pendingAuthRecovery;
|
|
414
|
+
pendingAuthRecovery = (async () => {
|
|
415
|
+
let overrideToken = void 0;
|
|
416
|
+
const setToken = (token) => {
|
|
417
|
+
overrideToken = token;
|
|
418
|
+
};
|
|
419
|
+
return {
|
|
420
|
+
decision: await handler({
|
|
421
|
+
...ctx,
|
|
422
|
+
setToken
|
|
423
|
+
}),
|
|
424
|
+
overrideToken
|
|
425
|
+
};
|
|
426
|
+
})().finally(() => {
|
|
427
|
+
pendingAuthRecovery = null;
|
|
428
|
+
});
|
|
429
|
+
return pendingAuthRecovery;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Build an {@link AuthErrorHandler} from any `refresh()` function that
|
|
433
|
+
* returns the new token (or `null` if the session is truly expired).
|
|
434
|
+
*
|
|
435
|
+
* Catches refresh errors and surfaces them as `'skip'` by default so the
|
|
436
|
+
* original 401 reaches the consumer instead of a misleading "refresh failed"
|
|
437
|
+
* trace — consumers expect to handle "session expired" once, not twice. Pass
|
|
438
|
+
* `onRefreshError: 'throw'` to opt in to propagation.
|
|
439
|
+
*
|
|
440
|
+
* @example Better Auth (or any session-based lib)
|
|
441
|
+
* ```ts
|
|
442
|
+
* import { configureAuth, createAuthRefreshHandler } from '@classytic/arc-next/client';
|
|
443
|
+
* import { authClient } from '@/lib/auth-client';
|
|
444
|
+
*
|
|
445
|
+
* configureAuth({
|
|
446
|
+
* getToken: () => authClient.getSession().data?.session.token ?? null,
|
|
447
|
+
* onAuthError: createAuthRefreshHandler({
|
|
448
|
+
* refresh: async () => {
|
|
449
|
+
* const { data } = await authClient.getSession({ disableCookieCache: true });
|
|
450
|
+
* return data?.session.token ?? null;
|
|
451
|
+
* },
|
|
452
|
+
* }),
|
|
453
|
+
* });
|
|
454
|
+
* ```
|
|
455
|
+
*
|
|
456
|
+
* @example Custom OAuth refresh
|
|
457
|
+
* ```ts
|
|
458
|
+
* configureAuth({
|
|
459
|
+
* getToken: () => tokenStore.getAccessToken(),
|
|
460
|
+
* onAuthError: createAuthRefreshHandler({
|
|
461
|
+
* refresh: () => oauthClient.refresh(tokenStore.getRefreshToken()),
|
|
462
|
+
* }),
|
|
463
|
+
* });
|
|
464
|
+
* ```
|
|
465
|
+
*/
|
|
466
|
+
function createAuthRefreshHandler(opts) {
|
|
467
|
+
return async ({ setToken }) => {
|
|
468
|
+
try {
|
|
469
|
+
const token = await opts.refresh();
|
|
470
|
+
if (token == null) return "skip";
|
|
471
|
+
setToken(token);
|
|
472
|
+
return "retry";
|
|
473
|
+
} catch (err) {
|
|
474
|
+
if (opts.onRefreshError === "throw") throw err;
|
|
475
|
+
return "skip";
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
331
480
|
* Build an auth-aware URL using the global client + auth singletons.
|
|
332
481
|
*
|
|
333
482
|
* Single source of truth for {@link import('./sse.js').buildSseUrl} (HTTP) and
|
|
@@ -470,7 +619,14 @@ function computeBackoff(retry, attempt) {
|
|
|
470
619
|
if (strategy === "linear") return 300 * (attempt + 1);
|
|
471
620
|
return Math.min(300 * Math.pow(2, attempt), 1e4);
|
|
472
621
|
}
|
|
473
|
-
|
|
622
|
+
/**
|
|
623
|
+
* Inner request loop — handles 5xx + network-failure backoff per
|
|
624
|
+
* {@link ClientConfig.retry}. Knows nothing about 401 recovery; that's the
|
|
625
|
+
* outer {@link executeRequest} wrapper. Split so the two retry families don't
|
|
626
|
+
* tangle: the backoff loop has its own attempt counter and predicate, the
|
|
627
|
+
* auth loop has its own cap and dedup.
|
|
628
|
+
*/
|
|
629
|
+
async function executeWithBackoff(config, method, endpoint, options = {}) {
|
|
474
630
|
const totalAttempts = Math.max(1, config.retry?.attempts ?? 1);
|
|
475
631
|
const shouldRetry = (() => {
|
|
476
632
|
const r = config.retry?.retryOn;
|
|
@@ -489,6 +645,47 @@ async function executeRequest(config, method, endpoint, options = {}) {
|
|
|
489
645
|
}
|
|
490
646
|
throw lastError;
|
|
491
647
|
}
|
|
648
|
+
/**
|
|
649
|
+
* Top-level request entry. Wraps the 5xx-backoff loop with the 401-recovery
|
|
650
|
+
* loop so the two retry families compose cleanly:
|
|
651
|
+
*
|
|
652
|
+
* 401 (auth) ─→ onAuthError ─→ retry with fresh token ─→ may 5xx ─→ backoff
|
|
653
|
+
*
|
|
654
|
+
* `maxAuthRetries` (default 1) caps the auth loop independently of the
|
|
655
|
+
* backoff loop's `attempts`. AbortSignal propagates through both loops.
|
|
656
|
+
*
|
|
657
|
+
* The auth loop only fires when {@link AuthConfig.onAuthError} is configured —
|
|
658
|
+
* apps that haven't wired a refresh handler see the original behavior (401
|
|
659
|
+
* surfaces immediately, no extra round-trip).
|
|
660
|
+
*/
|
|
661
|
+
async function executeRequest(config, method, endpoint, options = {}) {
|
|
662
|
+
const handler = authConfig?.onAuthError;
|
|
663
|
+
if (!handler) return executeWithBackoff(config, method, endpoint, options);
|
|
664
|
+
const retryOn403 = authConfig?.retryOn403 ?? false;
|
|
665
|
+
const maxAuthRetries = Math.max(0, authConfig?.maxAuthRetries ?? 1);
|
|
666
|
+
let currentOptions = options;
|
|
667
|
+
for (let authAttempt = 0; authAttempt <= maxAuthRetries; authAttempt++) try {
|
|
668
|
+
return await executeWithBackoff(config, method, endpoint, currentOptions);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (authAttempt >= maxAuthRetries || !isAuthRecoverable(error, retryOn403)) throw error;
|
|
671
|
+
if (currentOptions.signal?.aborted) throw error;
|
|
672
|
+
const { decision, overrideToken } = await runAuthRecovery(handler, {
|
|
673
|
+
error,
|
|
674
|
+
request: {
|
|
675
|
+
method,
|
|
676
|
+
endpoint
|
|
677
|
+
},
|
|
678
|
+
attempt: authAttempt + 1
|
|
679
|
+
});
|
|
680
|
+
if (decision !== "retry") throw error;
|
|
681
|
+
const nextToken = overrideToken !== void 0 ? overrideToken : readToken(authConfig?.getToken);
|
|
682
|
+
currentOptions = {
|
|
683
|
+
...currentOptions,
|
|
684
|
+
token: nextToken
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
throw new Error("arc-next: auth retry loop terminated without resolution");
|
|
688
|
+
}
|
|
492
689
|
/** Sleep that resolves early if the signal aborts. */
|
|
493
690
|
function sleepAbortable(ms, signal) {
|
|
494
691
|
return new Promise((resolve, reject) => {
|
|
@@ -517,21 +714,23 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
|
|
|
517
714
|
...config.defaultHeaders ?? {}
|
|
518
715
|
};
|
|
519
716
|
if (config.internalApiKey) headers["x-internal-api-key"] = config.internalApiKey;
|
|
520
|
-
if (token)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
717
|
+
if (token) {
|
|
718
|
+
if (config.authMode === "header") {
|
|
719
|
+
const headerName = authConfig?.headerName ?? "x-api-key";
|
|
720
|
+
headers[headerName] = token;
|
|
721
|
+
} else if (config.authMode !== "cookie") headers["Authorization"] = `Bearer ${token}`;
|
|
722
|
+
}
|
|
524
723
|
if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
|
|
525
724
|
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
526
725
|
if (elevated ?? config.elevated ?? false) headers["x-arc-scope"] = "platform";
|
|
527
|
-
if (body !== void 0 && body !== null && !(body
|
|
726
|
+
if (body !== void 0 && body !== null && !isNonJsonBody(body)) headers["Content-Type"] = "application/json";
|
|
528
727
|
if (headerOptions) headers = {
|
|
529
728
|
...headers,
|
|
530
729
|
...headerOptions
|
|
531
730
|
};
|
|
532
731
|
const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
|
|
533
732
|
let serializedBody = void 0;
|
|
534
|
-
if (body !== void 0 && body !== null) serializedBody = body
|
|
733
|
+
if (body !== void 0 && body !== null) serializedBody = isNonJsonBody(body) ? body : JSON.stringify(body);
|
|
535
734
|
if (config.beforeRequest) {
|
|
536
735
|
const ctx = await config.beforeRequest({
|
|
537
736
|
method,
|
|
@@ -692,6 +891,183 @@ function createQueryString(params = {}) {
|
|
|
692
891
|
});
|
|
693
892
|
return searchParams.toString();
|
|
694
893
|
}
|
|
894
|
+
/**
|
|
895
|
+
* Body shapes that carry their own Content-Type and must NOT be re-serialized
|
|
896
|
+
* by arc-next:
|
|
897
|
+
*
|
|
898
|
+
* - `FormData` — multipart with a runtime-computed boundary.
|
|
899
|
+
* - `Blob` / `File` — carries `.type`.
|
|
900
|
+
* - `URLSearchParams` — `application/x-www-form-urlencoded`.
|
|
901
|
+
* - `ArrayBuffer` / typed arrays — raw bytes; caller controls Content-Type.
|
|
902
|
+
* - `ReadableStream` — caller controls Content-Type.
|
|
903
|
+
* - `string` — caller controls Content-Type (could be plain text, XML, etc.).
|
|
904
|
+
*
|
|
905
|
+
* Plain objects and arrays fall through and get `JSON.stringify`d with
|
|
906
|
+
* `Content-Type: application/json`.
|
|
907
|
+
*/
|
|
908
|
+
function isNonJsonBody(body) {
|
|
909
|
+
if (body == null) return false;
|
|
910
|
+
if (typeof body === "string") return true;
|
|
911
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) return true;
|
|
912
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) return true;
|
|
913
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true;
|
|
914
|
+
if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) return true;
|
|
915
|
+
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(body)) return true;
|
|
916
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return true;
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Returns the auth headers arc-next would inject on a fetch right now —
|
|
921
|
+
* `Authorization` (or the configured custom `headerName` for `authMode:
|
|
922
|
+
* 'header'`), `x-organization-id`, and `x-internal-api-key`. Use for the
|
|
923
|
+
* rare case where you need full `Response` control via plain `fetch` but
|
|
924
|
+
* still want arc-next's auth wiring.
|
|
925
|
+
*
|
|
926
|
+
* @example
|
|
927
|
+
* const res = await fetch(url, {
|
|
928
|
+
* headers: { ...arcAuthHeaders(), 'X-Custom': '1' },
|
|
929
|
+
* credentials: getAuthMode() === 'cookie' ? 'include' : 'same-origin',
|
|
930
|
+
* });
|
|
931
|
+
*/
|
|
932
|
+
function arcAuthHeaders() {
|
|
933
|
+
const { token, organizationId } = getAuthContext();
|
|
934
|
+
const authMode = getAuthMode();
|
|
935
|
+
const headers = {};
|
|
936
|
+
if (token) {
|
|
937
|
+
if (authMode === "header") headers[authConfig?.headerName ?? "x-api-key"] = token;
|
|
938
|
+
else if (authMode !== "cookie") headers.Authorization = `Bearer ${token}`;
|
|
939
|
+
}
|
|
940
|
+
if (organizationId) headers["x-organization-id"] = organizationId;
|
|
941
|
+
if (clientConfig?.internalApiKey) headers["x-internal-api-key"] = clientConfig.internalApiKey;
|
|
942
|
+
return headers;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Headers that arc-next owns and a caller must not override via
|
|
946
|
+
* {@link ArcFetchOptions.headers}. Passing one of these in `options.headers`
|
|
947
|
+
* is silently dropped — see the `arcFetch` JSDoc for the rationale.
|
|
948
|
+
*
|
|
949
|
+
* Lower-cased on lookup so case-insensitive HTTP header semantics are
|
|
950
|
+
* honored (`Authorization` vs `authorization` both protected).
|
|
951
|
+
*/
|
|
952
|
+
const ARC_FETCH_PROTECTED_HEADERS = new Set([
|
|
953
|
+
"authorization",
|
|
954
|
+
"x-organization-id",
|
|
955
|
+
"x-internal-api-key"
|
|
956
|
+
]);
|
|
957
|
+
let defaultArcFetchClient = null;
|
|
958
|
+
function getDefaultArcFetchClient() {
|
|
959
|
+
if (!defaultArcFetchClient) defaultArcFetchClient = createAuthAwareClient();
|
|
960
|
+
return defaultArcFetchClient;
|
|
961
|
+
}
|
|
962
|
+
/** @internal — tests reset the default client between cases. */
|
|
963
|
+
function _resetArcFetchClient() {
|
|
964
|
+
defaultArcFetchClient = null;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Strip protected auth headers from a caller-supplied map. Case-insensitive
|
|
968
|
+
* (HTTP header names are case-insensitive). The custom-auth `headerName`
|
|
969
|
+
* (when `authMode: 'header'` is configured) is computed at call time so
|
|
970
|
+
* apps that reconfigure auth modes don't lose protection on the renamed
|
|
971
|
+
* header.
|
|
972
|
+
*/
|
|
973
|
+
function sanitizeUserHeaders(headers) {
|
|
974
|
+
if (!headers) return {};
|
|
975
|
+
const customHeader = authConfig?.headerName?.toLowerCase();
|
|
976
|
+
const out = {};
|
|
977
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
978
|
+
const lower = name.toLowerCase();
|
|
979
|
+
if (ARC_FETCH_PROTECTED_HEADERS.has(lower)) continue;
|
|
980
|
+
if (customHeader && lower === customHeader) continue;
|
|
981
|
+
out[name] = value;
|
|
982
|
+
}
|
|
983
|
+
return out;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Authenticated, tenant-scoped fetch to an arc endpoint — one line for the
|
|
987
|
+
* non-hook contexts where `useQuery` / `useMutation` aren't available
|
|
988
|
+
* (event handlers, service workers, server actions, custom MDX submits,
|
|
989
|
+
* background polls).
|
|
990
|
+
*
|
|
991
|
+
* Auto-injects on every call:
|
|
992
|
+
* - `Authorization: Bearer <token>` (from `configureAuth().getToken`), or
|
|
993
|
+
* the custom header for `authMode: 'header'`
|
|
994
|
+
* - `x-organization-id` (from `configureAuth().getOrgId`)
|
|
995
|
+
* - `Content-Type: application/json` (only for plain object/array bodies)
|
|
996
|
+
* - `x-internal-api-key`, `Accept-Version`, `Idempotency-Key`,
|
|
997
|
+
* `x-arc-scope` when configured
|
|
998
|
+
*
|
|
999
|
+
* Composes with everything else `configureClient` + `configureAuth` do:
|
|
1000
|
+
* - `retry` (5xx backoff)
|
|
1001
|
+
* - `onAuthError` (401 → refresh → retry, with concurrent dedup)
|
|
1002
|
+
* - `beforeRequest` / `afterResponse` interceptors
|
|
1003
|
+
* - `cookie` / `bearer` / `header` auth modes
|
|
1004
|
+
*
|
|
1005
|
+
* Response handling:
|
|
1006
|
+
* - 2xx → parsed body (JSON for `application/json`, Blob for binary,
|
|
1007
|
+
* text for `text/*`).
|
|
1008
|
+
* - non-2xx → throws `ArcApiError` with parsed body, status, endpoint,
|
|
1009
|
+
* method. Use `isArcApiError(err)` + `err.code` to discriminate.
|
|
1010
|
+
*
|
|
1011
|
+
* For full `Response` control (rare), use plain `fetch` with
|
|
1012
|
+
* {@link arcAuthHeaders} instead.
|
|
1013
|
+
*
|
|
1014
|
+
* @example
|
|
1015
|
+
* import { arc } from '@classytic/arc-next/client';
|
|
1016
|
+
*
|
|
1017
|
+
* // Before — 15 lines of header dance + error parse + JSON
|
|
1018
|
+
* // After:
|
|
1019
|
+
* const result = await arc.post<{ ok: true }>('/api/statements', statements);
|
|
1020
|
+
*/
|
|
1021
|
+
function arcFetch(path, options = {}) {
|
|
1022
|
+
const { method = "GET", body, headers, signal, elevated, idempotencyKey, revalidate, tags, cache, client } = options;
|
|
1023
|
+
const transport = client ?? getDefaultArcFetchClient();
|
|
1024
|
+
const apiOptions = {
|
|
1025
|
+
body,
|
|
1026
|
+
headerOptions: sanitizeUserHeaders(headers),
|
|
1027
|
+
...signal ? { signal } : {},
|
|
1028
|
+
...elevated !== void 0 ? { elevated } : {},
|
|
1029
|
+
...idempotencyKey ? { idempotencyKey } : {},
|
|
1030
|
+
...revalidate !== void 0 ? { revalidate } : {},
|
|
1031
|
+
...tags ? { tags } : {},
|
|
1032
|
+
...cache ? { cache } : {}
|
|
1033
|
+
};
|
|
1034
|
+
return transport.request(method, path, apiOptions);
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Method-specific shorthands for the 90% case. Each mirrors `arcFetch` with
|
|
1038
|
+
* the HTTP verb pre-filled; mutating verbs accept `body` as the second arg
|
|
1039
|
+
* so the call reads as a sentence:
|
|
1040
|
+
*
|
|
1041
|
+
* `arc.post('/path', payload)` instead of
|
|
1042
|
+
* `arcFetch('/path', { method: 'POST', body: payload })`.
|
|
1043
|
+
*
|
|
1044
|
+
* Identical composition with `onAuthError`, retry, and interceptors.
|
|
1045
|
+
*/
|
|
1046
|
+
const arc = {
|
|
1047
|
+
get: (path, opts = {}) => arcFetch(path, {
|
|
1048
|
+
...opts,
|
|
1049
|
+
method: "GET"
|
|
1050
|
+
}),
|
|
1051
|
+
post: (path, body, opts = {}) => arcFetch(path, {
|
|
1052
|
+
...opts,
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
body
|
|
1055
|
+
}),
|
|
1056
|
+
put: (path, body, opts = {}) => arcFetch(path, {
|
|
1057
|
+
...opts,
|
|
1058
|
+
method: "PUT",
|
|
1059
|
+
body
|
|
1060
|
+
}),
|
|
1061
|
+
patch: (path, body, opts = {}) => arcFetch(path, {
|
|
1062
|
+
...opts,
|
|
1063
|
+
method: "PATCH",
|
|
1064
|
+
body
|
|
1065
|
+
}),
|
|
1066
|
+
delete: (path, opts = {}) => arcFetch(path, {
|
|
1067
|
+
...opts,
|
|
1068
|
+
method: "DELETE"
|
|
1069
|
+
})
|
|
1070
|
+
};
|
|
695
1071
|
|
|
696
1072
|
//#endregion
|
|
697
|
-
export { ArcApiError, KNOWN_ARC_ERROR_CODES, _resetAuthWarnings, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|
|
1073
|
+
export { ArcApiError, KNOWN_ARC_ERROR_CODES, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };
|
package/dist/hooks.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { getAuthMode, getClientAuthContext } from "./client.js";
|
|
3
|
+
import { getAuthMode, getClientAuthContext, hasGlobalStaticAuth } from "./client.js";
|
|
4
4
|
import { isKeysetPagination, isOffsetPagination } from "./api.js";
|
|
5
|
-
import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, updateListCache } from "./cache.js";
|
|
6
|
-
import { useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
|
|
5
|
+
import { DEFAULT_QUERY_CONFIG, createCacheUtils, createQueryKeys, extractItem, getItemId, syncDetailToLists, updateListCache } from "./cache.js";
|
|
6
|
+
import { findItemInListCache, useDetailQuery, useInfiniteListQuery, useListQuery } from "./query.js";
|
|
7
7
|
import { useMutationWithTransition, useOptimisticMutation } from "./mutation.js";
|
|
8
8
|
import { subscribeToEvents } from "./sse.js";
|
|
9
9
|
import { connectWs } from "./ws.js";
|
|
@@ -33,8 +33,19 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
33
33
|
const pluralName = plural ?? `${singular}s`;
|
|
34
34
|
/** Resolve auth context — per-client auth takes priority over global */
|
|
35
35
|
const resolveAuth = () => getClientAuthContext(client);
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Whether auth is provided via static config — no per-request token needed.
|
|
38
|
+
* Sources (in priority order):
|
|
39
|
+
* 1. Per-client `defaultHeaders` / `internalApiKey` (when `client` is passed in).
|
|
40
|
+
* 2. Per-client custom auth (e.g. `createClient({ getToken })`).
|
|
41
|
+
* 3. Global `configureClient({ internalApiKey | defaultHeaders | authMode: 'cookie' })`.
|
|
42
|
+
*
|
|
43
|
+
* Resolved lazily on every render so that an app calling `configureClient`
|
|
44
|
+
* inside a "use client" provider after factory creation still picks up the
|
|
45
|
+
* global static-auth signal — previously a global `internalApiKey` was
|
|
46
|
+
* ignored, leaving every protected query stuck in a permanently-disabled state.
|
|
47
|
+
*/
|
|
48
|
+
const resolveHasStaticAuth = () => !!(client?.config?.defaultHeaders || client?.config?.internalApiKey || client?.auth) || hasGlobalStaticAuth();
|
|
38
49
|
/** Extract ID from an item using configured idField, falling back to _id → id */
|
|
39
50
|
function resolveItemId(item) {
|
|
40
51
|
if (!item || typeof item !== "object") return null;
|
|
@@ -99,7 +110,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
99
110
|
...requestOpts
|
|
100
111
|
}
|
|
101
112
|
}),
|
|
102
|
-
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
113
|
+
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
103
114
|
options: {
|
|
104
115
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
105
116
|
gcTime: queryOpts.gcTime ?? config.gcTime,
|
|
@@ -108,9 +119,6 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
108
119
|
refetchInterval: queryOpts.refetchInterval,
|
|
109
120
|
refetchIntervalInBackground: queryOpts.refetchIntervalInBackground
|
|
110
121
|
},
|
|
111
|
-
prefillDetailCache: queryOpts.prefillDetailCache ?? true,
|
|
112
|
-
detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
|
|
113
|
-
itemIdResolver: resolveItemId,
|
|
114
122
|
select: queryOpts.select
|
|
115
123
|
});
|
|
116
124
|
}
|
|
@@ -131,8 +139,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
131
139
|
}
|
|
132
140
|
const { organizationId, params: queryParams, request: requestOpts, ...restOptions } = options;
|
|
133
141
|
const detailKey = KEYS.scopedDetail(id || "", organizationId ?? null);
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
const fullDetailKey = queryParams ? [...detailKey, queryParams] : detailKey;
|
|
143
|
+
const queryClient = useQueryClient();
|
|
144
|
+
const listPlaceholder = useCallback(() => id ? findItemInListCache(queryClient, KEYS.lists(), id, idField) : void 0, [queryClient, id]);
|
|
145
|
+
const detailResult = useDetailQuery({
|
|
146
|
+
queryKey: fullDetailKey,
|
|
136
147
|
queryFn: ({ signal }) => api.getById({
|
|
137
148
|
id,
|
|
138
149
|
token,
|
|
@@ -143,7 +154,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
143
154
|
...requestOpts
|
|
144
155
|
}
|
|
145
156
|
}),
|
|
146
|
-
enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(),
|
|
157
|
+
enabled: !!id && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
|
|
147
158
|
options: {
|
|
148
159
|
staleTime: restOptions.staleTime ?? config.staleTime,
|
|
149
160
|
gcTime: restOptions.gcTime ?? config.gcTime,
|
|
@@ -152,8 +163,18 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
152
163
|
refetchInterval: restOptions.refetchInterval,
|
|
153
164
|
refetchIntervalInBackground: restOptions.refetchIntervalInBackground
|
|
154
165
|
},
|
|
155
|
-
select: restOptions.select
|
|
166
|
+
select: restOptions.select,
|
|
167
|
+
placeholderData: listPlaceholder
|
|
156
168
|
});
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!detailResult.item || detailResult.isPlaceholderData) return;
|
|
171
|
+
syncDetailToLists(queryClient, KEYS.lists(), detailResult.item, idField ? { idField } : {});
|
|
172
|
+
}, [
|
|
173
|
+
detailResult.item,
|
|
174
|
+
detailResult.isPlaceholderData,
|
|
175
|
+
queryClient
|
|
176
|
+
]);
|
|
177
|
+
return detailResult;
|
|
157
178
|
}
|
|
158
179
|
function useActions() {
|
|
159
180
|
const queryClient = useQueryClient();
|
|
@@ -413,7 +434,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
413
434
|
}
|
|
414
435
|
});
|
|
415
436
|
},
|
|
416
|
-
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
437
|
+
enabled: createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
417
438
|
initialPageParam: restParams.after ? restParams.after : 1,
|
|
418
439
|
getNextPageParam: (lastPage) => {
|
|
419
440
|
const page = lastPage;
|
|
@@ -498,7 +519,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
498
519
|
}
|
|
499
520
|
});
|
|
500
521
|
},
|
|
501
|
-
enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
522
|
+
enabled: !!api.getDeleted && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
502
523
|
options: {
|
|
503
524
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
504
525
|
gcTime: queryOpts.gcTime ?? config.gcTime
|
|
@@ -512,7 +533,9 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
512
533
|
const resolvedOptions = options ?? {};
|
|
513
534
|
const organizationId = resolvedOptions.organizationId ?? auth.organizationId;
|
|
514
535
|
const { params: queryParams, request: requestOpts, ...restOptions } = resolvedOptions;
|
|
515
|
-
|
|
536
|
+
const queryClient = useQueryClient();
|
|
537
|
+
const listPlaceholder = useCallback(() => slug ? findItemInListCache(queryClient, KEYS.lists(), slug, "slug") : void 0, [queryClient, slug]);
|
|
538
|
+
const slugResult = useDetailQuery({
|
|
516
539
|
queryKey: queryParams ? KEYS.custom("slug", slug, queryParams) : KEYS.custom("slug", slug),
|
|
517
540
|
queryFn: ({ signal }) => {
|
|
518
541
|
if (!api.getBySlug) return Promise.reject(/* @__PURE__ */ new Error(`[arc-next] "${entityKey}" api does not define a getBySlug method`));
|
|
@@ -527,15 +550,25 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
527
550
|
}
|
|
528
551
|
});
|
|
529
552
|
},
|
|
530
|
-
enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(),
|
|
553
|
+
enabled: !!api.getBySlug && !!slug && createEnabledRule(token, restOptions, resolveAuthMode(), resolveHasStaticAuth()),
|
|
531
554
|
options: {
|
|
532
555
|
staleTime: restOptions.staleTime ?? config.staleTime,
|
|
533
556
|
gcTime: restOptions.gcTime ?? config.gcTime,
|
|
534
557
|
refetchOnWindowFocus: restOptions.refetchOnWindowFocus ?? config.refetchOnWindowFocus,
|
|
535
558
|
structuralSharing: restOptions.structuralSharing ?? config.structuralSharing
|
|
536
559
|
},
|
|
537
|
-
select: restOptions.select
|
|
560
|
+
select: restOptions.select,
|
|
561
|
+
placeholderData: listPlaceholder
|
|
538
562
|
});
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (!slugResult.item || slugResult.isPlaceholderData) return;
|
|
565
|
+
syncDetailToLists(queryClient, KEYS.lists(), slugResult.item, idField ? { idField } : {});
|
|
566
|
+
}, [
|
|
567
|
+
slugResult.item,
|
|
568
|
+
slugResult.isPlaceholderData,
|
|
569
|
+
queryClient
|
|
570
|
+
]);
|
|
571
|
+
return slugResult;
|
|
539
572
|
}
|
|
540
573
|
function useTree(params, options) {
|
|
541
574
|
const auth = resolveAuth();
|
|
@@ -561,7 +594,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
561
594
|
}
|
|
562
595
|
});
|
|
563
596
|
},
|
|
564
|
-
enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
597
|
+
enabled: !!api.getTree && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
565
598
|
options: {
|
|
566
599
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
567
600
|
gcTime: queryOpts.gcTime ?? config.gcTime
|
|
@@ -594,14 +627,11 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
594
627
|
}
|
|
595
628
|
});
|
|
596
629
|
},
|
|
597
|
-
enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(),
|
|
630
|
+
enabled: !!api.getChildren && !!parentId && createEnabledRule(token, queryOpts, resolveAuthMode(), resolveHasStaticAuth()),
|
|
598
631
|
options: {
|
|
599
632
|
staleTime: queryOpts.staleTime ?? config.staleTime,
|
|
600
633
|
gcTime: queryOpts.gcTime ?? config.gcTime
|
|
601
634
|
},
|
|
602
|
-
prefillDetailCache: queryOpts.prefillDetailCache ?? true,
|
|
603
|
-
detailKeyBuilder: (id) => KEYS.scopedDetail(id, organizationId ?? null),
|
|
604
|
-
itemIdResolver: resolveItemId,
|
|
605
635
|
select: queryOpts.select
|
|
606
636
|
});
|
|
607
637
|
}
|
|
@@ -806,7 +836,7 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
806
836
|
enabled: !!name && !!api.aggregate && createEnabledRule(auth.token, {
|
|
807
837
|
public: isPublic,
|
|
808
838
|
enabled
|
|
809
|
-
}, resolveAuthMode(),
|
|
839
|
+
}, resolveAuthMode(), resolveHasStaticAuth()),
|
|
810
840
|
staleTime,
|
|
811
841
|
gcTime,
|
|
812
842
|
refetchOnWindowFocus,
|
|
@@ -912,8 +942,8 @@ function createCrudHooks({ api, entityKey, singular, plural, idField, defaults =
|
|
|
912
942
|
const id = resolveItemId(item);
|
|
913
943
|
if (id) {
|
|
914
944
|
const orgId = resolveAuth().organizationId;
|
|
915
|
-
queryClient.setQueryData(KEYS.scopedDetail(id, orgId),
|
|
916
|
-
if (orgId) queryClient.setQueryData(KEYS.detail(id),
|
|
945
|
+
queryClient.setQueryData(KEYS.scopedDetail(id, orgId), item);
|
|
946
|
+
if (orgId) queryClient.setQueryData(KEYS.detail(id), item);
|
|
917
947
|
}
|
|
918
948
|
if (!router) return;
|
|
919
949
|
const { scroll = true, replace = false } = options;
|