@classytic/arc-next 0.4.0 → 0.5.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.js CHANGED
@@ -1,5 +1,51 @@
1
1
  //#region src/client.ts
2
2
  /**
3
+ * Top-level error codes arc's `errorHandlerPlugin` emits on the response root
4
+ * (`json.code`). Mirrors `statusCodeToCode()` + the special `VALIDATION_ERROR`
5
+ * / `DUPLICATE_KEY` / `INTERNAL_ERROR` branches.
6
+ *
7
+ * Open via intersection with `(string & {})` so unknown codes from custom
8
+ * `errorMappers` / `errorMap` still satisfy the type.
9
+ */
10
+ /**
11
+ * Top-level HTTP-status error codes arc's `errorHandlerPlugin` emits on
12
+ * `json.code`. Exported as a `const` array first so callers get both
13
+ * compile-time exhaustiveness AND runtime iteration (e.g. building a
14
+ * client-side i18n lookup, or whitelisting which codes get retried).
15
+ */
16
+ const KNOWN_TOP_LEVEL_CODES = [
17
+ "BAD_REQUEST",
18
+ "UNAUTHORIZED",
19
+ "FORBIDDEN",
20
+ "NOT_FOUND",
21
+ "METHOD_NOT_ALLOWED",
22
+ "CONFLICT",
23
+ "UNPROCESSABLE_ENTITY",
24
+ "RATE_LIMITED",
25
+ "INTERNAL_ERROR",
26
+ "BAD_GATEWAY",
27
+ "SERVICE_UNAVAILABLE",
28
+ "GATEWAY_TIMEOUT",
29
+ "VALIDATION_ERROR",
30
+ "DUPLICATE_KEY",
31
+ "DOMAIN_ERROR"
32
+ ];
33
+ /**
34
+ * Nested business-logic codes arc's mixins / preset routes / org guards emit
35
+ * on `json.details.code`. These are distinct from the HTTP-status code above —
36
+ * `error.code` (top-level) vs `error.detailsCode` (nested). When a route
37
+ * returns 403 with `details.code: 'ORG_CONTEXT_REQUIRED'`, hosts read
38
+ * `detailsCode` to disambiguate "missing org context" from "permission denied".
39
+ */
40
+ const KNOWN_DETAILS_CODES = [
41
+ "ORG_CONTEXT_REQUIRED",
42
+ "ORG_ROLE_REQUIRED",
43
+ "OWNERSHIP_DENIED",
44
+ "MIXED_UPDATE_SHAPE",
45
+ "ALL_FIELDS_STRIPPED",
46
+ "BEFORE_RESTORE_HOOK_ERROR"
47
+ ];
48
+ /**
3
49
  * Rich API error with status code, response payload, and request metadata.
4
50
  * Extends Error so existing `catch (e) { if (e instanceof Error) }` still works.
5
51
  *
@@ -28,9 +74,63 @@ var ArcApiError = class extends Error {
28
74
  this.endpoint = options.endpoint;
29
75
  this.method = options.method;
30
76
  }
31
- /** Extract field-level validation errors if present (e.g. `{ field: "message" }`) */
77
+ /**
78
+ * Top-level error code from arc's response envelope (`json.code`).
79
+ *
80
+ * One of {@link ArcTopLevelErrorCode} — covers HTTP-status codes (`FORBIDDEN`,
81
+ * `CONFLICT`...) plus the dedicated `VALIDATION_ERROR` / `DUPLICATE_KEY`
82
+ * branches arc emits. Returns `null` if the response carries no envelope.
83
+ *
84
+ * @example
85
+ * if (error.code === 'DUPLICATE_KEY') showRetryAsAdmin();
86
+ */
87
+ get code() {
88
+ const j = this.json;
89
+ return j && typeof j.code === "string" ? j.code : null;
90
+ }
91
+ /**
92
+ * Nested business-logic code from arc's `json.details.code` slot.
93
+ *
94
+ * Arc's preset mixins (bulk, softDelete) and org guards emit specific reason
95
+ * codes here — distinct from the HTTP-status `code` above. The most common
96
+ * is `ORG_CONTEXT_REQUIRED` (bulk operations + orgGuard reject when the
97
+ * caller's `request.scope.organizationId` is missing).
98
+ *
99
+ * @example
100
+ * if (error.detailsCode === 'ORG_CONTEXT_REQUIRED') {
101
+ * alert('Configure auth before bulk operations: configureAuth({ getOrgId })');
102
+ * }
103
+ */
104
+ get detailsCode() {
105
+ const c = this.json?.details?.code;
106
+ return typeof c === "string" ? c : null;
107
+ }
108
+ /**
109
+ * Extract field-level validation errors as `{ field: message }` map.
110
+ *
111
+ * Reads three response shapes used by Arc:
112
+ * 1. `{ errors: { email: 'invalid' } }` — record form (legacy / app-level handlers).
113
+ * 2. `{ details: { errors: [{ field, message }] } }` — Fastify AJV + Arc errorHandler emit this.
114
+ * 3. `{ details: { errors: [{ instancePath, message, params }] } }` — raw AJV passthrough.
115
+ *
116
+ * Returns null when none of the shapes match.
117
+ */
32
118
  get fieldErrors() {
33
- return this.json?.errors ?? null;
119
+ const j = this.json;
120
+ if (!j) return null;
121
+ if (j.errors && !Array.isArray(j.errors) && typeof j.errors === "object") return j.errors;
122
+ const errorList = Array.isArray(j.errors) ? j.errors : Array.isArray(j.details?.errors) ? j.details.errors : null;
123
+ if (!errorList) return null;
124
+ const map = {};
125
+ for (const item of errorList) {
126
+ if (!item || typeof item !== "object") continue;
127
+ const e = item;
128
+ const key = typeof e.field === "string" && e.field || typeof e.instancePath === "string" && e.instancePath.replace(/^\//, "") || typeof e.path === "string" && e.path || typeof e.params?.missingProperty === "string" && e.params.missingProperty || "";
129
+ if (!key) continue;
130
+ const msg = typeof e.message === "string" ? e.message : "Invalid value";
131
+ if (!(key in map)) map[key] = msg;
132
+ }
133
+ return Object.keys(map).length > 0 ? map : null;
34
134
  }
35
135
  };
