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.
- package/core/client/adapter/datastar.js +248 -51
- package/core/client/api.js +328 -22
- package/core/client/controller.js +921 -38
- package/core/client/debug.js +12 -12
- package/core/client/devtool.js +1239 -0
- package/core/client/forms.js +184 -21
- package/core/client/fragments.js +566 -72
- package/core/client/host.js +365 -0
- package/core/client/index.d.ts +37 -0
- package/core/client/index.js +190 -15
- package/core/client/lite.js +112 -4
- package/core/client/server-fragments.js +111 -24
- package/core/client/services.js +588 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +324 -1
- package/core/index.js +4 -0
- package/index.d.ts +523 -39
- package/index.js +7 -0
- package/package.json +7 -8
- package/src/bundle/index.js +175 -14
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +6 -18
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/banner.js +4 -1
- package/src/dev-reload/index.js +29 -1
- package/src/file-router.js +12 -8
- package/src/routing/mount-discovered-routes.js +9 -2
- package/src/server.js +42 -24
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
package/core/client/api.js
CHANGED
|
@@ -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 '
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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' && !
|
|
312
|
+
if (rawFieldErrors && typeof rawFieldErrors === 'object' && !isArray(rawFieldErrors)) {
|
|
300
313
|
for (const [field, msgOrArr] of Object.entries(rawFieldErrors)) {
|
|
301
|
-
const list =
|
|
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
|
-
(
|
|
313
|
-
(
|
|
314
|
-
(
|
|
315
|
-
(
|
|
316
|
-
(
|
|
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 (
|
|
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
|
-
!
|
|
386
|
-
|
|
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)
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|