@ahkohd/yagami 0.1.2
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/.beads/.beads-credential-key +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +54 -0
- package/.beads/hooks/post-checkout +24 -0
- package/.beads/hooks/post-merge +24 -0
- package/.beads/hooks/pre-commit +24 -0
- package/.beads/hooks/pre-push +24 -0
- package/.beads/hooks/prepare-commit-msg +24 -0
- package/.beads/metadata.json +7 -0
- package/.github/workflows/ci.yml +43 -0
- package/.github/workflows/release.yml +115 -0
- package/AGENTS.md +150 -0
- package/README.md +210 -0
- package/biome.json +36 -0
- package/config/mcporter.json +8 -0
- package/dist/cli/theme.js +202 -0
- package/dist/cli/theme.js.map +1 -0
- package/dist/cli.js +1883 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +223 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.js +745 -0
- package/dist/daemon.js.map +1 -0
- package/dist/engine/constants.js +131 -0
- package/dist/engine/constants.js.map +1 -0
- package/dist/engine/deep-research.js +167 -0
- package/dist/engine/deep-research.js.map +1 -0
- package/dist/engine/defuddle-utils.js +57 -0
- package/dist/engine/defuddle-utils.js.map +1 -0
- package/dist/engine/github-fetch.js +232 -0
- package/dist/engine/github-fetch.js.map +1 -0
- package/dist/engine/helpers.js +372 -0
- package/dist/engine/helpers.js.map +1 -0
- package/dist/engine/limiter.js +75 -0
- package/dist/engine/limiter.js.map +1 -0
- package/dist/engine/policy.js +313 -0
- package/dist/engine/policy.js.map +1 -0
- package/dist/engine/runtime-utils.js +65 -0
- package/dist/engine/runtime-utils.js.map +1 -0
- package/dist/engine/search-discovery.js +275 -0
- package/dist/engine/search-discovery.js.map +1 -0
- package/dist/engine/url-utils.js +72 -0
- package/dist/engine/url-utils.js.map +1 -0
- package/dist/engine.js +2030 -0
- package/dist/engine.js.map +1 -0
- package/dist/mcp.js +282 -0
- package/dist/mcp.js.map +1 -0
- package/dist/types/cli.js +2 -0
- package/dist/types/cli.js.map +1 -0
- package/dist/types/config.js +2 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/daemon.js +2 -0
- package/dist/types/daemon.js.map +1 -0
- package/dist/types/engine.js +2 -0
- package/dist/types/engine.js.map +1 -0
- package/package.json +66 -0
- package/packages/pi-yagami-search/README.md +39 -0
- package/packages/pi-yagami-search/extensions/yagami-search.ts +273 -0
- package/packages/pi-yagami-search/package.json +41 -0
- package/src/cli/theme.ts +260 -0
- package/src/cli.ts +2226 -0
- package/src/config.ts +250 -0
- package/src/daemon.ts +990 -0
- package/src/engine/constants.ts +147 -0
- package/src/engine/deep-research.ts +207 -0
- package/src/engine/defuddle-utils.ts +75 -0
- package/src/engine/github-fetch.ts +265 -0
- package/src/engine/helpers.ts +394 -0
- package/src/engine/limiter.ts +97 -0
- package/src/engine/policy.ts +392 -0
- package/src/engine/runtime-utils.ts +79 -0
- package/src/engine/search-discovery.ts +351 -0
- package/src/engine/url-utils.ts +86 -0
- package/src/engine.ts +2516 -0
- package/src/mcp.ts +337 -0
- package/src/shims-cli.d.ts +3 -0
- package/src/types/cli.ts +7 -0
- package/src/types/config.ts +53 -0
- package/src/types/daemon.ts +22 -0
- package/src/types/engine.ts +194 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type { CategoryProfile, CountryProfile, SearchCategory } from "../types/engine.js";
|
|
2
|
+
import { COMPANY_COUNTRY_ALIASES, COMPANY_COUNTRY_PROFILES, URL_REGEX } from "./constants.js";
|
|
3
|
+
import { normalizeUniqueUrls, normalizeUrl } from "./url-utils.js";
|
|
4
|
+
|
|
5
|
+
export function clampInteger(value: unknown, fallback: number, options: { min?: number; max?: number } = {}): number {
|
|
6
|
+
const min = options.min ?? Number.MIN_SAFE_INTEGER;
|
|
7
|
+
const max = options.max ?? Number.MAX_SAFE_INTEGER;
|
|
8
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
9
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
10
|
+
return Math.max(min, Math.min(max, parsed));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
|
14
|
+
const normalized = String(value ?? "")
|
|
15
|
+
.trim()
|
|
16
|
+
.toLowerCase() as T;
|
|
17
|
+
return allowed.includes(normalized) ? normalized : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeCountryCode(value: unknown): string | null {
|
|
21
|
+
const normalized = String(value ?? "")
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
if (!normalized) return null;
|
|
25
|
+
return COMPANY_COUNTRY_ALIASES[normalized] ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getCompanyCountryProfile(countryCode: string | null): CountryProfile | null {
|
|
29
|
+
if (!countryCode) return null;
|
|
30
|
+
return COMPANY_COUNTRY_PROFILES[countryCode] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toArray<T>(value: T | T[] | null | undefined): T[] {
|
|
34
|
+
if (value === undefined || value === null) return [];
|
|
35
|
+
return Array.isArray(value) ? value : [value];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseStringList(value: unknown): string[] {
|
|
39
|
+
const output: string[] = [];
|
|
40
|
+
for (const entry of toArray(value as string | string[] | null | undefined)) {
|
|
41
|
+
if (typeof entry !== "string") continue;
|
|
42
|
+
for (const part of entry.split(",")) {
|
|
43
|
+
const item = part.trim();
|
|
44
|
+
if (item) output.push(item);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return Array.from(new Set(output));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseUrlList(value: unknown): string[] {
|
|
51
|
+
return parseStringList(value)
|
|
52
|
+
.map((entry) => {
|
|
53
|
+
try {
|
|
54
|
+
return normalizeUrl(entry);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
.filter((entry): entry is string => Boolean(entry));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toBool(value: unknown, fallback = false): boolean {
|
|
63
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
64
|
+
if (typeof value === "boolean") return value;
|
|
65
|
+
const normalized = String(value).trim().toLowerCase();
|
|
66
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
|
|
67
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeWhitespace(value: unknown): string {
|
|
72
|
+
return String(value ?? "")
|
|
73
|
+
.replace(/\s+/g, " ")
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function decodeHtmlEntities(value: unknown): string {
|
|
78
|
+
return String(value ?? "")
|
|
79
|
+
.replace(/&/g, "&")
|
|
80
|
+
.replace(/</g, "<")
|
|
81
|
+
.replace(/>/g, ">")
|
|
82
|
+
.replace(/"/g, '"')
|
|
83
|
+
.replace(/'/g, "'")
|
|
84
|
+
.replace(/ /g, " ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function stripHtml(value: unknown): string {
|
|
88
|
+
return normalizeWhitespace(decodeHtmlEntities(String(value ?? "").replace(/<[^>]*>/g, " ")));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeDomainFilter(value: unknown): string {
|
|
92
|
+
const raw = String(value ?? "")
|
|
93
|
+
.trim()
|
|
94
|
+
.toLowerCase();
|
|
95
|
+
if (!raw) return "";
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.replace(/^www\./, "");
|
|
99
|
+
} catch {
|
|
100
|
+
return (
|
|
101
|
+
raw
|
|
102
|
+
.replace(/^https?:\/\//, "")
|
|
103
|
+
.replace(/^www\./, "")
|
|
104
|
+
.split("/")[0]
|
|
105
|
+
?.trim() ?? ""
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function domainMatches(hostname: string, domains: Iterable<string>): boolean {
|
|
111
|
+
if (!hostname) return false;
|
|
112
|
+
for (const domain of domains) {
|
|
113
|
+
if (!domain) continue;
|
|
114
|
+
if (hostname === domain) return true;
|
|
115
|
+
if (hostname.endsWith(`.${domain}`)) return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function isDiscoveryDomain(hostname: string): boolean {
|
|
121
|
+
return domainMatches(hostname, ["duckduckgo.com", "bing.com", "google.com", "search.brave.com"]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isValidPublicHostname(hostname: string): boolean {
|
|
125
|
+
if (!hostname) return false;
|
|
126
|
+
return hostname === "localhost" || hostname.includes(".");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseIsoDate(value: unknown): Date | null {
|
|
130
|
+
if (!value) return null;
|
|
131
|
+
const normalized = String(value).trim();
|
|
132
|
+
if (!normalized) return null;
|
|
133
|
+
const parsed = new Date(normalized);
|
|
134
|
+
if (!Number.isFinite(parsed.getTime())) return null;
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isChallengeLikeContent(title: unknown, content: unknown): boolean {
|
|
139
|
+
const haystack = `${String(title ?? "")}\n${String(content ?? "")}`.toLowerCase();
|
|
140
|
+
const tokens = [
|
|
141
|
+
"just a moment",
|
|
142
|
+
"attention required",
|
|
143
|
+
"verify you are human",
|
|
144
|
+
"checking your browser",
|
|
145
|
+
"access denied",
|
|
146
|
+
"cloudflare",
|
|
147
|
+
"cf-ray",
|
|
148
|
+
"captcha",
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
return tokens.some((token) => haystack.includes(token));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function extractTopTerms(value: unknown, maxTerms = 6): string[] {
|
|
155
|
+
const stopwords = new Set([
|
|
156
|
+
"the",
|
|
157
|
+
"and",
|
|
158
|
+
"for",
|
|
159
|
+
"with",
|
|
160
|
+
"from",
|
|
161
|
+
"that",
|
|
162
|
+
"this",
|
|
163
|
+
"your",
|
|
164
|
+
"about",
|
|
165
|
+
"into",
|
|
166
|
+
"their",
|
|
167
|
+
"have",
|
|
168
|
+
"will",
|
|
169
|
+
"not",
|
|
170
|
+
"are",
|
|
171
|
+
"you",
|
|
172
|
+
"how",
|
|
173
|
+
"what",
|
|
174
|
+
"when",
|
|
175
|
+
"where",
|
|
176
|
+
"why",
|
|
177
|
+
"who",
|
|
178
|
+
"www",
|
|
179
|
+
"http",
|
|
180
|
+
"https",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const words = normalizeWhitespace(value)
|
|
184
|
+
.toLowerCase()
|
|
185
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
186
|
+
.split(/\s+/)
|
|
187
|
+
.filter((word) => word.length >= 4 && !stopwords.has(word));
|
|
188
|
+
|
|
189
|
+
const counts = new Map<string, number>();
|
|
190
|
+
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
191
|
+
|
|
192
|
+
return [...counts.entries()]
|
|
193
|
+
.sort((a, b) => b[1] - a[1])
|
|
194
|
+
.slice(0, maxTerms)
|
|
195
|
+
.map(([word]) => word);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function unwrapDuckDuckGoHref(rawHref: unknown): string {
|
|
199
|
+
if (!rawHref) return "";
|
|
200
|
+
|
|
201
|
+
const trimmed = String(rawHref).trim();
|
|
202
|
+
const absolute = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const url = new URL(absolute);
|
|
206
|
+
const hostname = url.hostname.toLowerCase();
|
|
207
|
+
const pathname = url.pathname || "";
|
|
208
|
+
|
|
209
|
+
if (hostname.endsWith("duckduckgo.com") && pathname.startsWith("/l/")) {
|
|
210
|
+
const target = url.searchParams.get("uddg") || url.searchParams.get("rut");
|
|
211
|
+
if (target) return decodeURIComponent(target);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (hostname.endsWith("google.com") && pathname === "/url") {
|
|
215
|
+
const target = url.searchParams.get("q") || url.searchParams.get("url");
|
|
216
|
+
if (target) return decodeURIComponent(target);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return url.toString();
|
|
220
|
+
} catch {
|
|
221
|
+
return trimmed;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function isTrackingOrAdUrl(rawUrl: unknown): boolean {
|
|
226
|
+
try {
|
|
227
|
+
const url = new URL(String(rawUrl));
|
|
228
|
+
const hostname = url.hostname.toLowerCase();
|
|
229
|
+
|
|
230
|
+
if (hostname.endsWith("duckduckgo.com")) return true;
|
|
231
|
+
if ((hostname === "google.com" || hostname === "www.google.com") && url.pathname === "/url") return true;
|
|
232
|
+
if (hostname === "search.brave.com") return true;
|
|
233
|
+
if (hostname.endsWith("bing.com") && (url.pathname === "/aclick" || url.pathname.startsWith("/ck/"))) return true;
|
|
234
|
+
|
|
235
|
+
const adParams = ["ad_domain", "ad_provider", "ad_type", "click_metadata", "u3", "rut"];
|
|
236
|
+
if (adParams.some((param) => url.searchParams.has(param))) return true;
|
|
237
|
+
|
|
238
|
+
return false;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const CATEGORY_PROFILES: Record<SearchCategory, CategoryProfile> = {
|
|
245
|
+
company: {
|
|
246
|
+
queryHint: "official site products services company overview funding news",
|
|
247
|
+
includeDomains: [],
|
|
248
|
+
includeText: ["company"],
|
|
249
|
+
},
|
|
250
|
+
"research paper": {
|
|
251
|
+
queryHint: "research paper arxiv preprint",
|
|
252
|
+
includeDomains: ["arxiv.org", "openreview.net", "acm.org", "ieee.org"],
|
|
253
|
+
includeText: ["abstract"],
|
|
254
|
+
},
|
|
255
|
+
news: {
|
|
256
|
+
queryHint: "latest news",
|
|
257
|
+
includeDomains: [],
|
|
258
|
+
includeText: ["news"],
|
|
259
|
+
},
|
|
260
|
+
pdf: {
|
|
261
|
+
queryHint: "filetype:pdf pdf",
|
|
262
|
+
includeDomains: [],
|
|
263
|
+
includeText: [],
|
|
264
|
+
},
|
|
265
|
+
github: {
|
|
266
|
+
queryHint: "github repository",
|
|
267
|
+
includeDomains: ["github.com"],
|
|
268
|
+
includeText: [],
|
|
269
|
+
},
|
|
270
|
+
tweet: {
|
|
271
|
+
queryHint: "x twitter thread",
|
|
272
|
+
includeDomains: ["x.com", "twitter.com"],
|
|
273
|
+
includeText: [],
|
|
274
|
+
},
|
|
275
|
+
"personal site": {
|
|
276
|
+
queryHint: "personal blog portfolio",
|
|
277
|
+
includeDomains: [],
|
|
278
|
+
includeText: ["about"],
|
|
279
|
+
},
|
|
280
|
+
people: {
|
|
281
|
+
queryHint: "biography profile",
|
|
282
|
+
includeDomains: [],
|
|
283
|
+
includeText: [],
|
|
284
|
+
},
|
|
285
|
+
"financial report": {
|
|
286
|
+
queryHint: "annual report 10-k earnings",
|
|
287
|
+
includeDomains: ["sec.gov"],
|
|
288
|
+
includeText: ["annual report", "10-k", "earnings"],
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export function categoryProfile(category: unknown): CategoryProfile {
|
|
293
|
+
const value = String(category ?? "")
|
|
294
|
+
.trim()
|
|
295
|
+
.toLowerCase() as SearchCategory;
|
|
296
|
+
return CATEGORY_PROFILES[value] ?? { queryHint: "", includeDomains: [], includeText: [] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function truncateText(text: unknown, maxChars: number): string {
|
|
300
|
+
if (typeof text !== "string") return "";
|
|
301
|
+
if (text.length <= maxChars) return text;
|
|
302
|
+
return `${text.slice(0, maxChars)}\n\n[Truncated to ${maxChars} characters]`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function countWords(text: unknown): number {
|
|
306
|
+
if (!text) return 0;
|
|
307
|
+
return String(text).trim().split(/\s+/).filter(Boolean).length;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
interface ContentBlock {
|
|
311
|
+
type?: unknown;
|
|
312
|
+
text?: unknown;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface MessageLike {
|
|
316
|
+
content?: unknown;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function extractAssistantText(message: MessageLike | null | undefined): string {
|
|
320
|
+
if (!message) return "";
|
|
321
|
+
if (typeof message.content === "string") return message.content;
|
|
322
|
+
if (!Array.isArray(message.content)) return "";
|
|
323
|
+
|
|
324
|
+
return message.content
|
|
325
|
+
.filter((block: ContentBlock) => block?.type === "text")
|
|
326
|
+
.map((block: ContentBlock) => String(block.text ?? ""))
|
|
327
|
+
.join("\n")
|
|
328
|
+
.trim();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function extractTextContent(contentBlocks: unknown): string {
|
|
332
|
+
if (!Array.isArray(contentBlocks)) return "";
|
|
333
|
+
|
|
334
|
+
return contentBlocks
|
|
335
|
+
.filter((block: ContentBlock) => block?.type === "text")
|
|
336
|
+
.map((block: ContentBlock) => String(block.text ?? ""))
|
|
337
|
+
.join("\n")
|
|
338
|
+
.trim();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function buildContext(
|
|
342
|
+
entries: Array<{ error?: unknown; title?: unknown; url?: unknown; snippet?: unknown; content?: unknown }>,
|
|
343
|
+
maxChars: number,
|
|
344
|
+
): string {
|
|
345
|
+
const chunks: string[] = [];
|
|
346
|
+
|
|
347
|
+
for (const entry of entries) {
|
|
348
|
+
if (entry.error) continue;
|
|
349
|
+
|
|
350
|
+
chunks.push(
|
|
351
|
+
[
|
|
352
|
+
`TITLE: ${String(entry.title ?? "Untitled")}`,
|
|
353
|
+
`URL: ${String(entry.url ?? "")}`,
|
|
354
|
+
entry.snippet ? `SNIPPET: ${String(entry.snippet)}` : "",
|
|
355
|
+
entry.content ? `CONTENT:\n${String(entry.content)}` : "",
|
|
356
|
+
]
|
|
357
|
+
.filter(Boolean)
|
|
358
|
+
.join("\n"),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return truncateText(chunks.join("\n\n---\n\n"), maxChars);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getHostname(url: unknown): string {
|
|
366
|
+
try {
|
|
367
|
+
return new URL(String(url)).hostname.toLowerCase();
|
|
368
|
+
} catch {
|
|
369
|
+
return "";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function isHostAllowed(hostname: string, allowedHosts: Iterable<string>): boolean {
|
|
374
|
+
if (!hostname) return false;
|
|
375
|
+
for (const allowed of allowedHosts) {
|
|
376
|
+
if (hostname === allowed) return true;
|
|
377
|
+
if (hostname.endsWith(`.${allowed}`)) return true;
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function extractSeedUrls(text: unknown): string[] {
|
|
383
|
+
const matches = String(text ?? "").match(URL_REGEX) ?? [];
|
|
384
|
+
return normalizeUniqueUrls(matches);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function extractCitationUrls(text: unknown): string[] {
|
|
388
|
+
const matches = String(text ?? "").match(URL_REGEX) ?? [];
|
|
389
|
+
return normalizeUniqueUrls(matches);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function normalizePotentialUrls(values: Iterable<unknown>): string[] {
|
|
393
|
+
return normalizeUniqueUrls(values);
|
|
394
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
type Waiter = {
|
|
2
|
+
resolve: (release: () => void) => void;
|
|
3
|
+
reject: (error: Error) => void;
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
onAbort?: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function abortError(message: string): Error {
|
|
9
|
+
return new Error(message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ConcurrencyLimiter {
|
|
13
|
+
readonly maxConcurrency: number;
|
|
14
|
+
|
|
15
|
+
private activeCount = 0;
|
|
16
|
+
private queue: Waiter[] = [];
|
|
17
|
+
|
|
18
|
+
constructor(maxConcurrency: number) {
|
|
19
|
+
this.maxConcurrency = Math.max(1, Math.floor(maxConcurrency || 1));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get active(): number {
|
|
23
|
+
return this.activeCount;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get pending(): number {
|
|
27
|
+
return this.queue.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async acquire(signal?: AbortSignal, abortMessage = "operation aborted"): Promise<() => void> {
|
|
31
|
+
if (signal?.aborted) {
|
|
32
|
+
throw abortError(abortMessage);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this.activeCount < this.maxConcurrency) {
|
|
36
|
+
this.activeCount += 1;
|
|
37
|
+
return () => this.release();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return await new Promise<() => void>((resolve, reject) => {
|
|
41
|
+
const waiter: Waiter = {
|
|
42
|
+
resolve,
|
|
43
|
+
reject,
|
|
44
|
+
signal,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const onAbort = () => {
|
|
48
|
+
this.removeWaiter(waiter);
|
|
49
|
+
reject(abortError(abortMessage));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
waiter.onAbort = onAbort;
|
|
53
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
54
|
+
|
|
55
|
+
this.queue.push(waiter);
|
|
56
|
+
this.drain();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private release(): void {
|
|
61
|
+
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
62
|
+
this.drain();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private removeWaiter(target: Waiter): void {
|
|
66
|
+
const idx = this.queue.indexOf(target);
|
|
67
|
+
if (idx >= 0) {
|
|
68
|
+
this.queue.splice(idx, 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (target.signal && target.onAbort) {
|
|
72
|
+
target.signal.removeEventListener("abort", target.onAbort);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private drain(): void {
|
|
77
|
+
while (this.activeCount < this.maxConcurrency && this.queue.length > 0) {
|
|
78
|
+
const waiter = this.queue.shift();
|
|
79
|
+
if (!waiter) break;
|
|
80
|
+
|
|
81
|
+
if (waiter.signal?.aborted) {
|
|
82
|
+
if (waiter.signal && waiter.onAbort) {
|
|
83
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
84
|
+
}
|
|
85
|
+
waiter.reject(abortError("operation aborted"));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (waiter.signal && waiter.onAbort) {
|
|
90
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.activeCount += 1;
|
|
94
|
+
waiter.resolve(() => this.release());
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|