36
136
  /**
@@ -39,6 +139,84 @@ var ArcApiError = class extends Error {
39
139
  function isArcApiError(error) {
40
140
  return error instanceof ArcApiError;
41
141
  }
142
+ /**
143
+ * Detects request cancellation (AbortSignal triggered) regardless of runtime.
144
+ *
145
+ * Filtering out abort errors from real failures is a common need — without it,
146
+ * unmounting a React component mid-fetch produces noisy logs and bogus error
147
+ * toasts that look like API failures. Use this predicate to skip the catch
148
+ * branch when the request was deliberately cancelled.
149
+ *
150
+ * Handles the three AbortError shapes you'll see in the wild:
151
+ * 1. Browser `DOMException { name: 'AbortError' }`
152
+ * 2. Node 18+ / undici `Error { name: 'AbortError' }` (no DOMException)
153
+ * 3. Some polyfills / older runtimes where the error has `code: 'ERR_ABORTED'`
154
+ *
155
+ * @example
156
+ * try {
157
+ * await api.getAll({ options: { signal } });
158
+ * } catch (err) {
159
+ * if (isAbortError(err)) return; // user navigated away — silence
160
+ * showToast('Failed to load: ' + err.message);
161
+ * }
162
+ */
163
+ function isAbortError(error) {
164
+ if (!error) return false;
165
+ if (typeof error !== "object") return false;
166
+ const e = error;
167
+ if (e.name === "AbortError") return true;
168
+ if (e.code === "ERR_ABORTED") return true;
169
+ return false;
170
+ }
171
+ /**
172
+ * Generic check: is this an `ArcApiError` carrying a specific top-level OR
173
+ * nested `details.code`? Matches whichever slot the code lives in — saves
174
+ * call sites from having to know whether arc emitted it at the root or under
175
+ * `details`.
176
+ *
177
+ * @example
178
+ * if (isArcErrorCode(error, 'DUPLICATE_KEY')) showRetryUI();
179
+ * if (isArcErrorCode(error, 'ORG_CONTEXT_REQUIRED')) promptOrgSelector();
180
+ */
181
+ function isArcErrorCode(error, code) {
182
+ if (!isArcApiError(error)) return false;
183
+ return error.code === code || error.detailsCode === code;
184
+ }
185
+ /**
186
+ * Specific predicate for arc's bulk-preset + orgGuard safety code.
187
+ *
188
+ * Arc's bulk endpoints (`POST/PATCH/DELETE /:resource/bulk`) reject any call
189
+ * where `request.scope.organizationId` is missing — the wire signal is
190
+ * `403 { details: { code: 'ORG_CONTEXT_REQUIRED' } }`. Hosts hitting this
191
+ * need to call `configureAuth({ getOrgId })` before retrying.
192
+ *
193
+ * @example
194
+ * try { await api.bulkCreate({ data: [...] }); }
195
+ * catch (e) {
196
+ * if (isOrgContextRequiredError(e)) {
197
+ * console.warn('Bulk requires org context. Configure: configureAuth({ getOrgId })');
198
+ * } else throw e;
199
+ * }
200
+ */
201
+ function isOrgContextRequiredError(error) {
202
+ return isArcErrorCode(error, "ORG_CONTEXT_REQUIRED");
203
+ }
204
+ /**
205
+ * Specific predicate for arc's `VALIDATION_ERROR` (Fastify AJV + Mongoose
206
+ * ValidationError). When true, `error.fieldErrors` is populated with the
207
+ * `{ field: message }` map.
208
+ */
209
+ function isValidationError(error) {
210
+ return isArcErrorCode(error, "VALIDATION_ERROR");
211
+ }
212
+ /**
213
+ * Specific predicate for arc's `DUPLICATE_KEY` (unique-constraint violation).
214
+ * Arc's errorHandler classifies these uniformly across MongoDB E11000,
215
+ * Postgres 23505, and Prisma P2002.
216
+ */
217
+ function isDuplicateKeyError(error) {
218
+ return isArcErrorCode(error, "DUPLICATE_KEY");
219
+ }
42
220
  let clientConfig = null;
