arthron 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -506
- package/dist/index.cjs +26 -16
- package/dist/index.mjs +27 -17
- package/dist/plugins.cjs +45 -25
- package/dist/plugins.d.cts +10 -1
- package/dist/plugins.d.mts +10 -1
- package/dist/plugins.mjs +46 -26
- package/dist/shared.cjs +138 -22
- package/dist/shared.mjs +127 -23
- package/llms.txt +8 -8
- package/package.json +5 -1
package/dist/shared.cjs
CHANGED
|
@@ -13,7 +13,7 @@ function redactUrl(url) {
|
|
|
13
13
|
const parsed = new URL(url);
|
|
14
14
|
return `${parsed.origin}${parsed.pathname}`;
|
|
15
15
|
} catch {
|
|
16
|
-
return url
|
|
16
|
+
return withoutSecrets(url);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
@@ -66,9 +66,15 @@ function httpOrigin(url) {
|
|
|
66
66
|
function httpOrigins(urls, errors) {
|
|
67
67
|
if (urls.length === 0) throw new TypeError(errors.empty);
|
|
68
68
|
return new Set(urls.map((url) => {
|
|
69
|
-
const
|
|
69
|
+
const value = url;
|
|
70
|
+
if (typeof value !== "string") {
|
|
71
|
+
const kind = value === null ? "null" : typeof value;
|
|
72
|
+
const shown = kind === "null" ? kind : `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
|
|
73
|
+
throw new TypeError(errors.invalid(shown));
|
|
74
|
+
}
|
|
75
|
+
const origin = httpOrigin(value);
|
|
70
76
|
if (origin) return origin;
|
|
71
|
-
throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(
|
|
77
|
+
throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(value))));
|
|
72
78
|
}));
|
|
73
79
|
}
|
|
74
80
|
/** `allowedOrigins` as origins, checked once when the client is created. */
|
|
@@ -326,7 +332,7 @@ function serializeBody(body, headers) {
|
|
|
326
332
|
* redirects keep working.
|
|
327
333
|
*/
|
|
328
334
|
function rebuildResponse(original, body) {
|
|
329
|
-
const rebuilt =
|
|
335
|
+
const rebuilt = createResponse(body, {
|
|
330
336
|
status: original.status,
|
|
331
337
|
statusText: original.statusText,
|
|
332
338
|
headers: original.headers
|
|
@@ -338,6 +344,37 @@ function rebuildResponse(original, body) {
|
|
|
338
344
|
type
|
|
339
345
|
});
|
|
340
346
|
}
|
|
347
|
+
/** A status text `new Response` takes: tab, space, visible ASCII, Latin-1. */
|
|
348
|
+
const REASON_PHRASE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
349
|
+
/**
|
|
350
|
+
* `new Response(body, init)` for anything fetch hands over. The constructor
|
|
351
|
+
* takes a status of 200–599 and a Latin-1 status text, but fetch passes on
|
|
352
|
+
* what a server sends (LinkedIn answers bots with 999, and Node's fetch reads
|
|
353
|
+
* a reason phrase as UTF-8). One it refuses is built as 599 or "" and set
|
|
354
|
+
* back as an own property; built as 599, an unusual status is never `ok`.
|
|
355
|
+
*/
|
|
356
|
+
function createResponse(body, init) {
|
|
357
|
+
const { status, statusText = "" } = init;
|
|
358
|
+
const statusFits = status >= 200 && status <= 599;
|
|
359
|
+
const textFits = REASON_PHRASE.test(statusText);
|
|
360
|
+
if (statusFits && textFits) return new Response(body, init);
|
|
361
|
+
return withStatus(new Response(body, {
|
|
362
|
+
...init,
|
|
363
|
+
status: statusFits ? status : 599,
|
|
364
|
+
statusText: textFits ? statusText : ""
|
|
365
|
+
}), status, statusText);
|
|
366
|
+
}
|
|
367
|
+
function withStatus(target, status, statusText) {
|
|
368
|
+
const clone = target.clone.bind(target);
|
|
369
|
+
return Object.defineProperties(target, {
|
|
370
|
+
status: { value: status },
|
|
371
|
+
statusText: { value: statusText },
|
|
372
|
+
clone: {
|
|
373
|
+
value: () => withStatus(clone(), status, statusText),
|
|
374
|
+
configurable: true
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
341
378
|
/** Set those fields on `target` — and on its `clone()`s as well. */
|
|
342
379
|
function withFields(target, fields) {
|
|
343
380
|
const clone = target.clone.bind(target);
|
|
@@ -357,8 +394,11 @@ function withFields(target, fields) {
|
|
|
357
394
|
*/
|
|
358
395
|
function isCancellation(error, signal) {
|
|
359
396
|
if (signal?.aborted) return true;
|
|
360
|
-
|
|
361
|
-
|
|
397
|
+
return error?.name === "AbortError" || isTimeout(error);
|
|
398
|
+
}
|
|
399
|
+
/** A deadline ran out: `timeout()`, or `AbortSignal.timeout()` as the signal. Matched by name, like isCancellation. */
|
|
400
|
+
function isTimeout(error) {
|
|
401
|
+
return error?.name === "TimeoutError";
|
|
362
402
|
}
|
|
363
403
|
/**
|
|
364
404
|
* What a cancelled call rejects with: its signal's reason. WebKit's fetch
|
|
@@ -390,16 +430,16 @@ async function readCapped(response, limit) {
|
|
|
390
430
|
while (size <= limit) {
|
|
391
431
|
const { value, done } = await reader.read();
|
|
392
432
|
if (done) break;
|
|
393
|
-
chunks.push(value);
|
|
433
|
+
chunks.push(value.subarray(0, Math.max(0, limit - size)));
|
|
394
434
|
size += value.byteLength;
|
|
395
435
|
}
|
|
396
436
|
} catch {
|
|
397
437
|
return;
|
|
398
438
|
} finally {
|
|
399
|
-
|
|
439
|
+
reader?.cancel().catch(() => {});
|
|
400
440
|
reader?.releaseLock();
|
|
401
441
|
}
|
|
402
|
-
const bytes = new Uint8Array(size);
|
|
442
|
+
const bytes = new Uint8Array(Math.min(size, limit));
|
|
403
443
|
let offset = 0;
|
|
404
444
|
for (const chunk of chunks) {
|
|
405
445
|
bytes.set(chunk, offset);
|
|
@@ -454,17 +494,64 @@ async function responseError(response, requestContext, extractDescription = defa
|
|
|
454
494
|
* "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
|
|
455
495
|
*/
|
|
456
496
|
const STREAM_BODY_HINT = " — in a browser, a ReadableStream body streams only over HTTP/2; send a Blob or ArrayBuffer to an HTTP/1.1 server";
|
|
497
|
+
/** `error[key]`, or `undefined` if reading it throws. */
|
|
498
|
+
function readField(error, key) {
|
|
499
|
+
try {
|
|
500
|
+
return error[key];
|
|
501
|
+
} catch {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* An error that cannot be changed, copied with its prototype and own fields
|
|
507
|
+
* so the copy can be. Values are read through the original: V8's own
|
|
508
|
+
* `stack` accessor reads nothing on another object, and DOMException's
|
|
509
|
+
* `name`, `message` and `code` getters throw on one. Those become writable
|
|
510
|
+
* fields: stack rewriters assign to `stack`.
|
|
511
|
+
*/
|
|
512
|
+
function copyError(error) {
|
|
513
|
+
const descriptors = Object.getOwnPropertyDescriptors(error);
|
|
514
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
515
|
+
const descriptor = descriptors[key];
|
|
516
|
+
descriptors[key] = descriptor.get || descriptor.set ? {
|
|
517
|
+
value: readField(error, key),
|
|
518
|
+
writable: true,
|
|
519
|
+
enumerable: descriptor.enumerable ?? false,
|
|
520
|
+
configurable: true
|
|
521
|
+
} : {
|
|
522
|
+
...descriptor,
|
|
523
|
+
configurable: true
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
for (const key of [
|
|
527
|
+
"name",
|
|
528
|
+
"message",
|
|
529
|
+
"stack",
|
|
530
|
+
"code"
|
|
531
|
+
]) {
|
|
532
|
+
if (Object.hasOwn(descriptors, key)) continue;
|
|
533
|
+
const value = readField(error, key);
|
|
534
|
+
if (value !== void 0) descriptors[key] = {
|
|
535
|
+
value,
|
|
536
|
+
writable: true,
|
|
537
|
+
configurable: true
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return Object.create(Object.getPrototypeOf(error), descriptors);
|
|
541
|
+
}
|
|
457
542
|
/**
|
|
458
543
|
* Take `url`'s query and userinfo out of an error the runtime threw, in
|
|
459
|
-
* place: its message and stack, string fields (Node's
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* still work; a field that cannot be redefined (a frozen error) is left.
|
|
544
|
+
* place when possible: its message and stack, string fields (Node's
|
|
545
|
+
* `input`), and its causes. An immutable error is copied with its prototype
|
|
546
|
+
* and fields so it cannot put the full URL back into logs via `cause`.
|
|
463
547
|
*/
|
|
464
|
-
function redactErrorUrl(error, url, seen = /* @__PURE__ */ new
|
|
465
|
-
if (typeof error
|
|
466
|
-
|
|
548
|
+
function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Map()) {
|
|
549
|
+
if (typeof error === "string") return redactUrlIn(error, url);
|
|
550
|
+
if (typeof error !== "object" || error === null) return error;
|
|
551
|
+
if (seen.has(error)) return seen.get(error);
|
|
467
552
|
const fields = error;
|
|
553
|
+
let safe = error;
|
|
554
|
+
const changes = /* @__PURE__ */ new Map();
|
|
468
555
|
for (const key of /* @__PURE__ */ new Set([
|
|
469
556
|
"message",
|
|
470
557
|
"stack",
|
|
@@ -474,15 +561,32 @@ function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Set()) {
|
|
|
474
561
|
if (typeof value !== "string") continue;
|
|
475
562
|
const redacted = redactUrlIn(value, url);
|
|
476
563
|
if (redacted === value) continue;
|
|
477
|
-
|
|
564
|
+
changes.set(key, redacted);
|
|
565
|
+
if (safe === error) try {
|
|
478
566
|
Object.defineProperty(error, key, { value: redacted });
|
|
479
|
-
} catch {
|
|
567
|
+
} catch {
|
|
568
|
+
safe = copyError(error);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
seen.set(error, safe);
|
|
572
|
+
if (safe !== error) for (const [key, value] of changes) Object.defineProperty(safe, key, {
|
|
573
|
+
value,
|
|
574
|
+
configurable: true
|
|
575
|
+
});
|
|
576
|
+
const cause = fields.cause;
|
|
577
|
+
const safeCause = redactErrorUrl(cause, url, seen);
|
|
578
|
+
if (safeCause !== cause) try {
|
|
579
|
+
Object.defineProperty(safe, "cause", { value: safeCause });
|
|
580
|
+
} catch {
|
|
581
|
+
safe = copyError(safe);
|
|
582
|
+
Object.defineProperty(safe, "cause", { value: safeCause });
|
|
583
|
+
seen.set(error, safe);
|
|
480
584
|
}
|
|
481
|
-
|
|
585
|
+
return safe;
|
|
482
586
|
}
|
|
483
587
|
function networkError(cause, requestContext) {
|
|
484
|
-
redactErrorUrl(cause, requestContext.url);
|
|
485
|
-
const reason =
|
|
588
|
+
const safeCause = redactErrorUrl(cause, requestContext.url);
|
|
589
|
+
const reason = safeCause instanceof Error && safeCause.message ? safeCause.message : "network error";
|
|
486
590
|
const hint = isReadableStream(requestContext.init.body) ? STREAM_BODY_HINT : "";
|
|
487
591
|
return new ArthronError({
|
|
488
592
|
message: `${describeRequest(requestContext)} failed: ${reason}${hint}`,
|
|
@@ -491,7 +595,7 @@ function networkError(cause, requestContext) {
|
|
|
491
595
|
headers: new Headers(),
|
|
492
596
|
payload: void 0,
|
|
493
597
|
requestContext,
|
|
494
|
-
cause
|
|
598
|
+
cause: safeCause
|
|
495
599
|
});
|
|
496
600
|
}
|
|
497
601
|
//#endregion
|
|
@@ -507,6 +611,12 @@ Object.defineProperty(exports, "cancellationError", {
|
|
|
507
611
|
return cancellationError;
|
|
508
612
|
}
|
|
509
613
|
});
|
|
614
|
+
Object.defineProperty(exports, "createResponse", {
|
|
615
|
+
enumerable: true,
|
|
616
|
+
get: function() {
|
|
617
|
+
return createResponse;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
510
620
|
Object.defineProperty(exports, "fillPath", {
|
|
511
621
|
enumerable: true,
|
|
512
622
|
get: function() {
|
|
@@ -543,6 +653,12 @@ Object.defineProperty(exports, "isReadableStream", {
|
|
|
543
653
|
return isReadableStream;
|
|
544
654
|
}
|
|
545
655
|
});
|
|
656
|
+
Object.defineProperty(exports, "isTimeout", {
|
|
657
|
+
enumerable: true,
|
|
658
|
+
get: function() {
|
|
659
|
+
return isTimeout;
|
|
660
|
+
}
|
|
661
|
+
});
|
|
546
662
|
Object.defineProperty(exports, "leavesBase", {
|
|
547
663
|
enumerable: true,
|
|
548
664
|
get: function() {
|
package/dist/shared.mjs
CHANGED
|
@@ -13,7 +13,7 @@ function redactUrl(url) {
|
|
|
13
13
|
const parsed = new URL(url);
|
|
14
14
|
return `${parsed.origin}${parsed.pathname}`;
|
|
15
15
|
} catch {
|
|
16
|
-
return url
|
|
16
|
+
return withoutSecrets(url);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
@@ -66,9 +66,15 @@ function httpOrigin(url) {
|
|
|
66
66
|
function httpOrigins(urls, errors) {
|
|
67
67
|
if (urls.length === 0) throw new TypeError(errors.empty);
|
|
68
68
|
return new Set(urls.map((url) => {
|
|
69
|
-
const
|
|
69
|
+
const value = url;
|
|
70
|
+
if (typeof value !== "string") {
|
|
71
|
+
const kind = value === null ? "null" : typeof value;
|
|
72
|
+
const shown = kind === "null" ? kind : `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
|
|
73
|
+
throw new TypeError(errors.invalid(shown));
|
|
74
|
+
}
|
|
75
|
+
const origin = httpOrigin(value);
|
|
70
76
|
if (origin) return origin;
|
|
71
|
-
throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(
|
|
77
|
+
throw new TypeError(errors.invalid(JSON.stringify(withoutSecrets(value))));
|
|
72
78
|
}));
|
|
73
79
|
}
|
|
74
80
|
/** `allowedOrigins` as origins, checked once when the client is created. */
|
|
@@ -326,7 +332,7 @@ function serializeBody(body, headers) {
|
|
|
326
332
|
* redirects keep working.
|
|
327
333
|
*/
|
|
328
334
|
function rebuildResponse(original, body) {
|
|
329
|
-
const rebuilt =
|
|
335
|
+
const rebuilt = createResponse(body, {
|
|
330
336
|
status: original.status,
|
|
331
337
|
statusText: original.statusText,
|
|
332
338
|
headers: original.headers
|
|
@@ -338,6 +344,37 @@ function rebuildResponse(original, body) {
|
|
|
338
344
|
type
|
|
339
345
|
});
|
|
340
346
|
}
|
|
347
|
+
/** A status text `new Response` takes: tab, space, visible ASCII, Latin-1. */
|
|
348
|
+
const REASON_PHRASE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
349
|
+
/**
|
|
350
|
+
* `new Response(body, init)` for anything fetch hands over. The constructor
|
|
351
|
+
* takes a status of 200–599 and a Latin-1 status text, but fetch passes on
|
|
352
|
+
* what a server sends (LinkedIn answers bots with 999, and Node's fetch reads
|
|
353
|
+
* a reason phrase as UTF-8). One it refuses is built as 599 or "" and set
|
|
354
|
+
* back as an own property; built as 599, an unusual status is never `ok`.
|
|
355
|
+
*/
|
|
356
|
+
function createResponse(body, init) {
|
|
357
|
+
const { status, statusText = "" } = init;
|
|
358
|
+
const statusFits = status >= 200 && status <= 599;
|
|
359
|
+
const textFits = REASON_PHRASE.test(statusText);
|
|
360
|
+
if (statusFits && textFits) return new Response(body, init);
|
|
361
|
+
return withStatus(new Response(body, {
|
|
362
|
+
...init,
|
|
363
|
+
status: statusFits ? status : 599,
|
|
364
|
+
statusText: textFits ? statusText : ""
|
|
365
|
+
}), status, statusText);
|
|
366
|
+
}
|
|
367
|
+
function withStatus(target, status, statusText) {
|
|
368
|
+
const clone = target.clone.bind(target);
|
|
369
|
+
return Object.defineProperties(target, {
|
|
370
|
+
status: { value: status },
|
|
371
|
+
statusText: { value: statusText },
|
|
372
|
+
clone: {
|
|
373
|
+
value: () => withStatus(clone(), status, statusText),
|
|
374
|
+
configurable: true
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
341
378
|
/** Set those fields on `target` — and on its `clone()`s as well. */
|
|
342
379
|
function withFields(target, fields) {
|
|
343
380
|
const clone = target.clone.bind(target);
|
|
@@ -357,8 +394,11 @@ function withFields(target, fields) {
|
|
|
357
394
|
*/
|
|
358
395
|
function isCancellation(error, signal) {
|
|
359
396
|
if (signal?.aborted) return true;
|
|
360
|
-
|
|
361
|
-
|
|
397
|
+
return error?.name === "AbortError" || isTimeout(error);
|
|
398
|
+
}
|
|
399
|
+
/** A deadline ran out: `timeout()`, or `AbortSignal.timeout()` as the signal. Matched by name, like isCancellation. */
|
|
400
|
+
function isTimeout(error) {
|
|
401
|
+
return error?.name === "TimeoutError";
|
|
362
402
|
}
|
|
363
403
|
/**
|
|
364
404
|
* What a cancelled call rejects with: its signal's reason. WebKit's fetch
|
|
@@ -390,16 +430,16 @@ async function readCapped(response, limit) {
|
|
|
390
430
|
while (size <= limit) {
|
|
391
431
|
const { value, done } = await reader.read();
|
|
392
432
|
if (done) break;
|
|
393
|
-
chunks.push(value);
|
|
433
|
+
chunks.push(value.subarray(0, Math.max(0, limit - size)));
|
|
394
434
|
size += value.byteLength;
|
|
395
435
|
}
|
|
396
436
|
} catch {
|
|
397
437
|
return;
|
|
398
438
|
} finally {
|
|
399
|
-
|
|
439
|
+
reader?.cancel().catch(() => {});
|
|
400
440
|
reader?.releaseLock();
|
|
401
441
|
}
|
|
402
|
-
const bytes = new Uint8Array(size);
|
|
442
|
+
const bytes = new Uint8Array(Math.min(size, limit));
|
|
403
443
|
let offset = 0;
|
|
404
444
|
for (const chunk of chunks) {
|
|
405
445
|
bytes.set(chunk, offset);
|
|
@@ -454,17 +494,64 @@ async function responseError(response, requestContext, extractDescription = defa
|
|
|
454
494
|
* "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
|
|
455
495
|
*/
|
|
456
496
|
const STREAM_BODY_HINT = " — in a browser, a ReadableStream body streams only over HTTP/2; send a Blob or ArrayBuffer to an HTTP/1.1 server";
|
|
497
|
+
/** `error[key]`, or `undefined` if reading it throws. */
|
|
498
|
+
function readField(error, key) {
|
|
499
|
+
try {
|
|
500
|
+
return error[key];
|
|
501
|
+
} catch {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* An error that cannot be changed, copied with its prototype and own fields
|
|
507
|
+
* so the copy can be. Values are read through the original: V8's own
|
|
508
|
+
* `stack` accessor reads nothing on another object, and DOMException's
|
|
509
|
+
* `name`, `message` and `code` getters throw on one. Those become writable
|
|
510
|
+
* fields: stack rewriters assign to `stack`.
|
|
511
|
+
*/
|
|
512
|
+
function copyError(error) {
|
|
513
|
+
const descriptors = Object.getOwnPropertyDescriptors(error);
|
|
514
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
515
|
+
const descriptor = descriptors[key];
|
|
516
|
+
descriptors[key] = descriptor.get || descriptor.set ? {
|
|
517
|
+
value: readField(error, key),
|
|
518
|
+
writable: true,
|
|
519
|
+
enumerable: descriptor.enumerable ?? false,
|
|
520
|
+
configurable: true
|
|
521
|
+
} : {
|
|
522
|
+
...descriptor,
|
|
523
|
+
configurable: true
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
for (const key of [
|
|
527
|
+
"name",
|
|
528
|
+
"message",
|
|
529
|
+
"stack",
|
|
530
|
+
"code"
|
|
531
|
+
]) {
|
|
532
|
+
if (Object.hasOwn(descriptors, key)) continue;
|
|
533
|
+
const value = readField(error, key);
|
|
534
|
+
if (value !== void 0) descriptors[key] = {
|
|
535
|
+
value,
|
|
536
|
+
writable: true,
|
|
537
|
+
configurable: true
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return Object.create(Object.getPrototypeOf(error), descriptors);
|
|
541
|
+
}
|
|
457
542
|
/**
|
|
458
543
|
* Take `url`'s query and userinfo out of an error the runtime threw, in
|
|
459
|
-
* place: its message and stack, string fields (Node's
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
* still work; a field that cannot be redefined (a frozen error) is left.
|
|
544
|
+
* place when possible: its message and stack, string fields (Node's
|
|
545
|
+
* `input`), and its causes. An immutable error is copied with its prototype
|
|
546
|
+
* and fields so it cannot put the full URL back into logs via `cause`.
|
|
463
547
|
*/
|
|
464
|
-
function redactErrorUrl(error, url, seen = /* @__PURE__ */ new
|
|
465
|
-
if (typeof error
|
|
466
|
-
|
|
548
|
+
function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Map()) {
|
|
549
|
+
if (typeof error === "string") return redactUrlIn(error, url);
|
|
550
|
+
if (typeof error !== "object" || error === null) return error;
|
|
551
|
+
if (seen.has(error)) return seen.get(error);
|
|
467
552
|
const fields = error;
|
|
553
|
+
let safe = error;
|
|
554
|
+
const changes = /* @__PURE__ */ new Map();
|
|
468
555
|
for (const key of /* @__PURE__ */ new Set([
|
|
469
556
|
"message",
|
|
470
557
|
"stack",
|
|
@@ -474,15 +561,32 @@ function redactErrorUrl(error, url, seen = /* @__PURE__ */ new Set()) {
|
|
|
474
561
|
if (typeof value !== "string") continue;
|
|
475
562
|
const redacted = redactUrlIn(value, url);
|
|
476
563
|
if (redacted === value) continue;
|
|
477
|
-
|
|
564
|
+
changes.set(key, redacted);
|
|
565
|
+
if (safe === error) try {
|
|
478
566
|
Object.defineProperty(error, key, { value: redacted });
|
|
479
|
-
} catch {
|
|
567
|
+
} catch {
|
|
568
|
+
safe = copyError(error);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
seen.set(error, safe);
|
|
572
|
+
if (safe !== error) for (const [key, value] of changes) Object.defineProperty(safe, key, {
|
|
573
|
+
value,
|
|
574
|
+
configurable: true
|
|
575
|
+
});
|
|
576
|
+
const cause = fields.cause;
|
|
577
|
+
const safeCause = redactErrorUrl(cause, url, seen);
|
|
578
|
+
if (safeCause !== cause) try {
|
|
579
|
+
Object.defineProperty(safe, "cause", { value: safeCause });
|
|
580
|
+
} catch {
|
|
581
|
+
safe = copyError(safe);
|
|
582
|
+
Object.defineProperty(safe, "cause", { value: safeCause });
|
|
583
|
+
seen.set(error, safe);
|
|
480
584
|
}
|
|
481
|
-
|
|
585
|
+
return safe;
|
|
482
586
|
}
|
|
483
587
|
function networkError(cause, requestContext) {
|
|
484
|
-
redactErrorUrl(cause, requestContext.url);
|
|
485
|
-
const reason =
|
|
588
|
+
const safeCause = redactErrorUrl(cause, requestContext.url);
|
|
589
|
+
const reason = safeCause instanceof Error && safeCause.message ? safeCause.message : "network error";
|
|
486
590
|
const hint = isReadableStream(requestContext.init.body) ? STREAM_BODY_HINT : "";
|
|
487
591
|
return new ArthronError({
|
|
488
592
|
message: `${describeRequest(requestContext)} failed: ${reason}${hint}`,
|
|
@@ -491,8 +595,8 @@ function networkError(cause, requestContext) {
|
|
|
491
595
|
headers: new Headers(),
|
|
492
596
|
payload: void 0,
|
|
493
597
|
requestContext,
|
|
494
|
-
cause
|
|
598
|
+
cause: safeCause
|
|
495
599
|
});
|
|
496
600
|
}
|
|
497
601
|
//#endregion
|
|
498
|
-
export {
|
|
602
|
+
export { redactUrlIn as C, redactUrl as S, withoutSecrets as T, httpOrigin as _, redactErrorUrl as a, originOf as b, rebuildResponse as c, runtimeStreamsRequestBody as d, serializeBody as f, fillPath as g, isArthronError as h, networkError as i, withFields as l, ArthronError as m, isCancellation as n, responseError as o, tagOf as p, isTimeout as r, createResponse as s, cancellationError as t, isReadableStream as u, httpOrigins as v, resolveUrl as w, originSet as x, leavesBase as y };
|
package/llms.txt
CHANGED
|
@@ -30,13 +30,13 @@ api.request(path, { method, body?, ...requestOptions }) // method: HttpMethod
|
|
|
30
30
|
// requestOptions = RequestInit minus method/body, plus queryParams?: Record<string, scalar | scalar[] | null | undefined> | URLSearchParams (arrays → repeated keys; object values → TypeError); headers values may be null to drop a default; meta?: ArthronMeta (plugin settings, not sent); pathParams?: Record<string, string|number> fills {name} placeholders, encoded per segment ("", ".", ".." refused) — use it instead of interpolating ids
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Every method returns `Promise<Response>` plus helpers:
|
|
33
|
+
Every request method returns `Promise<Response>` plus helpers:
|
|
34
34
|
`.json<T>()` (cast) / `.json(schema)` (an `ArthronSchema`: `StandardSchemaV1` from @standard-schema/spec under arthron's name — pass a Zod, Valibot or ArkType schema as is; typed as `ArthronSchema.Infer<S>`; a mismatch → `SchemaMismatchError` with `.issues: ArthronSchema.Issue[]`, not an ArthronError) `.text()` `.blob()` `.arrayBuffer()` `.formData()` `.sse({ maxLineLength?, maxDataLength? }?): AsyncIterable<{ type, data, id?, lastEventId, reconnectionTime? }>` (`type` is the `event:` field, `"message"` when there is none, as in a browser; `lastEventId` and `reconnectionTime` (ms, from `retry:`) persist across events, per spec; no reconnection) `.ndjson<T>({ maxLineLength? }?): AsyncIterable<T>` — limits default to 8 Mi characters; exceeding one rejects with RangeError and cancels the body
|
|
35
35
|
|
|
36
36
|
- Body: string/FormData/Blob/ArrayBuffer/URLSearchParams/ReadableStream sent as-is (matched by type tag, so polyfill/cross-realm instances count; streams get `duplex: "half"`; FormData is sent without content-type so the runtime sets the boundary). Any other value (incl. null) → JSON + `content-type: application/json`; a content type you set must be JSON-ish (else TypeError). Map/Set/iterables/async iterables (Node streams)/functions → TypeError. GET/HEAD with body → TypeError.
|
|
37
37
|
- URL: `baseURL` + path by string concatenation (`"https://a/v1" + "/u"` → `https://a/v1/u`). An absolute path must be http(s); with a baseURL it must be on baseURL's origin or in `allowedOrigins`, without one in `allowedOrigins` if set — else TypeError (with neither set, any http(s) URL goes). Config headers go to baseURL's origin only, never to an `allowedOrigins` entry (calls there carry per-call headers alone); a client with neither baseURL nor allowedOrigins sends them everywhere. On cross-origin redirects fetch drops Authorization but keeps custom headers (x-api-key) — use `redirect: "error"` where that matters. `queryParams` appended with `&` if a query exists, placed before `#fragment`.
|
|
38
38
|
- Argument errors (no baseURL, a URL that does not parse, BigInt body, missing method) reject the promise with TypeError before fetch; they do not throw synchronously and are never retried. URLs are pre-checked only for the global fetch; a `fetchFn` you pass decides its own URL rules (relative URLs are fine if it accepts them).
|
|
39
|
-
- `.sse()`/`.ndjson()`: leaving the loop early cancels the body. `.sse()` drops an event the
|
|
39
|
+
- `.sse()`/`.ndjson()`: leaving the loop early cancels the body. `.sse()` drops an incomplete event at the end of the stream (spec).
|
|
40
40
|
|
|
41
41
|
## Errors
|
|
42
42
|
|
|
@@ -57,8 +57,8 @@ class ArthronError extends Error {
|
|
|
57
57
|
isArthronError(e): e is ArthronError // branded with Symbol.for: works (and instanceof works) across the ESM and CJS copies
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
- Only `!response.ok` and fetch rejection become ArthronError — and not every non-ok: opaque responses (no-cors) and the 3xx of `redirect: "manual"` are returned; `throwOn(response)` (config or per call)
|
|
61
|
-
-
|
|
60
|
+
- Only `!response.ok` and fetch rejection become ArthronError — and not every non-ok: opaque responses (no-cors) and the 3xx of `redirect: "manual"` are returned; `throwOn(response)` (config or per call; the call's wins) replaces that default — it decides for every non-ok response, opaque and manual 3xx included. An ok response never rejects. Aborts (`AbortError`/`TimeoutError` or any custom `abort(reason)`, including during the error-body read) and exceptions thrown by plugins propagate untouched and skip error interceptors.
|
|
61
|
+
- ArthronError keeps requestContext, response and response headers non-enumerable; toJSON() omits request headers, body, URL query and userinfo. It includes server-provided payload and description, which may contain secrets if the API puts them there. The built-in logger prints a redacted URL and status; its onEvent callback receives the error object.
|
|
62
62
|
- `message` is arthron's (for logs); `description` is the server's (for people: `e.description ?? fallback`). Default `description`: payload `message` → `error` (string) → `error.message` → `detail`, else undefined. Override with `extractDescription: (payload) => (payload as { msg?: string }).msg`.
|
|
63
63
|
|
|
64
64
|
## Plugins
|
|
@@ -76,7 +76,7 @@ type RequestContext = { url: string; init: Omit<RequestInit, "headers"> & { head
|
|
|
76
76
|
// Add keys: declare module "arthron" { interface ArthronMeta { skipTrace?: boolean } }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch → response | error interceptors ) )` — a middleware
|
|
79
|
+
Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch → response | error interceptors ) )` — the outermost middleware runs once per call; one below a middleware that calls `next` again runs again each time; code after `await next(requestContext)` runs after the response interceptors. Inside `try`, write `return await next(requestContext)` or the catch misses the error. Default order: `[logging(), bearerAuth(…), timeout(ms), retry()]` (timeout before retry = whole call, after = per attempt). Plugins with the same pluginName replace each other; for two timeouts rename one: `{ ...timeout(30_000), pluginName: "callTimeout" }`. Values that must be fresh per attempt (token, signature) → request interceptor; the same on every attempt (idempotency key) → middleware before retry. Each attempt gets a fresh copy of requestContext (headers, and a FormData/URLSearchParams body).
|
|
80
80
|
|
|
81
81
|
`api.use(...plugins)`, `api.extend(clientOptions)` and `api.without(...pluginNames)` return NEW clients; the original is unchanged. A plugin replaces the one with the same pluginName at its position (built-ins: "bearerAuth", "retry", "timeout", "logging", "uploadProgress"); other names append.
|
|
82
82
|
|
|
@@ -85,9 +85,9 @@ Order: `middleware[0]( middleware[1]( attempt: request interceptors → fetch
|
|
|
85
85
|
## Built-in plugins (from "arthron/plugins")
|
|
86
86
|
|
|
87
87
|
- `retry({ retries=2, statuses=[408,429,500,502,503,504], methods=[GET,HEAD,OPTIONS,QUERY,PUT,DELETE], delayFn=(attempt)=>Math.random()*300*2**attempt /* full jitter; Retry-After wins; must return a finite number ≥ 0, else TypeError */, maxRetryAfter=60000, shouldRetry?(error, attempt, requestContext) → true|false|undefined, onRetry?(error, attempt, delay) })` — `attempt` is 0-based in all three. Re-runs the whole attempt (request interceptors included). Honours Retry-After (seconds or HTTP-date). Never retries ReadableStream bodies or aborted requests, nor beyond `retries`, nor past a Retry-After longer than `maxRetryAfter`; by default never non-ArthronErrors (shouldRetry may opt in, e.g. an inner TimeoutError). With methods: ["POST"], network errors (status 0) are retried too — refuse them with shouldRetry where a duplicate would hurt. Per call: `meta: { retry: false }` or `meta: { retry: { retries: 5 } }`. `retries` must be a whole number ≥ 0 (TypeError otherwise); waits are capped at 2^31-1 ms.
|
|
88
|
-
- `timeout(ms)` | `timeout({ total?, idle? })` — `total` (= `ms`): deadline from
|
|
89
|
-
- `bearerAuth({ tokenOrigins, accessTokenFn, refreshFn? })` — `authorization: Bearer <token>` on every attempt, only to requests on `tokenOrigins` (a list of URLs; only origins count; non-http(s), empty or a single string → TypeError; a relative
|
|
90
|
-
- `logging({ enabled=true, logger=console, onEvent
|
|
88
|
+
- `timeout(ms)` | `timeout({ total?, idle? })` — `total` (= `ms`): deadline from the start of what it wraps until the body is fully read; `idle`: aborts after that many ms without data while arthron waits on the server (for the headers, then the next chunk, read one ahead of the caller; time the caller spends between reads does not count), so `timeout({ idle: 30_000 })` never cuts a flowing stream — nor a body left unread past its first chunk (cancel it, or add `total`). Both together: whichever trips first. `{}` → TypeError; so is an object second argument (the 1.x `timeout(ms, { idle })`). Rejects with TimeoutError (not ArthronError; retry does not repeat it unless shouldRetry returns true for it). Place after `retry` for per-attempt, before it for total. Per call: `meta: { timeout: false }` lifts both, `meta: { timeout: ms }` replaces total, `meta: { timeout: { idle: 30_000 } }` replaces the parts it sets. `Infinity` = no deadline; NaN/negative/>2^31-1 → TypeError. Keeps `res.url`/`redirected`/`type` (through `clone()` too); timers are unref'd.
|
|
89
|
+
- `bearerAuth({ tokenOrigins, accessTokenFn, refreshFn? })` — `authorization: Bearer <token>` on every attempt, only to requests on `tokenOrigins` (a list of URLs; only origins count; non-http(s), empty or a single string → TypeError; requests from a relative baseURL are on the page's origin, so list that origin — `"/api"` itself → TypeError). `accessTokenFn: () => string | null | undefined | Promise<…>`, called per attempt; empty → no header. With `refreshFn: () => Promise<void>` (refreshes the session so the next accessTokenFn() returns the new token), a 401 refreshes and redoes the call once: concurrent 401s share one refresh, a call whose token was already replaced retries without refreshing, a second 401 is thrown, a rejected refresh reaches every waiting call unchanged. Without `refreshFn`, or for a ReadableStream body (cannot be resent), 401 is thrown without refreshing. Per call: `meta: { bearerAuth: false }` = no token, no refresh — use it on the login/refresh requests if they use the same client. A request refreshFn starts before its first await gets it automatically; one started after an await must pass it (else its 401 waits on the refresh it belongs to). Token is opaque: refresh ahead of a JWT's exp inside `accessTokenFn`.
|
|
90
|
+
- `logging({ enabled=true, logger=console, onEvent?, timeouts=false })` — start/success/error lines or structured events `{ phase, method, url /* query and userinfo removed */, status?, duration? /* ms, retries included */, error? }`. An error without a status prints its name where the status goes (`TimeoutError`, `TypeError`). A cancelled call has a start event but no success or error event; with `timeouts: true` a TimeoutError before the response arrives gets an error event (status undefined) — one while the body is read only rejects that read, after the success event.
|
|
91
91
|
- `uploadProgress()` — does nothing alone; a call with `meta: { uploadProgress: { onProgress: ({ loaded, total, fraction }) => … } }` and a body reports upload progress (`fraction` is 0–1, not a percentage; total/fraction undefined when the size is unknown; restarts at 0 per attempt; no events after the response or an abort). Browser (incl. Electron renderer): sent over XMLHttpRequest (cross-origin → CORS preflight; response not streamed; redirect "manual"/"error", mode "no-cors", credentials "omit" to the page's origin, a referrerPolicy/referrer of its own, integrity → TypeError before sending; cache/priority/keepalive ignored; ReadableStream body → no progress). Node (incl. jsdom on Node): fetch with the body counted in ≤ 64 KiB steps, content-length and Blob content-type kept (S3 presigned PUT works); FormData encoded with a known length, files streamed. Deno/Bun/Cloudflare Workers/Electron main/service workers, a client fetchFn or another plugin's → sent as usual without progress. A throwing onProgress rejects the call with its own error (not ArthronError, not retried) — keep uploadProgress last in plugins. Works through requestContext.fetchFn.
|
|
92
92
|
|
|
93
93
|
## Recipes
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arthron",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "A lightweight fetch client you assemble from plugins. Zero dependencies, returns native Response.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "lapidix",
|
|
7
|
+
"homepage": "https://lapidix.github.io/arthron/",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
10
|
"url": "git+https://github.com/lapidix/arthron.git"
|
|
@@ -61,6 +62,9 @@
|
|
|
61
62
|
"check:types-pkg": "attw --pack .",
|
|
62
63
|
"check:types-consumer": "node test/types-consumer/check.mjs",
|
|
63
64
|
"check:browsers": "pnpm --dir test/browser test",
|
|
65
|
+
"docs:dev": "pnpm --dir docs dev",
|
|
66
|
+
"docs:build": "pnpm --dir docs build",
|
|
67
|
+
"docs:preview": "pnpm --dir docs preview",
|
|
64
68
|
"release:approve": "node scripts/approve-release.mjs",
|
|
65
69
|
"prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && node scripts/smoke.mjs && pnpm check:types-pkg"
|
|
66
70
|
},
|