@yedoma-labs/ilyn-pages 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yedoma-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,217 @@
1
- # Temporary Holding Version
1
+ # @yedoma-labs/ilyn-pages
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Async iteration over every page of a paginated HTTP API, with guards against the usual failure modes:
4
+ endless cursor loops, skipped or duplicated rows, rate limits in the middle of a run and hanging requests.
5
+
6
+ Zero runtime dependencies. Uses only `fetch`, `URL`, `AbortController` and `Headers`, so it runs on
7
+ Node.js 20+, Bun and Deno.
8
+
9
+ ## Why
10
+
11
+ Fetching "all pages" of an API looks trivial and fails in production:
12
+
13
+ - **Offset pagination** (`?offset=200&limit=100`) skips or repeats rows when data changes between two
14
+ requests. Rows inserted before the current offset shift everything down by one, and rows deleted
15
+ shift everything up.
16
+ - **Cursor loops** never end when a server returns the same cursor again.
17
+ - **No page limit** means a misbehaving server can keep a client reading forever.
18
+ - **HTTP 429** in the middle of a loop aborts the whole run unless you handle it per page.
19
+ - **Next links** appear in the body (`{ "next": "..." }`) for some APIs and in the `Link` header
20
+ (RFC 8288) for others. Each needs its own parsing code.
21
+ - `@octokit/plugin-paginate-rest` solves this for GitHub only.
22
+
23
+ `ilyn-pages` is the generic layer for the rest.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ pnpm add @yedoma-labs/ilyn-pages
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ### Next link in the `Link` header
34
+
35
+ ```ts
36
+ import { createFetchPager, paginate } from "@yedoma-labs/ilyn-pages";
37
+
38
+ interface Item {
39
+ id: string;
40
+ }
41
+
42
+ const pager = createFetchPager<Item>({
43
+ url: "https://api.example.com/items?per_page=100",
44
+ extract: (body) => (body as { items: Item[] }).items,
45
+ // The next URL comes from `Link: <...>; rel="next"` by default.
46
+ });
47
+
48
+ for await (const item of paginate({ fetchPage: pager, maxItems: 10_000 })) {
49
+ console.log(item.id);
50
+ }
51
+ ```
52
+
53
+ ### Next link in the body
54
+
55
+ ```ts
56
+ const pager = createFetchPager<Item>({
57
+ url: "https://api.example.com/items",
58
+ extract: (body) => (body as { data: Item[] }).data,
59
+ nextFromResponse: (_res, body) => (body as { meta: { next: string | null } }).meta.next,
60
+ });
61
+ ```
62
+
63
+ Relative next URLs are resolved against the URL of the current request.
64
+
65
+ ### Opaque cursors with your own `fetchPage`
66
+
67
+ ```ts
68
+ import { paginate, type Page } from "@yedoma-labs/ilyn-pages";
69
+
70
+ const items = paginate({
71
+ fetchPage: async (cursor, signal): Promise<Page<Item>> => {
72
+ const url = new URL("https://api.example.com/items");
73
+ if (cursor !== undefined) url.searchParams.set("cursor", cursor);
74
+ const res = await fetch(url, { signal });
75
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
76
+ const body = (await res.json()) as { items: Item[]; nextCursor?: string | null };
77
+ return { items: body.items, nextCursor: body.nextCursor };
78
+ },
79
+ });
80
+ ```
81
+
82
+ ### Handling errors
83
+
84
+ ```ts
85
+ import { PaginationError } from "@yedoma-labs/ilyn-pages";
86
+
87
+ try {
88
+ for await (const item of pages) {
89
+ // ...
90
+ }
91
+ } catch (error) {
92
+ if (error instanceof PaginationError) {
93
+ switch (error.code) {
94
+ case "CURSOR_LOOP":
95
+ case "EMPTY_PAGES_LOOP":
96
+ // The server is misbehaving. Report it and stop.
97
+ break;
98
+ case "PAGE_LIMIT":
99
+ // The result is larger than maxPages. Raise the limit or narrow the query.
100
+ break;
101
+ case "RATE_LIMITED":
102
+ case "TIMEOUT":
103
+ // Retry later, the items already yielded are kept by the caller.
104
+ break;
105
+ default:
106
+ throw error;
107
+ }
108
+ console.error(`failed on page ${error.page}: ${error.message}`);
109
+ } else {
110
+ throw error;
111
+ }
112
+ }
113
+ ```
114
+
115
+ Items that were yielded before an error are not rolled back. Keep what you need as you go.
116
+
117
+ ## Guards
118
+
119
+ | Guard | Trigger | Result |
120
+ | --- | --- | --- |
121
+ | Cursor loop | A `nextCursor` was already used in this run | `CURSOR_LOOP` |
122
+ | Empty-page loop | 3 consecutive pages without items that still have a cursor | `EMPTY_PAGES_LOOP` |
123
+ | Page limit | More pages are available after `maxPages` (default 100) | `PAGE_LIMIT` |
124
+ | Item limit | `maxItems` items were yielded | Stops without error |
125
+ | Page timeout | One `fetchPage` call (including `Retry-After` waits) exceeds `timeoutMs` (default 30000) | `TIMEOUT` |
126
+ | Total timeout | The whole iteration exceeds `totalTimeoutMs` (default: none) | `TIMEOUT` |
127
+ | Abort | `signal` is aborted | `ABORTED` |
128
+ | Early exit | `break` in `for await`, or `return()` | Open request signal is aborted |
129
+ | Rate limit | HTTP 429 | Waits for `Retry-After` (seconds or HTTP date, capped at 60 s), retries the page up to 3 times, then `RATE_LIMITED` |
130
+ | Foreign next URL | Next URL has another origin than the start URL | `INVALID_LINK` (unless `allowCrossOriginNext: true`) |
131
+ | Broken `Link` header | Malformed link that mentions `rel="next"` and no valid next link | `INVALID_LINK` |
132
+
133
+ Every error is a `PaginationError` with a `code` and a `page` number (the page whose request or
134
+ response caused the error). Error messages never contain query values or cursors.
135
+
136
+ ## API
137
+
138
+ ### `paginate<T>(options): AsyncGenerator<T>`
139
+
140
+ | Option | Default | Description |
141
+ | --- | --- | --- |
142
+ | `fetchPage(cursor, signal)` | required | Loads one page. `cursor` is `undefined` for the first page. |
143
+ | `maxPages` | `100` | Maximum number of pages. A positive integer or `Infinity`. |
144
+ | `maxItems` | `Infinity` | Stops without error after this many items. |
145
+ | `timeoutMs` | `30000` | Timeout per `fetchPage` call. |
146
+ | `totalTimeoutMs` | `Infinity` | Timeout for the whole iteration. |
147
+ | `signal` | none | Aborts the iteration with `ABORTED`. |
148
+
149
+ `fetchPage` returns `{ items: T[]; nextCursor?: string | null }`. `undefined`, `null` and `""` all
150
+ mean that there is no next page.
151
+
152
+ Options are validated when `paginate()` is called and throw a `RangeError` for invalid values.
153
+ Iteration starts with the first `next()`.
154
+
155
+ ### `createFetchPager<T>(options): FetchPage<T>`
156
+
157
+ | Option | Default | Description |
158
+ | --- | --- | --- |
159
+ | `url` | required | URL of the first page. |
160
+ | `extract(body)` | required | Returns the items of the parsed JSON body. |
161
+ | `nextFromResponse(res, body)` | `Link` header | Returns the next URL or `null`. |
162
+ | `fetch` | `globalThis.fetch` | Injected fetch implementation. |
163
+ | `allowCrossOriginNext` | `false` | Allows next URLs on another origin. |
164
+
165
+ ### `parseLinkHeader(value): Record<string, string>`
166
+
167
+ Parses an RFC 8288 `Link` header. Handles quoted and unquoted `rel`, several relations in one `rel`,
168
+ several links, extra parameters, and URIs that contain commas or semicolons. Relation names are
169
+ lower-cased. The first link wins for duplicate relations. Malformed link values are skipped.
170
+
171
+ ### `nextFromLinkHeader(headers): string | undefined`
172
+
173
+ Returns the `rel="next"` URI from a `Headers` object. The URI may be relative.
174
+
175
+ ## Offset pagination
176
+
177
+ Offset pagination is not consistent under concurrent writes. Between two requests, rows inserted
178
+ before the offset shift the window and the same row appears on two pages. Rows deleted before the
179
+ offset cause a row to be skipped. The library cannot detect this, because each page is correct for
180
+ the data at the moment it was read.
181
+
182
+ Prefer cursor or keyset pagination. Keyset pagination uses the last sort key, for example
183
+ `?after_id=123`, and keeps working when rows change. If the API only offers offsets, sort by a
184
+ stable unique key, accept that the result is a snapshot per page, and compare the total count before
185
+ and after the run.
186
+
187
+ ## Security
188
+
189
+ The next URL comes from the server and is untrusted. By default, `createFetchPager` follows a next
190
+ URL only when its origin matches the origin of the start URL. A next URL on another host, or on
191
+ `http` for an `https` start URL, raises `INVALID_LINK` and is not requested. This stops a compromised
192
+ or misconfigured server from sending the client to a foreign host, where your tokens could leak.
193
+ Enable `allowCrossOriginNext` only if the API documents cross-origin next links.
194
+
195
+ Error messages mask query values (`?token=***`). Credentials and fragments are removed from URLs in
196
+ errors.
197
+
198
+ See [SECURITY.md](SECURITY.md) for the full security model and known limitations.
199
+
200
+ ## Runtime support
201
+
202
+ Node.js 20+, Bun and Deno. The package is ESM with a CommonJS build. Tests use an injected `fetch`
203
+ and never touch the network.
204
+
205
+ ## Development
206
+
207
+ ```sh
208
+ pnpm install
209
+ pnpm lint
210
+ pnpm typecheck
211
+ pnpm test
212
+ pnpm build
213
+ ```
214
+
215
+ ## License
216
+
217
+ MIT. See [LICENSE](LICENSE).
package/dist/index.cjs ADDED
@@ -0,0 +1,403 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ // src/errors.ts
3
+ /**
4
+ * Error raised by the pagination guards.
5
+ *
6
+ * `page` is the 1-based number of the page whose request or response caused the error.
7
+ * Adapters such as `createFetchPager` do not know the page number and use `0`; `paginate()`
8
+ * replaces it with the real number before the error reaches the caller.
9
+ */
10
+ var PaginationError = class extends Error {
11
+ code;
12
+ page;
13
+ constructor(code, page, message, options) {
14
+ super(message, options);
15
+ this.name = "PaginationError";
16
+ this.code = code;
17
+ this.page = page;
18
+ }
19
+ };
20
+ // src/link-header.ts
21
+ const TOKEN_CHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/;
22
+ const NEXT_MENTION = /\brel\s*=\s*"?[^";,]*\bnext\b/i;
23
+ function isTokenChar(ch) {
24
+ return TOKEN_CHAR.test(ch);
25
+ }
26
+ /**
27
+ * Tokenizer for RFC 8288 Link headers. URI references are read between `<` and `>`, so commas and
28
+ * semicolons inside a URL are not treated as separators. Quoted parameter values support backslash
29
+ * escapes. Malformed link-values are skipped; the scan resumes at the next `<`.
30
+ */
31
+ function scanLinkHeader(value) {
32
+ const links = {};
33
+ let malformedNext = false;
34
+ const n = value.length;
35
+ let i = 0;
36
+ const skipSpaces = () => {
37
+ while (i < n && (value.charAt(i) === " " || value.charAt(i) === " ")) i += 1;
38
+ };
39
+ while (i < n) {
40
+ skipSpaces();
41
+ if (i >= n) break;
42
+ if (value.charAt(i) === ",") {
43
+ i += 1;
44
+ continue;
45
+ }
46
+ const linkStart = i;
47
+ let target;
48
+ if (value.charAt(i) === "<") {
49
+ const close = value.indexOf(">", i + 1);
50
+ if (close !== -1) {
51
+ target = value.slice(i + 1, close).trim();
52
+ i = close + 1;
53
+ }
54
+ }
55
+ let ok = target !== void 0;
56
+ const rels = [];
57
+ while (ok) {
58
+ skipSpaces();
59
+ if (i >= n) break;
60
+ const ch = value.charAt(i);
61
+ if (ch === ",") {
62
+ i += 1;
63
+ break;
64
+ }
65
+ if (ch !== ";") {
66
+ ok = false;
67
+ break;
68
+ }
69
+ i += 1;
70
+ skipSpaces();
71
+ const nameStart = i;
72
+ while (i < n && isTokenChar(value.charAt(i))) i += 1;
73
+ const name = value.slice(nameStart, i).toLowerCase();
74
+ if (name === "") {
75
+ ok = false;
76
+ break;
77
+ }
78
+ skipSpaces();
79
+ let paramValue;
80
+ if (value.charAt(i) === "=") {
81
+ i += 1;
82
+ skipSpaces();
83
+ if (value.charAt(i) === "\"") {
84
+ i += 1;
85
+ let quoted = "";
86
+ let closed = false;
87
+ while (i < n) {
88
+ const c = value.charAt(i);
89
+ if (c === "\\" && i + 1 < n) {
90
+ quoted += value.charAt(i + 1);
91
+ i += 2;
92
+ continue;
93
+ }
94
+ i += 1;
95
+ if (c === "\"") {
96
+ closed = true;
97
+ break;
98
+ }
99
+ quoted += c;
100
+ }
101
+ if (!closed) {
102
+ ok = false;
103
+ break;
104
+ }
105
+ paramValue = quoted;
106
+ } else {
107
+ const valueStart = i;
108
+ while (i < n && isTokenChar(value.charAt(i))) i += 1;
109
+ if (i === valueStart) {
110
+ ok = false;
111
+ break;
112
+ }
113
+ paramValue = value.slice(valueStart, i);
114
+ }
115
+ }
116
+ if (name === "rel" && paramValue !== void 0) rels.push(...paramValue.split(/\s+/).filter((rel) => rel !== ""));
117
+ }
118
+ if (ok && target !== void 0) for (const rel of rels) {
119
+ const key = rel.toLowerCase();
120
+ if (key !== "__proto__" && !Object.hasOwn(links, key)) links[key] = target;
121
+ }
122
+ else {
123
+ malformedNext = malformedNext || NEXT_MENTION.test(value.slice(linkStart));
124
+ const nextLink = value.indexOf("<", linkStart + 1);
125
+ i = nextLink === -1 ? n : nextLink;
126
+ }
127
+ }
128
+ return {
129
+ links,
130
+ malformedNext
131
+ };
132
+ }
133
+ /**
134
+ * Parses an RFC 8288 `Link` header value into a map from relation type to URI reference.
135
+ *
136
+ * Handles quoted and unquoted `rel` values, several relation types in one `rel`, several links
137
+ * separated by commas, additional parameters and URIs that contain commas or semicolons.
138
+ * Relation names are lower-cased. For duplicate relations the first link wins. Malformed
139
+ * link-values are ignored.
140
+ */
141
+ function parseLinkHeader(value) {
142
+ return scanLinkHeader(value).links;
143
+ }
144
+ /**
145
+ * Returns the URI reference of the `rel="next"` link from a `Link` header, or `undefined`.
146
+ * The URI may be relative; resolve it against the request URL.
147
+ *
148
+ * Throws a `PaginationError` with code `INVALID_LINK` when a malformed link-value mentions
149
+ * `rel="next"` and no valid next link was found. Otherwise a broken header would look like the
150
+ * last page and silently truncate the data.
151
+ */
152
+ function nextFromLinkHeader(headers) {
153
+ const raw = headers.get("link");
154
+ if (raw === null) return;
155
+ const { links, malformedNext } = scanLinkHeader(raw);
156
+ if (Object.hasOwn(links, "next")) return links.next;
157
+ if (malformedNext) throw new PaginationError("INVALID_LINK", 0, "Malformed Link header: rel=\"next\" could not be parsed");
158
+ }
159
+ // src/mask-url.ts
160
+ /**
161
+ * Returns a URL that is safe to put into an error message: credentials are removed, query values
162
+ * are replaced with `***` (keys are kept) and fragments are dropped.
163
+ */
164
+ function maskUrl(input) {
165
+ let url;
166
+ try {
167
+ url = new URL(input);
168
+ } catch {
169
+ return "<invalid URL>";
170
+ }
171
+ const origin = url.origin === "null" ? url.protocol : url.origin;
172
+ const keys = [...new Set(url.searchParams.keys())];
173
+ const query = keys.length > 0 ? `?${keys.map((key) => `${encodeURIComponent(key)}=***`).join("&")}` : "";
174
+ return `${origin}${url.pathname}${query}`;
175
+ }
176
+ // src/fetch-pager.ts
177
+ const MAX_RETRIES = 3;
178
+ const DEFAULT_RETRY_AFTER_MS = 1e3;
179
+ const MAX_RETRY_AFTER_MS = 6e4;
180
+ /**
181
+ * Parses the `Retry-After` value (delay in seconds or HTTP date) into milliseconds, capped at
182
+ * 60 seconds. A missing or unparsable value falls back to one second.
183
+ */
184
+ function retryAfterMs(header, now) {
185
+ if (header === null) return DEFAULT_RETRY_AFTER_MS;
186
+ const value = header.trim();
187
+ let ms;
188
+ if (/^\d+$/.test(value)) ms = Number(value) * 1e3;
189
+ else {
190
+ const date = Date.parse(value);
191
+ if (Number.isNaN(date)) return DEFAULT_RETRY_AFTER_MS;
192
+ ms = Math.max(0, date - now);
193
+ }
194
+ return Math.min(ms, MAX_RETRY_AFTER_MS);
195
+ }
196
+ /** Waits for `ms`, rejecting with the signal's reason if the signal aborts first. */
197
+ function sleep(ms, signal) {
198
+ return new Promise((resolve, reject) => {
199
+ if (signal.aborted) {
200
+ reject(signal.reason);
201
+ return;
202
+ }
203
+ const onAbort = () => {
204
+ clearTimeout(timer);
205
+ reject(signal.reason);
206
+ };
207
+ const timer = setTimeout(() => {
208
+ signal.removeEventListener("abort", onAbort);
209
+ resolve();
210
+ }, ms);
211
+ signal.addEventListener("abort", onAbort, { once: true });
212
+ });
213
+ }
214
+ /**
215
+ * Creates a `fetchPage` function for `paginate()` that follows URL-based cursors.
216
+ *
217
+ * - Next URLs come from the `Link` header (RFC 8288) or from `nextFromResponse`.
218
+ * - Next URLs on a different origin than `url` are rejected with `INVALID_LINK`, unless
219
+ * `allowCrossOriginNext` is set.
220
+ * - HTTP 429 waits for `Retry-After` (seconds or HTTP date, capped at 60 s) and retries the same
221
+ * page up to 3 times, then throws `RATE_LIMITED`. The wait counts towards the page timeout.
222
+ * - Other non-2xx responses throw a plain `Error` that contains only the status code.
223
+ *
224
+ * Errors thrown here have `page: 0`; `paginate()` sets the real page number.
225
+ */
226
+ function createFetchPager(options) {
227
+ const doFetch = options.fetch ?? globalThis.fetch;
228
+ if (typeof doFetch !== "function") throw new TypeError("No fetch implementation available; pass options.fetch");
229
+ const startUrl = new URL(options.url);
230
+ const startOrigin = startUrl.origin;
231
+ const allowCrossOrigin = options.allowCrossOriginNext === true;
232
+ const extract = options.extract;
233
+ const nextFromResponse = options.nextFromResponse;
234
+ const resolveNext = (raw, base) => {
235
+ if (raw === null || raw === void 0 || raw === "") return null;
236
+ let next;
237
+ try {
238
+ next = new URL(raw, base);
239
+ } catch {
240
+ throw new PaginationError("INVALID_LINK", 0, "Next link is not a valid URL");
241
+ }
242
+ if (!allowCrossOrigin && next.origin !== startOrigin) throw new PaginationError("INVALID_LINK", 0, `Next link points to another origin (${next.origin}) and was not followed. Set allowCrossOriginNext to allow it. Link: ${maskUrl(next.href)}`);
243
+ return next.href;
244
+ };
245
+ return async (cursor, signal) => {
246
+ const target = cursor === void 0 ? startUrl.href : cursor;
247
+ let retries = 0;
248
+ for (;;) {
249
+ const res = await doFetch(target, { signal });
250
+ if (res.status === 429) {
251
+ await res.body?.cancel().catch(() => void 0);
252
+ if (retries >= MAX_RETRIES) throw new PaginationError("RATE_LIMITED", 0, `Still rate limited (HTTP 429) after ${MAX_RETRIES} retries`);
253
+ retries += 1;
254
+ await sleep(retryAfterMs(res.headers.get("retry-after"), Date.now()), signal);
255
+ continue;
256
+ }
257
+ if (!res.ok) throw new Error(`Request failed with HTTP ${res.status}`);
258
+ const body = await res.json();
259
+ const items = extract(body);
260
+ if (!Array.isArray(items)) throw new TypeError("extract() must return an array of items");
261
+ const raw = nextFromResponse ? nextFromResponse(res, body) : nextFromLinkHeader(res.headers);
262
+ return {
263
+ items,
264
+ nextCursor: resolveNext(raw, target)
265
+ };
266
+ }
267
+ };
268
+ }
269
+ // src/paginate.ts
270
+ const DEFAULT_MAX_PAGES = 100;
271
+ const DEFAULT_TIMEOUT_MS = 3e4;
272
+ const MAX_EMPTY_PAGES = 3;
273
+ function resolveLimits(options) {
274
+ const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
275
+ const maxItems = options.maxItems ?? Number.POSITIVE_INFINITY;
276
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
277
+ const totalTimeoutMs = options.totalTimeoutMs ?? Number.POSITIVE_INFINITY;
278
+ if (!(maxPages === Number.POSITIVE_INFINITY || Number.isInteger(maxPages) && maxPages >= 1)) throw new RangeError("maxPages must be a positive integer or Infinity");
279
+ if (!(maxItems === Number.POSITIVE_INFINITY || Number.isInteger(maxItems) && maxItems >= 0)) throw new RangeError("maxItems must be a non-negative integer or Infinity");
280
+ if (!(timeoutMs > 0)) throw new RangeError("timeoutMs must be greater than 0");
281
+ if (!(totalTimeoutMs > 0)) throw new RangeError("totalTimeoutMs must be greater than 0");
282
+ return {
283
+ maxPages,
284
+ maxItems,
285
+ timeoutMs,
286
+ totalTimeoutMs
287
+ };
288
+ }
289
+ /** `undefined`, `null` and `""` mean "no next page". */
290
+ function normalizeCursor(value) {
291
+ return value === void 0 || value === null || value === "" ? void 0 : value;
292
+ }
293
+ /** Adapters without page context throw with page 0; this fills in the real page number. */
294
+ function withPage(error, page) {
295
+ if (error instanceof PaginationError && error.page === 0) return new PaginationError(error.code, page, error.message, { cause: error });
296
+ return error;
297
+ }
298
+ async function* iterate(fetchPage, limits, userSignal) {
299
+ const run = new AbortController();
300
+ const inflight = {};
301
+ const seen = /* @__PURE__ */ new Set();
302
+ let pageNo = 1;
303
+ let cursor;
304
+ let emitted = 0;
305
+ let emptyStreak = 0;
306
+ let totalTimer;
307
+ const onUserAbort = () => {
308
+ run.abort(new PaginationError("ABORTED", pageNo, "Pagination was aborted", { cause: userSignal?.reason }));
309
+ };
310
+ /**
311
+ * Requests one page. The page signal is aborted when the run is aborted (user signal, total
312
+ * timeout or iterator return) and after `timeoutMs`. The promise rejects as soon as the signal
313
+ * fires, even if `fetchPage` ignores the signal.
314
+ */
315
+ const request = (pageCursor, page) => new Promise((resolve, reject) => {
316
+ if (run.signal.aborted) {
317
+ reject(run.signal.reason);
318
+ return;
319
+ }
320
+ const ctl = new AbortController();
321
+ inflight.ctl = ctl;
322
+ let timer;
323
+ const onParentAbort = () => ctl.abort(run.signal.reason);
324
+ const onPageAbort = () => settle(() => reject(ctl.signal.reason));
325
+ const settle = (done) => {
326
+ clearTimeout(timer);
327
+ run.signal.removeEventListener("abort", onParentAbort);
328
+ ctl.signal.removeEventListener("abort", onPageAbort);
329
+ done();
330
+ };
331
+ run.signal.addEventListener("abort", onParentAbort, { once: true });
332
+ ctl.signal.addEventListener("abort", onPageAbort, { once: true });
333
+ if (Number.isFinite(limits.timeoutMs)) timer = setTimeout(() => {
334
+ ctl.abort(new PaginationError("TIMEOUT", page, `Page ${page} timed out after ${limits.timeoutMs} ms`));
335
+ }, limits.timeoutMs);
336
+ let work;
337
+ try {
338
+ work = Promise.resolve(fetchPage(pageCursor, ctl.signal));
339
+ } catch (error) {
340
+ work = Promise.reject(error);
341
+ }
342
+ work.then((result) => settle(() => resolve(result)), (error) => settle(() => reject(withPage(error, page))));
343
+ });
344
+ if (userSignal?.aborted) onUserAbort();
345
+ else userSignal?.addEventListener("abort", onUserAbort, { once: true });
346
+ if (Number.isFinite(limits.totalTimeoutMs)) totalTimer = setTimeout(() => {
347
+ run.abort(new PaginationError("TIMEOUT", pageNo, `Total timeout of ${limits.totalTimeoutMs} ms exceeded`));
348
+ }, limits.totalTimeoutMs);
349
+ try {
350
+ while (emitted < limits.maxItems) {
351
+ if (run.signal.aborted) throw run.signal.reason;
352
+ const result = await request(cursor, pageNo);
353
+ if (!Array.isArray(result.items)) throw new TypeError("fetchPage must resolve to an object with an items array");
354
+ const next = normalizeCursor(result.nextCursor);
355
+ if (next !== void 0 && seen.has(next)) throw new PaginationError("CURSOR_LOOP", pageNo, `Server returned a cursor that was already used (page ${pageNo}); stopping`);
356
+ if (result.items.length === 0) {
357
+ if (next !== void 0) {
358
+ emptyStreak += 1;
359
+ if (emptyStreak >= MAX_EMPTY_PAGES) throw new PaginationError("EMPTY_PAGES_LOOP", pageNo, `${MAX_EMPTY_PAGES} consecutive empty pages with a next cursor (page ${pageNo}); stopping`);
360
+ }
361
+ } else emptyStreak = 0;
362
+ for (const item of result.items) {
363
+ if (run.signal.aborted) throw run.signal.reason;
364
+ yield item;
365
+ emitted += 1;
366
+ if (emitted >= limits.maxItems) return;
367
+ }
368
+ if (next === void 0) return;
369
+ if (pageNo >= limits.maxPages) throw new PaginationError("PAGE_LIMIT", pageNo, `Page limit of ${limits.maxPages} reached while more pages are available`);
370
+ seen.add(next);
371
+ cursor = next;
372
+ pageNo += 1;
373
+ }
374
+ } finally {
375
+ clearTimeout(totalTimer);
376
+ userSignal?.removeEventListener("abort", onUserAbort);
377
+ inflight.ctl?.abort();
378
+ run.abort();
379
+ }
380
+ }
381
+ /**
382
+ * Iterates over all items of a paginated API.
383
+ *
384
+ * Guards: repeated cursors (`CURSOR_LOOP`), a page limit (`PAGE_LIMIT`), endless empty pages
385
+ * (`EMPTY_PAGES_LOOP`), per-page and total timeouts (`TIMEOUT`) and an abort signal (`ABORTED`).
386
+ * Reaching `maxItems` stops without error.
387
+ *
388
+ * Breaking out of a `for await` loop calls `return()`, which aborts the signal of any request
389
+ * that is still open.
390
+ *
391
+ * Options are validated when `paginate()` is called. Iteration starts on the first `next()`.
392
+ */
393
+ function paginate(options) {
394
+ if (typeof options.fetchPage !== "function") throw new TypeError("fetchPage must be a function");
395
+ return iterate(options.fetchPage, resolveLimits(options), options.signal);
396
+ }
397
+ exports.PaginationError = PaginationError;
398
+ exports.createFetchPager = createFetchPager;
399
+ exports.nextFromLinkHeader = nextFromLinkHeader;
400
+ exports.paginate = paginate;
401
+ exports.parseLinkHeader = parseLinkHeader;
402
+
403
+ //# sourceMappingURL=index.cjs.map