webspresso 0.0.109 → 0.0.111

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.
@@ -13,6 +13,8 @@
13
13
  * - Raw & streaming escape hatches (`api.raw`, `api.stream`)
14
14
  */
15
15
 
16
+ const { isArray, isNumber } = require('./utils');
17
+
16
18
  const VALID_POLICIES = new Set(['latest', 'drop', 'queue', 'parallel']);
17
19
  const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
18
20
  const RESERVED_OPTION_KEYS = new Set([
@@ -27,17 +29,21 @@ const RESERVED_OPTION_KEYS = new Set([
27
29
  'csrfToken',
28
30
  'parseResponse',
29
31
  'onRefreshError',
32
+ 'timeout',
33
+ 'retry',
34
+ 'retryMutations',
35
+ 'raw',
30
36
  ]);
31
37
 
32
38
  /**
33
39
  * Structured Client Request Error
34
- * Distinguishes 'http', 'network', 'validation', and 'canceled' failure modes.
40
+ * Distinguishes 'http', 'network', 'validation', 'canceled', and 'timeout' failure modes.
35
41
  */
36
42
  class ClientRequestError extends Error {
37
43
  /**
38
44
  * @param {string} message
39
45
  * @param {object} details
40
- * @param {'http'|'network'|'validation'|'canceled'} details.kind
46
+ * @param {'http'|'network'|'validation'|'canceled'|'timeout'} details.kind
41
47
  * @param {number|null} [details.status]
42
48
  * @param {string} [details.code]
43
49
  * @param {string} [details.method]
@@ -50,6 +56,7 @@ class ClientRequestError extends Error {
50
56
  * @param {unknown} [details.data]
51
57
  * @param {boolean} [details.isDropped]
52
58
  * @param {boolean} [details.isSuperseded]
59
+ * @param {boolean} [details.isTimedOut]
53
60
  * @param {Error} [details.cause]
54
61
  */
55
62
  constructor(message, details = {}) {
@@ -63,13 +70,18 @@ class ClientRequestError extends Error {
63
70
  this.key = details.key || '';
64
71
  this.fieldErrors = details.fieldErrors || {};
65
72
  this.fieldErrorList = details.fieldErrorList || {};
66
- this.issues = Array.isArray(details.issues) ? details.issues : [];
73
+ this.issues = isArray(details.issues) ? details.issues : [];
67
74
  this.formError = details.formError || null;
68
75
  this.data = details.data !== undefined ? details.data : null;
69
76
  this.isCanceled = this.kind === 'canceled';
70
77
  this.isValidation = this.kind === 'validation';
71
78
  this.isNetwork = this.kind === 'network';
72
79
  this.isHttp = this.kind === 'http';
80
+ this.isTimedOut =
81
+ this.kind === 'timeout' ||
82
+ Boolean(details.isTimedOut) ||
83
+ this.code === 'REQUEST_TIMED_OUT' ||
84
+ this.status === 408;
73
85
  this.isDropped = Boolean(details.isDropped);
74
86
  this.isSuperseded = Boolean(details.isSuperseded);
75
87
  this.serverRollbackGuaranteed = false;
@@ -83,6 +95,7 @@ class ClientRequestError extends Error {
83
95
  }
84
96
 
85
97
  function defaultCodeForKind(kind, status) {
98
+ if (kind === 'timeout' || status === 408) return 'REQUEST_TIMED_OUT';
86
99
  if (kind === 'validation') return 'VALIDATION_ERROR';
87
100
  if (kind === 'canceled') return 'REQUEST_CANCELED';
88
101
  if (kind === 'network') return 'NETWORK_ERROR';
@@ -165,7 +178,7 @@ function buildRequestUrl(urlTemplate, params, query) {
165
178
  const searchParams = new URLSearchParams();
166
179
  for (const [key, rawVal] of Object.entries(query)) {
167
180
  if (rawVal === undefined) continue;
168
- if (Array.isArray(rawVal)) {
181
+ if (isArray(rawVal)) {
169
182
  for (const item of rawVal) {
170
183
  if (item === undefined) continue;
171
184
  searchParams.append(key, item instanceof Date ? item.toISOString() : String(item));
@@ -194,7 +207,7 @@ function containsBinaryValue(value) {
194
207
  if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
195
208
  if (typeof File !== 'undefined' && value instanceof File) return true;
196
209
  if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
197
- if (Array.isArray(value)) {
210
+ if (isArray(value)) {
198
211
  return value.some(containsBinaryValue);
199
212
  }
200
213
  for (const v of Object.values(value)) {
@@ -210,7 +223,7 @@ function objectToFormData(obj) {
210
223
  const fd = new FormData();
211
224
  for (const [key, val] of Object.entries(obj)) {
212
225
  if (val === undefined) continue;
213
- if (Array.isArray(val)) {
226
+ if (isArray(val)) {
214
227
  for (const item of val) {
215
228
  if (item === undefined) continue;
216
229
  if (typeof Blob !== 'undefined' && item instanceof Blob) {
@@ -296,9 +309,9 @@ function extractValidationErrors(payload) {
296
309
 
297
310
  // 1. Direct fieldErrors map (e.g. { title: 'Required' } or { title: ['Required'] })
298
311
  const rawFieldErrors = errObj.fieldErrors || payload.fieldErrors || errObj.fields || null;
299
- if (rawFieldErrors && typeof rawFieldErrors === 'object' && !Array.isArray(rawFieldErrors)) {
312
+ if (rawFieldErrors && typeof rawFieldErrors === 'object' && !isArray(rawFieldErrors)) {
300
313
  for (const [field, msgOrArr] of Object.entries(rawFieldErrors)) {
301
- const list = Array.isArray(msgOrArr) ? msgOrArr.map(String) : [String(msgOrArr)];
314
+ const list = isArray(msgOrArr) ? msgOrArr.map(String) : [String(msgOrArr)];
302
315
  if (list.length > 0) {
303
316
  fieldErrors[field] = list[0];
304
317
  fieldErrorList[field] = list;
@@ -309,17 +322,17 @@ function extractValidationErrors(payload) {
309
322
 
310
323
  // 2. Zod / Webspresso `issues` or `details` array
311
324
  const rawIssues =
312
- (Array.isArray(errObj.issues) && errObj.issues) ||
313
- (Array.isArray(payload.issues) && payload.issues) ||
314
- (Array.isArray(errObj.details) && errObj.details) ||
315
- (Array.isArray(payload.details) && payload.details) ||
316
- (Array.isArray(errObj.errors) && errObj.errors) ||
325
+ (isArray(errObj.issues) && errObj.issues) ||
326
+ (isArray(payload.issues) && payload.issues) ||
327
+ (isArray(errObj.details) && errObj.details) ||
328
+ (isArray(payload.details) && payload.details) ||
329
+ (isArray(errObj.errors) && errObj.errors) ||
317
330
  [];
318
331
 
319
332
  for (const issue of rawIssues) {
320
333
  if (!issue || typeof issue !== 'object') continue;
321
334
  let rawPath = '';
322
- if (Array.isArray(issue.path)) {
335
+ if (isArray(issue.path)) {
323
336
  // Strip leading 'body' / 'query' / 'params' prefix from route validation paths
324
337
  const cleanParts =
325
338
  issue.path.length > 1 &&
@@ -382,8 +395,8 @@ function unwrapApiResultAndInvalidate(parsedBody, responseHeaders) {
382
395
  if (
383
396
  parsedBody &&
384
397
  typeof parsedBody === 'object' &&
385
- !Array.isArray(parsedBody) &&
386
- Array.isArray(parsedBody.invalidate) &&
398
+ !isArray(parsedBody) &&
399
+ isArray(parsedBody.invalidate) &&
387
400
  'data' in parsedBody
388
401
  ) {
389
402
  for (const tag of parsedBody.invalidate) {
@@ -431,9 +444,13 @@ function normalizeGetArgs(inputOrOptions, explicitOptions) {
431
444
  'query' in inputOrOptions ||
432
445
  'params' in inputOrOptions ||
433
446
  'policy' in inputOrOptions ||
447
+ 'concurrency' in inputOrOptions ||
434
448
  'key' in inputOrOptions ||
435
449
  'signal' in inputOrOptions ||
436
- 'headers' in inputOrOptions;
450
+ 'headers' in inputOrOptions ||
451
+ 'timeout' in inputOrOptions ||
452
+ 'retry' in inputOrOptions ||
453
+ 'retryMutations' in inputOrOptions;
437
454
 
438
455
  if (hasExplicitWrapper && keys.every((k) => RESERVED_OPTION_KEYS.has(k))) {
439
456
  return { ...inputOrOptions };
@@ -456,6 +473,132 @@ function normalizeGetArgs(inputOrOptions, explicitOptions) {
456
473
  return opts;
457
474
  }
458
475
 
476
+ /**
477
+ * Normalize retry options into an actionable execution descriptor.
478
+ * Never allows retries on mutations unless `retryMutations: true` is explicitly opted into.
479
+ *
480
+ * @param {boolean|number|object} [retryOpt]
481
+ * @param {string} upperMethod
482
+ * @param {boolean} [retryMutations=false]
483
+ * @returns {object|null}
484
+ */
485
+ function normalizeRetryConfig(retryOpt, upperMethod, retryMutations = false) {
486
+ if (!retryOpt) return null;
487
+
488
+ // Strict invariant: never automatically retry mutation methods (POST, PUT, PATCH, DELETE)
489
+ if (MUTATION_METHODS.has(upperMethod) && !retryMutations) {
490
+ return null;
491
+ }
492
+
493
+ let count = 0;
494
+ let delay = 300;
495
+ let maxDelay = 5000;
496
+ let backoff = 'exponential';
497
+ let statusCodes = new Set([408, 429, 502, 503, 504]);
498
+ let onRetry = null;
499
+
500
+ if (isNumber(retryOpt)) {
501
+ count = Math.max(0, Math.floor(retryOpt));
502
+ } else if (retryOpt === true) {
503
+ count = 2;
504
+ } else if (typeof retryOpt === 'object' && retryOpt !== null) {
505
+ count = isNumber(retryOpt.count) ? Math.max(0, Math.floor(retryOpt.count)) : 2;
506
+ if (isNumber(retryOpt.delay)) delay = Math.max(0, retryOpt.delay);
507
+ if (isNumber(retryOpt.maxDelay)) maxDelay = Math.max(delay, retryOpt.maxDelay);
508
+ if (retryOpt.backoff === 'linear' || retryOpt.backoff === 'exponential') {
509
+ backoff = retryOpt.backoff;
510
+ }
511
+ if (isArray(retryOpt.statusCodes)) {
512
+ statusCodes = new Set(retryOpt.statusCodes);
513
+ }
514
+ if (typeof retryOpt.onRetry === 'function') {
515
+ onRetry = retryOpt.onRetry;
516
+ }
517
+ }
518
+
519
+ if (count <= 0) return null;
520
+
521
+ return {
522
+ count,
523
+ delay,
524
+ maxDelay,
525
+ backoff,
526
+ statusCodes,
527
+ onRetry,
528
+ };
529
+ }
530
+
531
+ /**
532
+ * Determine whether an error is transient and eligible for retry.
533
+ */
534
+ function isRetryableError(err, statusCodes) {
535
+ if (!err) return false;
536
+ // Canceled, dropped, or superseded requests must never retry
537
+ if (err.isCanceled || err.isDropped || err.isSuperseded) return false;
538
+ // Network failures and timeouts are retryable
539
+ if (err.kind === 'network' || err.kind === 'timeout' || err.isTimedOut) return true;
540
+ // Transient HTTP status codes
541
+ if (isNumber(err.status) && statusCodes && statusCodes.has(err.status)) {
542
+ return true;
543
+ }
544
+ return false;
545
+ }
546
+
547
+ /**
548
+ * Calculate retry delay with jitter.
549
+ */
550
+ function calculateRetryDelay(attempt, config) {
551
+ const base = config.delay;
552
+ const max = config.maxDelay;
553
+ let calculated;
554
+ if (config.backoff === 'linear') {
555
+ calculated = base * attempt;
556
+ } else {
557
+ calculated = base * Math.pow(2, attempt - 1);
558
+ }
559
+ const jitter = Math.random() * 50;
560
+ return Math.min(max, calculated + jitter);
561
+ }
562
+
563
+ /**
564
+ * Abortable delay helper that rejects immediately if any signal aborts during wait.
565
+ */
566
+ function abortableSleep(ms, signals = []) {
567
+ return new Promise((resolve, reject) => {
568
+ for (const sig of signals) {
569
+ if (sig && sig.aborted) {
570
+ return reject(new Error('Sleep aborted'));
571
+ }
572
+ }
573
+
574
+ let timer = null;
575
+ const cleanup = () => {
576
+ if (timer) clearTimeout(timer);
577
+ for (const sig of signals) {
578
+ if (sig && typeof sig.removeEventListener === 'function') {
579
+ sig.removeEventListener('abort', onAbort);
580
+ }
581
+ }
582
+ };
583
+
584
+ const onAbort = () => {
585
+ cleanup();
586
+ reject(new Error('Sleep aborted'));
587
+ };
588
+
589
+ for (const sig of signals) {
590
+ if (sig && typeof sig.addEventListener === 'function') {
591
+ sig.addEventListener('abort', onAbort, { once: true });
592
+ }
593
+ }
594
+
595
+ timer = setTimeout(() => {
596
+ cleanup();
597
+ resolve();
598
+ }, ms);
599
+ });
600
+ }
601
+
459
602
  /**
460
603
  * Create a Typed API Client instance.
461
604
  *
@@ -469,10 +612,19 @@ function normalizeGetArgs(inputOrOptions, explicitOptions) {
469
612
  * @param {() => boolean} [config.isConnected] - Controller connection check
470
613
  * @param {object} [config.debug] - Client debugger instance
471
614
  */
615
+ function trimTrailingSlashes(str) {
616
+ if (!str) return '';
617
+ let end = str.length;
618
+ while (end > 0 && str.charCodeAt(end - 1) === 47) {
619
+ end--;
620
+ }
621
+ return end === str.length ? str : str.slice(0, end);
622
+ }
623
+
472
624
  function createApiClient(config = {}) {
473
625
  const fetchImpl =
474
626
  config.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
475
- const baseUrl = config.baseUrl ? String(config.baseUrl).replace(/\/+$/, '') : '';
627
+ const baseUrl = config.baseUrl ? trimTrailingSlashes(String(config.baseUrl)) : '';
476
628
  const lifecycleSignal = config.lifecycleSignal || null;
477
629
  const isConnected = typeof config.isConnected === 'function' ? config.isConnected : () => true;
478
630
  const debug = config.debug || null;
@@ -551,11 +703,83 @@ function createApiClient(config = {}) {
551
703
  }
552
704
 
553
705
  // Policy: 'queue' -> Chain onto existing queue for this key
706
+ const retryConfig = normalizeRetryConfig(
707
+ options.retry,
708
+ upperMethod,
709
+ Boolean(options.retryMutations)
710
+ );
711
+
712
+ async function executeWithRetry(runFn) {
713
+ if (!retryConfig) {
714
+ return runFn(requestId);
715
+ }
716
+
717
+ let attempt = 0;
718
+ while (true) {
719
+ attempt++;
720
+ const currentReqId = attempt === 1 ? requestId : `req_${++reqCounter}_${Date.now()}`;
721
+ try {
722
+ return await runFn(currentReqId);
723
+ } catch (err) {
724
+ const canRetry =
725
+ attempt <= retryConfig.count &&
726
+ isConnected() &&
727
+ (!lifecycleSignal || !lifecycleSignal.aborted) &&
728
+ (!options.signal || !options.signal.aborted) &&
729
+ isRetryableError(err, retryConfig.statusCodes);
730
+
731
+ if (!canRetry) {
732
+ throw err;
733
+ }
734
+
735
+ const delayMs = calculateRetryDelay(attempt, retryConfig);
736
+
737
+ if (typeof retryConfig.onRetry === 'function') {
738
+ try {
739
+ retryConfig.onRetry(err, attempt, delayMs);
740
+ } catch (_cbErr) {
741
+ // Ignore callback error
742
+ }
743
+ }
744
+
745
+ if (debug && typeof debug.trackRequestRetry === 'function') {
746
+ debug.trackRequestRetry({
747
+ requestId: currentReqId,
748
+ key: requestKey,
749
+ attempt,
750
+ delay: delayMs,
751
+ error: err,
752
+ });
753
+ }
754
+
755
+ const activeSignals = [options.signal, lifecycleSignal].filter(Boolean);
756
+ try {
757
+ await abortableSleep(delayMs, activeSignals);
758
+ } catch (_sleepErr) {
759
+ throw new ClientRequestError(
760
+ `Request canceled during retry backoff (${upperMethod} ${requestUrl}).`,
761
+ {
762
+ kind: 'canceled',
763
+ code: 'REQUEST_CANCELED',
764
+ method: upperMethod,
765
+ url: requestUrl,
766
+ key: requestKey,
767
+ }
768
+ );
769
+ }
770
+ }
771
+ }
772
+ }
773
+
554
774
  if (policy === 'queue') {
555
775
  const prevTail = queueMap.get(requestKey) || Promise.resolve();
556
776
  const nextTask = prevTail
557
777
  .catch(() => {})
558
- .then(() => runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, 'queue'));
778
+ .then(() =>
779
+ executeWithRetry((subReqId) =>
780
+ runSingleRequest(upperMethod, requestUrl, body, options, requestKey, subReqId, 'queue')
781
+ )
782
+ );
559
783
  queueMap.set(
560
784
  requestKey,
561
785
  nextTask.catch(() => {})
@@ -563,14 +787,25 @@ function createApiClient(config = {}) {
563
787
  return nextTask;
564
788
  }
565
789
 
566
- return runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, policy);
790
+ return executeWithRetry((subReqId) =>
791
+ runSingleRequest(upperMethod, requestUrl, body, options, requestKey, subReqId, policy)
792
+ );
567
793
  }
568
794
 
569
795
  async function runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, policy) {
570
796
  const abortController = new AbortController();
571
797
  let superseded = false;
798
+ let timedOut = false;
799
+ let timeoutTimer = null;
572
800
  let currentSeq = 1;
573
801
 
802
+ if (isNumber(options.timeout) && options.timeout > 0) {
803
+ timeoutTimer = setTimeout(() => {
804
+ timedOut = true;
805
+ abortController.abort();
806
+ }, options.timeout);
807
+ }
808
+
574
809
  // Policy: 'latest' -> Abort previous in-flight request for this key and increment sequence
575
810
  if (policy === 'latest') {
576
811
  const prev = latestMap.get(requestKey);
@@ -656,10 +891,34 @@ function createApiClient(config = {}) {
656
891
  signal: abortController.signal,
657
892
  });
658
893
  } catch (fetchErr) {
894
+ if (timeoutTimer) {
895
+ clearTimeout(timeoutTimer);
896
+ timeoutTimer = null;
897
+ }
659
898
  const latestEntry = policy === 'latest' ? latestMap.get(requestKey) : null;
660
899
  if (latestEntry && (latestEntry.seq !== currentSeq || latestEntry.superseded)) {
661
900
  superseded = true;
662
901
  }
902
+
903
+ if (timedOut) {
904
+ if (debug) {
905
+ debug.trackRequestEnd(requestId, { status: 'failed', errorKind: 'timeout' });
906
+ }
907
+ throw new ClientRequestError(
908
+ `Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
909
+ {
910
+ kind: 'timeout',
911
+ status: 408,
912
+ code: 'REQUEST_TIMED_OUT',
913
+ method: upperMethod,
914
+ url: requestUrl,
915
+ key: requestKey,
916
+ isTimedOut: true,
917
+ cause: fetchErr,
918
+ }
919
+ );
920
+ }
921
+
663
922
  const isAbort =
664
923
  superseded ||
665
924
  abortController.signal.aborted ||
@@ -797,7 +1056,27 @@ function createApiClient(config = {}) {
797
1056
  if (status === 204 || status === 205) {
798
1057
  parsedBody = undefined;
799
1058
  } else if (typeof response.text === 'function') {
800
- const rawText = await response.text();
1059
+ let rawText;
1060
+ try {
1061
+ rawText = await response.text();
1062
+ } catch (bodyErr) {
1063
+ if (timedOut) {
1064
+ throw new ClientRequestError(
1065
+ `Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
1066
+ {
1067
+ kind: 'timeout',
1068
+ status: 408,
1069
+ code: 'REQUEST_TIMED_OUT',
1070
+ method: upperMethod,
1071
+ url: requestUrl,
1072
+ key: requestKey,
1073
+ isTimedOut: true,
1074
+ cause: bodyErr,
1075
+ }
1076
+ );
1077
+ }
1078
+ throw bodyErr;
1079
+ }
801
1080
  checkSupersededOrDisconnected();
802
1081
  if (!rawText || !rawText.trim()) {
803
1082
  parsedBody = undefined;
@@ -811,7 +1090,26 @@ function createApiClient(config = {}) {
811
1090
  parsedBody = rawText;
812
1091
  }
813
1092
  } else if (typeof response.json === 'function') {
814
- parsedBody = await response.json();
1093
+ try {
1094
+ parsedBody = await response.json();
1095
+ } catch (bodyErr) {
1096
+ if (timedOut) {
1097
+ throw new ClientRequestError(
1098
+ `Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
1099
+ {
1100
+ kind: 'timeout',
1101
+ status: 408,
1102
+ code: 'REQUEST_TIMED_OUT',
1103
+ method: upperMethod,
1104
+ url: requestUrl,
1105
+ key: requestKey,
1106
+ isTimedOut: true,
1107
+ cause: bodyErr,
1108
+ }
1109
+ );
1110
+ }
1111
+ throw bodyErr;
1112
+ }
815
1113
  checkSupersededOrDisconnected();
816
1114
  }
817
1115
 
@@ -916,6 +1214,10 @@ function createApiClient(config = {}) {
916
1214
 
917
1215
  return data;
918
1216
  } finally {
1217
+ if (timeoutTimer) {
1218
+ clearTimeout(timeoutTimer);
1219
+ timeoutTimer = null;
1220
+ }
919
1221
  const set = inFlightMap.get(requestKey);
920
1222
  if (set) {
921
1223
  set.delete(requestId);
@@ -1029,5 +1331,9 @@ module.exports = {
1029
1331
  buildRequestUrl,
1030
1332
  extractValidationErrors,
1031
1333
  unwrapApiResultAndInvalidate,
1334
+ normalizeRetryConfig,
1335
+ isRetryableError,
1336
+ calculateRetryDelay,
1337
+ abortableSleep,
1032
1338
  };
1033
1339