@classytic/arc-next 0.11.1 → 0.13.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/client.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { ErrorDetail } from "@classytic/repo-core/errors";
2
-
3
2
  //#region src/client.d.ts
4
3
  interface ToastHandler {
5
4
  success: (message: string) => void;
@@ -34,7 +33,7 @@ type UseRouterHook = () => {
34
33
  * `(string & {})` keeps the type open so domain packages and custom
35
34
  * `errorMappers` codes still satisfy it.
36
35
  */
37
- declare const KNOWN_ARC_ERROR_CODES: readonly ["validation_error", "not_found", "conflict", "unauthorized", "forbidden", "rate_limited", "idempotency_conflict", "precondition_failed", "internal_error", "service_unavailable", "timeout", "arc.bad_request", "arc.unauthorized", "arc.forbidden", "arc.not_found", "arc.conflict", "arc.unprocessable_entity", "arc.rate_limited", "arc.internal_error", "arc.bad_gateway", "arc.service_unavailable", "arc.gateway_timeout", "arc.validation_error", "arc.invalid_id", "arc.org.selection_required", "arc.org.access_denied", "ORG_CONTEXT_REQUIRED", "ORG_ROLE_REQUIRED", "OWNERSHIP_DENIED", "MIXED_UPDATE_SHAPE", "ALL_FIELDS_STRIPPED", "BEFORE_RESTORE_HOOK_ERROR", "duplicate_key"];
36
+ declare const KNOWN_ARC_ERROR_CODES: readonly [...("validation_error" | "not_found" | "conflict" | "unauthorized" | "forbidden" | "rate_limited" | "idempotency_conflict" | "precondition_failed" | "internal_error" | "service_unavailable" | "timeout")[], "arc.bad_request", "arc.unauthorized", "arc.forbidden", "arc.not_found", "arc.conflict", "arc.unprocessable_entity", "arc.rate_limited", "arc.internal_error", "arc.bad_gateway", "arc.service_unavailable", "arc.gateway_timeout", "arc.validation_error", "arc.invalid_id", "arc.tier_required", "arc.org.selection_required", "arc.org.access_denied", "ORG_CONTEXT_REQUIRED", "ORG_ROLE_REQUIRED", "OWNERSHIP_DENIED", "MIXED_UPDATE_SHAPE", "ALL_FIELDS_STRIPPED", "BEFORE_RESTORE_HOOK_ERROR", "duplicate_key"];
38
37
  /**
39
38
  * Canonical arc error code union. `(string & {})` keeps the type open so
40
39
  * domain packages can extend hierarchically (`'order.cart.locked'`,
@@ -47,6 +46,8 @@ interface ArcApiErrorOptions {
47
46
  json: unknown;
48
47
  endpoint: string;
49
48
  method: HttpMethod;
49
+ /** Parsed `Retry-After` response header in ms (429/503 pacing), if present. */
50
+ retryAfterMs?: number | null;
50
51
  }
51
52
  /**
52
53
  * Rich API error with status code, response payload, and request metadata.
@@ -68,6 +69,13 @@ declare class ArcApiError extends Error {
68
69
  readonly json: unknown;
69
70
  readonly endpoint: string;
70
71
  readonly method: HttpMethod;
72
+ /**
73
+ * Server-directed retry pacing from the `Retry-After` response header
74
+ * (seconds or HTTP-date form, normalized to ms). Populated on 429/503;
75
+ * the SDK's retry loop honors it INSTEAD of computed backoff so clients
76
+ * come back exactly when the server says capacity returns.
77
+ */
78
+ readonly retryAfterMs: number | null;
71
79
  constructor(message: string, options: ArcApiErrorOptions);
72
80
  /**
73
81
  * Canonical error code from arc's wire envelope (`json.code`).
@@ -185,6 +193,43 @@ declare function getQuotaDetails(error: unknown): QuotaDetails | null;
185
193
  * }
186
194
  */
187
195
  declare function isOrgContextRequiredError(error: unknown): error is ArcApiError;
196
+ /** The tier/mode a `arc.tier_required` error asked for, plus the active one. */
197
+ interface TierRequirement {
198
+ /** Minimum tier/mode the feature needs (e.g. `"enterprise"`). */
199
+ requiredMode: string;
200
+ /** The deployment's current tier/mode (e.g. `"standard"`). */
201
+ currentMode?: string;
202
+ }
203
+ /**
204
+ * TIER/CAPABILITY gate predicate. True when the backend refused because the
205
+ * deployment's tier (FLOW_MODE / feature tier) is below what the route needs —
206
+ * a `arc.tier_required` error (arc-inventory's mode gate → arc's
207
+ * `createDomainError`). This is the DISCRIMINABLE counterpart to a bare
208
+ * `arc.forbidden` (a role/permission denial): switch on THIS to render an
209
+ * "upgrade required / requires Enterprise" surface, and on `arc.forbidden` for
210
+ * "access denied". The required tier is available via {@link getTierRequirement}.
211
+ *
212
+ * @example
213
+ * if (isTierRequiredError(error)) {
214
+ * const { requiredMode } = getTierRequirement(error)!;
215
+ * showUpgradePanel(requiredMode); // "requires Enterprise"
216
+ * } else if (isArcErrorCode(error, "arc.forbidden")) {
217
+ * showAccessDenied(); // role/branch problem
218
+ * }
219
+ */
220
+ declare function isTierRequiredError(error: unknown): error is ArcApiError;
221
+ /**
222
+ * Extract the tier requirement from a `arc.tier_required` error's structured
223
+ * machine-readable context (`{ requiredMode, currentMode }`). Returns `null` for
224
+ * any other error, so the FE never has to hardcode a route→tier map or
225
+ * string-match a message.
226
+ *
227
+ * Reads `meta` (the canonical ErrorContract Record — where both arc gate paths
228
+ * put it: the global error handler serializes `ArcError.meta`, and the
229
+ * permission-slot applier emits the same `meta`). Falls back to `details` for
230
+ * resilience against any backend still emitting the pre-2026-07 shape.
231
+ */
232
+ declare function getTierRequirement(error: unknown): TierRequirement | null;
188
233
  /**
189
234
  * Specific predicate for validation failures (Fastify AJV + Mongoose
190
235
  * ValidationError). When true, `error.fieldErrors` is populated with the
@@ -240,7 +285,7 @@ interface ClientConfig {
240
285
  * - 'bearer' (default): Requires a token for authenticated requests. Queries are disabled until a token is provided.
241
286
  * - 'cookie': Auth is handled via HTTP-only cookies (e.g. Better Auth). Queries are always enabled — no token needed.
242
287
  */
243
- authMode?: 'bearer' | 'cookie' | 'header';
288
+ authMode?: "bearer" | "cookie" | "header";
244
289
  /**
245
290
  * Fetch credentials policy.
246
291
  * - 'include': Always send cookies cross-origin (required for cookie-based auth).
@@ -258,6 +303,13 @@ interface ClientConfig {
258
303
  * @example '2' // sends Accept-Version: 2
259
304
  */
260
305
  apiVersion?: string;
306
+ /**
307
+ * Default request cache mode applied to every request that expresses no
308
+ * caching intent of its own (no per-call `cache`, `revalidate`, or `next`) —
309
+ * the client-level analog of `BaseApiConfig.cache`, same precedence rule.
310
+ * Unset = the runtime's fetch default.
311
+ */
312
+ cache?: RequestCache;
261
313
  /**
262
314
  * Auto-generate `Idempotency-Key` header for POST/PUT/PATCH requests.
263
315
  * Prevents duplicate mutations on network retries.
@@ -271,6 +323,17 @@ interface ClientConfig {
271
323
  * Per-request override: set `elevated: true | false` on `RequestOptions`.
272
324
  */
273
325
  elevated?: boolean;
326
+ /**
327
+ * Per-attempt request timeout in ms. When set (> 0), every fetch attempt is
328
+ * aborted after this long and fails with a retryable `TimeoutError` —
329
+ * WITHOUT it, a hung connection pends forever unless the caller wires an
330
+ * `AbortSignal` themselves. Composed with the caller's signal (whichever
331
+ * fires first wins); each retry attempt gets a fresh timeout window.
332
+ * Default: disabled (0) — deliberate, to avoid killing legitimately slow
333
+ * report/export endpoints; set it explicitly (10–30s is typical).
334
+ * Per-request override: `ApiRequestOptions.timeoutMs` (0 disables).
335
+ */
336
+ timeoutMs?: number;
274
337
  /**
275
338
  * Network-failure + 5xx retry policy. Off by default — TanStack Query already
276
339
  * retries query reads (3× by default), but the SDK's mutation flow and any
@@ -330,7 +393,7 @@ interface RetryConfig {
330
393
  * - `'linear'`: `300 * (n + 1)` ms
331
394
  * - `(attempt) => number`: custom delay in ms; receives the 0-indexed retry attempt
332
395
  */
333
- backoff?: 'exponential' | 'linear' | ((attempt: number) => number);
396
+ backoff?: "exponential" | "linear" | ((attempt: number) => number);
334
397
  /**
335
398
  * Whitelist of statuses to retry on, OR a predicate. Defaults to a function
336
399
  * that returns true for: any non-`ArcApiError` (network failure / fetch
@@ -338,6 +401,13 @@ interface RetryConfig {
338
401
  * AbortError or 4xx.
339
402
  */
340
403
  retryOn?: number[] | ((error: unknown) => boolean);
404
+ /**
405
+ * Backoff jitter. `'full'` randomizes each delay uniformly in
406
+ * `[0, computed]` (AWS full-jitter) so a fleet of clients recovering from
407
+ * the same outage doesn't retry in lockstep and re-stampede the backend.
408
+ * Default `'none'` keeps the historical deterministic delays.
409
+ */
410
+ jitter?: "none" | "full";
341
411
  }
