arthron 1.0.0 → 1.0.1
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 +6 -1
- package/dist/index.cjs +203 -137
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -4
- package/dist/index.d.mts +6 -4
- package/dist/index.mjs +203 -137
- package/dist/index.mjs.map +1 -1
- package/llms.txt +1 -1
- package/package.json +3 -1
package/dist/index.mjs
CHANGED
|
@@ -287,6 +287,35 @@ function isReadableStream(value) {
|
|
|
287
287
|
return tagOf(value) === "ReadableStream";
|
|
288
288
|
}
|
|
289
289
|
/**
|
|
290
|
+
* Whether `RequestCtor` can send a ReadableStream body. Node and Chromium
|
|
291
|
+
* read `duplex` and leave the stream alone; Firefox ignores `duplex` and
|
|
292
|
+
* would send the text "[object ReadableStream]"; older Safari refuses it.
|
|
293
|
+
* The check from
|
|
294
|
+
* https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
|
|
295
|
+
*/
|
|
296
|
+
function canStreamRequestBody(RequestCtor) {
|
|
297
|
+
let duplexRead = false;
|
|
298
|
+
try {
|
|
299
|
+
const request = new RequestCtor("https://stream.test", {
|
|
300
|
+
method: "POST",
|
|
301
|
+
body: new ReadableStream(),
|
|
302
|
+
get duplex() {
|
|
303
|
+
duplexRead = true;
|
|
304
|
+
return "half";
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
return duplexRead && !request.headers.has("content-type");
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
let runtimeStreams;
|
|
313
|
+
/** `canStreamRequestBody` for the global `Request`, checked once. */
|
|
314
|
+
function runtimeStreamsRequestBody() {
|
|
315
|
+
runtimeStreams ??= typeof Request !== "function" || canStreamRequestBody(Request);
|
|
316
|
+
return runtimeStreams;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
290
319
|
* What JSON.stringify would turn into `{}`, `undefined` or internal state
|
|
291
320
|
* instead of the data — with the fix, so it reads as an error message.
|
|
292
321
|
*/
|
|
@@ -338,6 +367,146 @@ function serializeBody(body, headers) {
|
|
|
338
367
|
};
|
|
339
368
|
}
|
|
340
369
|
//#endregion
|
|
370
|
+
//#region src/response/rebuild-response.ts
|
|
371
|
+
/**
|
|
372
|
+
* A new Response with `body` but everything else taken from `original`. The
|
|
373
|
+
* constructor resets `url`, `redirected` and `type`, so those are copied over
|
|
374
|
+
* as own properties — callers that resolve relative links or detect
|
|
375
|
+
* redirects keep working.
|
|
376
|
+
*/
|
|
377
|
+
function rebuildResponse(original, body) {
|
|
378
|
+
return withFetchedFields(new Response(body, {
|
|
379
|
+
status: original.status,
|
|
380
|
+
statusText: original.statusText,
|
|
381
|
+
headers: original.headers
|
|
382
|
+
}), original);
|
|
383
|
+
}
|
|
384
|
+
/** Copy the fields the constructor resets — onto `clone()`s as well. */
|
|
385
|
+
function withFetchedFields(target, from) {
|
|
386
|
+
const clone = target.clone.bind(target);
|
|
387
|
+
return Object.defineProperties(target, {
|
|
388
|
+
url: { value: from.url },
|
|
389
|
+
redirected: { value: from.redirected },
|
|
390
|
+
type: { value: from.type },
|
|
391
|
+
clone: { value: () => withFetchedFields(clone(), from) }
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/error/create-error.ts
|
|
396
|
+
/**
|
|
397
|
+
* Cancellation is not an HTTP failure. Matched by name so it works across
|
|
398
|
+
* realms — or by the request's own `signal`, since `abort(reason)` rejects
|
|
399
|
+
* with whatever reason the caller chose.
|
|
400
|
+
*/
|
|
401
|
+
function isCancellation(error, signal) {
|
|
402
|
+
if (signal?.aborted) return true;
|
|
403
|
+
const name = error?.name;
|
|
404
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* What a cancelled call rejects with: its signal's reason. WebKit's fetch
|
|
408
|
+
* rejects, and errors a half-read body, with a generic AbortError instead,
|
|
409
|
+
* dropping a TimeoutError or the caller's own reason.
|
|
410
|
+
*/
|
|
411
|
+
function cancellationError(error, signal) {
|
|
412
|
+
return signal?.aborted ? signal.reason : error;
|
|
413
|
+
}
|
|
414
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
415
|
+
/** `message` → `error` (string) → `error.message` → `detail`. */
|
|
416
|
+
function defaultErrorDetail(data) {
|
|
417
|
+
if (!isRecord(data)) return void 0;
|
|
418
|
+
if (typeof data.message === "string") return data.message;
|
|
419
|
+
if (typeof data.error === "string") return data.error;
|
|
420
|
+
if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
|
|
421
|
+
if (typeof data.detail === "string") return data.detail;
|
|
422
|
+
}
|
|
423
|
+
/** How much of an error body arthron reads. Error bodies are for diagnosis; a hostile or broken server must not be able to fill memory with one. */
|
|
424
|
+
const MAX_ERROR_BODY_BYTES = 65536;
|
|
425
|
+
/** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
|
|
426
|
+
async function readCapped(response, limit) {
|
|
427
|
+
if (!response.body) return void 0;
|
|
428
|
+
let reader;
|
|
429
|
+
const chunks = [];
|
|
430
|
+
let size = 0;
|
|
431
|
+
try {
|
|
432
|
+
reader = response.body.getReader();
|
|
433
|
+
while (size <= limit) {
|
|
434
|
+
const { value, done } = await reader.read();
|
|
435
|
+
if (done) break;
|
|
436
|
+
chunks.push(value);
|
|
437
|
+
size += value.byteLength;
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
return;
|
|
441
|
+
} finally {
|
|
442
|
+
await reader?.cancel().catch(() => {});
|
|
443
|
+
reader?.releaseLock();
|
|
444
|
+
}
|
|
445
|
+
const bytes = new Uint8Array(size);
|
|
446
|
+
let offset = 0;
|
|
447
|
+
for (const chunk of chunks) {
|
|
448
|
+
bytes.set(chunk, offset);
|
|
449
|
+
offset += chunk.byteLength;
|
|
450
|
+
}
|
|
451
|
+
return size > limit ? {
|
|
452
|
+
bytes: bytes.subarray(0, limit),
|
|
453
|
+
truncated: true
|
|
454
|
+
} : {
|
|
455
|
+
bytes,
|
|
456
|
+
truncated: false
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
|
|
460
|
+
function parseBody(read) {
|
|
461
|
+
const text = new TextDecoder().decode(read.bytes);
|
|
462
|
+
if (text === "") return void 0;
|
|
463
|
+
if (read.truncated) return text;
|
|
464
|
+
try {
|
|
465
|
+
return JSON.parse(text);
|
|
466
|
+
} catch {
|
|
467
|
+
return text;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** The request as error messages name it: method and URL, no query or userinfo. */
|
|
471
|
+
function describeRequest(request) {
|
|
472
|
+
return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
|
|
473
|
+
}
|
|
474
|
+
async function responseError(response, request, resolve = defaultErrorDetail) {
|
|
475
|
+
const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
|
|
476
|
+
const data = read && parseBody(read);
|
|
477
|
+
if (read) response = rebuildResponse(response, read.bytes);
|
|
478
|
+
const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
|
|
479
|
+
return new ArthronError({
|
|
480
|
+
message: `${describeRequest(request)} failed with ${status}`,
|
|
481
|
+
detail: resolve(data, response) || void 0,
|
|
482
|
+
status: response.status,
|
|
483
|
+
statusText: response.statusText,
|
|
484
|
+
headers: response.headers,
|
|
485
|
+
data,
|
|
486
|
+
request,
|
|
487
|
+
response
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Browsers that stream a request body (Chromium, current WebKit) do it only
|
|
492
|
+
* over HTTP/2, and fail one sent to an HTTP/1.1 server with the same bare
|
|
493
|
+
* "Failed to fetch" / "Load failed" as a dead server, so name the likely cause.
|
|
494
|
+
*/
|
|
495
|
+
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";
|
|
496
|
+
function networkError(cause, request) {
|
|
497
|
+
const reason = cause instanceof Error && cause.message ? cause.message : "network error";
|
|
498
|
+
const hint = isReadableStream(request.init.body) ? STREAM_BODY_HINT : "";
|
|
499
|
+
return new ArthronError({
|
|
500
|
+
message: `${describeRequest(request)} failed: ${reason}${hint}`,
|
|
501
|
+
status: 0,
|
|
502
|
+
statusText: "",
|
|
503
|
+
headers: new Headers(),
|
|
504
|
+
data: void 0,
|
|
505
|
+
request,
|
|
506
|
+
cause
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
341
510
|
//#region src/response/schema-validation.ts
|
|
342
511
|
const pathOf = (issue) => (issue.path ?? []).map((part) => String(typeof part === "object" && part !== null ? part.key : part)).join(".");
|
|
343
512
|
/**
|
|
@@ -471,28 +640,35 @@ async function* parseNdjson(response, { maxLineLength = DEFAULT_LIMIT } = {}) {
|
|
|
471
640
|
//#endregion
|
|
472
641
|
//#region src/response/arthron-promise.ts
|
|
473
642
|
/** Defer the Response until iteration starts, so the helper itself never rejects. */
|
|
474
|
-
function iterate(promise, parse) {
|
|
643
|
+
function iterate(promise, parse, signal) {
|
|
475
644
|
promise.catch(() => {});
|
|
476
645
|
return { async *[Symbol.asyncIterator]() {
|
|
477
|
-
|
|
646
|
+
try {
|
|
647
|
+
yield* parse(await promise);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
throw cancellationError(error, signal);
|
|
650
|
+
}
|
|
478
651
|
} };
|
|
479
652
|
}
|
|
480
653
|
/**
|
|
481
654
|
* Sugar over `Promise<Response>`: `.json()` is `(await p).json()`. Awaiting
|
|
482
655
|
* without a helper still yields the untouched `Response`.
|
|
483
656
|
*/
|
|
484
|
-
function toArthronPromise(promise) {
|
|
657
|
+
function toArthronPromise(promise, signal) {
|
|
658
|
+
const read = (body) => promise.then(body).catch((error) => {
|
|
659
|
+
throw cancellationError(error, signal);
|
|
660
|
+
});
|
|
485
661
|
return Object.assign(promise, {
|
|
486
|
-
json: ((schema) =>
|
|
662
|
+
json: ((schema) => read(async (res) => {
|
|
487
663
|
const data = await res.json();
|
|
488
664
|
return schema ? validate(schema, data) : data;
|
|
489
665
|
})),
|
|
490
|
-
text: () =>
|
|
491
|
-
blob: () =>
|
|
492
|
-
arrayBuffer: () =>
|
|
493
|
-
formData: () =>
|
|
494
|
-
sse: (options) => iterate(promise, (res) => parseSse(res, options)),
|
|
495
|
-
ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options))
|
|
666
|
+
text: () => read((res) => res.text()),
|
|
667
|
+
blob: () => read((res) => res.blob()),
|
|
668
|
+
arrayBuffer: () => read((res) => res.arrayBuffer()),
|
|
669
|
+
formData: () => read((res) => res.formData()),
|
|
670
|
+
sse: (options) => iterate(promise, (res) => parseSse(res, options), signal),
|
|
671
|
+
ndjson: (options) => iterate(promise, (res) => parseNdjson(res, options), signal)
|
|
496
672
|
});
|
|
497
673
|
}
|
|
498
674
|
//#endregion
|
|
@@ -547,130 +723,6 @@ function resolveConfig(config) {
|
|
|
547
723
|
};
|
|
548
724
|
}
|
|
549
725
|
//#endregion
|
|
550
|
-
//#region src/response/rebuild-response.ts
|
|
551
|
-
/**
|
|
552
|
-
* A new Response with `body` but everything else taken from `original`. The
|
|
553
|
-
* constructor resets `url`, `redirected` and `type`, so those are copied over
|
|
554
|
-
* as own properties — callers that resolve relative links or detect
|
|
555
|
-
* redirects keep working.
|
|
556
|
-
*/
|
|
557
|
-
function rebuildResponse(original, body) {
|
|
558
|
-
return withFetchedFields(new Response(body, {
|
|
559
|
-
status: original.status,
|
|
560
|
-
statusText: original.statusText,
|
|
561
|
-
headers: original.headers
|
|
562
|
-
}), original);
|
|
563
|
-
}
|
|
564
|
-
/** Copy the fields the constructor resets — onto `clone()`s as well. */
|
|
565
|
-
function withFetchedFields(target, from) {
|
|
566
|
-
const clone = target.clone.bind(target);
|
|
567
|
-
return Object.defineProperties(target, {
|
|
568
|
-
url: { value: from.url },
|
|
569
|
-
redirected: { value: from.redirected },
|
|
570
|
-
type: { value: from.type },
|
|
571
|
-
clone: { value: () => withFetchedFields(clone(), from) }
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
//#endregion
|
|
575
|
-
//#region src/error/create-error.ts
|
|
576
|
-
/**
|
|
577
|
-
* Cancellation is not an HTTP failure. Matched by name so it works across
|
|
578
|
-
* realms — or by the request's own `signal`, since `abort(reason)` rejects
|
|
579
|
-
* with whatever reason the caller chose.
|
|
580
|
-
*/
|
|
581
|
-
function isCancellation(error, signal) {
|
|
582
|
-
if (signal?.aborted) return true;
|
|
583
|
-
const name = error?.name;
|
|
584
|
-
return name === "AbortError" || name === "TimeoutError";
|
|
585
|
-
}
|
|
586
|
-
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
587
|
-
/** `message` → `error` (string) → `error.message` → `detail`. */
|
|
588
|
-
function defaultErrorDetail(data) {
|
|
589
|
-
if (!isRecord(data)) return void 0;
|
|
590
|
-
if (typeof data.message === "string") return data.message;
|
|
591
|
-
if (typeof data.error === "string") return data.error;
|
|
592
|
-
if (isRecord(data.error) && typeof data.error.message === "string") return data.error.message;
|
|
593
|
-
if (typeof data.detail === "string") return data.detail;
|
|
594
|
-
}
|
|
595
|
-
/** How much of an error body arthron reads. Error bodies are for diagnosis; a hostile or broken server must not be able to fill memory with one. */
|
|
596
|
-
const MAX_ERROR_BODY_BYTES = 65536;
|
|
597
|
-
/** Up to `limit` bytes of the body; the rest is cancelled unread. `undefined` when there is no body or reading it failed. */
|
|
598
|
-
async function readCapped(response, limit) {
|
|
599
|
-
if (!response.body) return void 0;
|
|
600
|
-
let reader;
|
|
601
|
-
const chunks = [];
|
|
602
|
-
let size = 0;
|
|
603
|
-
try {
|
|
604
|
-
reader = response.body.getReader();
|
|
605
|
-
while (size <= limit) {
|
|
606
|
-
const { value, done } = await reader.read();
|
|
607
|
-
if (done) break;
|
|
608
|
-
chunks.push(value);
|
|
609
|
-
size += value.byteLength;
|
|
610
|
-
}
|
|
611
|
-
} catch {
|
|
612
|
-
return;
|
|
613
|
-
} finally {
|
|
614
|
-
await reader?.cancel().catch(() => {});
|
|
615
|
-
reader?.releaseLock();
|
|
616
|
-
}
|
|
617
|
-
const bytes = new Uint8Array(size);
|
|
618
|
-
let offset = 0;
|
|
619
|
-
for (const chunk of chunks) {
|
|
620
|
-
bytes.set(chunk, offset);
|
|
621
|
-
offset += chunk.byteLength;
|
|
622
|
-
}
|
|
623
|
-
return size > limit ? {
|
|
624
|
-
bytes: bytes.subarray(0, limit),
|
|
625
|
-
truncated: true
|
|
626
|
-
} : {
|
|
627
|
-
bytes,
|
|
628
|
-
truncated: false
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
/** JSON if the whole body parses, else the text (cut at the limit), else `undefined` when empty. */
|
|
632
|
-
function parseBody(read) {
|
|
633
|
-
const text = new TextDecoder().decode(read.bytes);
|
|
634
|
-
if (text === "") return void 0;
|
|
635
|
-
if (read.truncated) return text;
|
|
636
|
-
try {
|
|
637
|
-
return JSON.parse(text);
|
|
638
|
-
} catch {
|
|
639
|
-
return text;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
/** The request as error messages name it: method and URL, no query or userinfo. */
|
|
643
|
-
function describeRequest(request) {
|
|
644
|
-
return `${request.init.method ?? "GET"} ${redactUrl(request.url)}`;
|
|
645
|
-
}
|
|
646
|
-
async function responseError(response, request, resolve = defaultErrorDetail) {
|
|
647
|
-
const read = await readCapped(response, MAX_ERROR_BODY_BYTES);
|
|
648
|
-
const data = read && parseBody(read);
|
|
649
|
-
if (read) response = rebuildResponse(response, read.bytes);
|
|
650
|
-
const status = response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
|
|
651
|
-
return new ArthronError({
|
|
652
|
-
message: `${describeRequest(request)} failed with ${status}`,
|
|
653
|
-
detail: resolve(data, response) || void 0,
|
|
654
|
-
status: response.status,
|
|
655
|
-
statusText: response.statusText,
|
|
656
|
-
headers: response.headers,
|
|
657
|
-
data,
|
|
658
|
-
request,
|
|
659
|
-
response
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
function networkError(cause, request) {
|
|
663
|
-
return new ArthronError({
|
|
664
|
-
message: `${describeRequest(request)} failed: ${cause instanceof Error && cause.message ? cause.message : "network error"}`,
|
|
665
|
-
status: 0,
|
|
666
|
-
statusText: "",
|
|
667
|
-
headers: new Headers(),
|
|
668
|
-
data: void 0,
|
|
669
|
-
request,
|
|
670
|
-
cause
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
//#endregion
|
|
674
726
|
//#region src/client/send.ts
|
|
675
727
|
/**
|
|
676
728
|
* The global fetch rejects a malformed URL or method with a TypeError that
|
|
@@ -690,6 +742,16 @@ function assertSendable(url, method) {
|
|
|
690
742
|
}
|
|
691
743
|
}
|
|
692
744
|
/**
|
|
745
|
+
* A ReadableStream body the browser cannot stream is rejected before it is
|
|
746
|
+
* sent: Firefox would send the text "[object ReadableStream]", older Safari
|
|
747
|
+
* fails with a bare network error. Only for the global fetch, like
|
|
748
|
+
* assertSendable.
|
|
749
|
+
*/
|
|
750
|
+
function assertStreamable(url, method, body) {
|
|
751
|
+
if (!isReadableStream(body) || runtimeStreamsRequestBody()) return;
|
|
752
|
+
throw new TypeError(`arthron: cannot send ${method} ${redactUrl(url)} with a ReadableStream body — this browser cannot stream a request body and would send it garbled or not at all; pass a Blob or ArrayBuffer instead`);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
693
755
|
* Non-ok responses the caller asked for are not failures: an opaque one
|
|
694
756
|
* (`mode: "no-cors"`) and the 3xx of `redirect: "manual"`.
|
|
695
757
|
*/
|
|
@@ -700,13 +762,17 @@ function failedByDefault(res, init) {
|
|
|
700
762
|
/** The one place a failure becomes an ArthronError. Cancellation passes through. */
|
|
701
763
|
async function send(resolved, ctx, throwOn) {
|
|
702
764
|
const fetchImpl = resolved.fetch ?? globalThis.fetch;
|
|
703
|
-
if (resolved.fetch === void 0)
|
|
765
|
+
if (resolved.fetch === void 0) {
|
|
766
|
+
const method = ctx.init.method ?? "GET";
|
|
767
|
+
assertSendable(ctx.url, method);
|
|
768
|
+
assertStreamable(ctx.url, method, ctx.init.body);
|
|
769
|
+
}
|
|
704
770
|
const { signal } = ctx.init;
|
|
705
771
|
let res;
|
|
706
772
|
try {
|
|
707
773
|
res = await fetchImpl(ctx.url, ctx.init);
|
|
708
774
|
} catch (thrown) {
|
|
709
|
-
if (isCancellation(thrown, signal)) throw thrown;
|
|
775
|
+
if (isCancellation(thrown, signal)) throw cancellationError(thrown, signal);
|
|
710
776
|
throw networkError(thrown, ctx);
|
|
711
777
|
}
|
|
712
778
|
if (res.ok) return res;
|
|
@@ -756,7 +822,7 @@ function arthron(config = {}) {
|
|
|
756
822
|
url,
|
|
757
823
|
init: requestInit,
|
|
758
824
|
meta: { ...meta }
|
|
759
|
-
}, (ctx) => send(resolved, ctx, throwOn)));
|
|
825
|
+
}, (ctx) => send(resolved, ctx, throwOn)), init.signal);
|
|
760
826
|
} catch (thrown) {
|
|
761
827
|
return toArthronPromise(Promise.reject(thrown));
|
|
762
828
|
}
|