@stll/fetch 0.1.0 → 0.2.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 CHANGED
@@ -6,26 +6,32 @@
6
6
 
7
7
  ```ts
8
8
  import { fetchWithTimeout } from "@stll/fetch";
9
+ import { readBodyWithIdleDeadline } from "@stll/fetch/body-reader";
9
10
 
10
11
  // Abort when no response headers arrive within 5 seconds.
11
12
  const response = await fetchWithTimeout(url, {
12
13
  timeout: { type: "headers", ms: 5_000 },
13
14
  });
14
15
 
15
- // Abort when the headers, or any later body chunk, take longer than
16
- // 10 seconds: a slow but progressing download keeps going.
17
- const stream = await fetchWithTimeout(url, {
18
- timeout: { type: "idle", ms: 10_000 },
16
+ // Bound headers separately, then observe chunks in the caller's own read.
17
+ const download = await fetchWithTimeout(url, {
18
+ timeout: { type: "headers", ms: 10_000 },
19
19
  signal: controller.signal, // optional; either signal aborts the request
20
20
  });
21
+ const bytes = await readBodyWithIdleDeadline(download, {
22
+ idleMs: 10_000,
23
+ signal: controller.signal,
24
+ });
21
25
  ```
22
26
 
23
- The timeout is required by the type. `headers` bounds the wait for the
24
- response; `idle` also restarts on every body chunk and errors the body stream
25
- when it stalls. A timeout rejects (or errors the body) with a `TimeoutError`
26
- `DOMException`, and the caller's `signal` and a `Request`'s own signal still
27
- abort the request. `timeoutMs: number` is a total deadline over the request
28
- and its body.
27
+ The timeout is required by the type. `headers` bounds only the wait for the
28
+ response; `total` bounds the request and its body consumption through the
29
+ transport's own signal. Both policies return the transport's response
30
+ unchanged. `readBodyWithIdleDeadline` consumes the caller's own body reader,
31
+ restarts its deadline after every chunk, and cancels a stalled body with a
32
+ `TimeoutError` `DOMException`. The caller's `signal` and a `Request`'s own
33
+ signal still abort the request.
34
+ `timeoutMs: number` remains the legacy numeric form of the total policy.
29
35
 
30
36
  `createFetchWithTimeout(fetcher)` returns the same function over another
31
37
  `fetch` implementation (a proxy-aware client, or a fake in tests).
