hackernews-tui 0.1.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/src/index.tsx ADDED
@@ -0,0 +1,502 @@
1
+ import { createCliRenderer, type ScrollBoxRenderable } from "@opentui/core";
2
+ import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ COMMENT_BATCH_SIZE,
6
+ COMMENT_NEAR_BOTTOM_THRESHOLD,
7
+ FEED_OPTIONS,
8
+ SCORE_FILTER_OPTIONS,
9
+ SORT_LABELS,
10
+ SORT_OPTIONS,
11
+ STORY_FETCH_STEP,
12
+ TYPE_FILTER_LABELS,
13
+ TYPE_FILTER_OPTIONS,
14
+ UI,
15
+ type FocusZone,
16
+ } from "./app/constants";
17
+ import { useHnBrowserState } from "./hooks/use-hn-browser-state";
18
+ import { openExternalUrl } from "./lib/open-external-url";
19
+ import { cycleValue, formatRelativeTime, htmlToText, toSingleLine, truncate } from "./lib/hn-utils";
20
+
21
+ const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
22
+ notation: "compact",
23
+ maximumFractionDigits: 1,
24
+ });
25
+
26
+ function compact(value?: number): string {
27
+ return COMPACT_NUMBER.format(value ?? 0);
28
+ }
29
+
30
+ function App() {
31
+ const tuiRenderer = useRenderer();
32
+ const { width: terminalWidth } = useTerminalDimensions();
33
+ const storyListRef = useRef<ScrollBoxRenderable>(null);
34
+ const detailScrollRef = useRef<ScrollBoxRenderable>(null);
35
+ const isQuittingRef = useRef(false);
36
+
37
+ const [focusZone, setFocusZone] = useState<FocusZone>("list");
38
+
39
+ const {
40
+ feed,
41
+ setFeed,
42
+ storyLimit,
43
+ setStoryLimit,
44
+ feedIds,
45
+ stories,
46
+ selectedStoryIndex,
47
+ setSelectedStoryIndex,
48
+ feedLoading,
49
+ storyLoading,
50
+ detailLoading,
51
+ errorMessage,
52
+ searchQuery,
53
+ setSearchQuery,
54
+ sortMode,
55
+ setSortMode,
56
+ storyClass,
57
+ setStoryClass,
58
+ minScore,
59
+ setMinScore,
60
+ detailItem,
61
+ detailComments,
62
+ hasMoreComments,
63
+ loadingMoreComments,
64
+ feedOrder,
65
+ filteredStories,
66
+ selectedStory,
67
+ selectedStoryId,
68
+ loadMoreComments,
69
+ refreshFeed,
70
+ isCommentLoadInFlight,
71
+ } = useHnBrowserState();
72
+
73
+ const activeFeedIndex = FEED_OPTIONS.findIndex((entry) => entry.value === feed);
74
+ const activeFeedLabel = FEED_OPTIONS[activeFeedIndex >= 0 ? activeFeedIndex : 0]?.label ?? "Top";
75
+ const loadedStoriesCount = stories.length;
76
+ const targetLoadedCount = Math.min(storyLimit, feedIds.length);
77
+ const filteredCount = filteredStories.length;
78
+
79
+ const statusText = feedLoading
80
+ ? "Loading feed IDs..."
81
+ : storyLoading
82
+ ? `Loading stories ${loadedStoriesCount}/${targetLoadedCount}...`
83
+ : detailLoading
84
+ ? "Loading post details..."
85
+ : "Ready";
86
+
87
+ const headerLine = truncate(
88
+ `HN | Feed[${activeFeedIndex + 1 || 1}/6]:${activeFeedLabel} | Sort:${SORT_LABELS[sortMode]} | Type:${TYPE_FILTER_LABELS[storyClass]} | Min:${minScore}+ | Focus:${focusZone} | Stories:${filteredCount}/${loadedStoriesCount}/${feedIds.length} | ${statusText}`,
89
+ Math.max(40, terminalWidth - 12),
90
+ );
91
+
92
+ const footerLine1 = truncate(
93
+ "Navigate: Tab focus | / search | j/k or arrows move | Enter/L details | h back | Mouse wheel scroll | click selects",
94
+ Math.max(40, terminalWidth - 8),
95
+ );
96
+ const footerLine2 = truncate(
97
+ "Actions: 1-6 feed | s sort | t type | p min points | n load more | r refresh | o open URL | i open HN | q quit",
98
+ Math.max(40, terminalWidth - 8),
99
+ );
100
+
101
+ const maxVisibleRank = useMemo(() => {
102
+ if (filteredStories.length === 0) {
103
+ return 1;
104
+ }
105
+ return filteredStories.reduce((max, story, index) => {
106
+ const rank = (feedOrder.get(story.id) ?? index) + 1;
107
+ return Math.max(max, rank);
108
+ }, 1);
109
+ }, [filteredStories, feedOrder]);
110
+
111
+ const rankColumnWidth = Math.max(2, String(maxVisibleRank).length);
112
+ const rowPrefixWidth = rankColumnWidth + 3;
113
+
114
+ const quitApp = () => {
115
+ if (isQuittingRef.current) {
116
+ return;
117
+ }
118
+ isQuittingRef.current = true;
119
+
120
+ try {
121
+ tuiRenderer.destroy();
122
+ } finally {
123
+ setTimeout(() => {
124
+ process.exit(0);
125
+ }, 20);
126
+ }
127
+ };
128
+
129
+ useEffect(() => {
130
+ const list = storyListRef.current;
131
+ if (!list || filteredStories.length === 0) {
132
+ return;
133
+ }
134
+
135
+ const viewportHeight = Math.max(1, list.viewport.height);
136
+ const currentTop = Math.max(0, Math.floor(list.scrollTop));
137
+
138
+ if (selectedStoryIndex < currentTop) {
139
+ list.scrollTop = selectedStoryIndex;
140
+ return;
141
+ }
142
+
143
+ if (selectedStoryIndex >= currentTop + viewportHeight) {
144
+ list.scrollTop = selectedStoryIndex - viewportHeight + 1;
145
+ }
146
+ }, [selectedStoryIndex, filteredStories.length]);
147
+
148
+ useEffect(() => {
149
+ const interval = setInterval(() => {
150
+ if (!selectedStoryId || !hasMoreComments || isCommentLoadInFlight.current) {
151
+ return;
152
+ }
153
+
154
+ const detail = detailScrollRef.current;
155
+ if (!detail) {
156
+ return;
157
+ }
158
+
159
+ const viewportHeight = Math.max(1, detail.viewport.height);
160
+ const remaining = detail.scrollHeight - (detail.scrollTop + viewportHeight);
161
+ if (remaining <= COMMENT_NEAR_BOTTOM_THRESHOLD) {
162
+ void loadMoreComments(selectedStoryId, COMMENT_BATCH_SIZE);
163
+ }
164
+ }, 120);
165
+
166
+ return () => {
167
+ clearInterval(interval);
168
+ };
169
+ }, [selectedStoryId, hasMoreComments, isCommentLoadInFlight, loadMoreComments]);
170
+
171
+ useKeyboard((key) => {
172
+ const keyName = key.name.toLowerCase();
173
+
174
+ if (keyName === "q" || (key.ctrl && keyName === "c")) {
175
+ quitApp();
176
+ return;
177
+ }
178
+
179
+ if (key.name === "tab") {
180
+ const order: FocusZone[] = ["list", "detail", "search"];
181
+ const currentIndex = order.indexOf(focusZone);
182
+ const nextIndex = (currentIndex + 1) % order.length;
183
+ setFocusZone(order[nextIndex] ?? "list");
184
+ key.preventDefault();
185
+ return;
186
+ }
187
+
188
+ if (focusZone === "search") {
189
+ if (key.name === "escape" || key.name === "return" || key.name === "down") {
190
+ setFocusZone("list");
191
+ }
192
+ return;
193
+ }
194
+
195
+ if (key.name === "escape") {
196
+ setFocusZone("list");
197
+ return;
198
+ }
199
+
200
+ if (key.name === "/" || key.name === "slash") {
201
+ setFocusZone("search");
202
+ key.preventDefault();
203
+ return;
204
+ }
205
+
206
+ const numeric = Number.parseInt(key.name, 10);
207
+ if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= FEED_OPTIONS.length) {
208
+ const nextFeed = FEED_OPTIONS[numeric - 1]?.value;
209
+ if (nextFeed && nextFeed !== feed) {
210
+ setFeed(nextFeed);
211
+ }
212
+ return;
213
+ }
214
+
215
+ if (key.name === "s") {
216
+ setSortMode((current) => cycleValue(SORT_OPTIONS, current));
217
+ return;
218
+ }
219
+
220
+ if (key.name === "t") {
221
+ setStoryClass((current) => cycleValue(TYPE_FILTER_OPTIONS, current));
222
+ return;
223
+ }
224
+
225
+ if (key.name === "p") {
226
+ setMinScore((current) => cycleValue(SCORE_FILTER_OPTIONS, current));
227
+ return;
228
+ }
229
+
230
+ if (key.name === "n") {
231
+ setStoryLimit((current) => {
232
+ if (feedIds.length === 0) {
233
+ return current;
234
+ }
235
+ return Math.min(current + STORY_FETCH_STEP, feedIds.length);
236
+ });
237
+ return;
238
+ }
239
+
240
+ if (key.name === "r") {
241
+ refreshFeed();
242
+ return;
243
+ }
244
+
245
+ if (key.name === "o" && selectedStory) {
246
+ openExternalUrl(selectedStory.url ?? `https://news.ycombinator.com/item?id=${selectedStory.id}`);
247
+ return;
248
+ }
249
+
250
+ if (key.name === "i" && selectedStory) {
251
+ openExternalUrl(`https://news.ycombinator.com/item?id=${selectedStory.id}`);
252
+ return;
253
+ }
254
+
255
+ if (focusZone === "list") {
256
+ if (key.name === "j" || key.name === "down") {
257
+ setSelectedStoryIndex((current) => Math.min(current + 1, Math.max(filteredStories.length - 1, 0)));
258
+ key.preventDefault();
259
+ return;
260
+ }
261
+ if (key.name === "k" || key.name === "up") {
262
+ setSelectedStoryIndex((current) => Math.max(current - 1, 0));
263
+ key.preventDefault();
264
+ return;
265
+ }
266
+ if (key.name === "return" || key.name === "right" || key.name === "l") {
267
+ setFocusZone("detail");
268
+ }
269
+ return;
270
+ }
271
+
272
+ if (focusZone === "detail" && (key.name === "left" || key.name === "h")) {
273
+ setFocusZone("list");
274
+ }
275
+ });
276
+
277
+ const postBodyText = htmlToText(detailItem?.text);
278
+ const postBodyLines = postBodyText ? postBodyText.split("\n") : [];
279
+ const discussionUrl = detailItem ? `https://news.ycombinator.com/item?id=${detailItem.id}` : "";
280
+
281
+ return (
282
+ <box flexDirection="column" flexGrow={1} padding={1} backgroundColor={UI.appBackground}>
283
+ <box
284
+ border
285
+ borderColor={UI.border}
286
+ backgroundColor={UI.panelBackground}
287
+ paddingX={1}
288
+ height={3}
289
+ marginBottom={1}
290
+ >
291
+ <text fg={UI.muted}>{headerLine}</text>
292
+ </box>
293
+
294
+ <box
295
+ title="Search (press '/' to focus, Enter/Esc to leave)"
296
+ border
297
+ borderColor={UI.border}
298
+ backgroundColor={UI.panelBackground}
299
+ marginBottom={1}
300
+ minHeight={3}
301
+ >
302
+ <input
303
+ value={searchQuery}
304
+ placeholder="Search loaded stories: title, author, URL, text..."
305
+ backgroundColor={UI.inputBackground}
306
+ textColor={UI.inputText}
307
+ focusedBackgroundColor={UI.inputBackground}
308
+ focusedTextColor={UI.inputText}
309
+ placeholderColor={UI.inputPlaceholder}
310
+ onInput={setSearchQuery}
311
+ focused={focusZone === "search"}
312
+ />
313
+ </box>
314
+
315
+ <box flexDirection="row" flexGrow={1}>
316
+ <box
317
+ title={`Stories (${filteredCount})`}
318
+ border
319
+ borderColor={UI.border}
320
+ backgroundColor={UI.panelBackground}
321
+ width="45%"
322
+ marginRight={1}
323
+ >
324
+ <scrollbox
325
+ ref={storyListRef}
326
+ focused={focusZone === "list"}
327
+ rootOptions={{ paddingX: 1, paddingY: 0, backgroundColor: UI.listBackground }}
328
+ scrollbarOptions={{
329
+ showArrows: true,
330
+ trackOptions: { foregroundColor: UI.muted, backgroundColor: UI.listBackground },
331
+ }}
332
+ >
333
+ {filteredStories.length === 0 && (
334
+ <box height={1}>
335
+ <text fg={UI.muted}>
336
+ No stories match filters. Change search/filters or press 'n' to load more.
337
+ </text>
338
+ </box>
339
+ )}
340
+
341
+ {filteredStories.map((story, index) => {
342
+ const rank = (feedOrder.get(story.id) ?? index) + 1;
343
+ const selected = index === selectedStoryIndex;
344
+ const title = toSingleLine(story.title ?? "(untitled)");
345
+ const marker = selected ? "> " : " ";
346
+ const rankLabel = `${String(rank).padStart(rankColumnWidth, " ")}.`;
347
+ const prefixText = `${marker}${rankLabel}`;
348
+
349
+ return (
350
+ <box
351
+ key={story.id}
352
+ height={1}
353
+ width="100%"
354
+ flexDirection="row"
355
+ alignItems="center"
356
+ backgroundColor={selected ? UI.listSelectedBackground : UI.listBackground}
357
+ onMouseDown={() => {
358
+ setSelectedStoryIndex(index);
359
+ setFocusZone("list");
360
+ }}
361
+ >
362
+ <box width={rowPrefixWidth}>
363
+ <text fg={selected ? UI.listSelectedText : UI.text} width="100%" wrapMode="none" truncate>
364
+ {prefixText}
365
+ </text>
366
+ </box>
367
+ <box flexGrow={1}>
368
+ <text fg={selected ? UI.listSelectedText : UI.text} width="100%" wrapMode="none" truncate>
369
+ {` ${title}`}
370
+ </text>
371
+ </box>
372
+ </box>
373
+ );
374
+ })}
375
+ </scrollbox>
376
+ </box>
377
+
378
+ <box
379
+ title="Post + Thread"
380
+ border
381
+ borderColor={UI.border}
382
+ backgroundColor={UI.panelBackground}
383
+ flexGrow={1}
384
+ >
385
+ <scrollbox
386
+ ref={detailScrollRef}
387
+ focused={focusZone === "detail"}
388
+ rootOptions={{ padding: 1, backgroundColor: UI.listBackground }}
389
+ viewportOptions={{ paddingRight: 1 }}
390
+ scrollbarOptions={{
391
+ showArrows: true,
392
+ trackOptions: { foregroundColor: UI.muted, backgroundColor: UI.listBackground },
393
+ }}
394
+ >
395
+ {!detailItem && !detailLoading && <text fg={UI.muted}>Select a story from the list.</text>}
396
+
397
+ {detailItem && (
398
+ <box flexDirection="column">
399
+ <text fg={UI.accentWarm}>
400
+ <strong>{detailItem.title ?? "(untitled)"}</strong>
401
+ </text>
402
+ <text fg={UI.muted}>
403
+ {compact(detailItem.score)} pts | {compact(detailItem.descendants)} comments |{" "}
404
+ {detailItem.by ?? "unknown"} | {formatRelativeTime(detailItem.time)}
405
+ </text>
406
+ <box onMouseDown={() => openExternalUrl(discussionUrl)}>
407
+ <text fg={UI.link}>
408
+ HN:{" "}
409
+ <a href={discussionUrl}>
410
+ <u>{discussionUrl}</u>
411
+ </a>
412
+ </text>
413
+ </box>
414
+ {detailItem.url && (
415
+ <box onMouseDown={() => openExternalUrl(detailItem.url ?? discussionUrl)}>
416
+ <text fg={UI.link}>
417
+ URL:{" "}
418
+ <a href={detailItem.url}>
419
+ <u>{detailItem.url}</u>
420
+ </a>
421
+ </text>
422
+ </box>
423
+ )}
424
+
425
+ <box marginTop={1}>
426
+ <text fg={UI.success}>
427
+ <strong>Post text</strong>
428
+ </text>
429
+ </box>
430
+ {postBodyLines.length === 0 && <text fg={UI.muted}>No post body.</text>}
431
+ {postBodyLines.map((line, lineIndex) => (
432
+ <text fg={UI.text} key={`post-line-${lineIndex}`}>
433
+ {line.length === 0 ? " " : line}
434
+ </text>
435
+ ))}
436
+
437
+ <box marginTop={1}>
438
+ <text fg={UI.success}>
439
+ <strong>
440
+ Comments ({detailComments.length}
441
+ {hasMoreComments ? "+" : ""})
442
+ </strong>
443
+ </text>
444
+ </box>
445
+ {loadingMoreComments && <text fg={UI.muted}>Loading more comments...</text>}
446
+ {!loadingMoreComments && hasMoreComments && (
447
+ <text fg={UI.muted}>Scroll down to load more comments.</text>
448
+ )}
449
+ {detailComments.length === 0 && <text fg={UI.muted}>No comments available.</text>}
450
+
451
+ {detailComments.map((comment) => {
452
+ const author = comment.item.by ?? "unknown";
453
+ const age = formatRelativeTime(comment.item.time);
454
+ const indent = " ".repeat(Math.min(comment.depth * 2, 20));
455
+ const commentText = htmlToText(comment.item.text) || "[empty comment]";
456
+ const lines = commentText.split("\n");
457
+
458
+ return (
459
+ <box key={`comment-${comment.item.id}`} marginTop={1}>
460
+ <text fg={UI.link}>{`${indent}${author} | ${age}`}</text>
461
+ {lines.map((line, lineIndex) => (
462
+ <text fg={UI.text} key={`comment-${comment.item.id}-line-${lineIndex}`}>
463
+ {`${indent}${line.length === 0 ? " " : line}`}
464
+ </text>
465
+ ))}
466
+ </box>
467
+ );
468
+ })}
469
+ </box>
470
+ )}
471
+ </scrollbox>
472
+ </box>
473
+ </box>
474
+
475
+ {errorMessage && (
476
+ <box border borderColor={UI.danger} backgroundColor={UI.panelBackground} marginTop={1} padding={1}>
477
+ <text fg={UI.danger}>{errorMessage}</text>
478
+ </box>
479
+ )}
480
+
481
+ <box
482
+ border
483
+ borderColor={UI.border}
484
+ backgroundColor={UI.panelBackground}
485
+ marginTop={1}
486
+ paddingX={1}
487
+ height={4}
488
+ justifyContent="center"
489
+ >
490
+ <text fg={UI.muted} width="100%" wrapMode="none" truncate>
491
+ {footerLine1}
492
+ </text>
493
+ <text fg={UI.muted} width="100%" wrapMode="none" truncate>
494
+ {footerLine2}
495
+ </text>
496
+ </box>
497
+ </box>
498
+ );
499
+ }
500
+
501
+ const renderer = await createCliRenderer();
502
+ createRoot(renderer).render(<App />);
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createCommentStack, loadNextCommentBatch } from "./comment-loader";
3
+ import type { HNItem } from "./hn-utils";
4
+
5
+ describe("comment-loader", () => {
6
+ test("createCommentStack preserves top-level order", () => {
7
+ const stack = createCommentStack([101, 102, 103]);
8
+ expect(stack.pop()?.id).toBe(101);
9
+ expect(stack.pop()?.id).toBe(102);
10
+ expect(stack.pop()?.id).toBe(103);
11
+ });
12
+
13
+ test("loadNextCommentBatch performs DFS and skips non-comment/dead/deleted", async () => {
14
+ const items = new Map<number, HNItem>([
15
+ [1, { id: 1, type: "comment", kids: [10, 11] }],
16
+ [2, { id: 2, type: "story" }],
17
+ [3, { id: 3, type: "comment", kids: [30] }],
18
+ [10, { id: 10, type: "comment" }],
19
+ [11, { id: 11, type: "comment", dead: true }],
20
+ [30, { id: 30, type: "comment" }],
21
+ ]);
22
+
23
+ const getItem = async (id: number): Promise<HNItem | null> => items.get(id) ?? null;
24
+
25
+ const stack = createCommentStack([1, 2, 3]);
26
+
27
+ const firstBatch = await loadNextCommentBatch(stack, 2, getItem);
28
+ expect(firstBatch.map((entry) => `${entry.item.id}:${entry.depth}`)).toEqual(["1:0", "10:1"]);
29
+
30
+ const secondBatch = await loadNextCommentBatch(stack, 10, getItem);
31
+ expect(secondBatch.map((entry) => `${entry.item.id}:${entry.depth}`)).toEqual(["3:0", "30:1"]);
32
+ expect(stack.length).toBe(0);
33
+ });
34
+ });
@@ -0,0 +1,45 @@
1
+ import type { HNItem } from "./hn-utils";
2
+
3
+ export interface CommentStackNode {
4
+ id: number;
5
+ depth: number;
6
+ }
7
+
8
+ export interface FlatComment {
9
+ item: HNItem;
10
+ depth: number;
11
+ }
12
+
13
+ export function createCommentStack(rootKids?: number[]): CommentStackNode[] {
14
+ const stack: CommentStackNode[] = [];
15
+ const kids = rootKids ?? [];
16
+ for (let i = kids.length - 1; i >= 0; i -= 1) {
17
+ stack.push({ id: kids[i] as number, depth: 0 });
18
+ }
19
+ return stack;
20
+ }
21
+
22
+ export async function loadNextCommentBatch(
23
+ stack: CommentStackNode[],
24
+ batchSize: number,
25
+ getItem: (id: number) => Promise<HNItem | null>,
26
+ ): Promise<FlatComment[]> {
27
+ const loaded: FlatComment[] = [];
28
+
29
+ while (stack.length > 0 && loaded.length < batchSize) {
30
+ const current = stack.pop() as CommentStackNode;
31
+ const item = await getItem(current.id);
32
+ if (!item || item.type !== "comment" || item.deleted || item.dead) {
33
+ continue;
34
+ }
35
+
36
+ loaded.push({ item, depth: current.depth });
37
+
38
+ const kids = item.kids ?? [];
39
+ for (let i = kids.length - 1; i >= 0; i -= 1) {
40
+ stack.push({ id: kids[i] as number, depth: current.depth + 1 });
41
+ }
42
+ }
43
+
44
+ return loaded;
45
+ }
@@ -0,0 +1,74 @@
1
+ import type { FeedKey } from "../app/constants";
2
+ import type { HNItem } from "./hn-utils";
3
+
4
+ const HN_API_BASE = "https://hacker-news.firebaseio.com/v0";
5
+
6
+ const itemCache = new Map<number, HNItem | null | Promise<HNItem | null>>();
7
+
8
+ async function fetchJson<T>(url: string): Promise<T> {
9
+ const response = await fetch(url, {
10
+ headers: {
11
+ accept: "application/json",
12
+ },
13
+ });
14
+ if (!response.ok) {
15
+ throw new Error(`HTTP ${response.status} from ${url}`);
16
+ }
17
+ return (await response.json()) as T;
18
+ }
19
+
20
+ export async function getFeedIds(feed: FeedKey, cacheBust?: number): Promise<number[]> {
21
+ const suffix = cacheBust === undefined ? "" : `?t=${cacheBust}`;
22
+ return fetchJson<number[]>(`${HN_API_BASE}/${feed}.json${suffix}`);
23
+ }
24
+
25
+ export async function getItemCached(id: number): Promise<HNItem | null> {
26
+ const cached = itemCache.get(id);
27
+ if (cached !== undefined) {
28
+ if (typeof (cached as Promise<HNItem | null>).then === "function") {
29
+ return cached as Promise<HNItem | null>;
30
+ }
31
+ return cached as HNItem | null;
32
+ }
33
+
34
+ const pending = fetchJson<HNItem | null>(`${HN_API_BASE}/item/${id}.json`)
35
+ .then((item) => {
36
+ itemCache.set(id, item);
37
+ return item;
38
+ })
39
+ .catch(() => {
40
+ itemCache.set(id, null);
41
+ return null;
42
+ });
43
+
44
+ itemCache.set(id, pending);
45
+ return pending;
46
+ }
47
+
48
+ export async function mapWithConcurrency<T, R>(
49
+ values: readonly T[],
50
+ concurrency: number,
51
+ mapper: (value: T) => Promise<R>,
52
+ ): Promise<R[]> {
53
+ if (values.length === 0) {
54
+ return [];
55
+ }
56
+
57
+ const results = new Array<R>(values.length);
58
+ let index = 0;
59
+
60
+ const worker = async () => {
61
+ while (true) {
62
+ const next = index;
63
+ index += 1;
64
+ if (next >= values.length) {
65
+ return;
66
+ }
67
+ results[next] = await mapper(values[next] as T);
68
+ }
69
+ };
70
+
71
+ const workerCount = Math.max(1, Math.min(concurrency, values.length));
72
+ await Promise.all(Array.from({ length: workerCount }, worker));
73
+ return results;
74
+ }