342
412
  /** Context handed to {@link ClientConfig.beforeRequest}. Mutate + return to override. */
343
413
  interface BeforeRequestContext {
@@ -384,7 +454,7 @@ declare function configureClient(config: ClientConfig): void;
384
454
  /**
385
455
  * Get the configured auth mode. Returns 'bearer' if not configured.
386
456
  */
387
- declare function getAuthMode(): 'bearer' | 'cookie' | 'header';
457
+ declare function getAuthMode(): "bearer" | "cookie" | "header";
388
458
  /** Get the configured base URL. Returns empty string if not configured. */
389
459
  declare function getBaseUrl(): string;
390
460
  /** Whether auto-idempotency is enabled on the global client. */
@@ -483,7 +553,7 @@ interface AuthErrorContext {
483
553
  setToken: (token: string | null) => void;
484
554
  }
485
555
  /** {@link AuthConfig.onAuthError} signature. */
486
- type AuthErrorHandler = (ctx: AuthErrorContext) => Promise<'retry' | 'skip'>;
556
+ type AuthErrorHandler = (ctx: AuthErrorContext) => Promise<"retry" | "skip">;
487
557
  /**
488
558
  * Configure auth context for automatic token/orgId injection.
489
559
  * When configured, hooks auto-inject these values so you don't need to pass them manually.
@@ -521,7 +591,7 @@ declare function _resetAuthWarnings(): void;
521
591
  * "retry without a token" (rare but valid for public endpoints).
522
592
  */
523
593
  interface AuthRecoveryResult {
524
- decision: 'retry' | 'skip';
594
+ decision: "retry" | "skip";
525
595
  overrideToken: string | null | undefined;
526
596
  }
527
597
  /** @internal — exposed for tests; clears the dedup so they don't bleed. */
@@ -545,7 +615,7 @@ declare function _getAuthErrorHandler(): {
545
615
  * as the fetch path — concurrent callers (XHR upload + WebSocket reconnect
546
616
  * + SSE probe firing at once) collapse to one refresh.
547
617
  */
548
- declare function _runAuthRecovery(handler: AuthErrorHandler, ctx: Omit<AuthErrorContext, 'setToken'>): Promise<AuthRecoveryResult>;
618
+ declare function _runAuthRecovery(handler: AuthErrorHandler, ctx: Omit<AuthErrorContext, "setToken">): Promise<AuthRecoveryResult>;
549
619
  /**
550
620
  * @internal
551
621
  * Resolve the next-attempt token. Mirrors the priority in `executeRequest`'s
@@ -601,10 +671,10 @@ declare function createAuthRefreshHandler(opts: {
601
671
  * Behavior when the `refresh()` call itself throws. Default: `'skip'`
602
672
  * (the original 401 surfaces; consumers handle "session expired" once).
603
673
  */
604
- onRefreshError?: 'skip' | 'throw';
674
+ onRefreshError?: "skip" | "throw";
605
675
  }): AuthErrorHandler;