@@ -0,0 +1,22 @@
1
+ //#region src/body-reader.d.ts
2
+ declare const BodyByteLimitError_base: import("better-result").TaggedErrorClass<"BodyByteLimitError">;
3
+ export declare class BodyByteLimitError extends BodyByteLimitError_base<{
4
+ message: string;
5
+ limit: number;
6
+ }> {}
7
+ type BodyIdleDeadlineOptions = {
8
+ idleMs: number;
9
+ maxBytes?: number | undefined;
10
+ signal?: AbortSignal | undefined;
11
+ };
12
+ /**
13
+ * Yields response chunks while bounding the wait for each successive chunk.
14
+ * @yields Each byte chunk from the transport body.
15
+ */
16
+ export declare function iterateBodyWithIdleDeadline(response: Response, { idleMs, maxBytes, signal }: BodyIdleDeadlineOptions): AsyncGenerator<Uint8Array, void, void>;
17
+ /** Reads a response body while bounding the wait for each successive chunk. */
18
+ export declare const readBodyWithIdleDeadline: (response: Response, options: BodyIdleDeadlineOptions) => Promise<Uint8Array>;
19
+ export declare const readBodyTextWithIdleDeadline: (response: Response, options: BodyIdleDeadlineOptions) => Promise<string>;
20
+ export declare const readBodyJsonWithIdleDeadline: (response: Response, options: BodyIdleDeadlineOptions) => Promise<unknown>;
21
+ //#endregion
22
+ export {};
@@ -0,0 +1,78 @@
1
+ import { t as raceAbort } from "./race-abort-wtommv8G.js";
2
+ import { TaggedError } from "better-result";
3
+ //#region src/body-reader.ts
4
+ var BodyByteLimitError = class extends TaggedError("BodyByteLimitError") {};
5
+ const invalidIdleMs = (idleMs) => {
6
+ if (!Number.isFinite(idleMs) || idleMs < 0 || idleMs > 2147483647) return new DOMException("Invalid body idle deadline duration", "NotSupportedError");
7
+ };
8
+ const responseReader = (body) => body.getReader();
9
+ /**
10
+ * Yields response chunks while bounding the wait for each successive chunk.
11
+ * @yields Each byte chunk from the transport body.
12
+ */
13
+ async function* iterateBodyWithIdleDeadline(response, { idleMs, maxBytes, signal }) {
14
+ const invalidDuration = invalidIdleMs(idleMs);
15
+ if (invalidDuration !== void 0) await Promise.reject(invalidDuration);
16
+ if (signal?.aborted === true) {
17
+ response.body?.cancel(signal.reason).catch((cancellationError) => {});
18
+ signal.throwIfAborted();
19
+ }
20
+ if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 0)) await Promise.reject(new DOMException("Invalid response body byte limit", "NotSupportedError"));
21
+ const body = response.body;
22
+ if (body === null) return;
23
+ const reader = responseReader(body);
24
+ let exitReason;
25
+ let byteLength = 0;
26
+ try {
27
+ for (;;) {
28
+ if (signal?.aborted === true) exitReason = signal.reason;
29
+ signal?.throwIfAborted();
30
+ const deadline = new AbortController();
31
+ const timeoutError = new DOMException("Response body stalled", "TimeoutError");
32
+ const timer = setTimeout(() => deadline.abort(timeoutError), idleMs);
33
+ const combined = signal === void 0 ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
34
+ let next;
35
+ try {
36
+ next = await raceAbort(reader.read(), combined);
37
+ } finally {
38
+ exitReason ??= combined.reason;
39
+ clearTimeout(timer);
40
+ }
41
+ if (next.done) return;
42
+ byteLength += next.value.byteLength;
43
+ if (maxBytes !== void 0 && byteLength > maxBytes) {
44
+ const limitError = new BodyByteLimitError({
45
+ message: `Response body exceeds the ${maxBytes}-byte limit`,
46
+ limit: maxBytes
47
+ });
48
+ exitReason = limitError;
49
+ await Promise.reject(limitError);
50
+ }
51
+ yield next.value;
52
+ }
53
+ } finally {
54
+ reader.cancel(exitReason).then(() => reader.releaseLock()).catch((cancellationError) => {
55
+ reader.releaseLock();
56
+ });
57
+ }
58
+ }
59
+ /** Reads a response body while bounding the wait for each successive chunk. */
60
+ const readBodyWithIdleDeadline = async (response, options) => {
61
+ const chunks = [];
62
+ let byteLength = 0;
63
+ for await (const chunk of iterateBodyWithIdleDeadline(response, options)) {
64
+ chunks.push(chunk);
65
+ byteLength += chunk.byteLength;
66
+ }
67
+ const bytes = new Uint8Array(byteLength);
68
+ let offset = 0;
69
+ for (const chunk of chunks) {
70
+ bytes.set(chunk, offset);
71
+ offset += chunk.byteLength;
72
+ }
73
+ return bytes;
74
+ };
75
+ const readBodyTextWithIdleDeadline = async (response, options) => new TextDecoder().decode(await readBodyWithIdleDeadline(response, options));
76
+ const readBodyJsonWithIdleDeadline = async (response, options) => JSON.parse(await readBodyTextWithIdleDeadline(response, options));
77
+ //#endregion
78
+ export { BodyByteLimitError, iterateBodyWithIdleDeadline, readBodyJsonWithIdleDeadline, readBodyTextWithIdleDeadline, readBodyWithIdleDeadline };
@@ -3,10 +3,10 @@ declare const isConnectionFailure: (cause: unknown) => boolean;
3
3
  //#endregion
4
4
  //#region src/index.d.ts
5
5
  type FetchTimeout = {
6
- type: "headers";
6
+ type: "total";
7
7
  ms: number;
8
8
  } | {
9
- type: "idle";
9
+ type: "headers";
10
10
  ms: number;
11
11
  };
12
12
  type TransportRequestInit = RequestInit & {
@@ -18,9 +18,9 @@ type FetchWithTimeoutInit = Omit<RequestInit, "signal"> & {
18
18
  signal?: AbortSignal | undefined;
19
19
  } & ({
20
20
  timeout: FetchTimeout;
21
- timeoutMs?: never;
21
+ timeoutMs?: number;
22
22
  } | {
23
- /** Choose a headers or idle timeout for new callers. */
23
+ /** Use an explicit timeout policy for new callers. */
24
24
  timeoutMs: number;
25
25
  timeout?: never;
26
26
  });
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as createFetchWithTimeout, i as TransportRequestInit, n as FetchWithTimeoutInit, o as fetchWithTimeout, r as Fetcher, s as isConnectionFailure, t as FetchTimeout } from "./index-DNw-Gzhr.js";
1
+ import { a as createFetchWithTimeout, i as TransportRequestInit, n as FetchWithTimeoutInit, o as fetchWithTimeout, r as Fetcher, s as isConnectionFailure, t as FetchTimeout } from "./index-C-MNDO3-.js";
2
2
  export { FetchTimeout, FetchWithTimeoutInit, Fetcher, TransportRequestInit, createFetchWithTimeout, fetchWithTimeout, isConnectionFailure };
