webspresso 0.0.109 → 0.0.110

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.
@@ -124,6 +124,16 @@ function createSignalsEngine() {
124
124
  };
125
125
  }
126
126
 
127
+ function untracked(fn) {
128
+ const prev = activeEffect;
129
+ activeEffect = null;
130
+ try {
131
+ return fn();
132
+ } finally {
133
+ activeEffect = prev;
134
+ }
135
+ }
136
+
127
137
  /**
128
138
  * Implements Datastar's public `SignalsRoot` contract:
129
139
  * exists(path), signal(path), value(path), setValue(path, val),
@@ -213,6 +223,7 @@ function createSignalsEngine() {
213
223
  return {
214
224
  signals: new CompatibleSignalsRoot(),
215
225
  effect,
226
+ untracked,
216
227
  };
217
228
  }
218
229
 
@@ -238,6 +249,123 @@ function resolveOperand(state, token) {
238
249
  return getByPath(state, t);
239
250
  }
240
251
 
252
+ /**
253
+ * Safely strips balanced enclosing parentheses if the entire expression is wrapped in them:
254
+ * e.g. `(a && b)` -> `a && b`, but `(a && b) || (c && d)` is left unchanged.
255
+ *
256
+ * @param {string} raw
257
+ * @returns {string}
258
+ */
259
+ function stripOuterParens(raw) {
260
+ let s = raw.trim();
261
+ while (s.startsWith('(') && s.endsWith(')')) {
262
+ let depth = 0;
263
+ let matched = true;
264
+ for (let i = 0; i < s.length - 1; i++) {
265
+ const ch = s[i];
266
+ if (ch === '(') depth++;
267
+ else if (ch === ')') {
268
+ depth--;
269
+ if (depth === 0) {
270
+ matched = false;
271
+ break;
272
+ }
273
+ }
274
+ }
275
+ if (matched) {
276
+ s = s.slice(1, -1).trim();
277
+ } else {
278
+ break;
279
+ }
280
+ }
281
+ return s;
282
+ }
283
+
284
+ /**
285
+ * Split an expression by an operator (e.g. '||', '&&') only at the top level
286
+ * (outside single/double quotes and outside balanced parentheses).
287
+ *
288
+ * @param {string} expr
289
+ * @param {string} op
290
+ * @returns {string[]|null}
291
+ */
292
+ function splitTopLevel(expr, op) {
293
+ const parts = [];
294
+ let inSingleQuote = false;
295
+ let inDoubleQuote = false;
296
+ let depth = 0;
297
+ let lastIndex = 0;
298
+ const opLen = op.length;
299
+ const len = expr.length;
300
+
301
+ for (let i = 0; i < len; i++) {
302
+ const ch = expr[i];
303
+ const prev = i > 0 ? expr[i - 1] : '';
304
+
305
+ if (ch === "'" && !inDoubleQuote && prev !== '\\') {
306
+ inSingleQuote = !inSingleQuote;
307
+ } else if (ch === '"' && !inSingleQuote && prev !== '\\') {
308
+ inDoubleQuote = !inDoubleQuote;
309
+ } else if (!inSingleQuote && !inDoubleQuote) {
310
+ if (ch === '(') {
311
+ depth++;
312
+ } else if (ch === ')') {
313
+ if (depth > 0) depth--;
314
+ } else if (depth === 0 && expr.slice(i, i + opLen) === op) {
315
+ parts.push(expr.slice(lastIndex, i).trim());
316
+ lastIndex = i + opLen;
317
+ i += opLen - 1;
318
+ }
319
+ }
320
+ }
321
+
322
+ if (parts.length > 0) {
323
+ parts.push(expr.slice(lastIndex).trim());
324
+ return parts;
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ /**
331
+ * Find the first comparison operator at the top level outside quotes and parentheses.
332
+ */
333
+ function findTopLevelComparison(expr) {
334
+ const operators = ['===', '!==', '==', '!=', '>=', '<=', '>', '<'];
335
+ let inSingleQuote = false;
336
+ let inDoubleQuote = false;
337
+ let depth = 0;
338
+ const len = expr.length;
339
+
340
+ for (let i = 0; i < len; i++) {
341
+ const ch = expr[i];
342
+ const prev = i > 0 ? expr[i - 1] : '';
343
+
344
+ if (ch === "'" && !inDoubleQuote && prev !== '\\') {
345
+ inSingleQuote = !inSingleQuote;
346
+ } else if (ch === '"' && !inSingleQuote && prev !== '\\') {
347
+ inDoubleQuote = !inDoubleQuote;
348
+ } else if (!inSingleQuote && !inDoubleQuote) {
349
+ if (ch === '(') {
350
+ depth++;
351
+ } else if (ch === ')') {
352
+ if (depth > 0) depth--;
353
+ } else if (depth === 0 && i > 0) {
354
+ for (const op of operators) {
355
+ if (expr.slice(i, i + op.length) === op) {
356
+ return {
357
+ op,
358
+ left: expr.slice(0, i).trim(),
359
+ right: expr.slice(i + op.length).trim(),
360
+ };
361
+ }
362
+ }
363
+ }
364
+ }
365
+ }
366
+ return null;
367
+ }
368
+
241
369
  /**
242
370
  * Safely evaluate a declarative binding expression against controller state without
243
371
  * naive regex transformations or `eval`/`new Function`.
@@ -246,6 +374,8 @@ function resolveOperand(state, token) {
246
374
  * - Property path: `count`, `user.name`, `items.length`
247
375
  * - Negation: `!pending`, `!!error`
248
376
  * - Simple comparisons: `status === 'active'`, `count > 0`, `step !== 1`
377
+ * - Logical operators: `isReady && !hasError`, `status === 'a' || status === 'b'`
378
+ * - Parenthesized groups: `(a || b) && c`
249
379
  *
250
380
  * @param {object} state
251
381
  * @param {string} rawExpr
@@ -253,39 +383,55 @@ function resolveOperand(state, token) {
253
383
  */
254
384
  function evaluateStateExpression(state, rawExpr) {
255
385
  if (typeof rawExpr !== 'string') return undefined;
256
- const expr = rawExpr.trim();
386
+ const expr = stripOuterParens(rawExpr.trim());
257
387
  if (!expr) return undefined;
258
388
 
259
- // Check simple comparison operators in precedence order
260
- const operators = ['===', '!==', '==', '!=', '>=', '<=', '>', '<'];
261
- for (const op of operators) {
262
- const idx = expr.indexOf(op);
263
- if (idx > 0) {
264
- const leftRaw = expr.slice(0, idx).trim();
265
- const rightRaw = expr.slice(idx + op.length).trim();
266
- if (leftRaw && rightRaw) {
267
- const left = resolveOperand(state, leftRaw);
268
- const right = resolveOperand(state, rightRaw);
269
- switch (op) {
270
- case '===':
271
- case '==':
272
- return left === right;
273
- case '!==':
274
- case '!=':
275
- return left !== right;
276
- case '>=':
277
- return Number(left) >= Number(right);
278
- case '<=':
279
- return Number(left) <= Number(right);
280
- case '>':
281
- return Number(left) > Number(right);
282
- case '<':
283
- return Number(left) < Number(right);
284
- }
285
- }
389
+ // 1. Logical OR (||) - lowest precedence, evaluated first so that A || B && C splits into A and (B && C)
390
+ const orParts = splitTopLevel(expr, '||');
391
+ if (orParts && orParts.length > 1) {
392
+ let result = false;
393
+ for (const part of orParts) {
394
+ result = evaluateStateExpression(state, part);
395
+ if (result) return result;
396
+ }
397
+ return result;
398
+ }
399
+
400
+ // 2. Logical AND (&&) - higher precedence than ||
401
+ const andParts = splitTopLevel(expr, '&&');
402
+ if (andParts && andParts.length > 1) {
403
+ let result = true;
404
+ for (const part of andParts) {
405
+ result = evaluateStateExpression(state, part);
406
+ if (!result) return result;
407
+ }
408
+ return result;
409
+ }
410
+
411
+ // 3. Comparison operators (===, !==, ==, !=, >=, <=, >, <) outside quotes
412
+ const comp = findTopLevelComparison(expr);
413
+ if (comp && comp.left && comp.right) {
414
+ const left = resolveOperand(state, comp.left);
415
+ const right = resolveOperand(state, comp.right);
416
+ switch (comp.op) {
417
+ case '===':
418
+ case '==':
419
+ return left === right;
420
+ case '!==':
421
+ case '!=':
422
+ return left !== right;
423
+ case '>=':
424
+ return Number(left) >= Number(right);
425
+ case '<=':
426
+ return Number(left) <= Number(right);
427
+ case '>':
428
+ return Number(left) > Number(right);
429
+ case '<':
430
+ return Number(left) < Number(right);
286
431
  }
287
432
  }
288
433
 
434
+ // 4. Negations (!!, !)
289
435
  if (expr.startsWith('!!')) {
290
436
  return Boolean(evaluateStateExpression(state, expr.slice(2)));
291
437
  }
@@ -293,6 +439,7 @@ function evaluateStateExpression(state, rawExpr) {
293
439
  return !Boolean(evaluateStateExpression(state, expr.slice(1)));
294
440
  }
295
441
 
442
+ // 5. Literal token or property path lookup
296
443
  return resolveOperand(state, expr);
297
444
  }
298
445
 
@@ -27,17 +27,21 @@ const RESERVED_OPTION_KEYS = new Set([
27
27
  'csrfToken',
28
28
  'parseResponse',
29
29
  'onRefreshError',
30
+ 'timeout',
31
+ 'retry',
32
+ 'retryMutations',
33
+ 'raw',
30
34
  ]);
31
35
 
32
36
  /**
33
37
  * Structured Client Request Error
34
- * Distinguishes 'http', 'network', 'validation', and 'canceled' failure modes.
38
+ * Distinguishes 'http', 'network', 'validation', 'canceled', and 'timeout' failure modes.
35
39
  */
36
40
  class ClientRequestError extends Error {
37
41
  /**
38
42
  * @param {string} message
39
43
  * @param {object} details
40
- * @param {'http'|'network'|'validation'|'canceled'} details.kind
44
+ * @param {'http'|'network'|'validation'|'canceled'|'timeout'} details.kind
41
45
  * @param {number|null} [details.status]
42
46
  * @param {string} [details.code]
43
47
  * @param {string} [details.method]
@@ -50,6 +54,7 @@ class ClientRequestError extends Error {
50
54
  * @param {unknown} [details.data]
51
55
  * @param {boolean} [details.isDropped]
52
56
  * @param {boolean} [details.isSuperseded]
57
+ * @param {boolean} [details.isTimedOut]
53
58
  * @param {Error} [details.cause]
54
59
  */
55
60
  constructor(message, details = {}) {
@@ -70,6 +75,11 @@ class ClientRequestError extends Error {
70
75
  this.isValidation = this.kind === 'validation';
71
76
  this.isNetwork = this.kind === 'network';
72
77
  this.isHttp = this.kind === 'http';
78
+ this.isTimedOut =
79
+ this.kind === 'timeout' ||
80
+ Boolean(details.isTimedOut) ||
81
+ this.code === 'REQUEST_TIMED_OUT' ||
82
+ this.status === 408;
73
83
  this.isDropped = Boolean(details.isDropped);
74
84
  this.isSuperseded = Boolean(details.isSuperseded);
75
85
  this.serverRollbackGuaranteed = false;
@@ -83,6 +93,7 @@ class ClientRequestError extends Error {
83
93
  }
84
94
 
85
95
  function defaultCodeForKind(kind, status) {
96
+ if (kind === 'timeout' || status === 408) return 'REQUEST_TIMED_OUT';
86
97
  if (kind === 'validation') return 'VALIDATION_ERROR';
87
98
  if (kind === 'canceled') return 'REQUEST_CANCELED';
88
99
  if (kind === 'network') return 'NETWORK_ERROR';
@@ -431,9 +442,13 @@ function normalizeGetArgs(inputOrOptions, explicitOptions) {
431
442
  'query' in inputOrOptions ||
432
443
  'params' in inputOrOptions ||
433
444
  'policy' in inputOrOptions ||
445
+ 'concurrency' in inputOrOptions ||
434
446
  'key' in inputOrOptions ||
435
447
  'signal' in inputOrOptions ||
436
- 'headers' in inputOrOptions;
448
+ 'headers' in inputOrOptions ||
449
+ 'timeout' in inputOrOptions ||
450
+ 'retry' in inputOrOptions ||
451
+ 'retryMutations' in inputOrOptions;
437
452
 
438
453
  if (hasExplicitWrapper && keys.every((k) => RESERVED_OPTION_KEYS.has(k))) {
439
454
  return { ...inputOrOptions };
@@ -456,6 +471,132 @@ function normalizeGetArgs(inputOrOptions, explicitOptions) {
456
471
  return opts;
457
472
  }
458
473
 
474
+ /**
475
+ * Normalize retry options into an actionable execution descriptor.
476
+ * Never allows retries on mutations unless `retryMutations: true` is explicitly opted into.
477
+ *
478
+ * @param {boolean|number|object} [retryOpt]
479
+ * @param {string} upperMethod
480
+ * @param {boolean} [retryMutations=false]
481
+ * @returns {object|null}
482
+ */
483
+ function normalizeRetryConfig(retryOpt, upperMethod, retryMutations = false) {
484
+ if (!retryOpt) return null;
485
+
486
+ // Strict invariant: never automatically retry mutation methods (POST, PUT, PATCH, DELETE)
487
+ if (MUTATION_METHODS.has(upperMethod) && !retryMutations) {
488
+ return null;
489
+ }
490
+
491
+ let count = 0;
492
+ let delay = 300;
493
+ let maxDelay = 5000;
494
+ let backoff = 'exponential';
495
+ let statusCodes = new Set([408, 429, 502, 503, 504]);
496
+ let onRetry = null;
497
+
498
+ if (typeof retryOpt === 'number') {
499
+ count = Math.max(0, Math.floor(retryOpt));
500
+ } else if (retryOpt === true) {
501
+ count = 2;
502
+ } else if (typeof retryOpt === 'object' && retryOpt !== null) {
503
+ count = typeof retryOpt.count === 'number' ? Math.max(0, Math.floor(retryOpt.count)) : 2;
504
+ if (typeof retryOpt.delay === 'number') delay = Math.max(0, retryOpt.delay);
505
+ if (typeof retryOpt.maxDelay === 'number') maxDelay = Math.max(delay, retryOpt.maxDelay);
506
+ if (retryOpt.backoff === 'linear' || retryOpt.backoff === 'exponential') {
507
+ backoff = retryOpt.backoff;
508
+ }
509
+ if (Array.isArray(retryOpt.statusCodes)) {
510
+ statusCodes = new Set(retryOpt.statusCodes);
511
+ }
512
+ if (typeof retryOpt.onRetry === 'function') {
513
+ onRetry = retryOpt.onRetry;
514
+ }
515
+ }
516
+
517
+ if (count <= 0) return null;
518
+
519
+ return {
520
+ count,
521
+ delay,
522
+ maxDelay,
523
+ backoff,
524
+ statusCodes,
525
+ onRetry,
526
+ };
527
+ }
528
+
529
+ /**
530
+ * Determine whether an error is transient and eligible for retry.
531
+ */
532
+ function isRetryableError(err, statusCodes) {
533
+ if (!err) return false;
534
+ // Canceled, dropped, or superseded requests must never retry
535
+ if (err.isCanceled || err.isDropped || err.isSuperseded) return false;
536
+ // Network failures and timeouts are retryable
537
+ if (err.kind === 'network' || err.kind === 'timeout' || err.isTimedOut) return true;
538
+ // Transient HTTP status codes
539
+ if (typeof err.status === 'number' && statusCodes && statusCodes.has(err.status)) {
540
+ return true;
541
+ }
542
+ return false;
543
+ }
544
+
545
+ /**
546
+ * Calculate retry delay with jitter.
547
+ */
548
+ function calculateRetryDelay(attempt, config) {
549
+ const base = config.delay;
550
+ const max = config.maxDelay;
551
+ let calculated;
552
+ if (config.backoff === 'linear') {
553
+ calculated = base * attempt;
554
+ } else {
555
+ calculated = base * Math.pow(2, attempt - 1);
556
+ }
557
+ const jitter = Math.random() * 50;
558
+ return Math.min(max, calculated + jitter);
559
+ }
560
+
561
+ /**
562
+ * Abortable delay helper that rejects immediately if any signal aborts during wait.
563
+ */
564
+ function abortableSleep(ms, signals = []) {
565
+ return new Promise((resolve, reject) => {
566
+ for (const sig of signals) {
567
+ if (sig && sig.aborted) {
568
+ return reject(new Error('Sleep aborted'));
569
+ }
570
+ }
571
+
572
+ let timer = null;
573
+ const cleanup = () => {
574
+ if (timer) clearTimeout(timer);
575
+ for (const sig of signals) {
576
+ if (sig && typeof sig.removeEventListener === 'function') {
577
+ sig.removeEventListener('abort', onAbort);
578
+ }
579
+ }
580
+ };
581
+
582
+ const onAbort = () => {
583
+ cleanup();
584
+ reject(new Error('Sleep aborted'));
585
+ };
586
+
587
+ for (const sig of signals) {
588
+ if (sig && typeof sig.addEventListener === 'function') {
589
+ sig.addEventListener('abort', onAbort, { once: true });
590
+ }
591
+ }
592
+
593
+ timer = setTimeout(() => {
594
+ cleanup();
595
+ resolve();
596
+ }, ms);
597
+ });
598
+ }
599
+
459
600
  /**
460
601
  * Create a Typed API Client instance.
461
602
  *
@@ -469,10 +610,19 @@ function normalizeGetArgs(inputOrOptions, explicitOptions) {
469
610
  * @param {() => boolean} [config.isConnected] - Controller connection check
470
611
  * @param {object} [config.debug] - Client debugger instance
471
612
  */
613
+ function trimTrailingSlashes(str) {
614
+ if (!str) return '';
615
+ let end = str.length;
616
+ while (end > 0 && str.charCodeAt(end - 1) === 47) {
617
+ end--;
618
+ }
619
+ return end === str.length ? str : str.slice(0, end);
620
+ }
621
+
472
622
  function createApiClient(config = {}) {
473
623
  const fetchImpl =
474
624
  config.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
475
- const baseUrl = config.baseUrl ? String(config.baseUrl).replace(/\/+$/, '') : '';
625
+ const baseUrl = config.baseUrl ? trimTrailingSlashes(String(config.baseUrl)) : '';
476
626
  const lifecycleSignal = config.lifecycleSignal || null;
477
627
  const isConnected = typeof config.isConnected === 'function' ? config.isConnected : () => true;
478
628
  const debug = config.debug || null;
@@ -551,11 +701,83 @@ function createApiClient(config = {}) {
551
701
  }
552
702
 
553
703
  // Policy: 'queue' -> Chain onto existing queue for this key
704
+ const retryConfig = normalizeRetryConfig(
705
+ options.retry,
706
+ upperMethod,
707
+ Boolean(options.retryMutations)
708
+ );
709
+
710
+ async function executeWithRetry(runFn) {
711
+ if (!retryConfig) {
712
+ return runFn(requestId);
713
+ }
714
+
715
+ let attempt = 0;
716
+ while (true) {
717
+ attempt++;
718
+ const currentReqId = attempt === 1 ? requestId : `req_${++reqCounter}_${Date.now()}`;
719
+ try {
720
+ return await runFn(currentReqId);
721
+ } catch (err) {
722
+ const canRetry =
723
+ attempt <= retryConfig.count &&
724
+ isConnected() &&
725
+ (!lifecycleSignal || !lifecycleSignal.aborted) &&
726
+ (!options.signal || !options.signal.aborted) &&
727
+ isRetryableError(err, retryConfig.statusCodes);
728
+
729
+ if (!canRetry) {
730
+ throw err;
731
+ }
732
+
733
+ const delayMs = calculateRetryDelay(attempt, retryConfig);
734
+
735
+ if (typeof retryConfig.onRetry === 'function') {
736
+ try {
737
+ retryConfig.onRetry(err, attempt, delayMs);
738
+ } catch (_cbErr) {
739
+ // Ignore callback error
740
+ }
741
+ }
742
+
743
+ if (debug && typeof debug.trackRequestRetry === 'function') {
744
+ debug.trackRequestRetry({
745
+ requestId: currentReqId,
746
+ key: requestKey,
747
+ attempt,
748
+ delay: delayMs,
749
+ error: err,
750
+ });
751
+ }
752
+
753
+ const activeSignals = [options.signal, lifecycleSignal].filter(Boolean);
754
+ try {
755
+ await abortableSleep(delayMs, activeSignals);
756
+ } catch (_sleepErr) {
757
+ throw new ClientRequestError(
758
+ `Request canceled during retry backoff (${upperMethod} ${requestUrl}).`,
759
+ {
760
+ kind: 'canceled',
761
+ code: 'REQUEST_CANCELED',
762
+ method: upperMethod,
763
+ url: requestUrl,
764
+ key: requestKey,
765
+ }
766
+ );
767
+ }
768
+ }
769
+ }
770
+ }
771
+
554
772
  if (policy === 'queue') {
555
773
  const prevTail = queueMap.get(requestKey) || Promise.resolve();
556
774
  const nextTask = prevTail
557
775
  .catch(() => {})
558
- .then(() => runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, 'queue'));
776
+ .then(() =>
777
+ executeWithRetry((subReqId) =>
778
+ runSingleRequest(upperMethod, requestUrl, body, options, requestKey, subReqId, 'queue')
779
+ )
780
+ );
559
781
  queueMap.set(
560
782
  requestKey,
561
783
  nextTask.catch(() => {})
@@ -563,14 +785,25 @@ function createApiClient(config = {}) {
563
785
  return nextTask;
564
786
  }
565
787
 
566
- return runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, policy);
788
+ return executeWithRetry((subReqId) =>
789
+ runSingleRequest(upperMethod, requestUrl, body, options, requestKey, subReqId, policy)
790
+ );
567
791
  }
568
792
 
569
793
  async function runSingleRequest(upperMethod, requestUrl, body, options, requestKey, requestId, policy) {
570
794
  const abortController = new AbortController();
571
795
  let superseded = false;
796
+ let timedOut = false;
797
+ let timeoutTimer = null;
572
798
  let currentSeq = 1;
573
799
 
800
+ if (typeof options.timeout === 'number' && options.timeout > 0) {
801
+ timeoutTimer = setTimeout(() => {
802
+ timedOut = true;
803
+ abortController.abort();
804
+ }, options.timeout);
805
+ }
806
+
574
807
  // Policy: 'latest' -> Abort previous in-flight request for this key and increment sequence
575
808
  if (policy === 'latest') {
576
809
  const prev = latestMap.get(requestKey);
@@ -656,10 +889,34 @@ function createApiClient(config = {}) {
656
889
  signal: abortController.signal,
657
890
  });
658
891
  } catch (fetchErr) {
892
+ if (timeoutTimer) {
893
+ clearTimeout(timeoutTimer);
894
+ timeoutTimer = null;
895
+ }
659
896
  const latestEntry = policy === 'latest' ? latestMap.get(requestKey) : null;
660
897
  if (latestEntry && (latestEntry.seq !== currentSeq || latestEntry.superseded)) {
661
898
  superseded = true;
662
899
  }
900
+
901
+ if (timedOut) {
902
+ if (debug) {
903
+ debug.trackRequestEnd(requestId, { status: 'failed', errorKind: 'timeout' });
904
+ }
905
+ throw new ClientRequestError(
906
+ `Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
907
+ {
908
+ kind: 'timeout',
909
+ status: 408,
910
+ code: 'REQUEST_TIMED_OUT',
911
+ method: upperMethod,
912
+ url: requestUrl,
913
+ key: requestKey,
914
+ isTimedOut: true,
915
+ cause: fetchErr,
916
+ }
917
+ );
918
+ }
919
+
663
920
  const isAbort =
664
921
  superseded ||
665
922
  abortController.signal.aborted ||
@@ -797,7 +1054,27 @@ function createApiClient(config = {}) {
797
1054
  if (status === 204 || status === 205) {
798
1055
  parsedBody = undefined;
799
1056
  } else if (typeof response.text === 'function') {
800
- const rawText = await response.text();
1057
+ let rawText;
1058
+ try {
1059
+ rawText = await response.text();
1060
+ } catch (bodyErr) {
1061
+ if (timedOut) {
1062
+ throw new ClientRequestError(
1063
+ `Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
1064
+ {
1065
+ kind: 'timeout',
1066
+ status: 408,
1067
+ code: 'REQUEST_TIMED_OUT',
1068
+ method: upperMethod,
1069
+ url: requestUrl,
1070
+ key: requestKey,
1071
+ isTimedOut: true,
1072
+ cause: bodyErr,
1073
+ }
1074
+ );
1075
+ }
1076
+ throw bodyErr;
1077
+ }
801
1078
  checkSupersededOrDisconnected();
802
1079
  if (!rawText || !rawText.trim()) {
803
1080
  parsedBody = undefined;
@@ -811,7 +1088,26 @@ function createApiClient(config = {}) {
811
1088
  parsedBody = rawText;
812
1089
  }
813
1090
  } else if (typeof response.json === 'function') {
814
- parsedBody = await response.json();
1091
+ try {
1092
+ parsedBody = await response.json();
1093
+ } catch (bodyErr) {
1094
+ if (timedOut) {
1095
+ throw new ClientRequestError(
1096
+ `Request timed out after ${options.timeout}ms (${upperMethod} ${requestUrl}).`,
1097
+ {
1098
+ kind: 'timeout',
1099
+ status: 408,
1100
+ code: 'REQUEST_TIMED_OUT',
1101
+ method: upperMethod,
1102
+ url: requestUrl,
1103
+ key: requestKey,
1104
+ isTimedOut: true,
1105
+ cause: bodyErr,
1106
+ }
1107
+ );
1108
+ }
1109
+ throw bodyErr;
1110
+ }
815
1111
  checkSupersededOrDisconnected();
816
1112
  }
817
1113
 
@@ -916,6 +1212,10 @@ function createApiClient(config = {}) {
916
1212
 
917
1213
  return data;
918
1214
  } finally {
1215
+ if (timeoutTimer) {
1216
+ clearTimeout(timeoutTimer);
1217
+ timeoutTimer = null;
1218
+ }
919
1219
  const set = inFlightMap.get(requestKey);
920
1220
  if (set) {
921
1221
  set.delete(requestId);
@@ -1029,5 +1329,9 @@ module.exports = {
1029
1329
  buildRequestUrl,
1030
1330
  extractValidationErrors,
1031
1331
  unwrapApiResultAndInvalidate,
1332
+ normalizeRetryConfig,
1333
+ isRetryableError,
1334
+ calculateRetryDelay,
1335
+ abortableSleep,
1032
1336
  };
1033
1337