43
221
  /**
44
222
  * Configure the API client. Call once at app init before any API requests.
@@ -64,22 +242,31 @@ function configureClient(config) {
64
242
  function getAuthMode() {
65
243
  return clientConfig?.authMode ?? "bearer";
66
244
  }
245
+ /** Get the configured base URL. Returns empty string if not configured. */
246
+ function getBaseUrl() {
247
+ return clientConfig?.baseUrl ?? "";
248
+ }
67
249
  /** Whether auto-idempotency is enabled on the global client. */
68
250
  function isAutoIdempotency() {
69
251
  return clientConfig?.autoIdempotency ?? false;
70
252
  }
71
253
  let authConfig = null;
254
+ let hasWarnedAsyncToken = false;
72
255
  /**
73
256
  * Configure auth context for automatic token/orgId injection.
74
257
  * When configured, hooks auto-inject these values so you don't need to pass them manually.
75
258
  *
76
259
  * **SSR safety:** This sets module-level state. Call only in client-side code.
77
260
  *
261
+ * **Token resolution is synchronous.** `getToken` must return `string | null`
262
+ * synchronously — never a Promise. See {@link AuthConfig.getToken} for guidance
263
+ * on bridging async auth libraries via cached values.
264
+ *
78
265
  * @example
79
266
  * // Cookie auth (no token needed)
80
267
  * configureAuth({ getOrgId: () => currentOrg.id });
81
268
  *
82
- * // Bearer auth
269
+ * // Bearer auth — token is cached synchronously by the auth library
83
270
  * configureAuth({
84
271
  * getToken: () => session?.accessToken ?? null,
85
272
  * getOrgId: () => currentOrg?.id ?? null,
@@ -88,40 +275,217 @@ let authConfig = null;
88
275
  function configureAuth(config) {
89
276
  if (typeof window === "undefined") console.warn("[arc-next] configureAuth() called on the server. This sets module-level state that persists across requests. Call only in client-side code (e.g., a 'use client' provider).");
90
277
  authConfig = config;
278
+ hasWarnedAsyncToken = false;
279
+ }
280
+ function readToken(getToken) {
281
+ if (!getToken) return null;
282
+ const result = getToken();
283
+ if (result && typeof result.then === "function") {
284
+ if (!hasWarnedAsyncToken) {
285
+ hasWarnedAsyncToken = true;
286
+ console.warn("[arc-next] configureAuth({ getToken }) returned a Promise. Tokens MUST resolve synchronously — async returns are dropped and requests will be unauthenticated. Cache your token (localStorage, memory, signal) and have getToken() return the cached value.");
287
+ }
288
+ return null;
289
+ }
290
+ return result ?? null;
91
291
  }
92
292
  /**
93
293
  * Get the current auth context. Returns nulls when not configured.
94
294
  */