package/dist/index.js CHANGED
@@ -53,14 +53,14 @@ const isConnectionFailure = (cause) => {
53
53
  const executeFetchWithTimeout = async (fetcher, input, { timeout, timeoutMs, signal, ...init }) => {
54
54
  const callerSignals = [signal, input instanceof Request ? input.signal : void 0].filter((candidate) => candidate !== void 0);
55
55
  const activeFetcher = fetcher ?? globalThis.fetch;
56
- if (timeout === void 0) {
57
- const total = AbortSignal.timeout(timeoutMs);
58
- return await activeFetcher(input, {
59
- ...init,
60
- signal: AbortSignal.any([...callerSignals, total])
61
- });
62
- }
63
- if (!Number.isFinite(timeout.ms) || timeout.ms < 0 || timeout.ms > 2147483647) throw new DOMException("Invalid fetch timeout duration", "NotSupportedError");
56
+ const duration = timeout?.ms ?? timeoutMs;
57
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0 || duration > 2147483647) throw new DOMException("Invalid fetch timeout duration", "NotSupportedError");
58
+ if (timeout === void 0 || timeout.type === "total") return await activeFetcher(input, {
59
+ ...init,
60
+ signal: AbortSignal.any([...callerSignals, AbortSignal.timeout(duration)]),
61
+ ...timeout === void 0 ? {} : { timeout },
62
+ timeoutMs: duration
63
+ });
64
64
  const controller = new AbortController();
65
65
  const combined = AbortSignal.any([...callerSignals, controller.signal]);
66
66
  const expire = () => controller.abort(new DOMException("Fetch timed out", "TimeoutError"));
@@ -69,86 +69,18 @@ const executeFetchWithTimeout = async (fetcher, input, { timeout, timeoutMs, sig
69
69
  clearTimeout(timer);
70
70
  timer = void 0;
71
71
  };
72
- const reset = () => {
73
- clear();
74
- timer = setTimeout(expire, timeout.ms);
75
- };
76
- reset();
72
+ timer = setTimeout(expire, duration);
77
73
  const response = await Promise.resolve().then(async () => await activeFetcher(input, {
78
74
  ...init,
79
- signal: combined
75
+ signal: combined,
76
+ timeout,
77
+ timeoutMs: duration
80
78
  })).finally(clear);
81
79
  if (combined.aborted) {
82
80
  await Promise.allSettled([response.body?.cancel(combined.reason)]);
83
81
  throw combined.reason;
84
82
  }
85
- if (timeout.type === "headers" || response.body === null) return response;
86
- const reader = response.body.getReader();
87
- let stopped = false;
88
- let cancellation;
89
- let abort;
90
- const finish = () => {
91
- stopped = true;
92
- clear();
93
- combined.removeEventListener("abort", abort);
94
- };
95
- const body = new ReadableStream({
96
- start(stream) {
97
- abort = () => {
98
- if (stopped) return;
99
- finish();
100
- stream.error(combined.reason);
101
- cancellation = reader.cancel(combined.reason).then(() => reader.releaseLock(), () => reader.releaseLock());
102
- };
103
- combined.addEventListener("abort", abort, { once: true });
104
- if (combined.aborted) {
105
- abort();
106
- return;
107
- }
108
- },
109
- async pull(stream) {
110
- if (stopped) {
111
- await cancellation;
112
- return;
113
- }
114
- reset();
115
- await reader.read().finally(clear).then(({ done, value }) => {
116
- if (stopped) return;
117
- if (done) {
118
- finish();
119
- reader.releaseLock();
120
- stream.close();
121
- return;
122
- }
123
- stream.enqueue(value);
124
- }, (error) => {
125
- if (stopped) return;
126
- finish();
127
- reader.releaseLock();
128
- stream.error(error);
129
- });
130
- await cancellation;
131
- },
132
- async cancel(reason) {
133
- if (stopped) {
134
- await cancellation;
135
- return;
136
- }
137
- finish();
138
- await reader.cancel(reason).finally(() => reader.releaseLock());
139
- }
140
- });
141
- const wrapped = new Response(body, {
142
- status: response.status,
143
- statusText: response.statusText,
144
- headers: response.headers
145
- });
146
- Object.defineProperties(wrapped, {
147
- url: { value: response.url },
148
- redirected: { value: response.redirected },
149
- type: { value: response.type }
150
- });
151
- return wrapped;
83
+ return response;
152
84
  };
153
85
  /** Creates a wrapper with the supplied transport and an explicit timeout policy. */
154
86
  const createFetchWithTimeout = (fetcher) => async (input, init) => await executeFetchWithTimeout(fetcher, input, init);
@@ -1,5 +1,5 @@
1
- import { i as TransportRequestInit } from "./index-DNw-Gzhr.js";
2
1
  import { Result } from "better-result";
2
+ import { i as TransportRequestInit } from "./index-C-MNDO3-.js";
3
3
  //#region src/publisher.d.ts
4
4
  export type PublisherPolicy = {
5
5
  type: "obey";
@@ -121,4 +121,5 @@ type ReadBoundedBodyOptions = {
121
121
  };
122
122
  export declare const readBoundedBody: (response: Response, { maxBytes, signal }: ReadBoundedBodyOptions) => Promise<Result<Uint8Array, PublisherBodyLimitError>>;
123
123
  export declare const createPolitePublisherFetch: <Target extends PublisherTarget>({ validateTarget, fetcher, clock, robotsTtlMs, robotsRetryMs, robotsLoadTimeoutMs, defaultMinimumIntervalMs, onTelemetry }: PublisherFetcherOptions<Target>) => (input: string | URL, options?: PublisherFetchOptions) => Promise<Result<PublisherFetchResult, PublisherPolicyError>>;
124
- //#endregion
124
+ //#endregion
125
+ export {};
package/dist/publisher.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as raceAbort } from "./race-abort-wtommv8G.js";
1
2
  import { Result, TaggedError } from "better-result";
2
3
  //#region src/publisher.ts
3
4
  const ROBOTS_MAX_BYTES = 524288;
@@ -193,19 +194,6 @@ const evaluatePublisherRobots = (groups, productToken, path) => {
193
194
  crawlDelayMs: Math.max(0, ...selected.map(({ crawlDelayMs }) => crawlDelayMs ?? 0))
194
195
  };
195
196
  };
