kinetex 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1164 -453
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3127 -715
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +137 -20
- package/dist/cjs/cache.js +101 -21
- package/dist/cjs/circuit-breaker.js +69 -7
- package/dist/cjs/client.js +838 -191
- package/dist/cjs/cookie-parser.js +110 -9
- package/dist/cjs/cookie-store.js +141 -36
- package/dist/cjs/core.js +501 -63
- package/dist/cjs/dedup.js +58 -18
- package/dist/cjs/digest.js +185 -23
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +362 -48
- package/dist/cjs/interceptors.js +285 -29
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +169 -16
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +261 -28
- package/dist/cjs/progress.js +282 -52
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +167 -36
- package/dist/cjs/sse.js +201 -34
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +32 -16
- package/dist/esm/aws-sigv4.js +137 -20
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +101 -21
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +69 -7
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +838 -191
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +110 -9
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +141 -36
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +501 -63
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +58 -18
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +185 -23
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +362 -48
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +285 -29
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +169 -16
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +261 -28
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +282 -52
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +167 -36
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +201 -34
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +32 -16
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +27 -2
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +14 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +98 -23
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +109 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +0 -7
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +31 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +62 -29
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +23 -4
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +7 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +139 -5
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/worker.d.ts +6 -6
- package/dist/types/worker.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/cjs/pagination.js
CHANGED
|
@@ -71,9 +71,29 @@ function buildPage(items, response, state, config, next, hasNextOverride) {
|
|
|
71
71
|
/**
|
|
72
72
|
* Core paginator — yields one Page<T> per fetch.
|
|
73
73
|
*/
|
|
74
|
+
/**
|
|
75
|
+
* Strategies whose next request is selected by a cursor, token or next-URL
|
|
76
|
+
* carried in `PaginationState`, rather than by the page and offset numbers.
|
|
77
|
+
* A repeated or absent value for those means the paginator has nothing new to
|
|
78
|
+
* ask for, however many pages `hasNext` is willing to agree to.
|
|
79
|
+
*/
|
|
80
|
+
const CURSOR_DRIVEN_STRATEGIES = new Set([
|
|
81
|
+
"cursor",
|
|
82
|
+
"relay",
|
|
83
|
+
"link-header",
|
|
84
|
+
"token",
|
|
85
|
+
"keyset",
|
|
86
|
+
]);
|
|
74
87
|
export async function* paginate(config, strategy = "page") {
|
|
75
88
|
let state = buildInitialState(config, strategy);
|
|
76
89
|
let pageNum = 0;
|
|
90
|
+
// Cursors/tokens already asked for. A server that keeps handing back the
|
|
91
|
+
// same one would otherwise be followed forever.
|
|
92
|
+
const seenCursors = new Set();
|
|
93
|
+
if (state.cursor !== null)
|
|
94
|
+
seenCursors.add(state.cursor);
|
|
95
|
+
if (state.token !== null)
|
|
96
|
+
seenCursors.add(state.token);
|
|
77
97
|
while (!state.done) {
|
|
78
98
|
if (config.signal?.aborted)
|
|
79
99
|
break;
|
|
@@ -94,25 +114,86 @@ export async function* paginate(config, strategy = "page") {
|
|
|
94
114
|
throw err;
|
|
95
115
|
}
|
|
96
116
|
// Extract + transform + filter items
|
|
97
|
-
|
|
117
|
+
const extracted = config.getItems(response, state);
|
|
118
|
+
// The count the *server* returned, before transform/filter. The offset
|
|
119
|
+
// advances by this, not by the surviving item count: a filter makes those
|
|
120
|
+
// differ, and advancing by the filtered count walks back over rows the
|
|
121
|
+
// client has already seen and steps over rows it has not. Measured with a
|
|
122
|
+
// parity filter over three-item pages, the paginator requested offsets
|
|
123
|
+
// 0, 2, 4, 6, 8, 10 instead of 0, 3, 6, 9 — never yielding the odd
|
|
124
|
+
// items and yielding every even one twice.
|
|
125
|
+
const rawCount = extracted.length;
|
|
126
|
+
let items = extracted;
|
|
98
127
|
if (config.transform)
|
|
99
128
|
items = items.map(config.transform);
|
|
100
129
|
if (config.filter)
|
|
101
130
|
items = items.filter(config.filter);
|
|
102
131
|
// Get next state
|
|
103
132
|
const nextPartial = config.getNext ? config.getNext(response, state) : null;
|
|
104
|
-
|
|
133
|
+
let hasMore = config.hasNext(response, state);
|
|
134
|
+
// Progress guard.
|
|
135
|
+
//
|
|
136
|
+
// `hasNext` and `getNext` are two independent callbacks, and nothing
|
|
137
|
+
// checked that agreeing to another page actually gave the fetcher
|
|
138
|
+
// something new to ask for. `maxPages` defaults to 0 — "unlimited" — so
|
|
139
|
+
// when they disagreed the loop had no bound at all: a Relay connection
|
|
140
|
+
// reporting `hasNextPage: true` with a null `endCursor`, or a cursor API
|
|
141
|
+
// that keeps returning the cursor it was given, turned the paginator into
|
|
142
|
+
// an unbounded request generator against a real API, re-fetching the same
|
|
143
|
+
// page forever. Stop when the next request would be identical to this one,
|
|
144
|
+
// and when a cursor or token repeats a value already used.
|
|
145
|
+
if (hasMore) {
|
|
146
|
+
const nextCursor = nextPartial?.cursor ?? state.cursor;
|
|
147
|
+
const nextToken = nextPartial?.token ?? state.token;
|
|
148
|
+
const nextPage = nextPartial?.page ?? state.page + 1;
|
|
149
|
+
const nextOffset = nextPartial?.offset ?? state.offset + rawCount;
|
|
150
|
+
// `getNext` answered "there is nothing new to ask for", on a strategy
|
|
151
|
+
// whose fetch is driven by a cursor or a token. There the page/offset
|
|
152
|
+
// arithmetic is arithmetic on fields the fetcher never sends, so it looks
|
|
153
|
+
// like progress and is not: a Relay connection reporting
|
|
154
|
+
// `hasNextPage: true` beside a null `endCursor` was followed forever,
|
|
155
|
+
// re-fetching the same page on every tick. Scoped to the cursor-driven
|
|
156
|
+
// strategies because a `page`/`offset` config is entitled to progress by
|
|
157
|
+
// those numbers, and there a null `getNext` is just a no-op. Every
|
|
158
|
+
// built-in cursor-driven strategy returns a cursor when it has one, so
|
|
159
|
+
// this cannot fire against a working server.
|
|
160
|
+
const cursorDriven = CURSOR_DRIVEN_STRATEGIES.has(state.strategy);
|
|
161
|
+
const noNext = cursorDriven &&
|
|
162
|
+
config.getNext !== undefined &&
|
|
163
|
+
(nextPartial === null || nextPartial === undefined);
|
|
164
|
+
const repeats = (nextCursor !== null && seenCursors.has(nextCursor)) ||
|
|
165
|
+
(nextToken !== null && seenCursors.has(nextToken));
|
|
166
|
+
const nothingMoved = nextPage === state.page &&
|
|
167
|
+
nextOffset === state.offset &&
|
|
168
|
+
nextCursor === state.cursor &&
|
|
169
|
+
nextToken === state.token;
|
|
170
|
+
if (noNext || repeats || nothingMoved) {
|
|
171
|
+
hasMore = false;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
if (nextCursor !== null)
|
|
175
|
+
seenCursors.add(nextCursor);
|
|
176
|
+
if (nextToken !== null)
|
|
177
|
+
seenCursors.add(nextToken);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
105
180
|
const page = buildPage(items, response, state, config, nextPartial, hasMore);
|
|
106
181
|
// Track previous cursor for bidirectional pagination
|
|
107
182
|
const newPrevCursor = state.cursor;
|
|
108
183
|
state = {
|
|
109
184
|
...state,
|
|
110
185
|
page: nextPartial?.page ?? state.page + 1,
|
|
111
|
-
offset: nextPartial?.offset ?? state.offset +
|
|
186
|
+
offset: nextPartial?.offset ?? state.offset + rawCount,
|
|
112
187
|
cursor: nextPartial?.cursor ?? state.cursor,
|
|
113
188
|
token: nextPartial?.token ?? state.token,
|
|
114
189
|
done: !hasMore,
|
|
115
|
-
|
|
190
|
+
// The count the server returned, for the same reason the offset below
|
|
191
|
+
// advances by `rawCount` rather than by `items.length`: a `filter` makes
|
|
192
|
+
// the two differ. "Fetched so far" is a statement about the requests
|
|
193
|
+
// made, not about what survived them, and it was reported as the
|
|
194
|
+
// surviving count — so a filter dropping half of every page also halved
|
|
195
|
+
// the total, understating the work done by half.
|
|
196
|
+
totalFetched: state.totalFetched + rawCount,
|
|
116
197
|
prevCursor: newPrevCursor,
|
|
117
198
|
};
|
|
118
199
|
pageNum++;
|
|
@@ -159,10 +240,22 @@ export async function collectPages(config, strategy = "page") {
|
|
|
159
240
|
* Take exactly N items across pages. Stops fetching once N items are collected.
|
|
160
241
|
*/
|
|
161
242
|
export async function takeItems(n, config, strategy = "page") {
|
|
243
|
+
// The loop pushed an item and only *then* tested `length >= n`, so
|
|
244
|
+
// `takeItems(0)` returned one item instead of none, and every negative N did
|
|
245
|
+
// too (1 >= -5). N <= 0 is a request for nothing and is answered without
|
|
246
|
+
// touching the network at all.
|
|
247
|
+
//
|
|
248
|
+
// NaN is excluded the same way, since every comparison against it is false
|
|
249
|
+
// and it would otherwise have walked every page to no end. `Infinity` is
|
|
250
|
+
// deliberately left to run: it is the documented "more than exists" case,
|
|
251
|
+
// and the `>=` below is simply never satisfied by it.
|
|
252
|
+
if (typeof n !== "number" || Number.isNaN(n) || n <= 0)
|
|
253
|
+
return [];
|
|
254
|
+
const limit = Math.floor(n);
|
|
162
255
|
const items = [];
|
|
163
256
|
for await (const item of paginateItems(config, strategy)) {
|
|
164
257
|
items.push(item);
|
|
165
|
-
if (items.length >=
|
|
258
|
+
if (items.length >= limit)
|
|
166
259
|
break;
|
|
167
260
|
}
|
|
168
261
|
return items;
|
|
@@ -325,13 +418,32 @@ export function createLinkHeaderPaginator(options) {
|
|
|
325
418
|
* // Returns: "https://api.example.com/items?page=2"
|
|
326
419
|
* ```
|
|
327
420
|
*/
|
|
421
|
+
/**
|
|
422
|
+
* One RFC 5988 link-value: `<uri>` followed by parameters, `rel` quoted either
|
|
423
|
+
* way.
|
|
424
|
+
*
|
|
425
|
+
* Case-insensitive, because RFC 5988 §3.1 states that relation types are
|
|
426
|
+
* compared in a case-insensitive manner, and RFC 8288 §4 makes parameter
|
|
427
|
+
* *names* case-insensitive as well. Matching `rel` case-sensitively meant a
|
|
428
|
+
* server sending `rel="Next"` — legal, and used in the wild — produced no next
|
|
429
|
+
* page: pagination silently stopped one page early with no error to explain
|
|
430
|
+
* it.
|
|
431
|
+
*/
|
|
432
|
+
const RE_LINK_ENTRY = /<([^>]+)>.*?rel=["']([^"']+)["']/i;
|
|
328
433
|
export function parseLinkHeaderNext(linkHeader) {
|
|
329
434
|
if (!linkHeader)
|
|
330
435
|
return null;
|
|
331
|
-
|
|
436
|
+
// Split *before* every link's opening bracket rather than on commas. A URI
|
|
437
|
+
// may contain a comma — `?ids=1,2,3` is routine — and splitting on it cut
|
|
438
|
+
// the entry in half, so the regex no longer found the `rel` and the function
|
|
439
|
+
// reported "no next page": the pagination stopping early against a server
|
|
440
|
+
// that had more to give. A lookahead split keys off the one character that
|
|
441
|
+
// genuinely cannot appear inside a link-value's parameters.
|
|
442
|
+
const parts = linkHeader.split(/(?=<)/).filter((p) => p.trimStart().startsWith("<"));
|
|
443
|
+
for (const part of parts) {
|
|
332
444
|
// Handle both double and single quoted rel values per RFC 5988
|
|
333
|
-
const match = part.match(
|
|
334
|
-
if (match && match[2] && match[2].split(/\s+/).includes("next"))
|
|
445
|
+
const match = part.match(RE_LINK_ENTRY);
|
|
446
|
+
if (match && match[2] && match[2].toLowerCase().split(/\s+/).includes("next"))
|
|
335
447
|
return match[1];
|
|
336
448
|
}
|
|
337
449
|
return null;
|
|
@@ -413,24 +525,53 @@ export function createKeysetPaginator(options) {
|
|
|
413
525
|
* Useful when pages are independent (offset/page strategy).
|
|
414
526
|
*/
|
|
415
527
|
export async function* prefetchPaginate(config, strategy = "page", prefetchAhead = 2) {
|
|
528
|
+
const ahead = Math.max(0, Math.floor(Number.isFinite(prefetchAhead) ? prefetchAhead : 0));
|
|
416
529
|
const queue = [];
|
|
417
530
|
const gen = paginate(config, strategy);
|
|
418
531
|
let done = false;
|
|
419
|
-
|
|
532
|
+
// The *pending call* is queued, not its value. Awaiting `gen.next()` here
|
|
533
|
+
// made this strictly sequential and worse than a plain `paginate`: the
|
|
534
|
+
// consumer was blocked on the next page's fetch before it could process the
|
|
535
|
+
// page it already held, so the queue cost bookkeeping and prefetched
|
|
536
|
+
// nothing. Queuing the call lets the next request be in flight while the
|
|
537
|
+
// consumer works, which is the entire point of the `prefetchAhead` argument.
|
|
538
|
+
//
|
|
539
|
+
// Note this is overlap, not request-level parallelism: `paginate` is a
|
|
540
|
+
// single async generator and runs one body at a time, so only one request is
|
|
541
|
+
// ever outstanding. `ahead` bounds how far the consumer may run ahead of the
|
|
542
|
+
// fetches, not how many sockets are open.
|
|
543
|
+
//
|
|
544
|
+
// Order is preserved because the generator resolves its own `next()` calls in
|
|
545
|
+
// the order they were made, so the queue stays in page order.
|
|
546
|
+
function enqueue() {
|
|
420
547
|
if (done)
|
|
421
548
|
return;
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
549
|
+
const pending = gen.next().then((r) => {
|
|
550
|
+
if (r.done) {
|
|
551
|
+
done = true;
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
return r.value ?? null;
|
|
555
|
+
});
|
|
556
|
+
// A consumer that stops early (a `break` in the `for await`) leaves queued
|
|
557
|
+
// requests in flight with nobody awaiting them. If one of those rejects —
|
|
558
|
+
// an aborted request, a server error on a page the consumer never wanted —
|
|
559
|
+
// the rejection is unhandled, which in Node terminates the process and in
|
|
560
|
+
// a browser surfaces as a global `unhandledrejection` the caller cannot
|
|
561
|
+
// catch or attribute. Marking it handled here is safe: awaiting the same
|
|
562
|
+
// promise below still observes the rejection and rethrows it, so a
|
|
563
|
+
// *consumed* failure is reported exactly as before.
|
|
564
|
+
pending.catch(() => { });
|
|
565
|
+
queue.push(pending);
|
|
425
566
|
}
|
|
426
567
|
// Fill prefetch queue
|
|
427
|
-
for (let i = 0; i <
|
|
428
|
-
|
|
568
|
+
for (let i = 0; i < ahead + 1; i++)
|
|
569
|
+
enqueue();
|
|
429
570
|
while (queue.length > 0) {
|
|
430
571
|
const page = await queue.shift();
|
|
431
572
|
if (page === null || page === undefined)
|
|
432
573
|
break;
|
|
433
|
-
|
|
574
|
+
enqueue();
|
|
434
575
|
yield page;
|
|
435
576
|
}
|
|
436
577
|
}
|
|
@@ -449,7 +590,23 @@ export async function* prefetchPaginate(config, strategy = "page", prefetchAhead
|
|
|
449
590
|
* @returns Base64-encoded JSON string
|
|
450
591
|
*/
|
|
451
592
|
export function serializePaginationState(state) {
|
|
452
|
-
|
|
593
|
+
// `btoa` takes a *binary* (Latin-1) string and throws InvalidCharacterError
|
|
594
|
+
// on anything above U+00FF. A cursor is opaque and can hold any character,
|
|
595
|
+
// so a perfectly ordinary state — `cursor: "curseur-e-acute-☃"` — made this
|
|
596
|
+
// throw and the paginator un-resumable. The transport has to be UTF-8.
|
|
597
|
+
const json = JSON.stringify(state);
|
|
598
|
+
if (typeof TextEncoder !== "undefined" && typeof btoa === "function") {
|
|
599
|
+
const bytes = new TextEncoder().encode(json);
|
|
600
|
+
let binary = "";
|
|
601
|
+
// Chunked: String.fromCharCode(...bytes) overflows the argument limit on
|
|
602
|
+
// a large state and throws a RangeError of its own.
|
|
603
|
+
const CHUNK = 0x8000;
|
|
604
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
605
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
606
|
+
}
|
|
607
|
+
return btoa(binary);
|
|
608
|
+
}
|
|
609
|
+
return btoa(json);
|
|
453
610
|
}
|
|
454
611
|
/**
|
|
455
612
|
* Deserialize a serialized pagination state string back to PaginationState.
|
|
@@ -458,15 +615,70 @@ export function serializePaginationState(state) {
|
|
|
458
615
|
* @returns The reconstructed pagination state
|
|
459
616
|
* @throws Error if the string is not valid base64/JSON
|
|
460
617
|
*/
|
|
618
|
+
/** The strategies a serialized state may name. Anything else is not a state. */
|
|
619
|
+
const PAGINATION_STRATEGIES = new Set([
|
|
620
|
+
"offset",
|
|
621
|
+
"page",
|
|
622
|
+
"cursor",
|
|
623
|
+
"relay",
|
|
624
|
+
"link-header",
|
|
625
|
+
"token",
|
|
626
|
+
"keyset",
|
|
627
|
+
]);
|
|
628
|
+
/** A count or position: a finite number, so `NaN` and `Infinity` are refused. */
|
|
629
|
+
function isFiniteNumber(v) {
|
|
630
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* A cursor field must be a string, `null`, or absent.
|
|
634
|
+
*
|
|
635
|
+
* `undefined` is allowed on purpose: these three fields are only meaningful for
|
|
636
|
+
* the strategies that use them, so states written by an older version — which
|
|
637
|
+
* the format carries no version tag for — legitimately omit them. A *number*
|
|
638
|
+
* or an object is never a cursor, and that is what the check is for.
|
|
639
|
+
*/
|
|
640
|
+
function isNullableString(v) {
|
|
641
|
+
return v === undefined || v === null || typeof v === "string";
|
|
642
|
+
}
|
|
461
643
|
export function deserializePaginationState(serialized) {
|
|
644
|
+
let parsed;
|
|
462
645
|
try {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
646
|
+
const binary = atob(serialized);
|
|
647
|
+
const bytes = new Uint8Array(binary.length);
|
|
648
|
+
for (let i = 0; i < binary.length; i++)
|
|
649
|
+
bytes[i] = binary.charCodeAt(i);
|
|
650
|
+
const json = typeof TextDecoder !== "undefined"
|
|
651
|
+
? new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
652
|
+
: binary;
|
|
653
|
+
parsed = JSON.parse(json);
|
|
466
654
|
}
|
|
467
655
|
catch {
|
|
468
656
|
throw new Error("Invalid pagination state string");
|
|
469
657
|
}
|
|
658
|
+
// FIX (H6): serialized state can originate from untrusted URLs/clients —
|
|
659
|
+
// strip prototype-pollution keys before the state is spread into requests.
|
|
660
|
+
const state = sanitizeParsedJSON(parsed);
|
|
661
|
+
// `atob` and `JSON.parse` succeeding says nothing about the *shape*. A
|
|
662
|
+
// string that decodes to `{"hello":"world"}`, or to `[1,2,3]`, or to
|
|
663
|
+
// `null`, was returned as a `PaginationState` and typed as one — a caller
|
|
664
|
+
// resuming from it read `page`/`offset`/`done` off an object that has none,
|
|
665
|
+
// and the paginator restarted from `undefined` rather than saying so.
|
|
666
|
+
if (typeof state !== "object" || state === null || Array.isArray(state)) {
|
|
667
|
+
throw new Error("Invalid pagination state string");
|
|
668
|
+
}
|
|
669
|
+
const s = state;
|
|
670
|
+
if (!isFiniteNumber(s.page) ||
|
|
671
|
+
!isFiniteNumber(s.offset) ||
|
|
672
|
+
!isFiniteNumber(s.totalFetched) ||
|
|
673
|
+
typeof s.strategy !== "string" ||
|
|
674
|
+
!PAGINATION_STRATEGIES.has(s.strategy) ||
|
|
675
|
+
typeof s.done !== "boolean" ||
|
|
676
|
+
!isNullableString(s.cursor) ||
|
|
677
|
+
!isNullableString(s.prevCursor) ||
|
|
678
|
+
!isNullableString(s.token)) {
|
|
679
|
+
throw new Error("Invalid pagination state string");
|
|
680
|
+
}
|
|
681
|
+
return state;
|
|
470
682
|
}
|
|
471
683
|
// ============================================================================
|
|
472
684
|
// §9 UTILITIES
|
|
@@ -478,12 +690,20 @@ function sleep(ms, signal) {
|
|
|
478
690
|
r();
|
|
479
691
|
return;
|
|
480
692
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
693
|
+
// The listener is removed on the timer path too. With `{ once: true }` it
|
|
694
|
+
// only self-removes when it FIRES, so a long-lived signal shared by many
|
|
695
|
+
// paginated requests accumulated one dead listener per completed sleep
|
|
696
|
+
// (MaxListeners warning + retained closures).
|
|
697
|
+
const onAbort = () => {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
signal?.removeEventListener("abort", onAbort);
|
|
700
|
+
r();
|
|
701
|
+
};
|
|
702
|
+
const timer = setTimeout(() => {
|
|
703
|
+
signal?.removeEventListener("abort", onAbort);
|
|
704
|
+
r();
|
|
705
|
+
}, ms);
|
|
706
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
487
707
|
});
|
|
488
708
|
}
|
|
489
709
|
/**
|
|
@@ -529,12 +749,25 @@ export async function* mergePaginators(...paginators) {
|
|
|
529
749
|
const active = new Set(iters.map((_, i) => i));
|
|
530
750
|
while (active.size > 0) {
|
|
531
751
|
for (const i of [...active]) {
|
|
532
|
-
|
|
533
|
-
|
|
752
|
+
let result;
|
|
753
|
+
try {
|
|
754
|
+
result = await iters[i].next();
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// A source that fails is dropped from the rotation, not allowed to
|
|
758
|
+
// abort the merge. One endpoint returning an error ended the whole
|
|
759
|
+
// iteration and discarded everything the other sources had already
|
|
760
|
+
// yielded — the opposite of what merging sources is for, and it did so
|
|
761
|
+
// silently. Isolating the failure matches what the transports and the
|
|
762
|
+
// batch flushes already do elsewhere in the client.
|
|
763
|
+
active.delete(i);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (result.done) {
|
|
534
767
|
active.delete(i);
|
|
535
768
|
continue;
|
|
536
769
|
}
|
|
537
|
-
yield value;
|
|
770
|
+
yield result.value;
|
|
538
771
|
}
|
|
539
772
|
}
|
|
540
773
|
}
|