95
295
  function getAuthContext() {
96
296
  return {
97
- token: authConfig?.getToken?.() ?? null,
297
+ token: readToken(authConfig?.getToken),
98
298
  organizationId: authConfig?.getOrgId?.() ?? null
99
299
  };
100
300
  }
301
+ /** @internal — exposed for tests; resets the dev-warn dedup flag. */
302
+ function _resetAuthWarnings() {
303
+ hasWarnedAsyncToken = false;
304
+ }
305
+ /**
306
+ * Build an auth-aware URL using the global client + auth singletons.
307
+ *
308
+ * Single source of truth for {@link import('./sse.js').buildSseUrl} (HTTP) and
309
+ * {@link import('./ws.js').buildWsUrl} (WebSocket). Both delegate here so the
310
+ * auth-injection rule (org always, token only when `authMode !== 'cookie'`)
311
+ * stays in one place and can't drift.
312
+ *
313
+ * @param path Path appended to the base URL (leading slash recommended).
314
+ * @param params Caller-supplied params; merged with auth params. Caller wins on key collision.
315
+ * @param protocol `'http'` (default) or `'ws'` — controls the protocol rewrite.
316
+ */
317
+ function buildStreamUrl(path, params = {}, protocol = "http") {
318
+ const auth = getAuthContext();
319
+ const qs = new URLSearchParams();
320
+ for (const [key, value] of Object.entries(params)) {
321
+ if (value === void 0 || value === null || value === "") continue;
322
+ qs.set(key, String(value));
323
+ }
324
+ if (auth.organizationId && !qs.has("organizationId")) qs.set("organizationId", auth.organizationId);
325
+ if (auth.token && getAuthMode() !== "cookie" && !qs.has("token")) qs.set("token", auth.token);
326
+ const origin = protocol === "ws" ? getBaseUrl().replace(/^http(s?):\/\//, "ws$1://") : getBaseUrl();
327
+ const suffix = qs.toString();
328
+ return `${origin}${path}${suffix ? `?${suffix}` : ""}`;
329
+ }
101
330
  /**
102
331
  * Create an isolated API client for a specific backend.
103
- * Use this when your app needs to talk to multiple APIs.
332
+ * Use this when your app needs to talk to multiple APIs with different auth.
104
333
  *
105
334
  * @example
335
+ * // Bearer auth for main API
336
+ * const mainClient = createClient({
337
+ * baseUrl: 'https://api.example.com',
338
+ * getToken: () => session.accessToken,
339
+ * });
340
+ *
341
+ * // API key auth for analytics
106
342
  * const analyticsClient = createClient({
107
343
  * baseUrl: 'https://analytics.example.com',
108
- * toast: { success: toast.success, error: toast.error },
109
- * navigation: useRouter,
344
+ * authMode: 'header',
345
+ * getToken: () => env.ANALYTICS_KEY,
346
+ * headerName: 'x-api-key',
110
347
  * });
111
- *
112
- * const eventsApi = createCrudApi('events', { client: analyticsClient });
113
348
  */
114
349
  function createClient(config) {
115
- const { toast, navigation, ...clientCfg } = config;
350
+ const { toast, navigation, getToken, getOrgId, headerName, ...clientCfg } = config;
351
+ const clientAuth = getToken || getOrgId || headerName ? {
352
+ getToken,
353
+ getOrgId,
354
+ headerName
355
+ } : void 0;
116
356
  return {
117
- request: (method, endpoint, options) => executeRequest(clientCfg, method, endpoint, options),
357
+ request: (method, endpoint, options) => {
358
+ if (clientAuth) {
359
+ const resolved = { ...options };
360
+ if (resolved.token === void 0 && clientAuth.getToken) resolved.token = readToken(clientAuth.getToken);
361
+ if (resolved.organizationId === void 0 && clientAuth.getOrgId) resolved.organizationId = clientAuth.getOrgId();
362
+ if (clientCfg.authMode === "header" && resolved.token) {
363
+ resolved.headerOptions = {
364
+ [clientAuth.headerName ?? "x-api-key"]: resolved.token,
365
+ ...resolved.headerOptions ?? {}
366
+ };
367
+ resolved.token = void 0;
368
+ }
369
+ return executeRequest(clientCfg, method, endpoint, resolved);
370
+ }
371
+ return executeRequest(clientCfg, method, endpoint, options);
372
+ },
118
373
  config: clientCfg,
119
374
  toast,
120
- navigation
375
+ navigation,
376
+ auth: clientAuth
377
+ };
378
+ }
379
+ /**
380
+ * Create an `ArcClient` wired to the global `configureClient` + `configureAuth` setup.
381
+ *
382
+ * Removes the boilerplate every consumer SDK writes by hand (auth-injection adapter
383
+ * + `BaseApi` constructor with `client: { request: handleApiRequest, config: ... }`).
384
+ * The returned client reads `getToken` / `getOrgId` lazily on each request, so token
385
+ * rotation in the global auth layer takes effect immediately.
386
+ *
387
+ * Equivalent to `createClient(...)` with `getToken`/`getOrgId` pulled from
388
+ * `getAuthContext()`. Pass `overrides` to customize specific fields without
389
+ * rebuilding the whole transport.
390
+ *
391
+ * @example
392
+ * // App init (somewhere in a "use client" provider)
393
+ * configureClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL! });
394
+ * configureAuth({
395
+ * getToken: () => session?.accessToken ?? null,
396
+ * getOrgId: () => currentOrg?.id ?? null,
397
+ * });
398
+ *
399
+ * // SDK module
400
+ * import { createAuthAwareClient } from '@classytic/arc-next/client';
401
+ * import { createCrudApi } from '@classytic/arc-next/api';
402
+ *
403
+ * const client = createAuthAwareClient();
404
+ * export const productsApi = createCrudApi('products', { client });
405
+ *
406
+ * // With per-call overrides
407
+ * const analyticsClient = createAuthAwareClient({
408
+ * baseUrl: 'https://analytics.example.com',
409
+ * authMode: 'header',
410
+ * headerName: 'x-api-key',
411
+ * });
412
+ */
413
+ function createAuthAwareClient(overrides = {}) {
414
+ return createClient({
415
+ baseUrl: overrides.baseUrl ?? getBaseUrl(),
416
+ authMode: overrides.authMode ?? getAuthMode(),
417
+ autoIdempotency: overrides.autoIdempotency ?? isAutoIdempotency(),
418
+ elevated: overrides.elevated ?? clientConfig?.elevated,
419
+ ...overrides,
420
+ getToken: overrides.getToken ?? (() => readToken(authConfig?.getToken)),
421
+ getOrgId: overrides.getOrgId ?? (() => authConfig?.getOrgId?.() ?? null),
422
+ headerName: overrides.headerName ?? authConfig?.headerName
423
+ });
424
+ }
425
+ /**
426
+ * Get auth context for a specific client instance, falling back to global.
427
+ */
428
+ function getClientAuthContext(client) {
429
+ if (client?.auth) return {
430
+ token: readToken(client.auth.getToken) ?? readToken(authConfig?.getToken),
431
+ organizationId: client.auth.getOrgId?.() ?? authConfig?.getOrgId?.() ?? null
121
432
  };
433
+ return getAuthContext();
434
+ }
435
+ /** Default retry predicate: retry on network failures and 5xx, never on Abort or 4xx. */
436
+ function defaultShouldRetry(error) {
437
+ if (isAbortError(error)) return false;
438
+ if (isArcApiError(error)) return error.status >= 500 && error.status < 600;
439
+ return error instanceof Error;
440
+ }
441
+ /** Compute the delay in ms before the Nth retry. */
442
+ function computeBackoff(retry, attempt) {
443
+ const strategy = retry.backoff ?? "exponential";
444
+ if (typeof strategy === "function") return Math.max(0, strategy(attempt));
445
+ if (strategy === "linear") return 300 * (attempt + 1);
446
+ return Math.min(300 * Math.pow(2, attempt), 1e4);
122
447
  }
123
448
  async function executeRequest(config, method, endpoint, options = {}) {
124
- const { body, token, organizationId, revalidate, headerOptions, tags, cache, signal, idempotencyKey } = options;
449
+ const totalAttempts = Math.max(1, config.retry?.attempts ?? 1);
450
+ const shouldRetry = (() => {
451
+ const r = config.retry?.retryOn;
452
+ if (typeof r === "function") return r;
453
+ if (Array.isArray(r)) return (e) => isArcApiError(e) && r.includes(e.status);
454
+ return defaultShouldRetry;
455
+ })();
456
+ let lastError;
457
+ for (let attempt = 0; attempt < totalAttempts; attempt++) try {
458
+ return await executeAttempt(config, method, endpoint, options, attempt);
459
+ } catch (error) {
460
+ lastError = error;
461
+ if (attempt === totalAttempts - 1 || !shouldRetry(error)) throw error;
462
+ const delay = computeBackoff(config.retry ?? {}, attempt);
463
+ if (delay > 0) await sleepAbortable(delay, options.signal);
464
+ }
465
+ throw lastError;
466
+ }
467
+ /** Sleep that resolves early if the signal aborts. */
468
+ function sleepAbortable(ms, signal) {
469
+ return new Promise((resolve, reject) => {
470
+ if (signal?.aborted) {
471
+ reject(Object.assign(/* @__PURE__ */ new Error("Aborted"), { name: "AbortError" }));
472
+ return;
473
+ }
474
+ const timer = setTimeout(() => {
475
+ signal?.removeEventListener("abort", onAbort);
476
+ resolve();
477
+ }, ms);
478
+ const onAbort = () => {
479
+ clearTimeout(timer);
480
+ reject(Object.assign(/* @__PURE__ */ new Error("Aborted"), { name: "AbortError" }));
481
+ };
482
+ signal?.addEventListener("abort", onAbort, { once: true });
483
+ });
484
+ }
485
+ /** A single fetch attempt — used by executeRequest's retry loop. */
486
+ async function executeAttempt(config, method, endpoint, options, attempt) {
487
+ const { body, token, organizationId, revalidate, headerOptions, tags, cache, signal, idempotencyKey, elevated } = options;
488
+ const startTime = Date.now();
125
489
  try {
126
490
  let headers = {
127
491
  ...organizationId ? { "x-organization-id": organizationId } : {},
@@ -134,19 +498,34 @@ async function executeRequest(config, method, endpoint, options = {}) {
134
498
  } else headers["Authorization"] = `Bearer ${token}`;
135
499
  if (config.apiVersion) headers["Accept-Version"] = config.apiVersion;
136
500
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
501
+ if (elevated ?? config.elevated ?? false) headers["x-arc-scope"] = "platform";
137
502
  if (body !== void 0 && body !== null && !(body instanceof FormData)) headers["Content-Type"] = "application/json";
138
503
  if (headerOptions) headers = {
139
504
  ...headers,
140
505
  ...headerOptions
141
506
  };
142
507
  const credentials = config.credentials ?? (config.authMode === "cookie" ? "include" : "same-origin");
508
+ let serializedBody = void 0;
509
+ if (body !== void 0 && body !== null) serializedBody = body instanceof FormData ? body : JSON.stringify(body);
510
+ if (config.beforeRequest) {
511
+ const ctx = await config.beforeRequest({
512
+ method,
513
+ endpoint,
514
+ headers,
515
+ body: serializedBody,
516
+ signal,
517
+ attempt
518
+ });
519
+ headers = ctx.headers;
520
+ serializedBody = ctx.body;
521
+ }
143
522
  const fetchOptions = {
144
523
  method,
145
524
  headers,
146
525
  credentials,
147
526
  ...signal ? { signal } : {}
148
527
  };
149
- if (body !== void 0 && body !== null) fetchOptions.body = body instanceof FormData ? body : JSON.stringify(body);
528
+ if (serializedBody !== void 0) fetchOptions.body = serializedBody;
150
529
  if (cache) fetchOptions.cache = cache;
151
530
  if (revalidate !== void 0) fetchOptions.next = {
152
531
  ...fetchOptions.next,
@@ -158,8 +537,22 @@ async function executeRequest(config, method, endpoint, options = {}) {
158
537
  };
159
538
  const response = await fetch(`${config.baseUrl}${endpoint}`, fetchOptions);
160
539
  if (!response.ok) {
161
- const json = await response.json().catch(() => null);
162
- throw new ArcApiError(json?.message || response.statusText, {
540
+ let json = null;
541
+ let errorMessage = response.statusText;
542
+ try {
543
+ json = await response.clone().json();
544
+ const j = json;
545
+ errorMessage = typeof j?.error === "string" && j.error || typeof j?.message === "string" && j.message || response.statusText;
546
+ } catch {
547
+ try {
548
+ const text = await response.text();
549
+ if (text) {
550
+ json = { rawBody: text };
551
+ errorMessage = text.slice(0, 200) || response.statusText;
552
+ }
553
+ } catch {}
554
+ }
555
+ throw new ArcApiError(errorMessage, {
163
556
  status: response.status,
164
557
  statusText: response.statusText,
165
558
  json,
@@ -187,12 +580,24 @@ async function executeRequest(config, method, endpoint, options = {}) {
187
580
  data: await response.clone().blob(),
188
581
  response
189
582
  };
190
- } catch {
191
- data = {
192
- data: await response.text(),
193
- response
194
- };
583
+ } catch (blobError) {
584
+ try {
585
+ data = {
586
+ data: await response.text(),
587
+ response
588
+ };
589
+ } catch {
590
+ throw new Error(`Failed to parse response body from ${method} ${endpoint}: blob error: ${blobError instanceof Error ? blobError.message : String(blobError)}`);
591
+ }
195
592
  }
593
+ if (config.afterResponse) data = (await config.afterResponse({
594
+ method,
595
+ endpoint,
596
+ status: response.status,
597
+ body: data,
598
+ durationMs: Date.now() - startTime,
599
+ response
600
+ })).body;
196
601
  return data;
197
602
  } catch (error) {
198
603
  if (error instanceof Error) throw error;
@@ -256,4 +661,4 @@ function createQueryString(params = {}) {
256
661
  }
257
662
 
258
663
  //#endregion
259
- export { ArcApiError, configureAuth, configureClient, createClient, createQueryString, getAuthContext, getAuthMode, handleApiRequest, isArcApiError, isAutoIdempotency };
664
+ export { ArcApiError, KNOWN_DETAILS_CODES, KNOWN_TOP_LEVEL_CODES, _resetAuthWarnings, buildStreamUrl, configureAuth, configureClient, createAuthAwareClient, createClient, createQueryString, getAuthContext, getAuthMode, getBaseUrl, getClientAuthContext, handleApiRequest, isAbortError, isArcApiError, isArcErrorCode, isAutoIdempotency, isDuplicateKeyError, isOrgContextRequiredError, isValidationError };