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.
- package/core/client/adapter/datastar.js +175 -28
- package/core/client/api.js +312 -8
- package/core/client/controller.js +655 -30
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +145 -2
- package/core/client/fragments.js +166 -10
- package/core/client/index.d.ts +19 -0
- package/core/client/index.js +47 -3
- package/core/client/lite.js +11 -0
- package/core/client/server-fragments.js +12 -3
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +137 -0
- package/core/index.js +4 -0
- package/index.d.ts +204 -14
- package/index.js +4 -0
- package/package.json +7 -8
- 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/file-router.js +12 -8
- package/src/routing/mount-discovered-routes.js +9 -2
- package/src/server.js +14 -24
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
|
@@ -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
|
-
//
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
package/core/client/api.js
CHANGED
|
@@ -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 '
|
|
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)
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|