@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 +21 -0
- package/README.md +216 -2
- package/dist/index.cjs +403 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +108 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +398 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/errors.ts","../src/link-header.ts","../src/mask-url.ts","../src/fetch-pager.ts","../src/paginate.ts"],"sourcesContent":["export type PaginationErrorCode =\n\t| \"CURSOR_LOOP\"\n\t| \"PAGE_LIMIT\"\n\t| \"RATE_LIMITED\"\n\t| \"TIMEOUT\"\n\t| \"ABORTED\"\n\t| \"EMPTY_PAGES_LOOP\"\n\t| \"INVALID_LINK\";\n\n/**\n * Error raised by the pagination guards.\n *\n * `page` is the 1-based number of the page whose request or response caused the error.\n * Adapters such as `createFetchPager` do not know the page number and use `0`; `paginate()`\n * replaces it with the real number before the error reaches the caller.\n */\nexport class PaginationError extends Error {\n\treadonly code: PaginationErrorCode;\n\treadonly page: number;\n\n\tconstructor(\n\t\tcode: PaginationErrorCode,\n\t\tpage: number,\n\t\tmessage: string,\n\t\toptions?: { cause?: unknown },\n\t) {\n\t\tsuper(message, options);\n\t\tthis.name = \"PaginationError\";\n\t\tthis.code = code;\n\t\tthis.page = page;\n\t}\n}\n","import { PaginationError } from \"./errors.js\";\n\ninterface LinkScan {\n\tlinks: Record<string, string>;\n\t/** True when a malformed link-value mentions `rel=\"next\"`. */\n\tmalformedNext: boolean;\n}\n\nconst TOKEN_CHAR = /[!#$%&'*+\\-.^_`|~0-9A-Za-z]/;\nconst NEXT_MENTION = /\\brel\\s*=\\s*\"?[^\";,]*\\bnext\\b/i;\n\nfunction isTokenChar(ch: string): boolean {\n\treturn TOKEN_CHAR.test(ch);\n}\n\n/**\n * Tokenizer for RFC 8288 Link headers. URI references are read between `<` and `>`, so commas and\n * semicolons inside a URL are not treated as separators. Quoted parameter values support backslash\n * escapes. Malformed link-values are skipped; the scan resumes at the next `<`.\n */\nfunction scanLinkHeader(value: string): LinkScan {\n\tconst links: Record<string, string> = {};\n\tlet malformedNext = false;\n\tconst n = value.length;\n\tlet i = 0;\n\n\tconst skipSpaces = (): void => {\n\t\twhile (i < n && (value.charAt(i) === \" \" || value.charAt(i) === \"\\t\")) {\n\t\t\ti += 1;\n\t\t}\n\t};\n\n\twhile (i < n) {\n\t\tskipSpaces();\n\t\tif (i >= n) {\n\t\t\tbreak;\n\t\t}\n\t\tif (value.charAt(i) === \",\") {\n\t\t\ti += 1;\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst linkStart = i;\n\t\tlet target: string | undefined;\n\t\tif (value.charAt(i) === \"<\") {\n\t\t\tconst close = value.indexOf(\">\", i + 1);\n\t\t\tif (close !== -1) {\n\t\t\t\ttarget = value.slice(i + 1, close).trim();\n\t\t\t\ti = close + 1;\n\t\t\t}\n\t\t}\n\n\t\tlet ok = target !== undefined;\n\t\tconst rels: string[] = [];\n\n\t\twhile (ok) {\n\t\t\tskipSpaces();\n\t\t\tif (i >= n) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tconst ch = value.charAt(i);\n\t\t\tif (ch === \",\") {\n\t\t\t\ti += 1;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif (ch !== \";\") {\n\t\t\t\tok = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\ti += 1;\n\t\t\tskipSpaces();\n\n\t\t\tconst nameStart = i;\n\t\t\twhile (i < n && isTokenChar(value.charAt(i))) {\n\t\t\t\ti += 1;\n\t\t\t}\n\t\t\tconst name = value.slice(nameStart, i).toLowerCase();\n\t\t\tif (name === \"\") {\n\t\t\t\tok = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tskipSpaces();\n\n\t\t\tlet paramValue: string | undefined;\n\t\t\tif (value.charAt(i) === \"=\") {\n\t\t\t\ti += 1;\n\t\t\t\tskipSpaces();\n\t\t\t\tif (value.charAt(i) === '\"') {\n\t\t\t\t\ti += 1;\n\t\t\t\t\tlet quoted = \"\";\n\t\t\t\t\tlet closed = false;\n\t\t\t\t\twhile (i < n) {\n\t\t\t\t\t\tconst c = value.charAt(i);\n\t\t\t\t\t\tif (c === \"\\\\\" && i + 1 < n) {\n\t\t\t\t\t\t\tquoted += value.charAt(i + 1);\n\t\t\t\t\t\t\ti += 2;\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t\ti += 1;\n\t\t\t\t\t\tif (c === '\"') {\n\t\t\t\t\t\t\tclosed = true;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tquoted += c;\n\t\t\t\t\t}\n\t\t\t\t\tif (!closed) {\n\t\t\t\t\t\tok = false;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tparamValue = quoted;\n\t\t\t\t} else {\n\t\t\t\t\tconst valueStart = i;\n\t\t\t\t\twhile (i < n && isTokenChar(value.charAt(i))) {\n\t\t\t\t\t\ti += 1;\n\t\t\t\t\t}\n\t\t\t\t\tif (i === valueStart) {\n\t\t\t\t\t\tok = false;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t\tparamValue = value.slice(valueStart, i);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (name === \"rel\" && paramValue !== undefined) {\n\t\t\t\trels.push(...paramValue.split(/\\s+/).filter((rel) => rel !== \"\"));\n\t\t\t}\n\t\t}\n\n\t\tif (ok && target !== undefined) {\n\t\t\tfor (const rel of rels) {\n\t\t\t\tconst key = rel.toLowerCase();\n\t\t\t\tif (key !== \"__proto__\" && !Object.hasOwn(links, key)) {\n\t\t\t\t\tlinks[key] = target;\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tmalformedNext = malformedNext || NEXT_MENTION.test(value.slice(linkStart));\n\t\t\tconst nextLink = value.indexOf(\"<\", linkStart + 1);\n\t\t\ti = nextLink === -1 ? n : nextLink;\n\t\t}\n\t}\n\n\treturn { links, malformedNext };\n}\n\n/**\n * Parses an RFC 8288 `Link` header value into a map from relation type to URI reference.\n *\n * Handles quoted and unquoted `rel` values, several relation types in one `rel`, several links\n * separated by commas, additional parameters and URIs that contain commas or semicolons.\n * Relation names are lower-cased. For duplicate relations the first link wins. Malformed\n * link-values are ignored.\n */\nexport function parseLinkHeader(value: string): Record<string, string> {\n\treturn scanLinkHeader(value).links;\n}\n\n/**\n * Returns the URI reference of the `rel=\"next\"` link from a `Link` header, or `undefined`.\n * The URI may be relative; resolve it against the request URL.\n *\n * Throws a `PaginationError` with code `INVALID_LINK` when a malformed link-value mentions\n * `rel=\"next\"` and no valid next link was found. Otherwise a broken header would look like the\n * last page and silently truncate the data.\n */\nexport function nextFromLinkHeader(headers: {\n\tget(name: string): string | null;\n}): string | undefined {\n\tconst raw = headers.get(\"link\");\n\tif (raw === null) {\n\t\treturn undefined;\n\t}\n\tconst { links, malformedNext } = scanLinkHeader(raw);\n\tif (Object.hasOwn(links, \"next\")) {\n\t\treturn links.next;\n\t}\n\tif (malformedNext) {\n\t\tthrow new PaginationError(\n\t\t\t\"INVALID_LINK\",\n\t\t\t0,\n\t\t\t'Malformed Link header: rel=\"next\" could not be parsed',\n\t\t);\n\t}\n\treturn undefined;\n}\n","/**\n * Returns a URL that is safe to put into an error message: credentials are removed, query values\n * are replaced with `***` (keys are kept) and fragments are dropped.\n */\nexport function maskUrl(input: string): string {\n\tlet url: URL;\n\ttry {\n\t\turl = new URL(input);\n\t} catch {\n\t\treturn \"<invalid URL>\";\n\t}\n\tconst origin = url.origin === \"null\" ? url.protocol : url.origin;\n\tconst keys = [...new Set(url.searchParams.keys())];\n\tconst query =\n\t\tkeys.length > 0 ? `?${keys.map((key) => `${encodeURIComponent(key)}=***`).join(\"&\")}` : \"\";\n\treturn `${origin}${url.pathname}${query}`;\n}\n","import { PaginationError } from \"./errors.js\";\nimport { nextFromLinkHeader } from \"./link-header.js\";\nimport { maskUrl } from \"./mask-url.js\";\nimport type { FetchPage } from \"./types.js\";\n\nconst MAX_RETRIES = 3;\nconst DEFAULT_RETRY_AFTER_MS = 1_000;\nconst MAX_RETRY_AFTER_MS = 60_000;\n\nexport interface FetchPagerOptions<T> {\n\t/** Fetch implementation to use. Defaults to `globalThis.fetch`. */\n\tfetch?: typeof fetch;\n\t/** URL of the first page. The cursor of every later page is a full URL. */\n\turl: string | URL;\n\t/** Maps the parsed JSON body to the items of the page. */\n\textract: (body: unknown) => T[];\n\t/**\n\t * Returns the URL of the next page, or `null` when there is none. Relative URLs are resolved\n\t * against the request URL. Defaults to the `rel=\"next\"` link of the `Link` header.\n\t */\n\tnextFromResponse?: (res: Response, body: unknown) => string | null | undefined;\n\t/**\n\t * Follow next URLs on a different origin than `url`. Defaults to `false`. Keep it `false`:\n\t * a compromised server could otherwise redirect the client to a foreign host and receive\n\t * credentials that the injected `fetch` attaches.\n\t */\n\tallowCrossOriginNext?: boolean;\n}\n\n/**\n * Parses the `Retry-After` value (delay in seconds or HTTP date) into milliseconds, capped at\n * 60 seconds. A missing or unparsable value falls back to one second.\n */\nfunction retryAfterMs(header: string | null, now: number): number {\n\tif (header === null) {\n\t\treturn DEFAULT_RETRY_AFTER_MS;\n\t}\n\tconst value = header.trim();\n\tlet ms: number;\n\tif (/^\\d+$/.test(value)) {\n\t\tms = Number(value) * 1000;\n\t} else {\n\t\tconst date = Date.parse(value);\n\t\tif (Number.isNaN(date)) {\n\t\t\treturn DEFAULT_RETRY_AFTER_MS;\n\t\t}\n\t\tms = Math.max(0, date - now);\n\t}\n\treturn Math.min(ms, MAX_RETRY_AFTER_MS);\n}\n\n/** Waits for `ms`, rejecting with the signal's reason if the signal aborts first. */\nfunction sleep(ms: number, signal: AbortSignal): Promise<void> {\n\treturn new Promise<void>((resolve, reject) => {\n\t\tif (signal.aborted) {\n\t\t\treject(signal.reason);\n\t\t\treturn;\n\t\t}\n\t\tconst onAbort = (): void => {\n\t\t\tclearTimeout(timer);\n\t\t\treject(signal.reason);\n\t\t};\n\t\tconst timer = setTimeout(() => {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n\n/**\n * Creates a `fetchPage` function for `paginate()` that follows URL-based cursors.\n *\n * - Next URLs come from the `Link` header (RFC 8288) or from `nextFromResponse`.\n * - Next URLs on a different origin than `url` are rejected with `INVALID_LINK`, unless\n * `allowCrossOriginNext` is set.\n * - HTTP 429 waits for `Retry-After` (seconds or HTTP date, capped at 60 s) and retries the same\n * page up to 3 times, then throws `RATE_LIMITED`. The wait counts towards the page timeout.\n * - Other non-2xx responses throw a plain `Error` that contains only the status code.\n *\n * Errors thrown here have `page: 0`; `paginate()` sets the real page number.\n */\nexport function createFetchPager<T>(options: FetchPagerOptions<T>): FetchPage<T> {\n\tconst doFetch = options.fetch ?? globalThis.fetch;\n\tif (typeof doFetch !== \"function\") {\n\t\tthrow new TypeError(\"No fetch implementation available; pass options.fetch\");\n\t}\n\tconst startUrl = new URL(options.url);\n\tconst startOrigin = startUrl.origin;\n\tconst allowCrossOrigin = options.allowCrossOriginNext === true;\n\tconst extract = options.extract;\n\tconst nextFromResponse = options.nextFromResponse;\n\n\tconst resolveNext = (raw: string | null | undefined, base: string): string | null => {\n\t\tif (raw === null || raw === undefined || raw === \"\") {\n\t\t\treturn null;\n\t\t}\n\t\tlet next: URL;\n\t\ttry {\n\t\t\tnext = new URL(raw, base);\n\t\t} catch {\n\t\t\tthrow new PaginationError(\"INVALID_LINK\", 0, \"Next link is not a valid URL\");\n\t\t}\n\t\tif (!allowCrossOrigin && next.origin !== startOrigin) {\n\t\t\tthrow new PaginationError(\n\t\t\t\t\"INVALID_LINK\",\n\t\t\t\t0,\n\t\t\t\t`Next link points to another origin (${next.origin}) and was not followed. ` +\n\t\t\t\t\t`Set allowCrossOriginNext to allow it. Link: ${maskUrl(next.href)}`,\n\t\t\t);\n\t\t}\n\t\treturn next.href;\n\t};\n\n\treturn async (cursor, signal) => {\n\t\tconst target = cursor === undefined ? startUrl.href : cursor;\n\t\tlet retries = 0;\n\n\t\tfor (;;) {\n\t\t\tconst res = await doFetch(target, { signal });\n\n\t\t\tif (res.status === 429) {\n\t\t\t\tawait res.body?.cancel().catch(() => undefined);\n\t\t\t\tif (retries >= MAX_RETRIES) {\n\t\t\t\t\tthrow new PaginationError(\n\t\t\t\t\t\t\"RATE_LIMITED\",\n\t\t\t\t\t\t0,\n\t\t\t\t\t\t`Still rate limited (HTTP 429) after ${MAX_RETRIES} retries`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tretries += 1;\n\t\t\t\tawait sleep(retryAfterMs(res.headers.get(\"retry-after\"), Date.now()), signal);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!res.ok) {\n\t\t\t\tthrow new Error(`Request failed with HTTP ${res.status}`);\n\t\t\t}\n\n\t\t\tconst body: unknown = await res.json();\n\t\t\tconst items = extract(body);\n\t\t\tif (!Array.isArray(items)) {\n\t\t\t\tthrow new TypeError(\"extract() must return an array of items\");\n\t\t\t}\n\t\t\tconst raw = nextFromResponse ? nextFromResponse(res, body) : nextFromLinkHeader(res.headers);\n\t\t\treturn { items, nextCursor: resolveNext(raw, target) };\n\t\t}\n\t};\n}\n","import { PaginationError } from \"./errors.js\";\nimport type { FetchPage, Page, PaginateOptions } from \"./types.js\";\n\nconst DEFAULT_MAX_PAGES = 100;\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst MAX_EMPTY_PAGES = 3;\n\ninterface Limits {\n\tmaxPages: number;\n\tmaxItems: number;\n\ttimeoutMs: number;\n\ttotalTimeoutMs: number;\n}\n\nfunction resolveLimits<T>(options: PaginateOptions<T>): Limits {\n\tconst maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;\n\tconst maxItems = options.maxItems ?? Number.POSITIVE_INFINITY;\n\tconst timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\tconst totalTimeoutMs = options.totalTimeoutMs ?? Number.POSITIVE_INFINITY;\n\n\tif (!(maxPages === Number.POSITIVE_INFINITY || (Number.isInteger(maxPages) && maxPages >= 1))) {\n\t\tthrow new RangeError(\"maxPages must be a positive integer or Infinity\");\n\t}\n\tif (!(maxItems === Number.POSITIVE_INFINITY || (Number.isInteger(maxItems) && maxItems >= 0))) {\n\t\tthrow new RangeError(\"maxItems must be a non-negative integer or Infinity\");\n\t}\n\tif (!(timeoutMs > 0)) {\n\t\tthrow new RangeError(\"timeoutMs must be greater than 0\");\n\t}\n\tif (!(totalTimeoutMs > 0)) {\n\t\tthrow new RangeError(\"totalTimeoutMs must be greater than 0\");\n\t}\n\treturn { maxPages, maxItems, timeoutMs, totalTimeoutMs };\n}\n\n/** `undefined`, `null` and `\"\"` mean \"no next page\". */\nfunction normalizeCursor(value: string | null | undefined): string | undefined {\n\treturn value === undefined || value === null || value === \"\" ? undefined : value;\n}\n\n/** Adapters without page context throw with page 0; this fills in the real page number. */\nfunction withPage(error: unknown, page: number): unknown {\n\tif (error instanceof PaginationError && error.page === 0) {\n\t\treturn new PaginationError(error.code, page, error.message, { cause: error });\n\t}\n\treturn error;\n}\n\nasync function* iterate<T>(\n\tfetchPage: FetchPage<T>,\n\tlimits: Limits,\n\tuserSignal: AbortSignal | undefined,\n): AsyncGenerator<T, void, undefined> {\n\tconst run = new AbortController();\n\tconst inflight: { ctl?: AbortController } = {};\n\tconst seen = new Set<string>();\n\tlet pageNo = 1;\n\tlet cursor: string | undefined;\n\tlet emitted = 0;\n\tlet emptyStreak = 0;\n\tlet totalTimer: ReturnType<typeof setTimeout> | undefined;\n\n\tconst onUserAbort = (): void => {\n\t\trun.abort(\n\t\t\tnew PaginationError(\"ABORTED\", pageNo, \"Pagination was aborted\", {\n\t\t\t\tcause: userSignal?.reason,\n\t\t\t}),\n\t\t);\n\t};\n\n\t/**\n\t * Requests one page. The page signal is aborted when the run is aborted (user signal, total\n\t * timeout or iterator return) and after `timeoutMs`. The promise rejects as soon as the signal\n\t * fires, even if `fetchPage` ignores the signal.\n\t */\n\tconst request = (pageCursor: string | undefined, page: number): Promise<Page<T>> =>\n\t\tnew Promise<Page<T>>((resolve, reject) => {\n\t\t\tif (run.signal.aborted) {\n\t\t\t\treject(run.signal.reason);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst ctl = new AbortController();\n\t\t\tinflight.ctl = ctl;\n\t\t\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\n\t\t\tconst onParentAbort = (): void => ctl.abort(run.signal.reason);\n\t\t\tconst onPageAbort = (): void => settle(() => reject(ctl.signal.reason));\n\t\t\tconst settle = (done: () => void): void => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\trun.signal.removeEventListener(\"abort\", onParentAbort);\n\t\t\t\tctl.signal.removeEventListener(\"abort\", onPageAbort);\n\t\t\t\tdone();\n\t\t\t};\n\n\t\t\trun.signal.addEventListener(\"abort\", onParentAbort, { once: true });\n\t\t\tctl.signal.addEventListener(\"abort\", onPageAbort, { once: true });\n\t\t\tif (Number.isFinite(limits.timeoutMs)) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tctl.abort(\n\t\t\t\t\t\tnew PaginationError(\n\t\t\t\t\t\t\t\"TIMEOUT\",\n\t\t\t\t\t\t\tpage,\n\t\t\t\t\t\t\t`Page ${page} timed out after ${limits.timeoutMs} ms`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}, limits.timeoutMs);\n\t\t\t}\n\n\t\t\tlet work: Promise<Page<T>>;\n\t\t\ttry {\n\t\t\t\twork = Promise.resolve(fetchPage(pageCursor, ctl.signal));\n\t\t\t} catch (error) {\n\t\t\t\twork = Promise.reject(error);\n\t\t\t}\n\t\t\twork.then(\n\t\t\t\t(result) => settle(() => resolve(result)),\n\t\t\t\t(error: unknown) => settle(() => reject(withPage(error, page))),\n\t\t\t);\n\t\t});\n\n\tif (userSignal?.aborted) {\n\t\tonUserAbort();\n\t} else {\n\t\tuserSignal?.addEventListener(\"abort\", onUserAbort, { once: true });\n\t}\n\tif (Number.isFinite(limits.totalTimeoutMs)) {\n\t\ttotalTimer = setTimeout(() => {\n\t\t\trun.abort(\n\t\t\t\tnew PaginationError(\n\t\t\t\t\t\"TIMEOUT\",\n\t\t\t\t\tpageNo,\n\t\t\t\t\t`Total timeout of ${limits.totalTimeoutMs} ms exceeded`,\n\t\t\t\t),\n\t\t\t);\n\t\t}, limits.totalTimeoutMs);\n\t}\n\n\ttry {\n\t\twhile (emitted < limits.maxItems) {\n\t\t\tif (run.signal.aborted) {\n\t\t\t\tthrow run.signal.reason;\n\t\t\t}\n\n\t\t\tconst result = await request(cursor, pageNo);\n\t\t\tif (!Array.isArray(result.items)) {\n\t\t\t\tthrow new TypeError(\"fetchPage must resolve to an object with an items array\");\n\t\t\t}\n\t\t\tconst next = normalizeCursor(result.nextCursor);\n\n\t\t\tif (next !== undefined && seen.has(next)) {\n\t\t\t\tthrow new PaginationError(\n\t\t\t\t\t\"CURSOR_LOOP\",\n\t\t\t\t\tpageNo,\n\t\t\t\t\t`Server returned a cursor that was already used (page ${pageNo}); stopping`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tif (result.items.length === 0) {\n\t\t\t\tif (next !== undefined) {\n\t\t\t\t\temptyStreak += 1;\n\t\t\t\t\tif (emptyStreak >= MAX_EMPTY_PAGES) {\n\t\t\t\t\t\tthrow new PaginationError(\n\t\t\t\t\t\t\t\"EMPTY_PAGES_LOOP\",\n\t\t\t\t\t\t\tpageNo,\n\t\t\t\t\t\t\t`${MAX_EMPTY_PAGES} consecutive empty pages with a next cursor (page ${pageNo}); stopping`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\temptyStreak = 0;\n\t\t\t}\n\n\t\t\tfor (const item of result.items) {\n\t\t\t\tif (run.signal.aborted) {\n\t\t\t\t\tthrow run.signal.reason;\n\t\t\t\t}\n\t\t\t\tyield item;\n\t\t\t\temitted += 1;\n\t\t\t\tif (emitted >= limits.maxItems) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (next === undefined) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (pageNo >= limits.maxPages) {\n\t\t\t\tthrow new PaginationError(\n\t\t\t\t\t\"PAGE_LIMIT\",\n\t\t\t\t\tpageNo,\n\t\t\t\t\t`Page limit of ${limits.maxPages} reached while more pages are available`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tseen.add(next);\n\t\t\tcursor = next;\n\t\t\tpageNo += 1;\n\t\t}\n\t} finally {\n\t\tclearTimeout(totalTimer);\n\t\tuserSignal?.removeEventListener(\"abort\", onUserAbort);\n\t\t// Cancels the open request when the consumer stops early (break, return) or on errors.\n\t\tinflight.ctl?.abort();\n\t\trun.abort();\n\t}\n}\n\n/**\n * Iterates over all items of a paginated API.\n *\n * Guards: repeated cursors (`CURSOR_LOOP`), a page limit (`PAGE_LIMIT`), endless empty pages\n * (`EMPTY_PAGES_LOOP`), per-page and total timeouts (`TIMEOUT`) and an abort signal (`ABORTED`).\n * Reaching `maxItems` stops without error.\n *\n * Breaking out of a `for await` loop calls `return()`, which aborts the signal of any request\n * that is still open.\n *\n * Options are validated when `paginate()` is called. Iteration starts on the first `next()`.\n */\nexport function paginate<T>(options: PaginateOptions<T>): AsyncGenerator<T, void, undefined> {\n\tif (typeof options.fetchPage !== \"function\") {\n\t\tthrow new TypeError(\"fetchPage must be a function\");\n\t}\n\treturn iterate(options.fetchPage, resolveLimits(options), options.signal);\n}\n"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
type PaginationErrorCode = "CURSOR_LOOP" | "PAGE_LIMIT" | "RATE_LIMITED" | "TIMEOUT" | "ABORTED" | "EMPTY_PAGES_LOOP" | "INVALID_LINK";
|
|
2
|
+
/**
|
|
3
|
+
* Error raised by the pagination guards.
|
|
4
|
+
*
|
|
5
|
+
* `page` is the 1-based number of the page whose request or response caused the error.
|
|
6
|
+
* Adapters such as `createFetchPager` do not know the page number and use `0`; `paginate()`
|
|
7
|
+
* replaces it with the real number before the error reaches the caller.
|
|
8
|
+
*/
|
|
9
|
+
export declare class PaginationError extends Error {
|
|
10
|
+
readonly code: PaginationErrorCode;
|
|
11
|
+
readonly page: number;
|
|
12
|
+
constructor(code: PaginationErrorCode, page: number, message: string, options?: {
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
interface Page<T> {
|
|
17
|
+
items: T[];
|
|
18
|
+
/**
|
|
19
|
+
* Cursor for the next page. `undefined`, `null` and `""` all mean that this was the last page.
|
|
20
|
+
*/
|
|
21
|
+
nextCursor?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Loads one page. `cursor` is `undefined` for the first page and the previous `nextCursor`
|
|
25
|
+
* afterwards. Implementations should honour `signal` so that timeouts and aborts can cancel
|
|
26
|
+
* the underlying request.
|
|
27
|
+
*/
|
|
28
|
+
type FetchPage<T> = (cursor: string | undefined, signal: AbortSignal) => Promise<Page<T>>;
|
|
29
|
+
interface PaginateOptions<T> {
|
|
30
|
+
fetchPage: FetchPage<T>;
|
|
31
|
+
/** Maximum number of pages that may be requested. Default: 100. */
|
|
32
|
+
maxPages?: number;
|
|
33
|
+
/** Stop without error after this many items. Default: Infinity. */
|
|
34
|
+
maxItems?: number;
|
|
35
|
+
/** Timeout for a single `fetchPage` call in milliseconds. Default: 30000. */
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Timeout for the whole iteration in milliseconds. Default: Infinity (disabled). */
|
|
38
|
+
totalTimeoutMs?: number;
|
|
39
|
+
/** Aborts the iteration. The iterator then throws a `PaginationError` with code `ABORTED`. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
interface FetchPagerOptions<T> {
|
|
43
|
+
/** Fetch implementation to use. Defaults to `globalThis.fetch`. */
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
/** URL of the first page. The cursor of every later page is a full URL. */
|
|
46
|
+
url: string | URL;
|
|
47
|
+
/** Maps the parsed JSON body to the items of the page. */
|
|
48
|
+
extract: (body: unknown) => T[];
|
|
49
|
+
/**
|
|
50
|
+
* Returns the URL of the next page, or `null` when there is none. Relative URLs are resolved
|
|
51
|
+
* against the request URL. Defaults to the `rel="next"` link of the `Link` header.
|
|
52
|
+
*/
|
|
53
|
+
nextFromResponse?: (res: Response, body: unknown) => string | null | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Follow next URLs on a different origin than `url`. Defaults to `false`. Keep it `false`:
|
|
56
|
+
* a compromised server could otherwise redirect the client to a foreign host and receive
|
|
57
|
+
* credentials that the injected `fetch` attaches.
|
|
58
|
+
*/
|
|
59
|
+
allowCrossOriginNext?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates a `fetchPage` function for `paginate()` that follows URL-based cursors.
|
|
63
|
+
*
|
|
64
|
+
* - Next URLs come from the `Link` header (RFC 8288) or from `nextFromResponse`.
|
|
65
|
+
* - Next URLs on a different origin than `url` are rejected with `INVALID_LINK`, unless
|
|
66
|
+
* `allowCrossOriginNext` is set.
|
|
67
|
+
* - HTTP 429 waits for `Retry-After` (seconds or HTTP date, capped at 60 s) and retries the same
|
|
68
|
+
* page up to 3 times, then throws `RATE_LIMITED`. The wait counts towards the page timeout.
|
|
69
|
+
* - Other non-2xx responses throw a plain `Error` that contains only the status code.
|
|
70
|
+
*
|
|
71
|
+
* Errors thrown here have `page: 0`; `paginate()` sets the real page number.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createFetchPager<T>(options: FetchPagerOptions<T>): FetchPage<T>;
|
|
74
|
+
/**
|
|
75
|
+
* Parses an RFC 8288 `Link` header value into a map from relation type to URI reference.
|
|
76
|
+
*
|
|
77
|
+
* Handles quoted and unquoted `rel` values, several relation types in one `rel`, several links
|
|
78
|
+
* separated by commas, additional parameters and URIs that contain commas or semicolons.
|
|
79
|
+
* Relation names are lower-cased. For duplicate relations the first link wins. Malformed
|
|
80
|
+
* link-values are ignored.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseLinkHeader(value: string): Record<string, string>;
|
|
83
|
+
/**
|
|
84
|
+
* Returns the URI reference of the `rel="next"` link from a `Link` header, or `undefined`.
|
|
85
|
+
* The URI may be relative; resolve it against the request URL.
|
|
86
|
+
*
|
|
87
|
+
* Throws a `PaginationError` with code `INVALID_LINK` when a malformed link-value mentions
|
|
88
|
+
* `rel="next"` and no valid next link was found. Otherwise a broken header would look like the
|
|
89
|
+
* last page and silently truncate the data.
|
|
90
|
+
*/
|
|
91
|
+
export declare function nextFromLinkHeader(headers: {
|
|
92
|
+
get(name: string): string | null;
|
|
93
|
+
}): string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Iterates over all items of a paginated API.
|
|
96
|
+
*
|
|
97
|
+
* Guards: repeated cursors (`CURSOR_LOOP`), a page limit (`PAGE_LIMIT`), endless empty pages
|
|
98
|
+
* (`EMPTY_PAGES_LOOP`), per-page and total timeouts (`TIMEOUT`) and an abort signal (`ABORTED`).
|
|
99
|
+
* Reaching `maxItems` stops without error.
|
|
100
|
+
*
|
|
101
|
+
* Breaking out of a `for await` loop calls `return()`, which aborts the signal of any request
|
|
102
|
+
* that is still open.
|
|
103
|
+
*
|
|
104
|
+
* Options are validated when `paginate()` is called. Iteration starts on the first `next()`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function paginate<T>(options: PaginateOptions<T>): AsyncGenerator<T, void, undefined>;
|
|
107
|
+
export type { FetchPage, FetchPagerOptions, Page, PaginateOptions, PaginationErrorCode };
|
|
108
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
type PaginationErrorCode = "CURSOR_LOOP" | "PAGE_LIMIT" | "RATE_LIMITED" | "TIMEOUT" | "ABORTED" | "EMPTY_PAGES_LOOP" | "INVALID_LINK";
|
|
2
|
+
/**
|
|
3
|
+
* Error raised by the pagination guards.
|
|
4
|
+
*
|
|
5
|
+
* `page` is the 1-based number of the page whose request or response caused the error.
|
|
6
|
+
* Adapters such as `createFetchPager` do not know the page number and use `0`; `paginate()`
|
|
7
|
+
* replaces it with the real number before the error reaches the caller.
|
|
8
|
+
*/
|
|
9
|
+
export declare class PaginationError extends Error {
|
|
10
|
+
readonly code: PaginationErrorCode;
|
|
11
|
+
readonly page: number;
|
|
12
|
+
constructor(code: PaginationErrorCode, page: number, message: string, options?: {
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
interface Page<T> {
|
|
17
|
+
items: T[];
|
|
18
|
+
/**
|
|
19
|
+
* Cursor for the next page. `undefined`, `null` and `""` all mean that this was the last page.
|
|
20
|
+
*/
|
|
21
|
+
nextCursor?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Loads one page. `cursor` is `undefined` for the first page and the previous `nextCursor`
|
|
25
|
+
* afterwards. Implementations should honour `signal` so that timeouts and aborts can cancel
|
|
26
|
+
* the underlying request.
|
|
27
|
+
*/
|
|
28
|
+
type FetchPage<T> = (cursor: string | undefined, signal: AbortSignal) => Promise<Page<T>>;
|
|
29
|
+
interface PaginateOptions<T> {
|
|
30
|
+
fetchPage: FetchPage<T>;
|
|
31
|
+
/** Maximum number of pages that may be requested. Default: 100. */
|
|
32
|
+
maxPages?: number;
|
|
33
|
+
/** Stop without error after this many items. Default: Infinity. */
|
|
34
|
+
maxItems?: number;
|
|
35
|
+
/** Timeout for a single `fetchPage` call in milliseconds. Default: 30000. */
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Timeout for the whole iteration in milliseconds. Default: Infinity (disabled). */
|
|
38
|
+
totalTimeoutMs?: number;
|
|
39
|
+
/** Aborts the iteration. The iterator then throws a `PaginationError` with code `ABORTED`. */
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
interface FetchPagerOptions<T> {
|
|
43
|
+
/** Fetch implementation to use. Defaults to `globalThis.fetch`. */
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
/** URL of the first page. The cursor of every later page is a full URL. */
|
|
46
|
+
url: string | URL;
|
|
47
|
+
/** Maps the parsed JSON body to the items of the page. */
|
|
48
|
+
extract: (body: unknown) => T[];
|
|
49
|
+
/**
|
|
50
|
+
* Returns the URL of the next page, or `null` when there is none. Relative URLs are resolved
|
|
51
|
+
* against the request URL. Defaults to the `rel="next"` link of the `Link` header.
|
|
52
|
+
*/
|
|
53
|
+
nextFromResponse?: (res: Response, body: unknown) => string | null | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Follow next URLs on a different origin than `url`. Defaults to `false`. Keep it `false`:
|
|
56
|
+
* a compromised server could otherwise redirect the client to a foreign host and receive
|
|
57
|
+
* credentials that the injected `fetch` attaches.
|
|
58
|
+
*/
|
|
59
|
+
allowCrossOriginNext?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates a `fetchPage` function for `paginate()` that follows URL-based cursors.
|
|
63
|
+
*
|
|
64
|
+
* - Next URLs come from the `Link` header (RFC 8288) or from `nextFromResponse`.
|
|
65
|
+
* - Next URLs on a different origin than `url` are rejected with `INVALID_LINK`, unless
|
|
66
|
+
* `allowCrossOriginNext` is set.
|
|
67
|
+
* - HTTP 429 waits for `Retry-After` (seconds or HTTP date, capped at 60 s) and retries the same
|
|
68
|
+
* page up to 3 times, then throws `RATE_LIMITED`. The wait counts towards the page timeout.
|
|
69
|
+
* - Other non-2xx responses throw a plain `Error` that contains only the status code.
|
|
70
|
+
*
|
|
71
|
+
* Errors thrown here have `page: 0`; `paginate()` sets the real page number.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createFetchPager<T>(options: FetchPagerOptions<T>): FetchPage<T>;
|
|
74
|
+
/**
|
|
75
|
+
* Parses an RFC 8288 `Link` header value into a map from relation type to URI reference.
|
|
76
|
+
*
|
|
77
|
+
* Handles quoted and unquoted `rel` values, several relation types in one `rel`, several links
|
|
78
|
+
* separated by commas, additional parameters and URIs that contain commas or semicolons.
|
|
79
|
+
* Relation names are lower-cased. For duplicate relations the first link wins. Malformed
|
|
80
|
+
* link-values are ignored.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseLinkHeader(value: string): Record<string, string>;
|
|
83
|
+
/**
|
|
84
|
+
* Returns the URI reference of the `rel="next"` link from a `Link` header, or `undefined`.
|
|
85
|
+
* The URI may be relative; resolve it against the request URL.
|
|
86
|
+
*
|
|
87
|
+
* Throws a `PaginationError` with code `INVALID_LINK` when a malformed link-value mentions
|
|
88
|
+
* `rel="next"` and no valid next link was found. Otherwise a broken header would look like the
|
|
89
|
+
* last page and silently truncate the data.
|
|
90
|
+
*/
|
|
91
|
+
export declare function nextFromLinkHeader(headers: {
|
|
92
|
+
get(name: string): string | null;
|
|
93
|
+
}): string | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* Iterates over all items of a paginated API.
|
|
96
|
+
*
|
|
97
|
+
* Guards: repeated cursors (`CURSOR_LOOP`), a page limit (`PAGE_LIMIT`), endless empty pages
|
|
98
|
+
* (`EMPTY_PAGES_LOOP`), per-page and total timeouts (`TIMEOUT`) and an abort signal (`ABORTED`).
|
|
99
|
+
* Reaching `maxItems` stops without error.
|
|
100
|
+
*
|
|
101
|
+
* Breaking out of a `for await` loop calls `return()`, which aborts the signal of any request
|
|
102
|
+
* that is still open.
|
|
103
|
+
*
|
|
104
|
+
* Options are validated when `paginate()` is called. Iteration starts on the first `next()`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function paginate<T>(options: PaginateOptions<T>): AsyncGenerator<T, void, undefined>;
|
|
107
|
+
export type { FetchPage, FetchPagerOptions, Page, PaginateOptions, PaginationErrorCode };
|
|
108
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Error raised by the pagination guards.
|
|
4
|
+
*
|
|
5
|
+
* `page` is the 1-based number of the page whose request or response caused the error.
|
|
6
|
+
* Adapters such as `createFetchPager` do not know the page number and use `0`; `paginate()`
|
|
7
|
+
* replaces it with the real number before the error reaches the caller.
|
|
8
|
+
*/
|
|
9
|
+
var PaginationError = class extends Error {
|
|
10
|
+
code;
|
|
11
|
+
page;
|
|
12
|
+
constructor(code, page, message, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.name = "PaginationError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.page = page;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
// src/link-header.ts
|
|
20
|
+
const TOKEN_CHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/;
|
|
21
|
+
const NEXT_MENTION = /\brel\s*=\s*"?[^";,]*\bnext\b/i;
|
|
22
|
+
function isTokenChar(ch) {
|
|
23
|
+
return TOKEN_CHAR.test(ch);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Tokenizer for RFC 8288 Link headers. URI references are read between `<` and `>`, so commas and
|
|
27
|
+
* semicolons inside a URL are not treated as separators. Quoted parameter values support backslash
|
|
28
|
+
* escapes. Malformed link-values are skipped; the scan resumes at the next `<`.
|
|
29
|
+
*/
|
|
30
|
+
function scanLinkHeader(value) {
|
|
31
|
+
const links = {};
|
|
32
|
+
let malformedNext = false;
|
|
33
|
+
const n = value.length;
|
|
34
|
+
let i = 0;
|
|
35
|
+
const skipSpaces = () => {
|
|
36
|
+
while (i < n && (value.charAt(i) === " " || value.charAt(i) === " ")) i += 1;
|
|
37
|
+
};
|
|
38
|
+
while (i < n) {
|
|
39
|
+
skipSpaces();
|
|
40
|
+
if (i >= n) break;
|
|
41
|
+
if (value.charAt(i) === ",") {
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const linkStart = i;
|
|
46
|
+
let target;
|
|
47
|
+
if (value.charAt(i) === "<") {
|
|
48
|
+
const close = value.indexOf(">", i + 1);
|
|
49
|
+
if (close !== -1) {
|
|
50
|
+
target = value.slice(i + 1, close).trim();
|
|
51
|
+
i = close + 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let ok = target !== void 0;
|
|
55
|
+
const rels = [];
|
|
56
|
+
while (ok) {
|
|
57
|
+
skipSpaces();
|
|
58
|
+
if (i >= n) break;
|
|
59
|
+
const ch = value.charAt(i);
|
|
60
|
+
if (ch === ",") {
|
|
61
|
+
i += 1;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (ch !== ";") {
|
|
65
|
+
ok = false;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
i += 1;
|
|
69
|
+
skipSpaces();
|
|
70
|
+
const nameStart = i;
|
|
71
|
+
while (i < n && isTokenChar(value.charAt(i))) i += 1;
|
|
72
|
+
const name = value.slice(nameStart, i).toLowerCase();
|
|
73
|
+
if (name === "") {
|
|
74
|
+
ok = false;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
skipSpaces();
|
|
78
|
+
let paramValue;
|
|
79
|
+
if (value.charAt(i) === "=") {
|
|
80
|
+
i += 1;
|
|
81
|
+
skipSpaces();
|
|
82
|
+
if (value.charAt(i) === "\"") {
|
|
83
|
+
i += 1;
|
|
84
|
+
let quoted = "";
|
|
85
|
+
let closed = false;
|
|
86
|
+
while (i < n) {
|
|
87
|
+
const c = value.charAt(i);
|
|
88
|
+
if (c === "\\" && i + 1 < n) {
|
|
89
|
+
quoted += value.charAt(i + 1);
|
|
90
|
+
i += 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
i += 1;
|
|
94
|
+
if (c === "\"") {
|
|
95
|
+
closed = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
quoted += c;
|
|
99
|
+
}
|
|
100
|
+
if (!closed) {
|
|
101
|
+
ok = false;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
paramValue = quoted;
|
|
105
|
+
} else {
|
|
106
|
+
const valueStart = i;
|
|
107
|
+
while (i < n && isTokenChar(value.charAt(i))) i += 1;
|
|
108
|
+
if (i === valueStart) {
|
|
109
|
+
ok = false;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
paramValue = value.slice(valueStart, i);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (name === "rel" && paramValue !== void 0) rels.push(...paramValue.split(/\s+/).filter((rel) => rel !== ""));
|
|
116
|
+
}
|
|
117
|
+
if (ok && target !== void 0) for (const rel of rels) {
|
|
118
|
+
const key = rel.toLowerCase();
|
|
119
|
+
if (key !== "__proto__" && !Object.hasOwn(links, key)) links[key] = target;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
malformedNext = malformedNext || NEXT_MENTION.test(value.slice(linkStart));
|
|
123
|
+
const nextLink = value.indexOf("<", linkStart + 1);
|
|
124
|
+
i = nextLink === -1 ? n : nextLink;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
links,
|
|
129
|
+
malformedNext
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Parses an RFC 8288 `Link` header value into a map from relation type to URI reference.
|
|
134
|
+
*
|
|
135
|
+
* Handles quoted and unquoted `rel` values, several relation types in one `rel`, several links
|
|
136
|
+
* separated by commas, additional parameters and URIs that contain commas or semicolons.
|
|
137
|
+
* Relation names are lower-cased. For duplicate relations the first link wins. Malformed
|
|
138
|
+
* link-values are ignored.
|
|
139
|
+
*/
|
|
140
|
+
function parseLinkHeader(value) {
|
|
141
|
+
return scanLinkHeader(value).links;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Returns the URI reference of the `rel="next"` link from a `Link` header, or `undefined`.
|
|
145
|
+
* The URI may be relative; resolve it against the request URL.
|
|
146
|
+
*
|
|
147
|
+
* Throws a `PaginationError` with code `INVALID_LINK` when a malformed link-value mentions
|
|
148
|
+
* `rel="next"` and no valid next link was found. Otherwise a broken header would look like the
|
|
149
|
+
* last page and silently truncate the data.
|
|
150
|
+
*/
|
|
151
|
+
function nextFromLinkHeader(headers) {
|
|
152
|
+
const raw = headers.get("link");
|
|
153
|
+
if (raw === null) return;
|
|
154
|
+
const { links, malformedNext } = scanLinkHeader(raw);
|
|
155
|
+
if (Object.hasOwn(links, "next")) return links.next;
|
|
156
|
+
if (malformedNext) throw new PaginationError("INVALID_LINK", 0, "Malformed Link header: rel=\"next\" could not be parsed");
|
|
157
|
+
}
|
|
158
|
+
// src/mask-url.ts
|
|
159
|
+
/**
|
|
160
|
+
* Returns a URL that is safe to put into an error message: credentials are removed, query values
|
|
161
|
+
* are replaced with `***` (keys are kept) and fragments are dropped.
|
|
162
|
+
*/
|
|
163
|
+
function maskUrl(input) {
|
|
164
|
+
let url;
|
|
165
|
+
try {
|
|
166
|
+
url = new URL(input);
|
|
167
|
+
} catch {
|
|
168
|
+
return "<invalid URL>";
|
|
169
|
+
}
|
|
170
|
+
const origin = url.origin === "null" ? url.protocol : url.origin;
|
|
171
|
+
const keys = [...new Set(url.searchParams.keys())];
|
|
172
|
+
const query = keys.length > 0 ? `?${keys.map((key) => `${encodeURIComponent(key)}=***`).join("&")}` : "";
|
|
173
|
+
return `${origin}${url.pathname}${query}`;
|
|
174
|
+
}
|
|
175
|
+
// src/fetch-pager.ts
|
|
176
|
+
const MAX_RETRIES = 3;
|
|
177
|
+
const DEFAULT_RETRY_AFTER_MS = 1e3;
|
|
178
|
+
const MAX_RETRY_AFTER_MS = 6e4;
|
|
179
|
+
/**
|
|
180
|
+
* Parses the `Retry-After` value (delay in seconds or HTTP date) into milliseconds, capped at
|
|
181
|
+
* 60 seconds. A missing or unparsable value falls back to one second.
|
|
182
|
+
*/
|
|
183
|
+
function retryAfterMs(header, now) {
|
|
184
|
+
if (header === null) return DEFAULT_RETRY_AFTER_MS;
|
|
185
|
+
const value = header.trim();
|
|
186
|
+
let ms;
|
|
187
|
+
if (/^\d+$/.test(value)) ms = Number(value) * 1e3;
|
|
188
|
+
else {
|
|
189
|
+
const date = Date.parse(value);
|
|
190
|
+
if (Number.isNaN(date)) return DEFAULT_RETRY_AFTER_MS;
|
|
191
|
+
ms = Math.max(0, date - now);
|
|
192
|
+
}
|
|
193
|
+
return Math.min(ms, MAX_RETRY_AFTER_MS);
|
|
194
|
+
}
|
|
195
|
+
/** Waits for `ms`, rejecting with the signal's reason if the signal aborts first. */
|
|
196
|
+
function sleep(ms, signal) {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
if (signal.aborted) {
|
|
199
|
+
reject(signal.reason);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const onAbort = () => {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
reject(signal.reason);
|
|
205
|
+
};
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
signal.removeEventListener("abort", onAbort);
|
|
208
|
+
resolve();
|
|
209
|
+
}, ms);
|
|
210
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Creates a `fetchPage` function for `paginate()` that follows URL-based cursors.
|
|
215
|
+
*
|
|
216
|
+
* - Next URLs come from the `Link` header (RFC 8288) or from `nextFromResponse`.
|
|
217
|
+
* - Next URLs on a different origin than `url` are rejected with `INVALID_LINK`, unless
|
|
218
|
+
* `allowCrossOriginNext` is set.
|
|
219
|
+
* - HTTP 429 waits for `Retry-After` (seconds or HTTP date, capped at 60 s) and retries the same
|
|
220
|
+
* page up to 3 times, then throws `RATE_LIMITED`. The wait counts towards the page timeout.
|
|
221
|
+
* - Other non-2xx responses throw a plain `Error` that contains only the status code.
|
|
222
|
+
*
|
|
223
|
+
* Errors thrown here have `page: 0`; `paginate()` sets the real page number.
|
|
224
|
+
*/
|
|
225
|
+
function createFetchPager(options) {
|
|
226
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
227
|
+
if (typeof doFetch !== "function") throw new TypeError("No fetch implementation available; pass options.fetch");
|
|
228
|
+
const startUrl = new URL(options.url);
|
|
229
|
+
const startOrigin = startUrl.origin;
|
|
230
|
+
const allowCrossOrigin = options.allowCrossOriginNext === true;
|
|
231
|
+
const extract = options.extract;
|
|
232
|
+
const nextFromResponse = options.nextFromResponse;
|
|
233
|
+
const resolveNext = (raw, base) => {
|
|
234
|
+
if (raw === null || raw === void 0 || raw === "") return null;
|
|
235
|
+
let next;
|
|
236
|
+
try {
|
|
237
|
+
next = new URL(raw, base);
|
|
238
|
+
} catch {
|
|
239
|
+
throw new PaginationError("INVALID_LINK", 0, "Next link is not a valid URL");
|
|
240
|
+
}
|
|
241
|
+
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)}`);
|
|
242
|
+
return next.href;
|
|
243
|
+
};
|
|
244
|
+
return async (cursor, signal) => {
|
|
245
|
+
const target = cursor === void 0 ? startUrl.href : cursor;
|
|
246
|
+
let retries = 0;
|
|
247
|
+
for (;;) {
|
|
248
|
+
const res = await doFetch(target, { signal });
|
|
249
|
+
if (res.status === 429) {
|
|
250
|
+
await res.body?.cancel().catch(() => void 0);
|
|
251
|
+
if (retries >= MAX_RETRIES) throw new PaginationError("RATE_LIMITED", 0, `Still rate limited (HTTP 429) after ${MAX_RETRIES} retries`);
|
|
252
|
+
retries += 1;
|
|
253
|
+
await sleep(retryAfterMs(res.headers.get("retry-after"), Date.now()), signal);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!res.ok) throw new Error(`Request failed with HTTP ${res.status}`);
|
|
257
|
+
const body = await res.json();
|
|
258
|
+
const items = extract(body);
|
|
259
|
+
if (!Array.isArray(items)) throw new TypeError("extract() must return an array of items");
|
|
260
|
+
const raw = nextFromResponse ? nextFromResponse(res, body) : nextFromLinkHeader(res.headers);
|
|
261
|
+
return {
|
|
262
|
+
items,
|
|
263
|
+
nextCursor: resolveNext(raw, target)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// src/paginate.ts
|
|
269
|
+
const DEFAULT_MAX_PAGES = 100;
|
|
270
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
271
|
+
const MAX_EMPTY_PAGES = 3;
|
|
272
|
+
function resolveLimits(options) {
|
|
273
|
+
const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
|
|
274
|
+
const maxItems = options.maxItems ?? Number.POSITIVE_INFINITY;
|
|
275
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
276
|
+
const totalTimeoutMs = options.totalTimeoutMs ?? Number.POSITIVE_INFINITY;
|
|
277
|
+
if (!(maxPages === Number.POSITIVE_INFINITY || Number.isInteger(maxPages) && maxPages >= 1)) throw new RangeError("maxPages must be a positive integer or Infinity");
|
|
278
|
+
if (!(maxItems === Number.POSITIVE_INFINITY || Number.isInteger(maxItems) && maxItems >= 0)) throw new RangeError("maxItems must be a non-negative integer or Infinity");
|
|
279
|
+
if (!(timeoutMs > 0)) throw new RangeError("timeoutMs must be greater than 0");
|
|
280
|
+
if (!(totalTimeoutMs > 0)) throw new RangeError("totalTimeoutMs must be greater than 0");
|
|
281
|
+
return {
|
|
282
|
+
maxPages,
|
|
283
|
+
maxItems,
|
|
284
|
+
timeoutMs,
|
|
285
|
+
totalTimeoutMs
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** `undefined`, `null` and `""` mean "no next page". */
|
|
289
|
+
function normalizeCursor(value) {
|
|
290
|
+
return value === void 0 || value === null || value === "" ? void 0 : value;
|
|
291
|
+
}
|
|
292
|
+
/** Adapters without page context throw with page 0; this fills in the real page number. */
|
|
293
|
+
function withPage(error, page) {
|
|
294
|
+
if (error instanceof PaginationError && error.page === 0) return new PaginationError(error.code, page, error.message, { cause: error });
|
|
295
|
+
return error;
|
|
296
|
+
}
|
|
297
|
+
async function* iterate(fetchPage, limits, userSignal) {
|
|
298
|
+
const run = new AbortController();
|
|
299
|
+
const inflight = {};
|
|
300
|
+
const seen = /* @__PURE__ */ new Set();
|
|
301
|
+
let pageNo = 1;
|
|
302
|
+
let cursor;
|
|
303
|
+
let emitted = 0;
|
|
304
|
+
let emptyStreak = 0;
|
|
305
|
+
let totalTimer;
|
|
306
|
+
const onUserAbort = () => {
|
|
307
|
+
run.abort(new PaginationError("ABORTED", pageNo, "Pagination was aborted", { cause: userSignal?.reason }));
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Requests one page. The page signal is aborted when the run is aborted (user signal, total
|
|
311
|
+
* timeout or iterator return) and after `timeoutMs`. The promise rejects as soon as the signal
|
|
312
|
+
* fires, even if `fetchPage` ignores the signal.
|
|
313
|
+
*/
|
|
314
|
+
const request = (pageCursor, page) => new Promise((resolve, reject) => {
|
|
315
|
+
if (run.signal.aborted) {
|
|
316
|
+
reject(run.signal.reason);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const ctl = new AbortController();
|
|
320
|
+
inflight.ctl = ctl;
|
|
321
|
+
let timer;
|
|
322
|
+
const onParentAbort = () => ctl.abort(run.signal.reason);
|
|
323
|
+
const onPageAbort = () => settle(() => reject(ctl.signal.reason));
|
|
324
|
+
const settle = (done) => {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
run.signal.removeEventListener("abort", onParentAbort);
|
|
327
|
+
ctl.signal.removeEventListener("abort", onPageAbort);
|
|
328
|
+
done();
|
|
329
|
+
};
|
|
330
|
+
run.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
331
|
+
ctl.signal.addEventListener("abort", onPageAbort, { once: true });
|
|
332
|
+
if (Number.isFinite(limits.timeoutMs)) timer = setTimeout(() => {
|
|
333
|
+
ctl.abort(new PaginationError("TIMEOUT", page, `Page ${page} timed out after ${limits.timeoutMs} ms`));
|
|
334
|
+
}, limits.timeoutMs);
|
|
335
|
+
let work;
|
|
336
|
+
try {
|
|
337
|
+
work = Promise.resolve(fetchPage(pageCursor, ctl.signal));
|
|
338
|
+
} catch (error) {
|
|
339
|
+
work = Promise.reject(error);
|
|
340
|
+
}
|
|
341
|
+
work.then((result) => settle(() => resolve(result)), (error) => settle(() => reject(withPage(error, page))));
|
|
342
|
+
});
|
|
343
|
+
if (userSignal?.aborted) onUserAbort();
|
|
344
|
+
else userSignal?.addEventListener("abort", onUserAbort, { once: true });
|
|
345
|
+
if (Number.isFinite(limits.totalTimeoutMs)) totalTimer = setTimeout(() => {
|
|
346
|
+
run.abort(new PaginationError("TIMEOUT", pageNo, `Total timeout of ${limits.totalTimeoutMs} ms exceeded`));
|
|
347
|
+
}, limits.totalTimeoutMs);
|
|
348
|
+
try {
|
|
349
|
+
while (emitted < limits.maxItems) {
|
|
350
|
+
if (run.signal.aborted) throw run.signal.reason;
|
|
351
|
+
const result = await request(cursor, pageNo);
|
|
352
|
+
if (!Array.isArray(result.items)) throw new TypeError("fetchPage must resolve to an object with an items array");
|
|
353
|
+
const next = normalizeCursor(result.nextCursor);
|
|
354
|
+
if (next !== void 0 && seen.has(next)) throw new PaginationError("CURSOR_LOOP", pageNo, `Server returned a cursor that was already used (page ${pageNo}); stopping`);
|
|
355
|
+
if (result.items.length === 0) {
|
|
356
|
+
if (next !== void 0) {
|
|
357
|
+
emptyStreak += 1;
|
|
358
|
+
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`);
|
|
359
|
+
}
|
|
360
|
+
} else emptyStreak = 0;
|
|
361
|
+
for (const item of result.items) {
|
|
362
|
+
if (run.signal.aborted) throw run.signal.reason;
|
|
363
|
+
yield item;
|
|
364
|
+
emitted += 1;
|
|
365
|
+
if (emitted >= limits.maxItems) return;
|
|
366
|
+
}
|
|
367
|
+
if (next === void 0) return;
|
|
368
|
+
if (pageNo >= limits.maxPages) throw new PaginationError("PAGE_LIMIT", pageNo, `Page limit of ${limits.maxPages} reached while more pages are available`);
|
|
369
|
+
seen.add(next);
|
|
370
|
+
cursor = next;
|
|
371
|
+
pageNo += 1;
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
clearTimeout(totalTimer);
|
|
375
|
+
userSignal?.removeEventListener("abort", onUserAbort);
|
|
376
|
+
inflight.ctl?.abort();
|
|
377
|
+
run.abort();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Iterates over all items of a paginated API.
|
|
382
|
+
*
|
|
383
|
+
* Guards: repeated cursors (`CURSOR_LOOP`), a page limit (`PAGE_LIMIT`), endless empty pages
|
|
384
|
+
* (`EMPTY_PAGES_LOOP`), per-page and total timeouts (`TIMEOUT`) and an abort signal (`ABORTED`).
|
|
385
|
+
* Reaching `maxItems` stops without error.
|
|
386
|
+
*
|
|
387
|
+
* Breaking out of a `for await` loop calls `return()`, which aborts the signal of any request
|
|
388
|
+
* that is still open.
|
|
389
|
+
*
|
|
390
|
+
* Options are validated when `paginate()` is called. Iteration starts on the first `next()`.
|
|
391
|
+
*/
|
|
392
|
+
function paginate(options) {
|
|
393
|
+
if (typeof options.fetchPage !== "function") throw new TypeError("fetchPage must be a function");
|
|
394
|
+
return iterate(options.fetchPage, resolveLimits(options), options.signal);
|
|
395
|
+
}
|
|
396
|
+
export { PaginationError, createFetchPager, nextFromLinkHeader, paginate, parseLinkHeader };
|
|
397
|
+
|
|
398
|
+
//# sourceMappingURL=index.js.map
|