196
- const abortReason = (signal) => signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
197
- const raceAbort = async (work, signal) => {
198
- if (!signal) return await work;
199
- const { promise: aborted, reject } = Promise.withResolvers();
200
- const onAbort = () => reject(abortReason(signal));
201
- if (signal.aborted) onAbort();
202
- else signal.addEventListener("abort", onAbort, { once: true });
203
- try {
204
- return await Promise.race([work, aborted]);
205
- } finally {
206
- signal.removeEventListener("abort", onAbort);
207
- }
208
- };
209
197
  var PublisherBodyLimitError = class extends TaggedError("publisher_body_limit") {};
210
198
  const readBoundedBody = async (response, { maxBytes, signal }) => {
211
199
  if (!response.body) return Result.ok(/* @__PURE__ */ new Uint8Array());
@@ -0,0 +1,16 @@
1
+ //#region src/race-abort.ts
2
+ const abortReason = (signal) => signal.reason ?? new DOMException("The operation was aborted", "AbortError");
3
+ const raceAbort = async (work, signal) => {
4
+ if (signal === void 0) return await work;
5
+ const { promise: aborted, reject } = Promise.withResolvers();
6
+ const onAbort = () => reject(abortReason(signal));
7
+ if (signal.aborted) onAbort();
8
+ else signal.addEventListener("abort", onAbort, { once: true });
9
+ try {
10
+ return await Promise.race([work, aborted]);
11
+ } finally {
12
+ signal.removeEventListener("abort", onAbort);
13
+ }
14
+ };
15
+ //#endregion
16
+ export { raceAbort as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stll/fetch",
3
- "version": "0.1.0",
4
- "description": "Fetch with a mandatory timeout (headers or idle body window), connection-failure classification, and a robots-aware polite publisher fetch",
3
+ "version": "0.2.0",
4
+ "description": "Fetch with mandatory total or header timeouts, idle-bounded body reads, connection-failure classification, and a robots-aware polite publisher fetch",
5
5
  "keywords": [
6
6
  "abort",
7
7
  "fetch",
@@ -33,6 +33,10 @@
33
33
  "./publisher": {
34
34
  "types": "./dist/publisher.d.ts",
35
35
  "import": "./dist/publisher.js"
36
+ },
37
+ "./body-reader": {
38
+ "types": "./dist/body-reader.d.ts",
39
+ "import": "./dist/body-reader.js"
36
40
  }
37
41
  },
38
42
  "publishConfig": {
@@ -57,7 +61,7 @@
57
61
  "@stll/typescript-config": "0.0.0",
58
62
  "bun-types": "1.4.4",
59
63
  "fast-check": "^4.10.2",
60
- "tsdown": "0.23.0"
64
+ "tsdown": "0.23.1"
61
65
  },
62
66
  "main": "./dist/index.js",
63
67
  "types": "./dist/index.d.ts"