@boy-offi9-inc/reqkit 0.7.0 → 0.8.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.
Files changed (34) hide show
  1. package/README.md +95 -55
  2. package/dist/reqkit.mjs +2143 -1907
  3. package/package.json +9 -5
  4. package/src/{createFetch.js → http/createFetch.js} +169 -30
  5. package/src/index.js +18 -18
  6. package/src/{_http.js → internal/http.js} +11 -1
  7. package/src/{paginate.js → pagination/paginate.js} +33 -4
  8. package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
  9. package/src/{withRetry.js → resilience/retry.js} +2 -2
  10. package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
  11. package/src/{downloadResumable.js → streams/download.js} +5 -5
  12. package/src/{streams.js → streams/readers.js} +64 -20
  13. package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
  14. package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
  15. package/types/errors.d.ts +24 -0
  16. package/types/http.d.ts +180 -0
  17. package/types/index.d.ts +7 -518
  18. package/types/pagination.d.ts +62 -0
  19. package/types/resilience.d.ts +171 -0
  20. package/types/streams.d.ts +105 -0
  21. package/types/testing.d.ts +44 -0
  22. /package/{RECIPES.md → docs/recipes.md} +0 -0
  23. /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
  24. /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
  25. /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
  26. /package/src/{_cache.js → http/cache/entry.js} +0 -0
  27. /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
  28. /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
  29. /package/src/{_sleep.js → internal/sleep.js} +0 -0
  30. /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
  31. /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
  32. /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
  33. /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
  34. /package/src/{withProgress.js → streams/progress.js} +0 -0
@@ -34,46 +34,83 @@ async function* chunks(source, signal) {
34
34
  }
35
35
  }
36
36
 
37
+ const DEFAULT_MAX_LINE_LENGTH = 16 * 1024 * 1024;
38
+
39
+ function lineTooLong(limit) {
40
+ return Object.assign(
41
+ new Error(`reqkit streams: a line exceeded ${limit} characters without a line break (raise \`maxLineLength\` if that's expected)`),
42
+ { code: "LINE_TOO_LONG" }
43
+ );
44
+ }
45
+
37
46
  /**
38
47
  * Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
39
48
  * multi-byte characters split across chunks, and a leading BOM.
40
49
  *
50
+ * Runs in linear time and bounded memory: only newly arrived text is scanned,
51
+ * and a "line" longer than `maxLineLength` characters (default 16 Mi — a
52
+ * server that never sends a newline) throws `code: "LINE_TOO_LONG"` instead
53
+ * of buffering forever.
54
+ *
41
55
  * ```js
42
56
  * for await (const line of readLines(response)) console.log(line);
43
57
  * ```
44
58
  *
45
59
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
46
- * @param {{ signal?: AbortSignal }} [opts]
60
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
47
61
  * @returns {AsyncGenerator<string>}
48
62
  */
