@mcptoolshop/registry-stats 3.2.0 → 3.2.3
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 +12 -2
- package/dist/cli.js +288 -106
- package/dist/index.cjs +210 -46
- package/dist/index.d.cts +63 -4
- package/dist/index.d.ts +63 -4
- package/dist/index.js +210 -46
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -76,43 +76,69 @@ interface ChartData {
|
|
|
76
76
|
}[];
|
|
77
77
|
}
|
|
78
78
|
declare class RegistryError extends Error {
|
|
79
|
-
registry: RegistryName;
|
|
79
|
+
registry: RegistryName | string;
|
|
80
80
|
statusCode: number;
|
|
81
81
|
retryAfter?: number | undefined;
|
|
82
|
-
constructor(registry: RegistryName, statusCode: number, message: string, retryAfter?: number | undefined);
|
|
82
|
+
constructor(registry: RegistryName | string, statusCode: number, message: string, retryAfter?: number | undefined);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Download stats calculation utilities for DailyDownloads time-series data. */
|
|
85
86
|
declare const calc: {
|
|
87
|
+
/** Sum all downloads in the given records. */
|
|
86
88
|
total(records: DailyDownloads[]): number;
|
|
89
|
+
/** Compute average daily downloads. Returns 0 for empty input. */
|
|
87
90
|
avg(records: DailyDownloads[]): number;
|
|
91
|
+
/** Group records by a custom key function. */
|
|
88
92
|
group(records: DailyDownloads[], fn: (r: DailyDownloads) => string): Record<string, DailyDownloads[]>;
|
|
93
|
+
/** Group records by month (YYYY-MM keys). */
|
|
89
94
|
monthly(records: DailyDownloads[]): Record<string, DailyDownloads[]>;
|
|
95
|
+
/** Group records by year (YYYY keys). */
|
|
90
96
|
yearly(records: DailyDownloads[]): Record<string, DailyDownloads[]>;
|
|
97
|
+
/** Sum downloads within each group. */
|
|
91
98
|
groupTotals(grouped: Record<string, DailyDownloads[]>): Record<string, number>;
|
|
99
|
+
/** Average downloads within each group. */
|
|
92
100
|
groupAvgs(grouped: Record<string, DailyDownloads[]>): Record<string, number>;
|
|
101
|
+
/** Detect trend direction (up/down/flat) by comparing recent vs previous window averages. */
|
|
93
102
|
trend(records: DailyDownloads[], windowDays?: number): {
|
|
94
103
|
slope: number;
|
|
95
104
|
direction: "up" | "down" | "flat";
|
|
96
105
|
changePercent: number;
|
|
97
106
|
};
|
|
107
|
+
/** Compute a simple moving average over a sliding window. */
|
|
98
108
|
movingAvg(records: DailyDownloads[], windowDays?: number): DailyDownloads[];
|
|
109
|
+
/** Compute a 0-100 popularity score based on log-scaled recent daily downloads. */
|
|
99
110
|
popularity(records: DailyDownloads[]): number;
|
|
111
|
+
/** Convert records to CSV string with date,downloads columns. */
|
|
100
112
|
toCSV(records: DailyDownloads[]): string;
|
|
113
|
+
/** Convert records to a ChartData object suitable for chart libraries. */
|
|
101
114
|
toChartData(records: DailyDownloads[], label?: string): ChartData;
|
|
102
115
|
};
|
|
103
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Load and validate a registry-stats config file by walking up from startDir.
|
|
119
|
+
* Returns null if no config file is found.
|
|
120
|
+
* Throws a descriptive error if the config is malformed or invalid.
|
|
121
|
+
*/
|
|
104
122
|
declare function loadConfig(startDir?: string): Config | null;
|
|
123
|
+
/** Returns sensible default config with all registries enabled. */
|
|
105
124
|
declare function defaultConfig(): Config;
|
|
125
|
+
/** Returns a starter config JSON string for `registry-stats init`. */
|
|
106
126
|
declare function starterConfig(): string;
|
|
107
127
|
|
|
108
128
|
interface ServerOptions {
|
|
109
129
|
port?: number;
|
|
110
130
|
cache?: boolean;
|
|
111
131
|
corsOrigin?: string;
|
|
132
|
+
/** Max requests per IP per window (default: 60) */
|
|
133
|
+
rateLimitMax?: number;
|
|
134
|
+
/** Rate limit window in seconds (default: 60) */
|
|
135
|
+
rateLimitWindowSeconds?: number;
|
|
136
|
+
/** Upstream fetch timeout in ms (default: 30000) */
|
|
137
|
+
requestTimeoutMs?: number;
|
|
112
138
|
}
|
|
113
139
|
type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
114
140
|
/** Creates a request handler suitable for Node http.createServer or serverless adapters. */
|
|
115
|
-
declare function createHandler(opts?: StatsOptions): Handler;
|
|
141
|
+
declare function createHandler(opts?: StatsOptions & Pick<ServerOptions, 'corsOrigin' | 'rateLimitMax' | 'rateLimitWindowSeconds' | 'requestTimeoutMs'>): Handler;
|
|
116
142
|
/** Starts an HTTP server. Returns the server instance. */
|
|
117
143
|
declare function serve(opts?: ServerOptions): node_http.Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
118
144
|
|
|
@@ -224,30 +250,56 @@ interface PortfolioInference {
|
|
|
224
250
|
* Forecast next N days using weighted linear regression on recent data.
|
|
225
251
|
* Uses the last 14 days with exponential weighting (recent days matter more).
|
|
226
252
|
* Returns predictions with 80% confidence intervals.
|
|
253
|
+
*
|
|
254
|
+
* @remarks
|
|
255
|
+
* - Requires at least 7 data points; returns empty array otherwise.
|
|
256
|
+
* - NaN/Infinity values in the input are filtered before processing.
|
|
257
|
+
* - Confidence intervals use a uniform-variance approximation for the x-spread
|
|
258
|
+
* (see inline comment) and an approximate DoF correction for weighted RSE.
|
|
259
|
+
* Both are adequate for windows of 7-14 points but not for long series.
|
|
227
260
|
*/
|
|
228
261
|
declare function forecast(series: number[], days?: number): ForecastPoint[];
|
|
229
262
|
/**
|
|
230
263
|
* Detect anomalies using adaptive z-score with rolling baseline.
|
|
231
264
|
* More sophisticated than simple global z-score — uses a 14-day rolling
|
|
232
265
|
* window so seasonal patterns don't trigger false positives.
|
|
266
|
+
*
|
|
267
|
+
* @remarks
|
|
268
|
+
* - Requires at least 7 data points; returns empty array otherwise.
|
|
269
|
+
* - NaN/Infinity values are filtered before processing.
|
|
270
|
+
* - Uses sample stddev (n-1) for z-score computation.
|
|
233
271
|
*/
|
|
234
272
|
declare function detectAnomalies(series: number[], threshold?: number): Anomaly[];
|
|
235
273
|
/**
|
|
236
274
|
* Segment a time series into directional trend segments.
|
|
237
275
|
* Uses a simple piecewise linear approach with minimum segment length.
|
|
276
|
+
*
|
|
277
|
+
* @remarks
|
|
278
|
+
* - NaN/Infinity values are filtered before processing.
|
|
279
|
+
* - O(n^2) complexity: calls linearRegression in a nested loop. Designed for
|
|
280
|
+
* series of up to ~365 points. Input is capped at 1000 elements as a safety guard.
|
|
238
281
|
*/
|
|
239
282
|
declare function segmentTrends(series: number[], minSegmentLength?: number): TrendSegment[];
|
|
240
283
|
/**
|
|
241
284
|
* Detect day-of-week seasonality patterns.
|
|
242
285
|
* Requires at least 14 days of data to identify weekly cycles.
|
|
286
|
+
*
|
|
287
|
+
* @param referenceDate - Optional fixed date for day-of-week calculation.
|
|
288
|
+
* Defaults to `new Date()`. Inject a fixed date for deterministic testing.
|
|
289
|
+
* @remarks
|
|
290
|
+
* - NaN/Infinity values are filtered before processing.
|
|
243
291
|
*/
|
|
244
|
-
declare function detectSeasonality(series: number[], startDaysAgo: number): {
|
|
292
|
+
declare function detectSeasonality(series: number[], startDaysAgo: number, referenceDate?: Date): {
|
|
245
293
|
dayOfWeek: number[];
|
|
246
294
|
peakDay: string;
|
|
247
295
|
} | null;
|
|
248
296
|
/**
|
|
249
297
|
* Compute a composite momentum score (-100 to +100).
|
|
250
298
|
* Combines: short-term trend, acceleration, volume, and consistency.
|
|
299
|
+
*
|
|
300
|
+
* @remarks
|
|
301
|
+
* - Requires at least 14 data points; returns 0 otherwise.
|
|
302
|
+
* - NaN/Infinity values are filtered before processing.
|
|
251
303
|
*/
|
|
252
304
|
declare function computeMomentum(series: number[]): number;
|
|
253
305
|
/**
|
|
@@ -293,9 +345,16 @@ declare function inferPortfolio(leaderboard: Array<{
|
|
|
293
345
|
totalWeekly?: number;
|
|
294
346
|
}): PortfolioInference;
|
|
295
347
|
|
|
348
|
+
/** Create an in-memory TTL cache for stats and range results. */
|
|
296
349
|
declare function createCache(): StatsCache;
|
|
297
350
|
|
|
351
|
+
/** Register a custom registry provider. The provider's name becomes the registry key for stats(). */
|
|
298
352
|
declare function registerProvider(provider: RegistryProvider): void;
|
|
353
|
+
/**
|
|
354
|
+
* Fetch download stats for a single package from one registry.
|
|
355
|
+
* Returns null if the package is not found (404).
|
|
356
|
+
* Supports caching via options.cache.
|
|
357
|
+
*/
|
|
299
358
|
declare function stats(registry: string, pkg: string, options?: StatsOptions): Promise<PackageStats | null>;
|
|
300
359
|
declare namespace stats {
|
|
301
360
|
var all: (pkg: string, options?: StatsOptions) => Promise<PackageStats[]>;
|
package/dist/index.js
CHANGED
|
@@ -14,9 +14,10 @@ var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
|
14
14
|
var MAX_RETRIES = 3;
|
|
15
15
|
var BASE_DELAY = 1e3;
|
|
16
16
|
var registryLocks = /* @__PURE__ */ new Map();
|
|
17
|
+
var MAX_LOCK_ENTRIES = 50;
|
|
17
18
|
var REGISTRY_DELAYS = {
|
|
18
|
-
npm:
|
|
19
|
-
// ~
|
|
19
|
+
npm: 800,
|
|
20
|
+
// ~1.25 req/s — safe for 91+ scoped packages (429s at 400ms)
|
|
20
21
|
pypi: 2200,
|
|
21
22
|
// 30 req/60s = 1 per 2s, with headroom
|
|
22
23
|
docker: 4e3
|
|
@@ -28,13 +29,27 @@ function acquireSlot(registry) {
|
|
|
28
29
|
const prev = registryLocks.get(registry) ?? Promise.resolve();
|
|
29
30
|
const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
|
|
30
31
|
registryLocks.set(registry, slot);
|
|
32
|
+
if (registryLocks.size > MAX_LOCK_ENTRIES) {
|
|
33
|
+
const oldest = registryLocks.keys().next().value;
|
|
34
|
+
if (oldest !== void 0) registryLocks.delete(oldest);
|
|
35
|
+
}
|
|
31
36
|
return prev;
|
|
32
37
|
}
|
|
33
|
-
async function
|
|
38
|
+
async function fetchRetryCore(url, registry, init, preRequest) {
|
|
34
39
|
let lastError;
|
|
35
40
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
36
|
-
await
|
|
37
|
-
|
|
41
|
+
if (preRequest) await preRequest();
|
|
42
|
+
let res;
|
|
43
|
+
try {
|
|
44
|
+
res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
47
|
+
lastError = new RegistryError(registry, 0, `Network error: ${message} \u2014 ${url}`);
|
|
48
|
+
if (attempt === MAX_RETRIES) break;
|
|
49
|
+
const backoff2 = BASE_DELAY * Math.pow(2, attempt);
|
|
50
|
+
await new Promise((r) => setTimeout(r, backoff2));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
38
53
|
if (res.status === 404) return null;
|
|
39
54
|
if (res.ok) return res.json();
|
|
40
55
|
const retryAfter = res.headers.get("retry-after");
|
|
@@ -51,29 +66,13 @@ async function fetchWithRetry(url, registry, init) {
|
|
|
51
66
|
const delay = Math.max(backoff, retryAfterMs);
|
|
52
67
|
await new Promise((r) => setTimeout(r, delay));
|
|
53
68
|
}
|
|
54
|
-
throw lastError;
|
|
69
|
+
throw lastError ?? new RegistryError(registry, 0, `Fetch failed after ${MAX_RETRIES} retries: ${url}`);
|
|
70
|
+
}
|
|
71
|
+
async function fetchWithRetry(url, registry, init) {
|
|
72
|
+
return fetchRetryCore(url, registry, init, () => acquireSlot(registry));
|
|
55
73
|
}
|
|
56
74
|
async function fetchDirect(url, registry, init) {
|
|
57
|
-
|
|
58
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
59
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
60
|
-
if (res.status === 404) return null;
|
|
61
|
-
if (res.ok) return res.json();
|
|
62
|
-
const retryAfter = res.headers.get("retry-after");
|
|
63
|
-
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
|
|
64
|
-
lastError = new RegistryError(
|
|
65
|
-
registry,
|
|
66
|
-
res.status,
|
|
67
|
-
`${res.statusText}: ${url}`,
|
|
68
|
-
retryAfterSeconds
|
|
69
|
-
);
|
|
70
|
-
if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
|
|
71
|
-
const backoff = BASE_DELAY * Math.pow(2, attempt);
|
|
72
|
-
const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
|
|
73
|
-
const delay = Math.max(backoff, retryAfterMs);
|
|
74
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
75
|
-
}
|
|
76
|
-
throw lastError;
|
|
75
|
+
return fetchRetryCore(url, registry, init);
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
// src/providers/npm.ts
|
|
@@ -85,7 +84,7 @@ var npm = {
|
|
|
85
84
|
const start = new Date(end);
|
|
86
85
|
start.setDate(start.getDate() - 30);
|
|
87
86
|
const data = await fetchWithRetry(
|
|
88
|
-
`${API}/range/${fmt(start)}:${fmt(end)}/${pkg}`,
|
|
87
|
+
`${API}/range/${fmt(start)}:${fmt(end)}/${encodeNpmPackage(pkg)}`,
|
|
89
88
|
"npm"
|
|
90
89
|
);
|
|
91
90
|
if (!data || !data.downloads || data.downloads.length === 0) return null;
|
|
@@ -112,7 +111,7 @@ var npm = {
|
|
|
112
111
|
const actualEnd = chunkEnd > endDate ? endDate : chunkEnd;
|
|
113
112
|
const s = fmt(cursor);
|
|
114
113
|
const e = fmt(actualEnd);
|
|
115
|
-
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${pkg}`, "npm");
|
|
114
|
+
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${encodeNpmPackage(pkg)}`, "npm");
|
|
116
115
|
if (data) {
|
|
117
116
|
for (const d of data.downloads) {
|
|
118
117
|
chunks.push({ date: d.day, downloads: d.downloads });
|
|
@@ -148,6 +147,9 @@ async function npmBulkPoint(packages, period = "last-month") {
|
|
|
148
147
|
function fmt(d) {
|
|
149
148
|
return d.toISOString().slice(0, 10);
|
|
150
149
|
}
|
|
150
|
+
function encodeNpmPackage(pkg) {
|
|
151
|
+
return encodeURIComponent(pkg);
|
|
152
|
+
}
|
|
151
153
|
|
|
152
154
|
// src/providers/pypi.ts
|
|
153
155
|
var API2 = "https://pypistats.org/api";
|
|
@@ -155,9 +157,10 @@ var pypi = {
|
|
|
155
157
|
name: "pypi",
|
|
156
158
|
rateLimit: { maxRequests: 30, windowSeconds: 60, authRaisesLimit: false },
|
|
157
159
|
async getStats(pkg) {
|
|
160
|
+
const safePkg = encodeURIComponent(pkg);
|
|
158
161
|
const [recent, overall] = await Promise.all([
|
|
159
|
-
fetchWithRetry(`${API2}/packages/${
|
|
160
|
-
fetchWithRetry(`${API2}/packages/${
|
|
162
|
+
fetchWithRetry(`${API2}/packages/${safePkg}/recent`, "pypi"),
|
|
163
|
+
fetchWithRetry(`${API2}/packages/${safePkg}/overall?mirrors=false`, "pypi")
|
|
161
164
|
]);
|
|
162
165
|
if (!recent && !overall) return null;
|
|
163
166
|
const total = overall?.data?.filter((d) => d.category === "without_mirrors")?.reduce((sum, d) => sum + d.downloads, 0);
|
|
@@ -174,8 +177,9 @@ var pypi = {
|
|
|
174
177
|
};
|
|
175
178
|
},
|
|
176
179
|
async getRange(pkg, start, end) {
|
|
180
|
+
const safePkg = encodeURIComponent(pkg);
|
|
177
181
|
const data = await fetchWithRetry(
|
|
178
|
-
`${API2}/packages/${
|
|
182
|
+
`${API2}/packages/${safePkg}/overall?mirrors=false`,
|
|
179
183
|
"pypi"
|
|
180
184
|
);
|
|
181
185
|
if (!data) return [];
|
|
@@ -298,13 +302,16 @@ var docker = {
|
|
|
298
302
|
|
|
299
303
|
// src/calc.ts
|
|
300
304
|
var calc = {
|
|
305
|
+
/** Sum all downloads in the given records. */
|
|
301
306
|
total(records) {
|
|
302
307
|
return records.reduce((sum, r) => sum + r.downloads, 0);
|
|
303
308
|
},
|
|
309
|
+
/** Compute average daily downloads. Returns 0 for empty input. */
|
|
304
310
|
avg(records) {
|
|
305
311
|
if (records.length === 0) return 0;
|
|
306
312
|
return calc.total(records) / records.length;
|
|
307
313
|
},
|
|
314
|
+
/** Group records by a custom key function. */
|
|
308
315
|
group(records, fn) {
|
|
309
316
|
const groups = {};
|
|
310
317
|
for (const r of records) {
|
|
@@ -313,12 +320,15 @@ var calc = {
|
|
|
313
320
|
}
|
|
314
321
|
return groups;
|
|
315
322
|
},
|
|
323
|
+
/** Group records by month (YYYY-MM keys). */
|
|
316
324
|
monthly(records) {
|
|
317
325
|
return calc.group(records, (r) => r.date.slice(0, 7));
|
|
318
326
|
},
|
|
327
|
+
/** Group records by year (YYYY keys). */
|
|
319
328
|
yearly(records) {
|
|
320
329
|
return calc.group(records, (r) => r.date.slice(0, 4));
|
|
321
330
|
},
|
|
331
|
+
/** Sum downloads within each group. */
|
|
322
332
|
groupTotals(grouped) {
|
|
323
333
|
const result = {};
|
|
324
334
|
for (const [key, records] of Object.entries(grouped)) {
|
|
@@ -326,6 +336,7 @@ var calc = {
|
|
|
326
336
|
}
|
|
327
337
|
return result;
|
|
328
338
|
},
|
|
339
|
+
/** Average downloads within each group. */
|
|
329
340
|
groupAvgs(grouped) {
|
|
330
341
|
const result = {};
|
|
331
342
|
for (const [key, records] of Object.entries(grouped)) {
|
|
@@ -333,6 +344,7 @@ var calc = {
|
|
|
333
344
|
}
|
|
334
345
|
return result;
|
|
335
346
|
},
|
|
347
|
+
/** Detect trend direction (up/down/flat) by comparing recent vs previous window averages. */
|
|
336
348
|
trend(records, windowDays = 7) {
|
|
337
349
|
if (records.length < windowDays * 2) {
|
|
338
350
|
return { slope: 0, direction: "flat", changePercent: 0 };
|
|
@@ -348,6 +360,7 @@ var calc = {
|
|
|
348
360
|
const direction = slope > threshold ? "up" : slope < -threshold ? "down" : "flat";
|
|
349
361
|
return { slope: Math.round(slope * 100) / 100, direction, changePercent: Math.round(changePercent * 100) / 100 };
|
|
350
362
|
},
|
|
363
|
+
/** Compute a simple moving average over a sliding window. */
|
|
351
364
|
movingAvg(records, windowDays = 7) {
|
|
352
365
|
if (records.length < windowDays) return [];
|
|
353
366
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -364,6 +377,7 @@ var calc = {
|
|
|
364
377
|
}
|
|
365
378
|
return result;
|
|
366
379
|
},
|
|
380
|
+
/** Compute a 0-100 popularity score based on log-scaled recent daily downloads. */
|
|
367
381
|
popularity(records) {
|
|
368
382
|
if (records.length === 0) return 0;
|
|
369
383
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -373,6 +387,7 @@ var calc = {
|
|
|
373
387
|
const score = Math.max(0, Math.min(100, Math.log10(Math.max(1, avgDaily)) / 6 * 100));
|
|
374
388
|
return Math.round(score * 10) / 10;
|
|
375
389
|
},
|
|
390
|
+
/** Convert records to CSV string with date,downloads columns. */
|
|
376
391
|
toCSV(records) {
|
|
377
392
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
378
393
|
const lines = ["date,downloads"];
|
|
@@ -381,6 +396,7 @@ var calc = {
|
|
|
381
396
|
}
|
|
382
397
|
return lines.join("\n");
|
|
383
398
|
},
|
|
399
|
+
/** Convert records to a ChartData object suitable for chart libraries. */
|
|
384
400
|
toChartData(records, label = "downloads") {
|
|
385
401
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
386
402
|
return {
|
|
@@ -400,7 +416,14 @@ function loadConfig(startDir) {
|
|
|
400
416
|
const configPath = resolve(dir, CONFIG_NAME);
|
|
401
417
|
if (existsSync(configPath)) {
|
|
402
418
|
const raw = readFileSync(configPath, "utf-8");
|
|
403
|
-
|
|
419
|
+
let parsed;
|
|
420
|
+
try {
|
|
421
|
+
parsed = JSON.parse(raw);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
424
|
+
throw new Error(`Invalid JSON in ${configPath}: ${message}`);
|
|
425
|
+
}
|
|
426
|
+
return validateConfig(parsed, configPath);
|
|
404
427
|
}
|
|
405
428
|
const parent = dirname(dir);
|
|
406
429
|
if (parent === dir) break;
|
|
@@ -408,6 +431,50 @@ function loadConfig(startDir) {
|
|
|
408
431
|
}
|
|
409
432
|
return null;
|
|
410
433
|
}
|
|
434
|
+
function validateConfig(raw, source) {
|
|
435
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
436
|
+
throw new Error(`Config in ${source} must be a JSON object, got ${Array.isArray(raw) ? "array" : typeof raw}`);
|
|
437
|
+
}
|
|
438
|
+
const obj = raw;
|
|
439
|
+
const config = {};
|
|
440
|
+
if (obj.registries !== void 0) {
|
|
441
|
+
if (!Array.isArray(obj.registries) || !obj.registries.every((r) => typeof r === "string")) {
|
|
442
|
+
throw new Error(`Config "registries" must be an array of strings in ${source}`);
|
|
443
|
+
}
|
|
444
|
+
config.registries = obj.registries;
|
|
445
|
+
}
|
|
446
|
+
if (obj.packages !== void 0) {
|
|
447
|
+
if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
|
|
448
|
+
throw new Error(`Config "packages" must be an object in ${source}`);
|
|
449
|
+
}
|
|
450
|
+
config.packages = obj.packages;
|
|
451
|
+
}
|
|
452
|
+
if (obj.cache !== void 0) {
|
|
453
|
+
if (typeof obj.cache !== "boolean") {
|
|
454
|
+
throw new Error(`Config "cache" must be a boolean in ${source}`);
|
|
455
|
+
}
|
|
456
|
+
config.cache = obj.cache;
|
|
457
|
+
}
|
|
458
|
+
if (obj.cacheTtlMs !== void 0) {
|
|
459
|
+
if (typeof obj.cacheTtlMs !== "number" || obj.cacheTtlMs <= 0 || !Number.isFinite(obj.cacheTtlMs)) {
|
|
460
|
+
throw new Error(`Config "cacheTtlMs" must be a positive number in ${source}`);
|
|
461
|
+
}
|
|
462
|
+
config.cacheTtlMs = obj.cacheTtlMs;
|
|
463
|
+
}
|
|
464
|
+
if (obj.concurrency !== void 0) {
|
|
465
|
+
if (typeof obj.concurrency !== "number" || obj.concurrency < 1 || !Number.isInteger(obj.concurrency)) {
|
|
466
|
+
throw new Error(`Config "concurrency" must be a positive integer in ${source}`);
|
|
467
|
+
}
|
|
468
|
+
config.concurrency = obj.concurrency;
|
|
469
|
+
}
|
|
470
|
+
if (obj.dockerToken !== void 0) {
|
|
471
|
+
if (typeof obj.dockerToken !== "string") {
|
|
472
|
+
throw new Error(`Config "dockerToken" must be a string in ${source}`);
|
|
473
|
+
}
|
|
474
|
+
config.dockerToken = obj.dockerToken;
|
|
475
|
+
}
|
|
476
|
+
return config;
|
|
477
|
+
}
|
|
411
478
|
function defaultConfig() {
|
|
412
479
|
return {
|
|
413
480
|
registries: ["npm", "pypi", "nuget", "vscode", "docker"],
|
|
@@ -436,6 +503,36 @@ function starterConfig() {
|
|
|
436
503
|
|
|
437
504
|
// src/server.ts
|
|
438
505
|
import { createServer as httpCreateServer } from "http";
|
|
506
|
+
function createRateLimiter(maxRequests, windowSeconds) {
|
|
507
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
508
|
+
const cleanup = setInterval(() => {
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
for (const [ip, bucket] of buckets) {
|
|
511
|
+
if (now > bucket.resetAt) buckets.delete(ip);
|
|
512
|
+
}
|
|
513
|
+
}, 6e4);
|
|
514
|
+
cleanup.unref();
|
|
515
|
+
return {
|
|
516
|
+
/** Returns true if the request is allowed, false if rate-limited. */
|
|
517
|
+
allow(ip) {
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
const bucket = buckets.get(ip);
|
|
520
|
+
if (!bucket || now > bucket.resetAt) {
|
|
521
|
+
buckets.set(ip, { count: 1, resetAt: now + windowSeconds * 1e3 });
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
bucket.count++;
|
|
525
|
+
return bucket.count <= maxRequests;
|
|
526
|
+
},
|
|
527
|
+
/** Exposed for testing — clear all buckets. */
|
|
528
|
+
reset() {
|
|
529
|
+
buckets.clear();
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function sanitizeFilename(value) {
|
|
534
|
+
return value.replace(/[^a-zA-Z0-9._@/-]/g, "_").slice(0, 200);
|
|
535
|
+
}
|
|
439
536
|
function json(res, data, status = 200) {
|
|
440
537
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
441
538
|
res.end(JSON.stringify(data));
|
|
@@ -455,13 +552,43 @@ function parseUrl(url) {
|
|
|
455
552
|
}
|
|
456
553
|
return { path, query };
|
|
457
554
|
}
|
|
555
|
+
function withTimeout(promise, ms) {
|
|
556
|
+
return new Promise((resolve2, reject) => {
|
|
557
|
+
const timer = setTimeout(() => reject(new Error("__TIMEOUT__")), ms);
|
|
558
|
+
promise.then(
|
|
559
|
+
(v) => {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
resolve2(v);
|
|
562
|
+
},
|
|
563
|
+
(e) => {
|
|
564
|
+
clearTimeout(timer);
|
|
565
|
+
reject(e);
|
|
566
|
+
}
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
function getClientIp(req) {
|
|
571
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
572
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
|
|
573
|
+
return req.socket.remoteAddress ?? "0.0.0.0";
|
|
574
|
+
}
|
|
458
575
|
function createHandler(opts) {
|
|
459
576
|
const options = { ...opts };
|
|
460
577
|
if (!options.cache) {
|
|
461
578
|
options.cache = createCache();
|
|
462
579
|
}
|
|
580
|
+
const corsOrigin = opts?.corsOrigin ?? "*";
|
|
581
|
+
const timeoutMs = opts?.requestTimeoutMs ?? 3e4;
|
|
582
|
+
const limiter = createRateLimiter(
|
|
583
|
+
opts?.rateLimitMax ?? 60,
|
|
584
|
+
opts?.rateLimitWindowSeconds ?? 60
|
|
585
|
+
);
|
|
463
586
|
return async (req, res) => {
|
|
464
|
-
res.setHeader("
|
|
587
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
588
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
589
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
590
|
+
res.setHeader("Cache-Control", "no-store");
|
|
591
|
+
res.setHeader("Access-Control-Allow-Origin", corsOrigin);
|
|
465
592
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
466
593
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
467
594
|
if (req.method === "OPTIONS") {
|
|
@@ -469,6 +596,11 @@ function createHandler(opts) {
|
|
|
469
596
|
res.end();
|
|
470
597
|
return;
|
|
471
598
|
}
|
|
599
|
+
const clientIp = getClientIp(req);
|
|
600
|
+
if (!limiter.allow(clientIp)) {
|
|
601
|
+
error(res, "Too many requests", 429);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
472
604
|
if (req.method !== "GET") {
|
|
473
605
|
error(res, "Method not allowed", 405);
|
|
474
606
|
return;
|
|
@@ -478,14 +610,14 @@ function createHandler(opts) {
|
|
|
478
610
|
if (path[0] === "stats") {
|
|
479
611
|
if (path.length === 2) {
|
|
480
612
|
const pkg = decodeURIComponent(path[1]);
|
|
481
|
-
const results = await stats.all(pkg, options);
|
|
613
|
+
const results = await withTimeout(stats.all(pkg, options), timeoutMs);
|
|
482
614
|
json(res, results);
|
|
483
615
|
return;
|
|
484
616
|
}
|
|
485
617
|
if (path.length >= 3) {
|
|
486
618
|
const registry = path[1];
|
|
487
619
|
const pkg = path.slice(2).join("/");
|
|
488
|
-
const result = await stats(registry, pkg, options);
|
|
620
|
+
const result = await withTimeout(stats(registry, pkg, options), timeoutMs);
|
|
489
621
|
if (!result) {
|
|
490
622
|
error(res, `Package "${pkg}" not found on ${registry}`, 404);
|
|
491
623
|
return;
|
|
@@ -497,7 +629,7 @@ function createHandler(opts) {
|
|
|
497
629
|
if (path[0] === "compare" && path.length >= 2) {
|
|
498
630
|
const pkg = decodeURIComponent(path[1]);
|
|
499
631
|
const registries = query.registries ? query.registries.split(",") : void 0;
|
|
500
|
-
const result = await stats.compare(pkg, registries, options);
|
|
632
|
+
const result = await withTimeout(stats.compare(pkg, registries, options), timeoutMs);
|
|
501
633
|
json(res, result);
|
|
502
634
|
return;
|
|
503
635
|
}
|
|
@@ -509,11 +641,14 @@ function createHandler(opts) {
|
|
|
509
641
|
error(res, "Missing start and end query parameters");
|
|
510
642
|
return;
|
|
511
643
|
}
|
|
512
|
-
const data = await stats.range(registry, pkg, start, end, options);
|
|
644
|
+
const data = await withTimeout(stats.range(registry, pkg, start, end, options), timeoutMs);
|
|
513
645
|
if (format === "csv") {
|
|
646
|
+
const safePkg = sanitizeFilename(pkg);
|
|
647
|
+
const safeStart = sanitizeFilename(start);
|
|
648
|
+
const safeEnd = sanitizeFilename(end);
|
|
514
649
|
res.writeHead(200, {
|
|
515
650
|
"Content-Type": "text/csv",
|
|
516
|
-
"Content-Disposition": `attachment; filename="${
|
|
651
|
+
"Content-Disposition": `attachment; filename="${safePkg}-${safeStart}-${safeEnd}.csv"`
|
|
517
652
|
});
|
|
518
653
|
res.end(calc.toCSV(data));
|
|
519
654
|
return;
|
|
@@ -539,11 +674,13 @@ function createHandler(opts) {
|
|
|
539
674
|
}
|
|
540
675
|
error(res, "Not found", 404);
|
|
541
676
|
} catch (e) {
|
|
542
|
-
if (e
|
|
677
|
+
if (e?.message === "__TIMEOUT__") {
|
|
678
|
+
error(res, "Gateway timeout", 504);
|
|
679
|
+
} else if (e instanceof RegistryError) {
|
|
543
680
|
const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
|
|
544
681
|
error(res, e.message, status);
|
|
545
682
|
} else {
|
|
546
|
-
error(res,
|
|
683
|
+
error(res, "Internal server error", 500);
|
|
547
684
|
}
|
|
548
685
|
}
|
|
549
686
|
};
|
|
@@ -551,7 +688,11 @@ function createHandler(opts) {
|
|
|
551
688
|
function serve(opts) {
|
|
552
689
|
const port = opts?.port ?? 3e3;
|
|
553
690
|
const handler = createHandler({
|
|
554
|
-
cache: opts?.cache !== false ? createCache() : void 0
|
|
691
|
+
cache: opts?.cache !== false ? createCache() : void 0,
|
|
692
|
+
corsOrigin: opts?.corsOrigin,
|
|
693
|
+
rateLimitMax: opts?.rateLimitMax,
|
|
694
|
+
rateLimitWindowSeconds: opts?.rateLimitWindowSeconds,
|
|
695
|
+
requestTimeoutMs: opts?.requestTimeoutMs
|
|
555
696
|
});
|
|
556
697
|
const server = httpCreateServer(handler);
|
|
557
698
|
server.listen(port, () => {
|
|
@@ -567,6 +708,9 @@ Endpoints:`);
|
|
|
567
708
|
}
|
|
568
709
|
|
|
569
710
|
// src/inference.ts
|
|
711
|
+
function sanitize(arr) {
|
|
712
|
+
return arr.filter((v) => Number.isFinite(v));
|
|
713
|
+
}
|
|
570
714
|
function mean(arr) {
|
|
571
715
|
if (arr.length === 0) return 0;
|
|
572
716
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
@@ -574,7 +718,7 @@ function mean(arr) {
|
|
|
574
718
|
function stddev(arr) {
|
|
575
719
|
if (arr.length < 2) return 0;
|
|
576
720
|
const m = mean(arr);
|
|
577
|
-
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
|
|
721
|
+
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
|
578
722
|
return Math.sqrt(variance);
|
|
579
723
|
}
|
|
580
724
|
function linearRegression(ys) {
|
|
@@ -602,6 +746,7 @@ function linearRegression(ys) {
|
|
|
602
746
|
}
|
|
603
747
|
var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
604
748
|
function forecast(series, days = 7) {
|
|
749
|
+
series = sanitize(series);
|
|
605
750
|
if (series.length < 7) return [];
|
|
606
751
|
const window = series.slice(-Math.min(14, series.length));
|
|
607
752
|
const n = window.length;
|
|
@@ -645,6 +790,7 @@ function forecast(series, days = 7) {
|
|
|
645
790
|
return results;
|
|
646
791
|
}
|
|
647
792
|
function detectAnomalies(series, threshold = 2) {
|
|
793
|
+
series = sanitize(series);
|
|
648
794
|
if (series.length < 7) return [];
|
|
649
795
|
const anomalies = [];
|
|
650
796
|
const windowSize = Math.min(14, Math.floor(series.length * 0.7));
|
|
@@ -667,7 +813,11 @@ function detectAnomalies(series, threshold = 2) {
|
|
|
667
813
|
return anomalies;
|
|
668
814
|
}
|
|
669
815
|
function segmentTrends(series, minSegmentLength = 5) {
|
|
816
|
+
series = sanitize(series);
|
|
670
817
|
if (series.length < minSegmentLength) return [];
|
|
818
|
+
if (series.length > 1e3) {
|
|
819
|
+
series = series.slice(-1e3);
|
|
820
|
+
}
|
|
671
821
|
const segments = [];
|
|
672
822
|
let segStart = 0;
|
|
673
823
|
while (segStart < series.length - minSegmentLength + 1) {
|
|
@@ -695,10 +845,11 @@ function segmentTrends(series, minSegmentLength = 5) {
|
|
|
695
845
|
}
|
|
696
846
|
return segments;
|
|
697
847
|
}
|
|
698
|
-
function detectSeasonality(series, startDaysAgo) {
|
|
848
|
+
function detectSeasonality(series, startDaysAgo, referenceDate) {
|
|
849
|
+
series = sanitize(series);
|
|
699
850
|
if (series.length < 14) return null;
|
|
700
851
|
const buckets = [[], [], [], [], [], [], []];
|
|
701
|
-
const today = /* @__PURE__ */ new Date();
|
|
852
|
+
const today = referenceDate ?? /* @__PURE__ */ new Date();
|
|
702
853
|
for (let i = 0; i < series.length; i++) {
|
|
703
854
|
const date = new Date(today);
|
|
704
855
|
date.setDate(date.getDate() - (startDaysAgo - i));
|
|
@@ -717,6 +868,7 @@ function detectSeasonality(series, startDaysAgo) {
|
|
|
717
868
|
};
|
|
718
869
|
}
|
|
719
870
|
function computeMomentum(series) {
|
|
871
|
+
series = sanitize(series);
|
|
720
872
|
if (series.length < 14) return 0;
|
|
721
873
|
const last7 = series.slice(-7);
|
|
722
874
|
const prev7 = series.slice(-14, -7);
|
|
@@ -869,8 +1021,7 @@ function generateActionableAdvice(packages, healthScores, opts = {}) {
|
|
|
869
1021
|
});
|
|
870
1022
|
}
|
|
871
1023
|
const highVolume = packages.filter((p) => {
|
|
872
|
-
|
|
873
|
-
return pkg && pkg.forecast7.length > 0 && pkg.forecast7[6]?.predicted > 100;
|
|
1024
|
+
return p.forecast7.length > 0 && p.forecast7[6]?.predicted > 100;
|
|
874
1025
|
});
|
|
875
1026
|
for (const pkg of highVolume.slice(0, 3)) {
|
|
876
1027
|
const weekSum = pkg.forecast7.reduce((s, f) => s + f.predicted, 0);
|
|
@@ -1042,6 +1193,18 @@ function inferPortfolio(leaderboard, opts = {}) {
|
|
|
1042
1193
|
}
|
|
1043
1194
|
|
|
1044
1195
|
// src/index.ts
|
|
1196
|
+
var VALID_PKG_NAME = /^[@a-zA-Z0-9][\w./@-]*$/;
|
|
1197
|
+
function validatePackageName(pkg, registry) {
|
|
1198
|
+
if (!pkg || typeof pkg !== "string") {
|
|
1199
|
+
throw new RegistryError(registry, 0, `Invalid package name: must be a non-empty string`);
|
|
1200
|
+
}
|
|
1201
|
+
if (pkg.includes("..") || pkg.includes("\\")) {
|
|
1202
|
+
throw new RegistryError(registry, 0, `Invalid package name "${pkg}": path traversal not allowed`);
|
|
1203
|
+
}
|
|
1204
|
+
if (!VALID_PKG_NAME.test(pkg)) {
|
|
1205
|
+
throw new RegistryError(registry, 0, `Invalid package name "${pkg}": contains illegal characters`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1045
1208
|
function createCache() {
|
|
1046
1209
|
const store = /* @__PURE__ */ new Map();
|
|
1047
1210
|
return {
|
|
@@ -1090,6 +1253,7 @@ function registerProvider(provider) {
|
|
|
1090
1253
|
}
|
|
1091
1254
|
var DEFAULT_TTL = 3e5;
|
|
1092
1255
|
async function stats(registry, pkg, options) {
|
|
1256
|
+
validatePackageName(pkg, registry);
|
|
1093
1257
|
const provider = providers[registry];
|
|
1094
1258
|
if (!provider) {
|
|
1095
1259
|
throw new RegistryError(registry, 0, `Unknown registry "${registry}". Use registerProvider() to add custom registries.`);
|