606
676
  /** Protocol family the URL should target. `http` keeps `getBaseUrl()` as-is; `ws` rewrites `http(s)://` → `ws(s)://`. */
607
- type StreamUrlProtocol = 'http' | 'ws';
677
+ type StreamUrlProtocol = "http" | "ws";
608
678
  /**
609
679
  * Build an auth-aware URL using the global client + auth singletons.
610
680
  *
@@ -618,7 +688,7 @@ type StreamUrlProtocol = 'http' | 'ws';
618
688
  * @param protocol `'http'` (default) or `'ws'` — controls the protocol rewrite.
619
689
  */
620
690
  declare function buildStreamUrl(path: string, params?: Record<string, string | number | boolean | null | undefined>, protocol?: StreamUrlProtocol): string;
621
- type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
691
+ type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
622
692
  /** Returned by `handleApiRequest` for PDF, image, and CSV responses. */
623
693
  interface BlobResponse {
624
694
  data: Blob;
@@ -677,6 +747,8 @@ interface ApiRequestOptions {
677
747
  next?: NextFetchOptions;
678
748
  cache?: RequestCache;
679
749
  signal?: AbortSignal;
750
+ /** Per-request timeout in ms — overrides `ClientConfig.timeoutMs`. `0` disables for this request. */
751
+ timeoutMs?: number;
680
752
  /** Explicit idempotency key for this request. Sent as `Idempotency-Key` header. */
681
753
  idempotencyKey?: string;
682
754
  /**
@@ -762,6 +834,47 @@ declare function createClient(config: ArcClientConfig): ArcClient;
762
834
  * });
763
835
  */
764
836
  declare function createAuthAwareClient(overrides?: Partial<ArcClientConfig>): ArcClient;
837
+ /**
838
+ * Request-scoped server client config — static credentials for exactly one
839
+ * request, no module singletons.
840
+ */
841
+ interface ServerClientConfig extends Omit<ArcClientConfig, "getToken" | "getOrgId" | "toast" | "navigation"> {
842
+ /** Bearer token for this request. `null`/omitted = unauthenticated. */
843
+ token?: string | null;
844
+ /** Tenant/org for this request, sent as `x-organization-id`. */
845
+ organizationId?: string | null;
846
+ }
847
+ /**
848
+ * Create a **request-scoped** client for server environments (Next.js App
849
+ * Router route handlers / Server Components, or any SSR runtime).
850
+ *
851
+ * Unlike `configureClient`/`configureAuth` — which set module-level state
852
+ * that leaks across concurrent server requests — this returns an isolated
853
+ * client whose credentials live only in the returned instance. Construct one
854
+ * per request, read cookies/headers in YOUR framework code, and pass the
855
+ * values in. The SDK stays framework-free: no `next` dependency, no
856
+ * `next/headers` import, no hidden cookie access.
857
+ *
858
+ * @example Next.js App Router (Server Component or route handler)
859
+ * ```ts
860
+ * import { cookies, headers } from 'next/headers'; // host code, not SDK code
861
+ * import { createServerClient } from '@classytic/arc-next/client';
862
+ * import { createCrudApi } from '@classytic/arc-next/api';
863
+ *
864
+ * export async function loadOrders() {
865
+ * const jar = await cookies();
866
+ * const client = createServerClient({
867
+ * baseUrl: process.env.API_URL!,
868
+ * token: jar.get('session')?.value ?? null,
869
+ * organizationId: (await headers()).get('x-organization-id'),
870
+ * });
871
+ * const orders = createCrudApi<Order>('orders', { client });
872
+ * // Next fetch-cache passthrough works per call:
873
+ * return orders.getAll({ options: { next: { revalidate: 60, tags: ['orders'] } } });
874
+ * }
875
+ * ```
876
+ */
877
+ declare function createServerClient(config: ServerClientConfig): ArcClient;
765
878
  /**
766
879
  * Get auth context for a specific client instance, falling back to global.
767
880
  */
@@ -809,7 +922,7 @@ declare function createQueryString<T extends Record<string, unknown>>(params?: T
809
922
  * });
810
923
  */
811
924
  declare function arcAuthHeaders(): Record<string, string>;
812
- interface ArcFetchOptions extends Omit<RequestInit, 'body' | 'headers'> {
925
+ interface ArcFetchOptions extends Omit<RequestInit, "body" | "headers"> {
813
926
  /**
814
927
  * Request body. Plain objects and arrays are auto-`JSON.stringify`d and
815
928
  * sent with `Content-Type: application/json`. Binary bodies (`FormData`,
@@ -903,4 +1016,4 @@ declare const arc: {
903
1016
  delete: <T = unknown>(path: string, opts?: ArcFetchOptions) => Promise<T>;
904
1017
  };
905
1018
  //#endregion
906
- 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, StreamUrlProtocol, TextResponse, ToastHandler, UseRouterHook, _getAuthErrorHandler, _isAuthRecoverable, _resetArcFetchClient, _resetAuthRecovery, _resetAuthWarnings, _resolveRefreshedToken, _runAuthRecovery, arc, arcAuthHeaders, arcFetch, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createAuthRefreshHandler, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
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 };
package/dist/client.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ERROR_CODES } from "@classytic/repo-core/errors";
2
+
1
3
  //#region src/client.ts
2
4
  /**
3
5
  * Canonical error codes arc and repo-core emit on `json.code`. Single
@@ -21,17 +23,7 @@
21
23
  * `errorMappers` codes still satisfy it.
22
24
  */
23
25
  const KNOWN_ARC_ERROR_CODES = [
24
- "validation_error",
25
- "not_found",
26
- "conflict",
27
- "unauthorized",
28
- "forbidden",
29
- "rate_limited",
30
- "idempotency_conflict",
31
- "precondition_failed",
32
- "internal_error",
33
- "service_unavailable",
34
- "timeout",
26
+ ...Object.values(ERROR_CODES),
35
27
  "arc.bad_request",
36
28
  "arc.unauthorized",
37
29
  "arc.forbidden",
@@ -45,6 +37,7 @@ const KNOWN_ARC_ERROR_CODES = [
45
37
  "arc.gateway_timeout",
46
38
  "arc.validation_error",
47
39
  "arc.invalid_id",
40
+ "arc.tier_required",
48
41
  "arc.org.selection_required",
49
42
  "arc.org.access_denied",
50
43
  "ORG_CONTEXT_REQUIRED",
@@ -75,6 +68,13 @@ var ArcApiError = class extends Error {
75
68
  json;
76
69
  endpoint;
77
70
  method;
71
+ /**
72
+ * Server-directed retry pacing from the `Retry-After` response header
73
+ * (seconds or HTTP-date form, normalized to ms). Populated on 429/503;
74
+ * the SDK's retry loop honors it INSTEAD of computed backoff so clients
75
+ * come back exactly when the server says capacity returns.
76
+ */
77
+ retryAfterMs;
78
78
  constructor(message, options) {
79
79
  super(message);
80
80
  this.name = "ArcApiError";
@@ -83,6 +83,7 @@ var ArcApiError = class extends Error {
83
83
  this.json = options.json;
84
84
  this.endpoint = options.endpoint;
85
85
  this.method = options.method;
86
+ this.retryAfterMs = options.retryAfterMs ?? null;
86
87
  }
87
88
  /**
88
89
  * Canonical error code from arc's wire envelope (`json.code`).
@@ -243,6 +244,48 @@ function isOrgContextRequiredError(error) {
243
244
  return isArcErrorCode(error, "ORG_CONTEXT_REQUIRED");
244
245
  }
245
246
  /**
247
+ * TIER/CAPABILITY gate predicate. True when the backend refused because the
248
+ * deployment's tier (FLOW_MODE / feature tier) is below what the route needs —
249
+ * a `arc.tier_required` error (arc-inventory's mode gate → arc's
250
+ * `createDomainError`). This is the DISCRIMINABLE counterpart to a bare
251
+ * `arc.forbidden` (a role/permission denial): switch on THIS to render an
252
+ * "upgrade required / requires Enterprise" surface, and on `arc.forbidden` for
253
+ * "access denied". The required tier is available via {@link getTierRequirement}.
254
+ *
255
+ * @example
256
+ * if (isTierRequiredError(error)) {
257
+ * const { requiredMode } = getTierRequirement(error)!;
258
+ * showUpgradePanel(requiredMode); // "requires Enterprise"
259
+ * } else if (isArcErrorCode(error, "arc.forbidden")) {
260
+ * showAccessDenied(); // role/branch problem
261
+ * }
262
+ */
263
+ function isTierRequiredError(error) {
264
+ return isArcErrorCode(error, "arc.tier_required");
265
+ }
266
+ /**
267
+ * Extract the tier requirement from a `arc.tier_required` error's structured
268
+ * machine-readable context (`{ requiredMode, currentMode }`). Returns `null` for
269
+ * any other error, so the FE never has to hardcode a route→tier map or
270
+ * string-match a message.
271
+ *
272
+ * Reads `meta` (the canonical ErrorContract Record — where both arc gate paths
273
+ * put it: the global error handler serializes `ArcError.meta`, and the
274
+ * permission-slot applier emits the same `meta`). Falls back to `details` for
275
+ * resilience against any backend still emitting the pre-2026-07 shape.
276
+ */
277
+ function getTierRequirement(error) {
278
+ if (!isTierRequiredError(error)) return null;
279
+ const j = error.json ?? {};
280
+ const bag = j.meta ?? j.details ?? {};
281
+ const requiredMode = bag.requiredMode;
282
+ if (typeof requiredMode !== "string") return null;
283
+ return {
284
+ requiredMode,
285
+ ...typeof bag.currentMode === "string" ? { currentMode: bag.currentMode } : {}
286
+ };
287
+ }
288
+ /**
246
289
  * Specific predicate for validation failures (Fastify AJV + Mongoose
247
290
  * ValidationError). When true, `error.fieldErrors` is populated with the
248
291
  * `{ field: message }` map. Matches arc's `arc.validation_error` and the
@@ -435,7 +478,7 @@ function isAuthRecoverable(error, retryOn403) {
435
478
  function runAuthRecovery(handler, ctx) {
436
479
  if (pendingAuthRecovery) return pendingAuthRecovery;
437
480
  pendingAuthRecovery = (async () => {
438
- let overrideToken = void 0;
481
+ let overrideToken;
439
482
  const setToken = (token) => {
440
483
  overrideToken = token;
441
484
  };
@@ -652,6 +695,44 @@ function createAuthAwareClient(overrides = {}) {
652
695
  };
653
696
  }
654
697
  /**
698
+ * Create a **request-scoped** client for server environments (Next.js App
699
+ * Router route handlers / Server Components, or any SSR runtime).
700
+ *
701
+ * Unlike `configureClient`/`configureAuth` — which set module-level state
702
+ * that leaks across concurrent server requests — this returns an isolated
703
+ * client whose credentials live only in the returned instance. Construct one
704
+ * per request, read cookies/headers in YOUR framework code, and pass the
705
+ * values in. The SDK stays framework-free: no `next` dependency, no
706
+ * `next/headers` import, no hidden cookie access.
707
+ *
708
+ * @example Next.js App Router (Server Component or route handler)
709
+ * ```ts
710
+ * import { cookies, headers } from 'next/headers'; // host code, not SDK code
711
+ * import { createServerClient } from '@classytic/arc-next/client';
712
+ * import { createCrudApi } from '@classytic/arc-next/api';
713
+ *
714
+ * export async function loadOrders() {
715
+ * const jar = await cookies();
716
+ * const client = createServerClient({
717
+ * baseUrl: process.env.API_URL!,
718
+ * token: jar.get('session')?.value ?? null,
719
+ * organizationId: (await headers()).get('x-organization-id'),
720
+ * });
721
+ * const orders = createCrudApi<Order>('orders', { client });
722
+ * // Next fetch-cache passthrough works per call:
723
+ * return orders.getAll({ options: { next: { revalidate: 60, tags: ['orders'] } } });
724
+ * }
725
+ * ```
726
+ */
727
+ function createServerClient(config) {
728
+ const { token = null, organizationId = null, ...rest } = config;
729
+ return createClient({
730
+ ...rest,
731
+ getToken: () => token,
732
+ getOrgId: () => organizationId
733
+ });
734
+ }
735
+ /**
655
736
  * Get auth context for a specific client instance, falling back to global.
656
737
  */
657
738
  function getClientAuthContext(client) {
@@ -668,11 +749,11 @@ function defaultShouldRetry(error) {
668
749
  return error instanceof Error;
669
750
  }
670
751
  /** Compute the delay in ms before the Nth retry. */
671
- function computeBackoff(retry, attempt) {
752
+ function computeBackoff(retry, attempt, error) {
753
+ if (isArcApiError(error) && error.retryAfterMs != null && (error.status === 429 || error.status === 503)) return error.retryAfterMs;
672
754
  const strategy = retry.backoff ?? "exponential";
673
- if (typeof strategy === "function") return Math.max(0, strategy(attempt));
674
- if (strategy === "linear") return 300 * (attempt + 1);
675
- return Math.min(300 * Math.pow(2, attempt), 1e4);
755
+ const base = typeof strategy === "function" ? Math.max(0, strategy(attempt)) : strategy === "linear" ? 300 * (attempt + 1) : Math.min(300 * 2 ** attempt, 1e4);
756
+ return retry.jitter === "full" ? Math.random() * base : base;
676
757
  }
677
758
  /**
678
759
  * Inner request loop — handles 5xx + network-failure backoff per
@@ -695,7 +776,7 @@ async function executeWithBackoff(config, method, endpoint, options = {}) {
695
776
  } catch (error) {
696
777
  lastError = error;
697
778
  if (attempt === totalAttempts - 1 || !shouldRetry(error)) throw error;
698
- const delay = computeBackoff(config.retry ?? {}, attempt);
779
+ const delay = computeBackoff(config.retry ?? {}, attempt, error);
699
780
  if (delay > 0) await sleepAbortable(delay, options.signal);
700
781
  }
701
782
  throw lastError;
@@ -714,6 +795,14 @@ async function executeWithBackoff(config, method, endpoint, options = {}) {
714
795
  * surfaces immediately, no extra round-trip).
715
796
  */
716
797
  async function executeRequest(config, method, endpoint, options = {}) {
798
+ if (config.autoIdempotency && options.idempotencyKey === void 0 && (method === "POST" || method === "PUT" || method === "PATCH")) options = {
799
+ ...options,
800
+ idempotencyKey: globalThis.crypto.randomUUID()
801
+ };
802
+ if (config.cache !== void 0 && options.cache === void 0 && options.revalidate === void 0 && options.next === void 0) options = {
803
+ ...options,
804
+ cache: config.cache
805
+ };
717
806
  const handler = authConfig?.onAuthError;
718
807
  if (!handler) return executeWithBackoff(config, method, endpoint, options);
719
808
  const retryOn403 = authConfig?.retryOn403 ?? false;
@@ -741,6 +830,15 @@ async function executeRequest(config, method, endpoint, options = {}) {
741
830
  }
742
831
  throw new Error("arc-next: auth retry loop terminated without resolution");
743
832
  }
833
+ /** Parse `Retry-After` (delta-seconds or HTTP-date) to ms; null when absent/invalid. */
834
+ function parseRetryAfterMs(header) {
835
+ if (!header) return null;
836
+ const seconds = Number(header);
837
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
838
+ const date = Date.parse(header);
839
+ if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
840
+ return null;
841
+ }
744
842
  /** Sleep that resolves early if the signal aborts. */
745
843
  function sleepAbortable(ms, signal) {
746
844
  return new Promise((resolve, reject) => {
@@ -759,9 +857,42 @@ function sleepAbortable(ms, signal) {
759
857
  signal?.addEventListener("abort", onAbort, { once: true });
760
858
  });
761
859
  }
860
+ /**
861
+ * Compose the caller's AbortSignal with a per-attempt timeout. Returns the
862
+ * effective signal plus a `timedOut()` probe so the catch path can tell a
863
+ * timeout abort (retryable failure) apart from a deliberate caller abort
864
+ * (silent cancellation).
865
+ */
866
+ function withTimeoutSignal(signal, timeoutMs) {
867
+ if (!(timeoutMs > 0)) return {
868
+ signal,
869
+ timedOut: () => false,
870
+ cleanup: () => {}
871
+ };
872
+ const controller = new AbortController();
873
+ let timedOut = false;
874
+ const onCallerAbort = () => controller.abort();
875
+ if (signal) if (signal.aborted) controller.abort();
876
+ else signal.addEventListener("abort", onCallerAbort, { once: true });
877
+ const timer = setTimeout(() => {
878
+ timedOut = true;
879
+ controller.abort();
880
+ }, timeoutMs);
881
+ return {
882
+ signal: controller.signal,
883
+ timedOut: () => timedOut,
884
+ cleanup: () => {
885
+ clearTimeout(timer);
886
+ signal?.removeEventListener("abort", onCallerAbort);
887
+ }
888
+ };
889
+ }
762
890
  /** A single fetch attempt — used by executeRequest's retry loop. */
763
891
  async function executeAttempt(config, method, endpoint, options, attempt) {
764
- const { body, token, organizationId, revalidate, headerOptions, tags, next, cache, signal, idempotencyKey, elevated } = options;
892
+ const { body, token, organizationId, revalidate, headerOptions, tags, next, cache, signal: callerSignal, idempotencyKey, elevated } = options;
893
+ const timeoutMs = options.timeoutMs ?? config.timeoutMs ?? 0;
894
+ const timeout = withTimeoutSignal(callerSignal, timeoutMs);
895
+ const signal = timeout.signal;
765
896
  const startTime = Date.now();
766
897
  try {
767
898
  let headers = {
@@ -773,7 +904,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
773
904
  if (config.authMode === "header") {
774
905
  const headerName = authConfig?.headerName ?? "x-api-key";
775
906
  headers[headerName] = token;
776
- } else if (config.authMode !== "cookie") headers["Authorization"] = `Bearer ${token}`;
907
+ } else if (config.authMode !== "cookie") headers.Authorization = `Bearer ${token}`;
777
908
  }
778
909
  if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
779
910
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
@@ -784,7 +915,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
784
915
  ...headerOptions
785
916
  };
786
917
  const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
787
- let serializedBody = void 0;
918
+ let serializedBody;
788
919
  if (body !== void 0 && body !== null) serializedBody = isNonJsonBody(body) ? body : JSON.stringify(body);
789
920
  const encryption = config.encryption;
790
921
  if (encryption?.encryptRequests && encryption.encrypt && typeof serializedBody === "string") {
@@ -815,7 +946,7 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
815
946
  if (revalidate !== void 0) nextCfg.revalidate = revalidate;
816
947
  if (tags) nextCfg.tags = nextCfg.tags ? [...nextCfg.tags, ...tags] : tags;
817
948
  if (nextCfg.revalidate !== void 0 || nextCfg.tags) fetchOptions.next = nextCfg;
818
- if (!/^https?:\/\//i.test(endpoint) && !config.baseUrl) throw new Error(`[arc-next] handleApiRequest(${method} ${endpoint}): baseUrl is empty. Call configureClient({ baseUrl: '...' }) BEFORE the first request. If you use createAuthAwareClient() at module top-level, make sure the Providers component runs configureClient() first (e.g. in a useState() initializer, before the children render).`);
949
+ if (!/^https?:\/\//i.test(endpoint) && !config.baseUrl) throw new Error(`[arc-next] handleApiRequest(${method} ${endpoint}): baseUrl is empty. ` + (typeof window === "undefined" ? "On the server, configureClient() is a no-op — configure a request-scoped client instead: createServerClient({ baseUrl }), or your SDK's server config (e.g. a server env baseUrl / runWithSDKConfig) so SSR reads resolve." : "Call configureClient({ baseUrl: '...' }) at app boot (Providers) BEFORE the first request — e.g. in a useState() initializer, before the children render."));
819
950
  const response = await fetch(`${config.baseUrl}${endpoint}`, fetchOptions);
820
951
  if (!response.ok) {
821
952
  let json = null;
@@ -838,7 +969,8 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
838
969
  statusText: response.statusText,
839
970
  json,
840
971
  endpoint,
841
- method
972
+ method,
973
+ retryAfterMs: parseRetryAfterMs(response.headers.get("Retry-After"))
842
974
  });
843
975
  }
844
976
  const contentType = response.headers.get("Content-Type");
@@ -886,8 +1018,11 @@ async function executeAttempt(config, method, endpoint, options, attempt) {
886
1018
  })).body;
887
1019
  return data;
888
1020
  } catch (error) {
1021
+ if (timeout.timedOut() && isAbortError(error)) throw Object.assign(/* @__PURE__ */ new Error(`[arc-next] Request timed out after ${timeoutMs}ms: ${method} ${endpoint}`), { name: "TimeoutError" });
889
1022
  if (error instanceof Error) throw error;
890
1023
  throw new Error("An error occurred while fetching data.");
1024
+ } finally {
1025
+ timeout.cleanup();
891
1026
  }
892
1027
  }
893
1028
  /**
@@ -1017,7 +1152,7 @@ function arcAuthHeaders() {
1017
1152
  * Lower-cased on lookup so case-insensitive HTTP header semantics are
1018
1153
  * honored (`Authorization` vs `authorization` both protected).
1019
1154
  */
1020
- const ARC_FETCH_PROTECTED_HEADERS = new Set([
1155
+ const ARC_FETCH_PROTECTED_HEADERS = /* @__PURE__ */ new Set([
1021
1156
  "authorization",
1022
1157
  "x-organization-id",
1023
1158
  "x-internal-api-key"
@@ -1139,4 +1274,4 @@ const arc = {
1139
1274
  };
1140
1275
 
1141
1276
  //#endregion
1142
- 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, getQuotaDetails, handleApiRequest, hasGlobalStaticAuth, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isQuotaExceeded, isValidationError };
1277
+ 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 };
@@ -1,5 +1,4 @@
1
1
  import { ClientEncryptionConfig } from "./client.js";
2
-
3
2
  //#region src/encryption.d.ts
4
3
  /** Key material accepted by jose in browser + Node (Web Crypto). */
5
4
  type JoseKey = CryptoKey | Uint8Array;
@@ -31,6 +30,6 @@ interface JoseEncryptionOptions {
31
30
  * using `jose`. Spread the result into `encryption` and add `encryptRequests`
32
31
  * / content-type overrides as needed.
33
32
  */
34
- declare function createJoseEncryption(options: JoseEncryptionOptions): Pick<ClientEncryptionConfig, 'encrypt' | 'decrypt'>;
33
+ declare function createJoseEncryption(options: JoseEncryptionOptions): Pick<ClientEncryptionConfig, "encrypt" | "decrypt">;
35
34
  //#endregion
36
35
  export { JoseEncryptionOptions, createJoseEncryption };