49
63
  async function* readLines(source, opts = {}) {
64
+ const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
50
65
  const decoder = new TextDecoder("utf-8");
51
- let buffer = "";
66
+ const terminator = /[\r\n]/g;
67
+
68
+ let parts = []; // pieces of the line being assembled (no terminators in them)
69
+ let partsLength = 0;
70
+ let skipLF = false; // the previous line ended with "\r", so a leading "\n" belongs to it
52
71
  let first = true;
53
72
 
54
73
  for await (const chunk of chunks(source, opts.signal)) {
55
- buffer += decoder.decode(chunk, { stream: true });
56
- if (first && buffer.length > 0) {
57
- buffer = buffer.replace(/^\uFEFF/, "");
74
+ let text = decoder.decode(chunk, { stream: true });
75
+ if (first && text.length > 0) {
76
+ text = text.replace(/^\uFEFF/, "");
58
77
  first = false;
59
78
  }
60
79
 
61
- let pos = 0;
62
- for (;;) {
63
- const rest = buffer.slice(pos);
64
- const i = rest.search(/\r\n|\n|\r/);
65
- if (i === -1) break;
66
- // A trailing "\r" might be the first half of "\r\n" — wait for more data.
67
- if (rest[i] === "\r" && i === rest.length - 1) break;
68
- yield rest.slice(0, i);
69
- pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
80
+ let start = 0;
81
+ terminator.lastIndex = 0;
82
+ let match;
83
+ while ((match = terminator.exec(text)) !== null) {
84
+ const ch = match[0];
85
+ if (ch === "\n" && skipLF && match.index === start) {
86
+ skipLF = false; // the "\n" of a "\r\n" pair — swallow it
87
+ start = match.index + 1;
88
+ continue;
89
+ }
90
+ skipLF = false;
91
+
92
+ const piece = text.slice(start, match.index);
93
+ if (partsLength + piece.length > maxLineLength) throw lineTooLong(maxLineLength);
94
+ const line = parts.length > 0 ? parts.join("") + piece : piece;
95
+ parts = [];
96
+ partsLength = 0;
97
+ yield line;
98
+
99
+ start = match.index + 1;
100
+ skipLF = ch === "\r";
101
+ }
102
+
103
+ if (start < text.length) {
104
+ const rest = text.slice(start);
105
+ partsLength += rest.length;
106
+ if (partsLength > maxLineLength) throw lineTooLong(maxLineLength);
107
+ parts.push(rest);
108
+ skipLF = false;
70
109
  }
71
- buffer = buffer.slice(pos);
72
110
  }
73
111
 
74
- buffer += decoder.decode();
75
- if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
76
- if (buffer.length > 0) yield buffer;
112
+ const tail = parts.join("") + decoder.decode();
113
+ if (tail.length > 0) yield tail;
77
114
  }
78
115
 
79
116
  /**
@@ -113,11 +150,13 @@ async function* readNDJSON(source, opts = {}) {
113
150
  * ```
114
151
  *
115
152
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
116
- * @param {{ signal?: AbortSignal }} [opts]
153
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
117
154
  */
118
155
  async function* readSSE(source, opts = {}) {
156
+ const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
119
157
  let event = "";
120
158
  let data = [];
159
+ let dataLength = 0;
121
160
  let retry;
122
161
  let lastId = "";
123
162
 
@@ -126,6 +165,7 @@ async function* readSSE(source, opts = {}) {
126
165
  if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
127
166
  event = "";
128
167
  data = [];
168
+ dataLength = 0;
129
169
  retry = undefined;
130
170
  continue;
131
171
  }
@@ -137,7 +177,11 @@ async function* readSSE(source, opts = {}) {
137
177
  if (value.startsWith(" ")) value = value.slice(1);
138
178
 
139
179
  if (field === "event") event = value;
140
- else if (field === "data") data.push(value);
180
+ else if (field === "data") {
181
+ dataLength += value.length + 1;
182
+ if (dataLength > maxEventLength) throw lineTooLong(maxEventLength);
183
+ data.push(value);
184
+ }
141
185
  else if (field === "id") {
142
186
  if (!value.includes("\0")) lastId = value;
143
187
  } else if (field === "retry") {
@@ -1,8 +1,8 @@
1
- const { readSSE } = require("./streams");
2
- const { HTTPError } = require("./HTTPError");
3
- const { normalizeError } = require("./normalizeError");
4
- const { getWaitMsFromHeaders, discardBody } = require("./_http");
5
- const { abortableSleep } = require("./_sleep");
1
+ const { readSSE } = require("./readers");
2
+ const { HTTPError } = require("../errors/HTTPError");
3
+ const { normalizeError } = require("../errors/normalizeError");
4
+ const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
5
+ const { abortableSleep } = require("../internal/sleep");
6
6
 
7
7
  function isRetryableStatus(status) {
8
8
  return status === 408 || status === 425 || status === 429 || status >= 500;
@@ -1,4 +1,4 @@
1
- const { abortableSleep } = require("./_sleep");
1
+ const { abortableSleep } = require("../internal/sleep");
2
2
 
3
3
  const BODYLESS = new Set([101, 204, 205, 304]);
4
4
 
@@ -0,0 +1,24 @@
1
+ // Error types and classification — src/errors/
2
+
3
+ export interface NormalizedError {
4
+ message: string;
5
+ status: number | null;
6
+ code: string | null;
7
+ isNetworkError: boolean;
8
+ isTimeout: boolean;
9
+ /** True when the request was cancelled on purpose (AbortController, axios cancel). */
10
+ isAborted: boolean;
11
+ cause: unknown;
12
+ }
13
+
14
+ export function normalizeError(err: unknown): NormalizedError;
15
+
16
+ export class HTTPError extends Error {
17
+ name: "HTTPError";
18
+ status: number;
19
+ statusText: string;
20
+ response: Response;
21
+ url: string;
22
+ method: string;
23
+ constructor(response: Response, request: { method: string; url: string });
24
+ }
@@ -0,0 +1,180 @@
1
+ // createFetch and the request/response helpers — src/http/
2
+ import type { CircuitBreaker, CircuitBreakerOptions, CircuitState, RateLimiter, RateLimiterOptions, RetryOptions } from "./resilience";
3
+
4
+ export interface JsonSafeResult<T = unknown> {
5
+ data: T | null;
6
+ error: Error | null;
7
+ }
8
+
9
+ export function parseJsonSafe<T = unknown>(input: Response | string): Promise<JsonSafeResult<T>>;
10
+
11
+ export function buildQuery(params: Record<string, unknown> | null | undefined): string;
12
+
13
+ export interface CreateFetchRetryOptions {
14
+ /** Default 2. */
15
+ retries?: number;
16
+ /** Default [408, 425, 429, 500, 502, 503, 504]. */
17
+ statusCodes?: number[];
18
+ /** Methods that may be retried. Default GET, HEAD, OPTIONS, PUT, DELETE. */
19
+ methods?: string[];
20
+ baseDelayMs?: number;
21
+ maxDelayMs?: number;
22
+ factor?: number;
23
+ jitter?: number | "full";
24
+ maxRetryTime?: number;
25
+ shouldRetry?: (err: unknown, attempt: number) => boolean;
26
+ calculateDelay?: RetryOptions["calculateDelay"];
27
+ onRetry?: RetryOptions["onRetry"];
28
+ }
29
+
30
+ export interface FetchHookContext {
31
+ url: string;
32
+ init: RequestInit & { headers: Headers };
33
+ }
34
+
35
+ export interface CreateFetchDefaults {
36
+ baseUrl?: string;
37
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
38
+ /** Per attempt, in ms. false disables. Default 30000. */
39
+ timeout?: number | false;
40
+ /** false disables retrying. */
41
+ retry?: false | CreateFetchRetryOptions;
42
+ /**
43
+ * `true` only learns from X-RateLimit-* / Retry-After headers; `{ requests, perMs }`
44
+ * also enforces a budget; or share a createRateLimiter() instance.
45
+ */
46
+ rateLimit?: boolean | RateLimiterOptions | RateLimiter;
47
+ /** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
48
+ circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
49
+ /**
50
+ * Extra origins ("https://cdn.example.com") that may receive the default credentials
51
+ * (`auth` and default Authorization/Cookie/X-API-Key headers), besides `baseUrl`'s.
52
+ * Absolute URLs to any other origin don't get them. With neither `baseUrl` nor
53
+ * `trustedOrigins` there's nothing to compare against and credentials go everywhere.
54
+ */
55
+ trustedOrigins?: string[];
56
+ /** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
57
+ auth?: AuthOptions;
58
+ /** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
59
+ cache?: boolean | CacheOptions;
60
+ /** Merge identical concurrent GET/HEAD calls. Default false. */
61
+ dedupe?: boolean;
62
+ /** Throw HTTPError on non-2xx. Default true. */
63
+ throwHttpErrors?: boolean;
64
+ fetch?: typeof fetch;
65
+ onRequest?: (ctx: FetchHookContext) => void | Promise<void>;
66
+ onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
67
+ /** One callback for everything that happens. Errors thrown by the handler are ignored. */
68
+ onEvent?: (event: FetchEvent) => void | Promise<void>;
69
+ }
70
+
71
+ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"> {
72
+ headers?: HeadersInit;
73
+ query?: Record<string, unknown>;
74
+ /** Serialized as the body with a JSON content-type. */
75
+ json?: unknown;
76
+ timeout?: number | false;
77
+ retry?: false | CreateFetchRetryOptions;
78
+ throwHttpErrors?: boolean;
79
+ /** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
80
+ cache?: false | "no-store" | "reload";
81
+ }
82
+
83
+ export interface ReqkitFetch {
84
+ (input: string, init?: ReqkitRequestInit): Promise<Response>;
85
+ /** The `baseUrl` this instance was created with. */
86
+ readonly baseUrl: string | undefined;
87
+ /** Resolves to the parsed JSON body (null for an empty body). */
88
+ json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
89
+ /** New instance with these defaults merged over the current ones. */
90
+ extend(overrides?: CreateFetchDefaults): ReqkitFetch;
91
+ }
92
+
93
+ export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
94
+
95
+ export interface CacheStore {
96
+ get(key: string): any | Promise<any>;
97
+ set(key: string, value: any): void | Promise<void>;
98
+ delete(key: string): any;
99
+ }
100
+
101
+ export interface MemoryCache extends CacheStore {
102
+ clear(): void;
103
+ readonly size: number;
104
+ }
105
+
106
+ export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
107
+
108
+ export interface CacheEvent {
109
+ type: "hit" | "stale" | "revalidated" | "miss" | "store" | "stale-if-error";
110
+ url: string;
111
+ }
112
+
113
+ export interface CacheOptions {
114
+ /** How long an entry is served without asking the server, in ms. Default 60000. */
115
+ ttl?: number;
116
+ /** After the TTL, keep serving the stale copy for this long while refreshing in the background. Default 0. */
117
+ staleWhileRevalidate?: number;
118
+ /**
119
+ * How long past the TTL a cached copy may still be served when the origin is failing
120
+ * (network error, timeout, open circuit, or a 5xx). 4xx responses and aborts are never masked. Default 0 (off).
121
+ */
122
+ staleIfError?: number;
123
+ /** For the built-in memory store. Default 100. */
124
+ maxEntries?: number;
125
+ /** Responses larger than this aren't cached. Default 1 MiB. */
126
+ maxBodyBytes?: number;
127
+ /** Bring your own store (Redis, IndexedDB, a Map...). */
128
+ store?: CacheStore;
129
+ onEvent?: (event: CacheEvent) => void;
130
+ }
131
+
132
+ export interface AuthOptions {
133
+ /** The current token (sync or async). Falsy = send no auth header. */
134
+ getToken(): string | null | undefined | Promise<string | null | undefined>;
135
+ /**
136
+ * Called ONCE when requests are rejected (401 by default), no matter how many
137
+ * are waiting. Return the new token, or update your store and return nothing
138
+ * (reqkit then calls getToken() again). Throwing rejects every waiting request.
139
+ */
140
+ refresh(ctx: { token: string; response: Response }): string | void | undefined | Promise<string | void | undefined>;
141
+ /** Default "authorization". */
142
+ header?: string;
143
+ /** Default "Bearer". Use "" to send the bare token (e.g. for an X-API-Key header). */
144
+ scheme?: string;
145
+ /** Default: `res.status === 401`. */
146
+ shouldRefresh?: (response: Response) => boolean;
147
+ }
148
+
149
+ /** Events delivered to `createFetch({ onEvent })`. */
150
+ export type FetchEvent =
151
+ | { type: "request"; url: string; method: string }
152
+ | { type: "attempt"; url: string; method: string; /** 1-based */ attempt: number }
153
+ | {
154
+ type: "retry";
155
+ url: string;
156
+ method: string;
157
+ /** The attempt that failed (1-based). */
158
+ attempt: number;
159
+ delayMs: number;
160
+ reason: "error" | "status";
161
+ status?: number;
162
+ error?: unknown;
163
+ }
164
+ | {
165
+ type: "response";
166
+ url: string;
167
+ method: string;
168
+ status: number;
169
+ ok: boolean;
170
+ durationMs: number;
171
+ /** Network attempts made (0 for a cache hit). */
172
+ attempts: number;
173
+ /** How the cache was involved, if it was. */
174
+ cache?: "hit" | "stale" | "revalidated" | "miss" | "stale-if-error";
175
+ }
176
+ | { type: "error"; url?: string; method?: string; error: unknown; durationMs: number; attempts: number }
177
+ | { type: "cache"; url: string; method: "GET"; event: CacheEvent["type"] }
178
+ | { type: "ratelimit"; url: string; method: string; waitedMs: number }
179
+ | { type: "circuit"; origin: string; state: CircuitState; previous: CircuitState }
180
+ | { type: "auth"; url: string; method: string; outcome: "refreshed" | "reused" | "failed"; error?: unknown };