@bedolla/enriweb 0.1.5 → 0.1.7
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 +48 -20
- package/dist/client/EnriProxyClient.d.ts +176 -5
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +255 -31
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/index.js +65 -25
- package/dist/index.js.map +1 -1
- package/dist/package-info.d.ts +26 -0
- package/dist/package-info.d.ts.map +1 -1
- package/dist/package-info.js +29 -2
- package/dist/package-info.js.map +1 -1
- package/dist/server/EnriWebServer.d.ts +33 -0
- package/dist/server/EnriWebServer.d.ts.map +1 -1
- package/dist/server/EnriWebServer.js +338 -28
- package/dist/server/EnriWebServer.js.map +1 -1
- package/dist/shared/Utf8SafeTextSlicer.d.ts +47 -0
- package/dist/shared/Utf8SafeTextSlicer.d.ts.map +1 -0
- package/dist/shared/Utf8SafeTextSlicer.js +97 -0
- package/dist/shared/Utf8SafeTextSlicer.js.map +1 -0
- package/dist/shared/validation.d.ts +13 -1
- package/dist/shared/validation.d.ts.map +1 -1
- package/dist/shared/validation.js +41 -17
- package/dist/shared/validation.js.map +1 -1
- package/dist/tools/WebFetchNpmProjection.d.ts +143 -0
- package/dist/tools/WebFetchNpmProjection.d.ts.map +1 -0
- package/dist/tools/WebFetchNpmProjection.js +480 -0
- package/dist/tools/WebFetchNpmProjection.js.map +1 -0
- package/dist/tools/WebFetchParamsParser.d.ts +26 -0
- package/dist/tools/WebFetchParamsParser.d.ts.map +1 -0
- package/dist/tools/WebFetchParamsParser.js +157 -0
- package/dist/tools/WebFetchParamsParser.js.map +1 -0
- package/dist/tools/WebFetchRangesExecutor.d.ts +86 -0
- package/dist/tools/WebFetchRangesExecutor.d.ts.map +1 -0
- package/dist/tools/WebFetchRangesExecutor.js +277 -0
- package/dist/tools/WebFetchRangesExecutor.js.map +1 -0
- package/dist/tools/WebFetchTool.d.ts +187 -71
- package/dist/tools/WebFetchTool.d.ts.map +1 -1
- package/dist/tools/WebFetchTool.js +145 -367
- package/dist/tools/WebFetchTool.js.map +1 -1
- package/dist/tools/WebFetchToolTextFormatter.d.ts +57 -0
- package/dist/tools/WebFetchToolTextFormatter.d.ts.map +1 -0
- package/dist/tools/WebFetchToolTextFormatter.js +135 -0
- package/dist/tools/WebFetchToolTextFormatter.js.map +1 -0
- package/dist/tools/WebSearchRegistryHttpReader.d.ts +116 -0
- package/dist/tools/WebSearchRegistryHttpReader.d.ts.map +1 -0
- package/dist/tools/WebSearchRegistryHttpReader.js +157 -0
- package/dist/tools/WebSearchRegistryHttpReader.js.map +1 -0
- package/dist/tools/WebSearchRegistryVerifier.d.ts +111 -6
- package/dist/tools/WebSearchRegistryVerifier.d.ts.map +1 -1
- package/dist/tools/WebSearchRegistryVerifier.js +406 -125
- package/dist/tools/WebSearchRegistryVerifier.js.map +1 -1
- package/dist/tools/WebSearchTool.d.ts +85 -2
- package/dist/tools/WebSearchTool.d.ts.map +1 -1
- package/dist/tools/WebSearchTool.js +197 -19
- package/dist/tools/WebSearchTool.js.map +1 -1
- package/package.json +3 -2
|
@@ -1,15 +1,38 @@
|
|
|
1
|
+
import { WebSearchRegistryHttpReader } from "./WebSearchRegistryHttpReader.js";
|
|
2
|
+
import { sliceUtf8Safe } from "../shared/Utf8SafeTextSlicer.js";
|
|
1
3
|
/**
|
|
2
4
|
* Enriches web search results with registry version verification.
|
|
3
5
|
*/
|
|
4
6
|
export class WebSearchRegistryVerifier {
|
|
7
|
+
/**
|
|
8
|
+
* Capped JSON HTTP reader owning the registry transport concerns.
|
|
9
|
+
*/
|
|
10
|
+
httpReader;
|
|
5
11
|
/**
|
|
6
12
|
* NuGet V3 service index endpoint.
|
|
7
13
|
*/
|
|
8
14
|
static NUGET_SERVICE_INDEX_URL = "https://api.nuget.org/v3/index.json";
|
|
15
|
+
/**
|
|
16
|
+
* Maximum bytes read from one npm packument (full-version manifest).
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Popular packages legitimately exceed the shared 5 MB registry cap
|
|
20
|
+
* (thousands of versions with full metadata); parity with the EnriCode
|
|
21
|
+
* client plane's 16 MiB download budget for the same endpoint.
|
|
22
|
+
*/
|
|
23
|
+
static NPM_PACKUMENT_MAX_BYTES = 16_777_216;
|
|
9
24
|
/**
|
|
10
25
|
* Default concurrency for registry verification.
|
|
11
26
|
*/
|
|
12
27
|
static DEFAULT_CONCURRENCY = 3;
|
|
28
|
+
/**
|
|
29
|
+
* Time one cached error entity stays fresh before a retry.
|
|
30
|
+
*/
|
|
31
|
+
static ERROR_CACHE_TTL_MS = 60 * 1000;
|
|
32
|
+
/**
|
|
33
|
+
* Maximum cached verification entries (LRU eviction beyond this count).
|
|
34
|
+
*/
|
|
35
|
+
static MAX_CACHE_ENTRIES = 200;
|
|
13
36
|
/**
|
|
14
37
|
* Dependencies.
|
|
15
38
|
*/
|
|
@@ -18,6 +41,16 @@ export class WebSearchRegistryVerifier {
|
|
|
18
41
|
* In-memory TTL cache.
|
|
19
42
|
*/
|
|
20
43
|
cache;
|
|
44
|
+
/**
|
|
45
|
+
* In-flight verification promises keyed by cache key.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* Single-flight coalescing: concurrent MCP requests verifying the same
|
|
49
|
+
* entity join one registry fetch instead of racing duplicates. Entries
|
|
50
|
+
* are removed when the verification settles (success, error or caller
|
|
51
|
+
* abort), so the map never outlives its requests.
|
|
52
|
+
*/
|
|
53
|
+
inFlight = new Map();
|
|
21
54
|
/**
|
|
22
55
|
* Cached NuGet service index resolution.
|
|
23
56
|
*/
|
|
@@ -28,6 +61,10 @@ export class WebSearchRegistryVerifier {
|
|
|
28
61
|
* @param deps - Dependencies
|
|
29
62
|
*/
|
|
30
63
|
constructor(deps) {
|
|
64
|
+
this.httpReader = new WebSearchRegistryHttpReader({
|
|
65
|
+
fetchImpl: deps.fetchImpl,
|
|
66
|
+
timeoutMs: deps.timeoutMs
|
|
67
|
+
});
|
|
31
68
|
this.deps = deps;
|
|
32
69
|
this.cache = new Map();
|
|
33
70
|
this.nugetServiceIndexCache = null;
|
|
@@ -36,17 +73,38 @@ export class WebSearchRegistryVerifier {
|
|
|
36
73
|
* Attempts to verify canonical versions for registry entities found in search results.
|
|
37
74
|
*
|
|
38
75
|
* @param results - Search results
|
|
76
|
+
* @param signal - Optional caller abort signal (stops queueing new verifications)
|
|
39
77
|
* @returns Verified entities (best-effort)
|
|
40
78
|
*/
|
|
41
|
-
async verifyFromSearchResults(results) {
|
|
42
|
-
|
|
79
|
+
async verifyFromSearchResults(results, signal) {
|
|
80
|
+
if (signal?.aborted) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
// A non-array `results` payload (proxy shape drift) carries no
|
|
84
|
+
// candidates; it must degrade to unverified instead of throwing
|
|
85
|
+
// "results is not iterable" into the tool result.
|
|
86
|
+
const entries = Array.isArray(results) ? results : [];
|
|
87
|
+
const candidates = this.collectCandidates(entries);
|
|
43
88
|
if (candidates.length === 0) {
|
|
44
89
|
return [];
|
|
45
90
|
}
|
|
46
91
|
const tasks = candidates.map((candidate) => async () => {
|
|
47
|
-
|
|
92
|
+
if (signal?.aborted) {
|
|
93
|
+
throw new Error("Verificación cancelada por el cliente.");
|
|
94
|
+
}
|
|
95
|
+
return await this.verifyCandidate(candidate.kind, candidate.name, signal);
|
|
48
96
|
});
|
|
49
|
-
|
|
97
|
+
try {
|
|
98
|
+
return await this.runWithConcurrencyLimit(tasks, WebSearchRegistryVerifier.DEFAULT_CONCURRENCY);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// Cancellation must degrade to unverified results, never fail the search
|
|
102
|
+
// whose results already succeeded.
|
|
103
|
+
if (signal?.aborted) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
50
108
|
}
|
|
51
109
|
/**
|
|
52
110
|
* Collects registry candidates from search result URLs.
|
|
@@ -58,9 +116,12 @@ export class WebSearchRegistryVerifier {
|
|
|
58
116
|
const unique = new Set();
|
|
59
117
|
const candidates = [];
|
|
60
118
|
for (const entry of results) {
|
|
61
|
-
if (
|
|
119
|
+
if (candidates.length >= this.deps.maxEntitiesPerCall) {
|
|
62
120
|
break;
|
|
63
121
|
}
|
|
122
|
+
if (!entry.url) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
64
125
|
let parsedUrl;
|
|
65
126
|
try {
|
|
66
127
|
parsedUrl = new URL(entry.url);
|
|
@@ -116,44 +177,146 @@ export class WebSearchRegistryVerifier {
|
|
|
116
177
|
}
|
|
117
178
|
return candidates;
|
|
118
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Maximum `releases` pages followed per GitHub verification.
|
|
182
|
+
*/
|
|
183
|
+
static GITHUB_RELEASES_MAX_PAGES = 3;
|
|
119
184
|
/**
|
|
120
185
|
* Verifies a single candidate, using cache when available.
|
|
121
186
|
*
|
|
122
187
|
* @param kind - Registry kind
|
|
123
188
|
* @param name - Candidate name
|
|
189
|
+
* @param signal - Optional caller abort signal forwarded to registry fetches
|
|
124
190
|
* @returns Verification result
|
|
125
191
|
*/
|
|
126
|
-
async verifyCandidate(kind, name) {
|
|
192
|
+
async verifyCandidate(kind, name, signal) {
|
|
127
193
|
const cacheKey = `${kind}:${name.toLowerCase()}`;
|
|
128
194
|
const nowMs = Date.now();
|
|
129
195
|
const cached = this.cache.get(cacheKey);
|
|
130
196
|
if (cached && cached.expiresAtMs > nowMs) {
|
|
197
|
+
// LRU semantics: `Map.set` on an existing key updates the value
|
|
198
|
+
// WITHOUT moving the insertion-order record (ECMAScript spec), so a
|
|
199
|
+
// true recency refresh requires delete-then-set; otherwise eviction
|
|
200
|
+
// below stays FIFO and evicts hot entries inserted early.
|
|
201
|
+
this.cache.delete(cacheKey);
|
|
202
|
+
this.cache.set(cacheKey, cached);
|
|
131
203
|
return cached.value;
|
|
132
204
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
205
|
+
// Single-flight: concurrent MCP requests verifying the same entity
|
|
206
|
+
// join the in-flight promise instead of racing duplicate registry
|
|
207
|
+
// fetches (GitHub unauthenticated quota is 60 req/h per IP).
|
|
208
|
+
const inFlight = this.inFlight.get(cacheKey);
|
|
209
|
+
if (inFlight !== undefined) {
|
|
210
|
+
return await inFlight;
|
|
211
|
+
}
|
|
212
|
+
const verification = (async () => {
|
|
213
|
+
let value;
|
|
214
|
+
try {
|
|
215
|
+
if (kind === "npm") {
|
|
216
|
+
value = await this.verifyNpm(name, signal);
|
|
217
|
+
}
|
|
218
|
+
else if (kind === "pypi") {
|
|
219
|
+
value = await this.verifyPyPi(name, signal);
|
|
220
|
+
}
|
|
221
|
+
else if (kind === "crates") {
|
|
222
|
+
value = await this.verifyCrates(name, signal);
|
|
223
|
+
}
|
|
224
|
+
else if (kind === "nuget") {
|
|
225
|
+
value = await this.verifyNuGet(name, signal);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
value = await this.verifyGitHub(name, signal);
|
|
229
|
+
}
|
|
137
230
|
}
|
|
138
|
-
|
|
139
|
-
|
|
231
|
+
catch (error) {
|
|
232
|
+
// Map transport noise to stable Spanish before caching so error
|
|
233
|
+
// entities never carry raw undici English strings downstream.
|
|
234
|
+
value = {
|
|
235
|
+
kind,
|
|
236
|
+
name,
|
|
237
|
+
status: "error",
|
|
238
|
+
error: WebSearchRegistryVerifier.describeFetchError(error)
|
|
239
|
+
};
|
|
240
|
+
// Caller cancellations are not transient registry failures: caching
|
|
241
|
+
// them would poison the next search with "Operación cancelada…" rows,
|
|
242
|
+
// so the entity is returned uncached and re-verified on the next call.
|
|
243
|
+
if (WebSearchRegistryVerifier.isCallerCancellation(error, signal)) {
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
140
246
|
}
|
|
141
|
-
|
|
142
|
-
|
|
247
|
+
const ttlMs = value.status === "error" ? WebSearchRegistryVerifier.ERROR_CACHE_TTL_MS : this.deps.cacheTtlMs;
|
|
248
|
+
this.cache.delete(cacheKey);
|
|
249
|
+
this.cache.set(cacheKey, { value, expiresAtMs: nowMs + ttlMs });
|
|
250
|
+
while (this.cache.size > WebSearchRegistryVerifier.MAX_CACHE_ENTRIES) {
|
|
251
|
+
const oldestKey = this.cache.keys().next().value;
|
|
252
|
+
if (oldestKey === undefined) {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
this.cache.delete(oldestKey);
|
|
143
256
|
}
|
|
144
|
-
|
|
145
|
-
|
|
257
|
+
return value;
|
|
258
|
+
})();
|
|
259
|
+
this.inFlight.set(cacheKey, verification);
|
|
260
|
+
try {
|
|
261
|
+
return await verification;
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
this.inFlight.delete(cacheKey);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Reports whether one failure is a caller cancellation.
|
|
269
|
+
*
|
|
270
|
+
* @remarks
|
|
271
|
+
* Internal subfetch timeouts are reclassified as `TimeoutError` upstream
|
|
272
|
+
* and stay cacheable like any transient error; only genuine caller aborts
|
|
273
|
+
* (or errors shaped like one, e.g. a Spanish "cancelada" message) skip
|
|
274
|
+
* the error cache.
|
|
275
|
+
*
|
|
276
|
+
* @param error - Failure from a registry fetch.
|
|
277
|
+
* @param signal - Caller abort signal threaded into the verification.
|
|
278
|
+
* @returns True for caller cancellations.
|
|
279
|
+
*/
|
|
280
|
+
static isCallerCancellation(error, signal) {
|
|
281
|
+
if (signal?.aborted) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (error instanceof Error) {
|
|
285
|
+
if (error.name === "TimeoutError" || /timeout|timed out|ETIMEDOUT/iu.test(error.message)) {
|
|
286
|
+
return false;
|
|
146
287
|
}
|
|
147
|
-
|
|
148
|
-
|
|
288
|
+
if (error.name === "AbortError" || /abort|cancelad/iu.test(error.message)) {
|
|
289
|
+
return true;
|
|
149
290
|
}
|
|
150
291
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Maps transport failures to stable Spanish text.
|
|
296
|
+
*
|
|
297
|
+
* @param error - Failure from a registry fetch.
|
|
298
|
+
* @returns Spanish description for the cached error entity.
|
|
299
|
+
*/
|
|
300
|
+
static describeFetchError(error) {
|
|
301
|
+
if (error instanceof Error) {
|
|
302
|
+
const name = error.name;
|
|
303
|
+
const message = error.message;
|
|
304
|
+
if (name === "TimeoutError" || /timeout|timed out|ETIMEDOUT/iu.test(message)) {
|
|
305
|
+
return "Tiempo de espera agotado al consultar el registro; se reintentará en la próxima búsqueda.";
|
|
306
|
+
}
|
|
307
|
+
if (name === "AbortError" || /abort|cancelad/iu.test(message)) {
|
|
308
|
+
return "Operación cancelada antes de completar la verificación del registro.";
|
|
309
|
+
}
|
|
310
|
+
// Unknown failures (undici transport errors are English by nature,
|
|
311
|
+
// e.g. "fetch failed", "ENOTFOUND", "ECONNRESET") never reach the
|
|
312
|
+
// model raw: they are wrapped in a bounded Spanish note so
|
|
313
|
+
// `verified[].error` stays model-facing Spanish with a short
|
|
314
|
+
// technical tail.
|
|
315
|
+
const bounded = sliceUtf8Safe(message.replace(/\s+/gu, " ").trim(), 0, 120);
|
|
316
|
+
return `Fallo de red al consultar el registro (${bounded.length > 0 ? bounded : "sin detalle"}).`;
|
|
154
317
|
}
|
|
155
|
-
|
|
156
|
-
return
|
|
318
|
+
const boundedRaw = sliceUtf8Safe(String(error).replace(/\s+/gu, " ").trim(), 0, 120);
|
|
319
|
+
return `Fallo de red al consultar el registro (${boundedRaw.length > 0 ? boundedRaw : "sin detalle"}).`;
|
|
157
320
|
}
|
|
158
321
|
/**
|
|
159
322
|
* Runs tasks with a fixed concurrency limit.
|
|
@@ -188,15 +351,23 @@ export class WebSearchRegistryVerifier {
|
|
|
188
351
|
* Verifies npm package versions via the npm registry.
|
|
189
352
|
*
|
|
190
353
|
* @param packageName - npm package name (may be scoped)
|
|
354
|
+
* @param signal - Optional caller abort signal
|
|
191
355
|
* @returns Verified registry entity
|
|
192
356
|
*/
|
|
193
|
-
async verifyNpm(packageName) {
|
|
357
|
+
async verifyNpm(packageName, signal) {
|
|
194
358
|
const encoded = encodeURIComponent(packageName);
|
|
195
359
|
const sourceUrl = `https://registry.npmjs.org/${encoded}`;
|
|
196
|
-
|
|
197
|
-
|
|
360
|
+
// Popular packuments (full metadata for thousands of versions) exceed the
|
|
361
|
+
// default 5 MB cap; parity with the EnriCode client plane, which reads
|
|
362
|
+
// the same packument endpoint with a 16 MiB budget.
|
|
363
|
+
const bodyRaw = await this.httpReader.fetchJson(sourceUrl, {
|
|
364
|
+
accept: "application/json",
|
|
365
|
+
signal,
|
|
366
|
+
maxBytes: WebSearchRegistryVerifier.NPM_PACKUMENT_MAX_BYTES
|
|
367
|
+
});
|
|
368
|
+
const body = this.tryGetRecord(bodyRaw.value);
|
|
198
369
|
if (!body) {
|
|
199
|
-
throw new Error(`
|
|
370
|
+
throw new Error(`Respuesta npm inesperada para ${sourceUrl}`);
|
|
200
371
|
}
|
|
201
372
|
const distTags = this.tryGetRecord(body["dist-tags"]);
|
|
202
373
|
const versionsRecord = this.tryGetRecord(body["versions"]);
|
|
@@ -233,15 +404,16 @@ export class WebSearchRegistryVerifier {
|
|
|
233
404
|
* Verifies PyPI project versions via the PyPI JSON API.
|
|
234
405
|
*
|
|
235
406
|
* @param projectName - PyPI project name
|
|
407
|
+
* @param signal - Optional caller abort signal
|
|
236
408
|
* @returns Verified registry entity
|
|
237
409
|
*/
|
|
238
|
-
async verifyPyPi(projectName) {
|
|
410
|
+
async verifyPyPi(projectName, signal) {
|
|
239
411
|
const encoded = encodeURIComponent(projectName);
|
|
240
412
|
const sourceUrl = `https://pypi.org/pypi/${encoded}/json`;
|
|
241
|
-
const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
|
|
242
|
-
const body = this.tryGetRecord(bodyRaw);
|
|
413
|
+
const bodyRaw = await this.httpReader.fetchJson(sourceUrl, { accept: "application/json", signal });
|
|
414
|
+
const body = this.tryGetRecord(bodyRaw.value);
|
|
243
415
|
if (!body) {
|
|
244
|
-
throw new Error(`
|
|
416
|
+
throw new Error(`Respuesta PyPI inesperada para ${sourceUrl}`);
|
|
245
417
|
}
|
|
246
418
|
const releases = this.tryGetRecord(body["releases"]);
|
|
247
419
|
const candidates = [];
|
|
@@ -272,15 +444,16 @@ export class WebSearchRegistryVerifier {
|
|
|
272
444
|
* Verifies crates.io package versions via the crates.io API.
|
|
273
445
|
*
|
|
274
446
|
* @param crateName - Crate name
|
|
447
|
+
* @param signal - Optional caller abort signal
|
|
275
448
|
* @returns Verified registry entity
|
|
276
449
|
*/
|
|
277
|
-
async verifyCrates(crateName) {
|
|
450
|
+
async verifyCrates(crateName, signal) {
|
|
278
451
|
const encoded = encodeURIComponent(crateName);
|
|
279
452
|
const sourceUrl = `https://crates.io/api/v1/crates/${encoded}`;
|
|
280
|
-
const bodyRaw = await this.fetchJson(sourceUrl, { accept: "application/json" });
|
|
281
|
-
const body = this.tryGetRecord(bodyRaw);
|
|
453
|
+
const bodyRaw = await this.httpReader.fetchJson(sourceUrl, { accept: "application/json", signal });
|
|
454
|
+
const body = this.tryGetRecord(bodyRaw.value);
|
|
282
455
|
if (!body) {
|
|
283
|
-
throw new Error(`
|
|
456
|
+
throw new Error(`Respuesta crates.io inesperada para ${sourceUrl}`);
|
|
284
457
|
}
|
|
285
458
|
const versions = Array.isArray(body["versions"]) ? body["versions"] : [];
|
|
286
459
|
const stableCandidates = [];
|
|
@@ -335,24 +508,25 @@ export class WebSearchRegistryVerifier {
|
|
|
335
508
|
* Verifies NuGet package versions via NuGet V3 endpoints.
|
|
336
509
|
*
|
|
337
510
|
* @param packageId - NuGet package ID
|
|
511
|
+
* @param signal - Optional caller abort signal
|
|
338
512
|
* @returns Verified registry entity
|
|
339
513
|
*/
|
|
340
|
-
async verifyNuGet(packageId) {
|
|
514
|
+
async verifyNuGet(packageId, signal) {
|
|
341
515
|
const lowerId = packageId.toLowerCase();
|
|
342
|
-
const serviceIndex = await this.getNuGetServiceIndex();
|
|
516
|
+
const serviceIndex = await this.getNuGetServiceIndex(signal);
|
|
343
517
|
if (!serviceIndex.packageBaseAddressUrl) {
|
|
344
518
|
return {
|
|
345
519
|
kind: "nuget",
|
|
346
520
|
name: packageId,
|
|
347
521
|
status: "error",
|
|
348
|
-
error: "
|
|
522
|
+
error: "El índice de NuGet no proporcionó PackageBaseAddress."
|
|
349
523
|
};
|
|
350
524
|
}
|
|
351
525
|
const versionsUrl = `${serviceIndex.packageBaseAddressUrl}${lowerId}/index.json`;
|
|
352
|
-
const versionsBodyRaw = await this.fetchJson(versionsUrl, { accept: "application/json" });
|
|
353
|
-
const versionsBody = this.tryGetRecord(versionsBodyRaw);
|
|
526
|
+
const versionsBodyRaw = await this.httpReader.fetchJson(versionsUrl, { accept: "application/json", signal });
|
|
527
|
+
const versionsBody = this.tryGetRecord(versionsBodyRaw.value);
|
|
354
528
|
if (!versionsBody) {
|
|
355
|
-
throw new Error(`
|
|
529
|
+
throw new Error(`Respuesta NuGet inesperada para ${versionsUrl}`);
|
|
356
530
|
}
|
|
357
531
|
const versions = Array.isArray(versionsBody["versions"]) ? versionsBody["versions"] : [];
|
|
358
532
|
const versionStrings = versions
|
|
@@ -361,10 +535,10 @@ export class WebSearchRegistryVerifier {
|
|
|
361
535
|
const bestStable = this.pickBestSemVer(versionStrings, { prerelease: false });
|
|
362
536
|
const bestPre = this.pickBestSemVer(versionStrings, { prerelease: true });
|
|
363
537
|
const stablePublishedAt = bestStable && serviceIndex.registrationsBaseUrl
|
|
364
|
-
? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable)
|
|
538
|
+
? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestStable, signal)
|
|
365
539
|
: null;
|
|
366
540
|
const prePublishedAt = bestPre && serviceIndex.registrationsBaseUrl
|
|
367
|
-
? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre)
|
|
541
|
+
? await this.tryFetchNuGetLeafPublishedAt(serviceIndex.registrationsBaseUrl, lowerId, bestPre, signal)
|
|
368
542
|
: null;
|
|
369
543
|
return {
|
|
370
544
|
kind: "nuget",
|
|
@@ -381,92 +555,117 @@ export class WebSearchRegistryVerifier {
|
|
|
381
555
|
/**
|
|
382
556
|
* Verifies GitHub repository releases via GitHub REST API.
|
|
383
557
|
*
|
|
558
|
+
* @remarks
|
|
559
|
+
* `latest_stable` comes from `/releases/latest` (GitHub's own newest
|
|
560
|
+
* non-prerelease, non-draft pick, immune to first-page truncation);
|
|
561
|
+
* prerelease candidates page through `/releases?per_page=100` following
|
|
562
|
+
* the `Link` header (up to {@link GITHUB_RELEASES_MAX_PAGES} pages, i.e.
|
|
563
|
+
* 300 newest releases — beyond that the first pages are what we keep).
|
|
564
|
+
*
|
|
384
565
|
* @param repoSlug - Repository slug in the form "owner/repo"
|
|
566
|
+
* @param signal - Optional caller abort signal
|
|
385
567
|
* @returns Verified registry entity
|
|
386
568
|
*/
|
|
387
|
-
async verifyGitHub(repoSlug) {
|
|
388
|
-
const
|
|
569
|
+
async verifyGitHub(repoSlug, signal) {
|
|
570
|
+
const cleanSlug = repoSlug.trim().replace(/\.git$/iu, "");
|
|
571
|
+
const parts = cleanSlug.split("/");
|
|
389
572
|
const owner = parts[0];
|
|
390
573
|
const repo = parts[1];
|
|
391
574
|
if (!owner || !repo) {
|
|
392
|
-
return { kind: "github", name: repoSlug, status: "error", error: "
|
|
575
|
+
return { kind: "github", name: repoSlug, status: "error", error: "Repositorio inválido." };
|
|
393
576
|
}
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
if (!
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
if (record["draft"] === true) {
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
const tagNameRaw = this.tryGetNonEmptyString(record["tag_name"]);
|
|
411
|
-
if (!tagNameRaw) {
|
|
412
|
-
continue;
|
|
577
|
+
const latestUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
|
578
|
+
const listUrl = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=100`;
|
|
579
|
+
let latestStable = null;
|
|
580
|
+
try {
|
|
581
|
+
const latestPage = await this.httpReader.fetchJson(latestUrl, {
|
|
582
|
+
accept: "application/vnd.github+json",
|
|
583
|
+
githubToken: this.deps.githubToken,
|
|
584
|
+
signal
|
|
585
|
+
});
|
|
586
|
+
const latest = this.tryParseGitHubReleaseCandidate(latestPage.value);
|
|
587
|
+
if (latest !== null && !latest.prerelease) {
|
|
588
|
+
latestStable = { version: latest.version, publishedAt: latest.publishedAt };
|
|
413
589
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
// /releases/latest 404s when the repo has no published stable release
|
|
593
|
+
// at all; any other failure fails the entity honestly.
|
|
594
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
595
|
+
if (!/HTTP 404/u.test(message)) {
|
|
596
|
+
throw error;
|
|
420
597
|
}
|
|
421
|
-
|
|
422
|
-
|
|
598
|
+
}
|
|
599
|
+
const stableListFallbacks = [];
|
|
600
|
+
const preCandidates = [];
|
|
601
|
+
let nextUrl = listUrl;
|
|
602
|
+
let fetchedPages = 0;
|
|
603
|
+
while (nextUrl !== null && fetchedPages < WebSearchRegistryVerifier.GITHUB_RELEASES_MAX_PAGES) {
|
|
604
|
+
const page = await this.httpReader.fetchJson(nextUrl, {
|
|
605
|
+
accept: "application/vnd.github+json",
|
|
606
|
+
githubToken: this.deps.githubToken,
|
|
607
|
+
signal
|
|
608
|
+
});
|
|
609
|
+
fetchedPages += 1;
|
|
610
|
+
const releases = Array.isArray(page.value) ? page.value : [];
|
|
611
|
+
for (const item of releases) {
|
|
612
|
+
const candidate = this.tryParseGitHubReleaseCandidate(item);
|
|
613
|
+
if (candidate === null) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (candidate.prerelease) {
|
|
617
|
+
preCandidates.push(candidate);
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
stableListFallbacks.push(candidate);
|
|
621
|
+
}
|
|
423
622
|
}
|
|
623
|
+
nextUrl = page.nextUrl;
|
|
424
624
|
}
|
|
425
|
-
const bestStable = this.pickBestVersionCandidate(
|
|
625
|
+
const bestStable = latestStable ?? this.pickBestVersionCandidate(stableListFallbacks, { prerelease: false });
|
|
426
626
|
const bestPre = this.pickBestVersionCandidate(preCandidates, { prerelease: true });
|
|
427
627
|
return {
|
|
428
628
|
kind: "github",
|
|
429
629
|
name: repoSlug,
|
|
430
630
|
latest_stable: bestStable
|
|
431
|
-
? {
|
|
631
|
+
? {
|
|
632
|
+
version: bestStable.version,
|
|
633
|
+
published_at: bestStable.publishedAt ?? undefined,
|
|
634
|
+
// Provenance honesty: attribute the fallback pick to the list
|
|
635
|
+
// endpoint it actually came from, not to /releases/latest.
|
|
636
|
+
source_url: latestStable !== null ? latestUrl : listUrl
|
|
637
|
+
}
|
|
432
638
|
: undefined,
|
|
433
639
|
latest_prerelease: bestPre
|
|
434
|
-
? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url:
|
|
640
|
+
? { version: bestPre.version, published_at: bestPre.publishedAt ?? undefined, source_url: listUrl }
|
|
435
641
|
: undefined,
|
|
436
642
|
status: "ok"
|
|
437
643
|
};
|
|
438
644
|
}
|
|
439
645
|
/**
|
|
440
|
-
*
|
|
646
|
+
* Parses one GitHub release entry into a version candidate.
|
|
441
647
|
*
|
|
442
|
-
* @param
|
|
443
|
-
* @
|
|
444
|
-
* @returns Parsed JSON object/array
|
|
648
|
+
* @param raw - Release entry from the GitHub API
|
|
649
|
+
* @returns Candidate, or null for drafts/entries without a tag
|
|
445
650
|
*/
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
Accept: options.accept,
|
|
451
|
-
"User-Agent": "enriweb"
|
|
452
|
-
};
|
|
453
|
-
if (options.githubToken && options.githubToken.trim()) {
|
|
454
|
-
headers["Authorization"] = `Bearer ${options.githubToken.trim()}`;
|
|
651
|
+
tryParseGitHubReleaseCandidate(raw) {
|
|
652
|
+
const record = this.tryGetRecord(raw);
|
|
653
|
+
if (!record) {
|
|
654
|
+
return null;
|
|
455
655
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
method: "GET",
|
|
459
|
-
headers,
|
|
460
|
-
signal: controller.signal
|
|
461
|
-
});
|
|
462
|
-
if (!response.ok) {
|
|
463
|
-
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
464
|
-
}
|
|
465
|
-
return await response.json();
|
|
656
|
+
if (record["draft"] === true) {
|
|
657
|
+
return null;
|
|
466
658
|
}
|
|
467
|
-
|
|
468
|
-
|
|
659
|
+
const tagNameRaw = this.tryGetNonEmptyString(record["tag_name"]);
|
|
660
|
+
if (!tagNameRaw) {
|
|
661
|
+
return null;
|
|
469
662
|
}
|
|
663
|
+
const tagName = tagNameRaw.startsWith("v") ? tagNameRaw.slice(1) : tagNameRaw;
|
|
664
|
+
return {
|
|
665
|
+
version: tagName,
|
|
666
|
+
publishedAt: this.tryGetNonEmptyString(record["published_at"]),
|
|
667
|
+
prerelease: record["prerelease"] === true
|
|
668
|
+
};
|
|
470
669
|
}
|
|
471
670
|
/**
|
|
472
671
|
* Attempts to parse the npm package name from an npmjs.com URL.
|
|
@@ -479,7 +678,17 @@ export class WebSearchRegistryVerifier {
|
|
|
479
678
|
if (hostname !== "www.npmjs.com" && hostname !== "npmjs.com") {
|
|
480
679
|
return null;
|
|
481
680
|
}
|
|
482
|
-
const segments = url.pathname
|
|
681
|
+
const segments = url.pathname
|
|
682
|
+
.split("/")
|
|
683
|
+
.filter(Boolean)
|
|
684
|
+
.map((segment) => {
|
|
685
|
+
try {
|
|
686
|
+
return decodeURIComponent(segment);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
return segment;
|
|
690
|
+
}
|
|
691
|
+
});
|
|
483
692
|
if (segments.length < 2 || segments[0] !== "package") {
|
|
484
693
|
return null;
|
|
485
694
|
}
|
|
@@ -487,6 +696,13 @@ export class WebSearchRegistryVerifier {
|
|
|
487
696
|
if (!first) {
|
|
488
697
|
return null;
|
|
489
698
|
}
|
|
699
|
+
if (first.startsWith("@") && first.includes("/")) {
|
|
700
|
+
const [scope, name] = first.split("/");
|
|
701
|
+
if (!scope || !name) {
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
return `${scope}/${name}`;
|
|
705
|
+
}
|
|
490
706
|
if (first.startsWith("@")) {
|
|
491
707
|
const second = segments[2];
|
|
492
708
|
if (!second) {
|
|
@@ -566,10 +782,11 @@ export class WebSearchRegistryVerifier {
|
|
|
566
782
|
return null;
|
|
567
783
|
}
|
|
568
784
|
const owner = segments[0];
|
|
569
|
-
const
|
|
570
|
-
if (!owner || !
|
|
785
|
+
const repoRaw = segments[1];
|
|
786
|
+
if (!owner || !repoRaw) {
|
|
571
787
|
return null;
|
|
572
788
|
}
|
|
789
|
+
const repo = repoRaw.replace(/\.git$/iu, "");
|
|
573
790
|
return `${owner}/${repo}`;
|
|
574
791
|
}
|
|
575
792
|
/**
|
|
@@ -609,6 +826,10 @@ export class WebSearchRegistryVerifier {
|
|
|
609
826
|
/**
|
|
610
827
|
* Best-effort prerelease detection for PyPI version strings.
|
|
611
828
|
*
|
|
829
|
+
* @remarks
|
|
830
|
+
* Covers PEP 440 separators and attached suffixes (`1.0b1`, `2.0beta1`)
|
|
831
|
+
* where the marker follows digits without a delimiter.
|
|
832
|
+
*
|
|
612
833
|
* @param version - Version string
|
|
613
834
|
* @returns True when prerelease-like
|
|
614
835
|
*/
|
|
@@ -620,6 +841,9 @@ export class WebSearchRegistryVerifier {
|
|
|
620
841
|
if (/(?:^|[._-])(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
|
|
621
842
|
return true;
|
|
622
843
|
}
|
|
844
|
+
if (/\d(?:a|b|rc|dev|alpha|beta|pre|preview)\d*/.test(lower)) {
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
623
847
|
return false;
|
|
624
848
|
}
|
|
625
849
|
/**
|
|
@@ -672,41 +896,86 @@ export class WebSearchRegistryVerifier {
|
|
|
672
896
|
}
|
|
673
897
|
let bestByTime = filtered[0];
|
|
674
898
|
for (const item of filtered.slice(1)) {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
else if (item.publishedAt && !bestByTime.publishedAt) {
|
|
899
|
+
const itemTime = this.tryParseTimeMs(item.publishedAt);
|
|
900
|
+
const bestTime = bestByTime !== undefined ? this.tryParseTimeMs(bestByTime.publishedAt) : null;
|
|
901
|
+
if (itemTime !== null && (bestTime === null || itemTime > bestTime)) {
|
|
681
902
|
bestByTime = item;
|
|
682
903
|
}
|
|
683
904
|
}
|
|
684
905
|
return bestByTime;
|
|
685
906
|
}
|
|
907
|
+
/**
|
|
908
|
+
* Parses one ISO timestamp to epoch milliseconds.
|
|
909
|
+
*
|
|
910
|
+
* @param value - Timestamp string, if present.
|
|
911
|
+
* @returns Epoch milliseconds, or null when missing or unparsable.
|
|
912
|
+
*/
|
|
913
|
+
tryParseTimeMs(value) {
|
|
914
|
+
if (value === null || !value.trim()) {
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
const parsed = Date.parse(value);
|
|
918
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
919
|
+
}
|
|
686
920
|
/**
|
|
687
921
|
* Parses SemVer strings (best-effort).
|
|
688
922
|
*
|
|
923
|
+
* @remarks
|
|
924
|
+
* Accepts two to four numeric components (`1.2` → `1.2.0`, `1.2.3.4`
|
|
925
|
+
* keeps the fourth as a build tiebreak) so partial registry versions
|
|
926
|
+
* still rank instead of dropping out.
|
|
927
|
+
*
|
|
689
928
|
* @param raw - Version string
|
|
690
929
|
* @returns Parsed semver or null
|
|
691
930
|
*/
|
|
692
931
|
tryParseSemVer(raw) {
|
|
693
932
|
const trimmed = raw.trim();
|
|
694
933
|
const normalized = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
|
|
695
|
-
const match = /^(\d+)\.(\d+)
|
|
934
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(normalized);
|
|
696
935
|
if (!match) {
|
|
697
|
-
return
|
|
936
|
+
return this.tryParseAttachedPrerelease(normalized);
|
|
698
937
|
}
|
|
699
938
|
const major = Number.parseInt(match[1] ?? "", 10);
|
|
700
939
|
const minor = Number.parseInt(match[2] ?? "", 10);
|
|
701
|
-
const patch = Number.parseInt(match[3]
|
|
702
|
-
|
|
940
|
+
const patch = match[3] === undefined ? 0 : Number.parseInt(match[3], 10);
|
|
941
|
+
const build = match[4] === undefined ? 0 : Number.parseInt(match[4], 10);
|
|
942
|
+
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch) || !Number.isFinite(build)) {
|
|
703
943
|
return null;
|
|
704
944
|
}
|
|
705
|
-
const prereleaseRaw = match[
|
|
945
|
+
const prereleaseRaw = match[5];
|
|
706
946
|
const prerelease = prereleaseRaw
|
|
707
947
|
? prereleaseRaw.split(".").filter((p) => p.length > 0)
|
|
708
948
|
: [];
|
|
709
|
-
return { raw: normalized, major, minor, patch, prerelease };
|
|
949
|
+
return { raw: normalized, major, minor, patch, build, prerelease };
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Parses versions with attached prerelease suffixes (`2024.1b1`, `2.0rc2`).
|
|
953
|
+
*
|
|
954
|
+
* @param normalized - Version string without a leading "v".
|
|
955
|
+
* @returns Parsed semver or null.
|
|
956
|
+
*/
|
|
957
|
+
tryParseAttachedPrerelease(normalized) {
|
|
958
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(a|b|rc|alpha|beta|pre|preview|dev)(\d*)$/i.exec(normalized);
|
|
959
|
+
if (!match) {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
const major = Number.parseInt(match[1] ?? "", 10);
|
|
963
|
+
const minor = Number.parseInt(match[2] ?? "", 10);
|
|
964
|
+
const patch = match[3] === undefined ? 0 : Number.parseInt(match[3], 10);
|
|
965
|
+
const build = match[4] === undefined ? 0 : Number.parseInt(match[4], 10);
|
|
966
|
+
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch) || !Number.isFinite(build)) {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
const marker = (match[5] ?? "").toLowerCase();
|
|
970
|
+
const number = match[6] ?? "";
|
|
971
|
+
return {
|
|
972
|
+
raw: normalized,
|
|
973
|
+
major,
|
|
974
|
+
minor,
|
|
975
|
+
patch,
|
|
976
|
+
build,
|
|
977
|
+
prerelease: [number ? `${marker}.${number}` : marker]
|
|
978
|
+
};
|
|
710
979
|
}
|
|
711
980
|
/**
|
|
712
981
|
* Compares two SemVer values.
|
|
@@ -725,6 +994,9 @@ export class WebSearchRegistryVerifier {
|
|
|
725
994
|
if (a.patch !== b.patch) {
|
|
726
995
|
return a.patch - b.patch;
|
|
727
996
|
}
|
|
997
|
+
if (a.build !== b.build) {
|
|
998
|
+
return a.build - b.build;
|
|
999
|
+
}
|
|
728
1000
|
const aPre = a.prerelease;
|
|
729
1001
|
const bPre = b.prerelease;
|
|
730
1002
|
if (aPre.length === 0 && bPre.length === 0) {
|
|
@@ -790,6 +1062,7 @@ export class WebSearchRegistryVerifier {
|
|
|
790
1062
|
return null;
|
|
791
1063
|
}
|
|
792
1064
|
let best = null;
|
|
1065
|
+
let bestMs = -1;
|
|
793
1066
|
for (const item of releaseFiles) {
|
|
794
1067
|
const record = this.tryGetRecord(item);
|
|
795
1068
|
if (!record) {
|
|
@@ -799,8 +1072,13 @@ export class WebSearchRegistryVerifier {
|
|
|
799
1072
|
if (!uploadTime) {
|
|
800
1073
|
continue;
|
|
801
1074
|
}
|
|
802
|
-
|
|
1075
|
+
const uploadMs = this.tryParseTimeMs(uploadTime);
|
|
1076
|
+
if (uploadMs === null) {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (best === null || uploadMs > bestMs) {
|
|
803
1080
|
best = uploadTime;
|
|
1081
|
+
bestMs = uploadMs;
|
|
804
1082
|
}
|
|
805
1083
|
}
|
|
806
1084
|
return best;
|
|
@@ -808,19 +1086,21 @@ export class WebSearchRegistryVerifier {
|
|
|
808
1086
|
/**
|
|
809
1087
|
* Resolves NuGet V3 endpoints from the service index, with caching.
|
|
810
1088
|
*
|
|
1089
|
+
* @param signal - Optional caller abort signal
|
|
811
1090
|
* @returns Service index cache
|
|
812
1091
|
*/
|
|
813
|
-
async getNuGetServiceIndex() {
|
|
1092
|
+
async getNuGetServiceIndex(signal) {
|
|
814
1093
|
const nowMs = Date.now();
|
|
815
1094
|
if (this.nugetServiceIndexCache && this.nugetServiceIndexCache.expiresAtMs > nowMs) {
|
|
816
1095
|
return this.nugetServiceIndexCache;
|
|
817
1096
|
}
|
|
818
|
-
const bodyRaw = await this.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
|
|
819
|
-
accept: "application/json"
|
|
1097
|
+
const bodyRaw = await this.httpReader.fetchJson(WebSearchRegistryVerifier.NUGET_SERVICE_INDEX_URL, {
|
|
1098
|
+
accept: "application/json",
|
|
1099
|
+
signal
|
|
820
1100
|
});
|
|
821
|
-
const body = this.tryGetRecord(bodyRaw);
|
|
1101
|
+
const body = this.tryGetRecord(bodyRaw.value);
|
|
822
1102
|
if (!body) {
|
|
823
|
-
throw new Error("
|
|
1103
|
+
throw new Error("Respuesta inesperada del índice de NuGet.");
|
|
824
1104
|
}
|
|
825
1105
|
const resources = Array.isArray(body["resources"]) ? body["resources"] : [];
|
|
826
1106
|
let packageBase = null;
|
|
@@ -866,13 +1146,14 @@ export class WebSearchRegistryVerifier {
|
|
|
866
1146
|
* @param registrationsBaseUrl - Registrations base URL
|
|
867
1147
|
* @param lowerId - Lowercase package ID
|
|
868
1148
|
* @param version - Version string
|
|
1149
|
+
* @param signal - Optional caller abort signal
|
|
869
1150
|
* @returns Published ISO 8601 timestamp or null
|
|
870
1151
|
*/
|
|
871
|
-
async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version) {
|
|
1152
|
+
async tryFetchNuGetLeafPublishedAt(registrationsBaseUrl, lowerId, version, signal) {
|
|
872
1153
|
const leafUrl = `${registrationsBaseUrl}${lowerId}/${encodeURIComponent(version)}.json`;
|
|
873
1154
|
try {
|
|
874
|
-
const bodyRaw = await this.fetchJson(leafUrl, { accept: "application/json" });
|
|
875
|
-
const body = this.tryGetRecord(bodyRaw);
|
|
1155
|
+
const bodyRaw = await this.httpReader.fetchJson(leafUrl, { accept: "application/json", signal });
|
|
1156
|
+
const body = this.tryGetRecord(bodyRaw.value);
|
|
876
1157
|
if (!body) {
|
|
877
1158
|
return null;
|
|
878
1159
|
}
|