@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.cjs
CHANGED
|
@@ -59,9 +59,10 @@ var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
|
59
59
|
var MAX_RETRIES = 3;
|
|
60
60
|
var BASE_DELAY = 1e3;
|
|
61
61
|
var registryLocks = /* @__PURE__ */ new Map();
|
|
62
|
+
var MAX_LOCK_ENTRIES = 50;
|
|
62
63
|
var REGISTRY_DELAYS = {
|
|
63
|
-
npm:
|
|
64
|
-
// ~
|
|
64
|
+
npm: 800,
|
|
65
|
+
// ~1.25 req/s — safe for 91+ scoped packages (429s at 400ms)
|
|
65
66
|
pypi: 2200,
|
|
66
67
|
// 30 req/60s = 1 per 2s, with headroom
|
|
67
68
|
docker: 4e3
|
|
@@ -73,13 +74,27 @@ function acquireSlot(registry) {
|
|
|
73
74
|
const prev = registryLocks.get(registry) ?? Promise.resolve();
|
|
74
75
|
const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
|
|
75
76
|
registryLocks.set(registry, slot);
|
|
77
|
+
if (registryLocks.size > MAX_LOCK_ENTRIES) {
|
|
78
|
+
const oldest = registryLocks.keys().next().value;
|
|
79
|
+
if (oldest !== void 0) registryLocks.delete(oldest);
|
|
80
|
+
}
|
|
76
81
|
return prev;
|
|
77
82
|
}
|
|
78
|
-
async function
|
|
83
|
+
async function fetchRetryCore(url, registry, init, preRequest) {
|
|
79
84
|
let lastError;
|
|
80
85
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
81
|
-
await
|
|
82
|
-
|
|
86
|
+
if (preRequest) await preRequest();
|
|
87
|
+
let res;
|
|
88
|
+
try {
|
|
89
|
+
res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
92
|
+
lastError = new RegistryError(registry, 0, `Network error: ${message} \u2014 ${url}`);
|
|
93
|
+
if (attempt === MAX_RETRIES) break;
|
|
94
|
+
const backoff2 = BASE_DELAY * Math.pow(2, attempt);
|
|
95
|
+
await new Promise((r) => setTimeout(r, backoff2));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
83
98
|
if (res.status === 404) return null;
|
|
84
99
|
if (res.ok) return res.json();
|
|
85
100
|
const retryAfter = res.headers.get("retry-after");
|
|
@@ -96,29 +111,13 @@ async function fetchWithRetry(url, registry, init) {
|
|
|
96
111
|
const delay = Math.max(backoff, retryAfterMs);
|
|
97
112
|
await new Promise((r) => setTimeout(r, delay));
|
|
98
113
|
}
|
|
99
|
-
throw lastError;
|
|
114
|
+
throw lastError ?? new RegistryError(registry, 0, `Fetch failed after ${MAX_RETRIES} retries: ${url}`);
|
|
115
|
+
}
|
|
116
|
+
async function fetchWithRetry(url, registry, init) {
|
|
117
|
+
return fetchRetryCore(url, registry, init, () => acquireSlot(registry));
|
|
100
118
|
}
|
|
101
119
|
async function fetchDirect(url, registry, init) {
|
|
102
|
-
|
|
103
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
104
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
105
|
-
if (res.status === 404) return null;
|
|
106
|
-
if (res.ok) return res.json();
|
|
107
|
-
const retryAfter = res.headers.get("retry-after");
|
|
108
|
-
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
|
|
109
|
-
lastError = new RegistryError(
|
|
110
|
-
registry,
|
|
111
|
-
res.status,
|
|
112
|
-
`${res.statusText}: ${url}`,
|
|
113
|
-
retryAfterSeconds
|
|
114
|
-
);
|
|
115
|
-
if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
|
|
116
|
-
const backoff = BASE_DELAY * Math.pow(2, attempt);
|
|
117
|
-
const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
|
|
118
|
-
const delay = Math.max(backoff, retryAfterMs);
|
|
119
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
120
|
-
}
|
|
121
|
-
throw lastError;
|
|
120
|
+
return fetchRetryCore(url, registry, init);
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
// src/providers/npm.ts
|
|
@@ -130,7 +129,7 @@ var npm = {
|
|
|
130
129
|
const start = new Date(end);
|
|
131
130
|
start.setDate(start.getDate() - 30);
|
|
132
131
|
const data = await fetchWithRetry(
|
|
133
|
-
`${API}/range/${fmt(start)}:${fmt(end)}/${pkg}`,
|
|
132
|
+
`${API}/range/${fmt(start)}:${fmt(end)}/${encodeNpmPackage(pkg)}`,
|
|
134
133
|
"npm"
|
|
135
134
|
);
|
|
136
135
|
if (!data || !data.downloads || data.downloads.length === 0) return null;
|
|
@@ -157,7 +156,7 @@ var npm = {
|
|
|
157
156
|
const actualEnd = chunkEnd > endDate ? endDate : chunkEnd;
|
|
158
157
|
const s = fmt(cursor);
|
|
159
158
|
const e = fmt(actualEnd);
|
|
160
|
-
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${pkg}`, "npm");
|
|
159
|
+
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${encodeNpmPackage(pkg)}`, "npm");
|
|
161
160
|
if (data) {
|
|
162
161
|
for (const d of data.downloads) {
|
|
163
162
|
chunks.push({ date: d.day, downloads: d.downloads });
|
|
@@ -193,6 +192,9 @@ async function npmBulkPoint(packages, period = "last-month") {
|
|
|
193
192
|
function fmt(d) {
|
|
194
193
|
return d.toISOString().slice(0, 10);
|
|
195
194
|
}
|
|
195
|
+
function encodeNpmPackage(pkg) {
|
|
196
|
+
return encodeURIComponent(pkg);
|
|
197
|
+
}
|
|
196
198
|
|
|
197
199
|
// src/providers/pypi.ts
|
|
198
200
|
var API2 = "https://pypistats.org/api";
|
|
@@ -200,9 +202,10 @@ var pypi = {
|
|
|
200
202
|
name: "pypi",
|
|
201
203
|
rateLimit: { maxRequests: 30, windowSeconds: 60, authRaisesLimit: false },
|
|
202
204
|
async getStats(pkg) {
|
|
205
|
+
const safePkg = encodeURIComponent(pkg);
|
|
203
206
|
const [recent, overall] = await Promise.all([
|
|
204
|
-
fetchWithRetry(`${API2}/packages/${
|
|
205
|
-
fetchWithRetry(`${API2}/packages/${
|
|
207
|
+
fetchWithRetry(`${API2}/packages/${safePkg}/recent`, "pypi"),
|
|
208
|
+
fetchWithRetry(`${API2}/packages/${safePkg}/overall?mirrors=false`, "pypi")
|
|
206
209
|
]);
|
|
207
210
|
if (!recent && !overall) return null;
|
|
208
211
|
const total = overall?.data?.filter((d) => d.category === "without_mirrors")?.reduce((sum, d) => sum + d.downloads, 0);
|
|
@@ -219,8 +222,9 @@ var pypi = {
|
|
|
219
222
|
};
|
|
220
223
|
},
|
|
221
224
|
async getRange(pkg, start, end) {
|
|
225
|
+
const safePkg = encodeURIComponent(pkg);
|
|
222
226
|
const data = await fetchWithRetry(
|
|
223
|
-
`${API2}/packages/${
|
|
227
|
+
`${API2}/packages/${safePkg}/overall?mirrors=false`,
|
|
224
228
|
"pypi"
|
|
225
229
|
);
|
|
226
230
|
if (!data) return [];
|
|
@@ -343,13 +347,16 @@ var docker = {
|
|
|
343
347
|
|
|
344
348
|
// src/calc.ts
|
|
345
349
|
var calc = {
|
|
350
|
+
/** Sum all downloads in the given records. */
|
|
346
351
|
total(records) {
|
|
347
352
|
return records.reduce((sum, r) => sum + r.downloads, 0);
|
|
348
353
|
},
|
|
354
|
+
/** Compute average daily downloads. Returns 0 for empty input. */
|
|
349
355
|
avg(records) {
|
|
350
356
|
if (records.length === 0) return 0;
|
|
351
357
|
return calc.total(records) / records.length;
|
|
352
358
|
},
|
|
359
|
+
/** Group records by a custom key function. */
|
|
353
360
|
group(records, fn) {
|
|
354
361
|
const groups = {};
|
|
355
362
|
for (const r of records) {
|
|
@@ -358,12 +365,15 @@ var calc = {
|
|
|
358
365
|
}
|
|
359
366
|
return groups;
|
|
360
367
|
},
|
|
368
|
+
/** Group records by month (YYYY-MM keys). */
|
|
361
369
|
monthly(records) {
|
|
362
370
|
return calc.group(records, (r) => r.date.slice(0, 7));
|
|
363
371
|
},
|
|
372
|
+
/** Group records by year (YYYY keys). */
|
|
364
373
|
yearly(records) {
|
|
365
374
|
return calc.group(records, (r) => r.date.slice(0, 4));
|
|
366
375
|
},
|
|
376
|
+
/** Sum downloads within each group. */
|
|
367
377
|
groupTotals(grouped) {
|
|
368
378
|
const result = {};
|
|
369
379
|
for (const [key, records] of Object.entries(grouped)) {
|
|
@@ -371,6 +381,7 @@ var calc = {
|
|
|
371
381
|
}
|
|
372
382
|
return result;
|
|
373
383
|
},
|
|
384
|
+
/** Average downloads within each group. */
|
|
374
385
|
groupAvgs(grouped) {
|
|
375
386
|
const result = {};
|
|
376
387
|
for (const [key, records] of Object.entries(grouped)) {
|
|
@@ -378,6 +389,7 @@ var calc = {
|
|
|
378
389
|
}
|
|
379
390
|
return result;
|
|
380
391
|
},
|
|
392
|
+
/** Detect trend direction (up/down/flat) by comparing recent vs previous window averages. */
|
|
381
393
|
trend(records, windowDays = 7) {
|
|
382
394
|
if (records.length < windowDays * 2) {
|
|
383
395
|
return { slope: 0, direction: "flat", changePercent: 0 };
|
|
@@ -393,6 +405,7 @@ var calc = {
|
|
|
393
405
|
const direction = slope > threshold ? "up" : slope < -threshold ? "down" : "flat";
|
|
394
406
|
return { slope: Math.round(slope * 100) / 100, direction, changePercent: Math.round(changePercent * 100) / 100 };
|
|
395
407
|
},
|
|
408
|
+
/** Compute a simple moving average over a sliding window. */
|
|
396
409
|
movingAvg(records, windowDays = 7) {
|
|
397
410
|
if (records.length < windowDays) return [];
|
|
398
411
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -409,6 +422,7 @@ var calc = {
|
|
|
409
422
|
}
|
|
410
423
|
return result;
|
|
411
424
|
},
|
|
425
|
+
/** Compute a 0-100 popularity score based on log-scaled recent daily downloads. */
|
|
412
426
|
popularity(records) {
|
|
413
427
|
if (records.length === 0) return 0;
|
|
414
428
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -418,6 +432,7 @@ var calc = {
|
|
|
418
432
|
const score = Math.max(0, Math.min(100, Math.log10(Math.max(1, avgDaily)) / 6 * 100));
|
|
419
433
|
return Math.round(score * 10) / 10;
|
|
420
434
|
},
|
|
435
|
+
/** Convert records to CSV string with date,downloads columns. */
|
|
421
436
|
toCSV(records) {
|
|
422
437
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
423
438
|
const lines = ["date,downloads"];
|
|
@@ -426,6 +441,7 @@ var calc = {
|
|
|
426
441
|
}
|
|
427
442
|
return lines.join("\n");
|
|
428
443
|
},
|
|
444
|
+
/** Convert records to a ChartData object suitable for chart libraries. */
|
|
429
445
|
toChartData(records, label = "downloads") {
|
|
430
446
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
431
447
|
return {
|
|
@@ -445,7 +461,14 @@ function loadConfig(startDir) {
|
|
|
445
461
|
const configPath = (0, import_node_path.resolve)(dir, CONFIG_NAME);
|
|
446
462
|
if ((0, import_node_fs.existsSync)(configPath)) {
|
|
447
463
|
const raw = (0, import_node_fs.readFileSync)(configPath, "utf-8");
|
|
448
|
-
|
|
464
|
+
let parsed;
|
|
465
|
+
try {
|
|
466
|
+
parsed = JSON.parse(raw);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
469
|
+
throw new Error(`Invalid JSON in ${configPath}: ${message}`);
|
|
470
|
+
}
|
|
471
|
+
return validateConfig(parsed, configPath);
|
|
449
472
|
}
|
|
450
473
|
const parent = (0, import_node_path.dirname)(dir);
|
|
451
474
|
if (parent === dir) break;
|
|
@@ -453,6 +476,50 @@ function loadConfig(startDir) {
|
|
|
453
476
|
}
|
|
454
477
|
return null;
|
|
455
478
|
}
|
|
479
|
+
function validateConfig(raw, source) {
|
|
480
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
481
|
+
throw new Error(`Config in ${source} must be a JSON object, got ${Array.isArray(raw) ? "array" : typeof raw}`);
|
|
482
|
+
}
|
|
483
|
+
const obj = raw;
|
|
484
|
+
const config = {};
|
|
485
|
+
if (obj.registries !== void 0) {
|
|
486
|
+
if (!Array.isArray(obj.registries) || !obj.registries.every((r) => typeof r === "string")) {
|
|
487
|
+
throw new Error(`Config "registries" must be an array of strings in ${source}`);
|
|
488
|
+
}
|
|
489
|
+
config.registries = obj.registries;
|
|
490
|
+
}
|
|
491
|
+
if (obj.packages !== void 0) {
|
|
492
|
+
if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
|
|
493
|
+
throw new Error(`Config "packages" must be an object in ${source}`);
|
|
494
|
+
}
|
|
495
|
+
config.packages = obj.packages;
|
|
496
|
+
}
|
|
497
|
+
if (obj.cache !== void 0) {
|
|
498
|
+
if (typeof obj.cache !== "boolean") {
|
|
499
|
+
throw new Error(`Config "cache" must be a boolean in ${source}`);
|
|
500
|
+
}
|
|
501
|
+
config.cache = obj.cache;
|
|
502
|
+
}
|
|
503
|
+
if (obj.cacheTtlMs !== void 0) {
|
|
504
|
+
if (typeof obj.cacheTtlMs !== "number" || obj.cacheTtlMs <= 0 || !Number.isFinite(obj.cacheTtlMs)) {
|
|
505
|
+
throw new Error(`Config "cacheTtlMs" must be a positive number in ${source}`);
|
|
506
|
+
}
|
|
507
|
+
config.cacheTtlMs = obj.cacheTtlMs;
|
|
508
|
+
}
|
|
509
|
+
if (obj.concurrency !== void 0) {
|
|
510
|
+
if (typeof obj.concurrency !== "number" || obj.concurrency < 1 || !Number.isInteger(obj.concurrency)) {
|
|
511
|
+
throw new Error(`Config "concurrency" must be a positive integer in ${source}`);
|
|
512
|
+
}
|
|
513
|
+
config.concurrency = obj.concurrency;
|
|
514
|
+
}
|
|
515
|
+
if (obj.dockerToken !== void 0) {
|
|
516
|
+
if (typeof obj.dockerToken !== "string") {
|
|
517
|
+
throw new Error(`Config "dockerToken" must be a string in ${source}`);
|
|
518
|
+
}
|
|
519
|
+
config.dockerToken = obj.dockerToken;
|
|
520
|
+
}
|
|
521
|
+
return config;
|
|
522
|
+
}
|
|
456
523
|
function defaultConfig() {
|
|
457
524
|
return {
|
|
458
525
|
registries: ["npm", "pypi", "nuget", "vscode", "docker"],
|
|
@@ -481,6 +548,36 @@ function starterConfig() {
|
|
|
481
548
|
|
|
482
549
|
// src/server.ts
|
|
483
550
|
var import_node_http = require("http");
|
|
551
|
+
function createRateLimiter(maxRequests, windowSeconds) {
|
|
552
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
553
|
+
const cleanup = setInterval(() => {
|
|
554
|
+
const now = Date.now();
|
|
555
|
+
for (const [ip, bucket] of buckets) {
|
|
556
|
+
if (now > bucket.resetAt) buckets.delete(ip);
|
|
557
|
+
}
|
|
558
|
+
}, 6e4);
|
|
559
|
+
cleanup.unref();
|
|
560
|
+
return {
|
|
561
|
+
/** Returns true if the request is allowed, false if rate-limited. */
|
|
562
|
+
allow(ip) {
|
|
563
|
+
const now = Date.now();
|
|
564
|
+
const bucket = buckets.get(ip);
|
|
565
|
+
if (!bucket || now > bucket.resetAt) {
|
|
566
|
+
buckets.set(ip, { count: 1, resetAt: now + windowSeconds * 1e3 });
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
bucket.count++;
|
|
570
|
+
return bucket.count <= maxRequests;
|
|
571
|
+
},
|
|
572
|
+
/** Exposed for testing — clear all buckets. */
|
|
573
|
+
reset() {
|
|
574
|
+
buckets.clear();
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
function sanitizeFilename(value) {
|
|
579
|
+
return value.replace(/[^a-zA-Z0-9._@/-]/g, "_").slice(0, 200);
|
|
580
|
+
}
|
|
484
581
|
function json(res, data, status = 200) {
|
|
485
582
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
486
583
|
res.end(JSON.stringify(data));
|
|
@@ -500,13 +597,43 @@ function parseUrl(url) {
|
|
|
500
597
|
}
|
|
501
598
|
return { path, query };
|
|
502
599
|
}
|
|
600
|
+
function withTimeout(promise, ms) {
|
|
601
|
+
return new Promise((resolve2, reject) => {
|
|
602
|
+
const timer = setTimeout(() => reject(new Error("__TIMEOUT__")), ms);
|
|
603
|
+
promise.then(
|
|
604
|
+
(v) => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
resolve2(v);
|
|
607
|
+
},
|
|
608
|
+
(e) => {
|
|
609
|
+
clearTimeout(timer);
|
|
610
|
+
reject(e);
|
|
611
|
+
}
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
function getClientIp(req) {
|
|
616
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
617
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
|
|
618
|
+
return req.socket.remoteAddress ?? "0.0.0.0";
|
|
619
|
+
}
|
|
503
620
|
function createHandler(opts) {
|
|
504
621
|
const options = { ...opts };
|
|
505
622
|
if (!options.cache) {
|
|
506
623
|
options.cache = createCache();
|
|
507
624
|
}
|
|
625
|
+
const corsOrigin = opts?.corsOrigin ?? "*";
|
|
626
|
+
const timeoutMs = opts?.requestTimeoutMs ?? 3e4;
|
|
627
|
+
const limiter = createRateLimiter(
|
|
628
|
+
opts?.rateLimitMax ?? 60,
|
|
629
|
+
opts?.rateLimitWindowSeconds ?? 60
|
|
630
|
+
);
|
|
508
631
|
return async (req, res) => {
|
|
509
|
-
res.setHeader("
|
|
632
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
633
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
634
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
635
|
+
res.setHeader("Cache-Control", "no-store");
|
|
636
|
+
res.setHeader("Access-Control-Allow-Origin", corsOrigin);
|
|
510
637
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
511
638
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
512
639
|
if (req.method === "OPTIONS") {
|
|
@@ -514,6 +641,11 @@ function createHandler(opts) {
|
|
|
514
641
|
res.end();
|
|
515
642
|
return;
|
|
516
643
|
}
|
|
644
|
+
const clientIp = getClientIp(req);
|
|
645
|
+
if (!limiter.allow(clientIp)) {
|
|
646
|
+
error(res, "Too many requests", 429);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
517
649
|
if (req.method !== "GET") {
|
|
518
650
|
error(res, "Method not allowed", 405);
|
|
519
651
|
return;
|
|
@@ -523,14 +655,14 @@ function createHandler(opts) {
|
|
|
523
655
|
if (path[0] === "stats") {
|
|
524
656
|
if (path.length === 2) {
|
|
525
657
|
const pkg = decodeURIComponent(path[1]);
|
|
526
|
-
const results = await stats.all(pkg, options);
|
|
658
|
+
const results = await withTimeout(stats.all(pkg, options), timeoutMs);
|
|
527
659
|
json(res, results);
|
|
528
660
|
return;
|
|
529
661
|
}
|
|
530
662
|
if (path.length >= 3) {
|
|
531
663
|
const registry = path[1];
|
|
532
664
|
const pkg = path.slice(2).join("/");
|
|
533
|
-
const result = await stats(registry, pkg, options);
|
|
665
|
+
const result = await withTimeout(stats(registry, pkg, options), timeoutMs);
|
|
534
666
|
if (!result) {
|
|
535
667
|
error(res, `Package "${pkg}" not found on ${registry}`, 404);
|
|
536
668
|
return;
|
|
@@ -542,7 +674,7 @@ function createHandler(opts) {
|
|
|
542
674
|
if (path[0] === "compare" && path.length >= 2) {
|
|
543
675
|
const pkg = decodeURIComponent(path[1]);
|
|
544
676
|
const registries = query.registries ? query.registries.split(",") : void 0;
|
|
545
|
-
const result = await stats.compare(pkg, registries, options);
|
|
677
|
+
const result = await withTimeout(stats.compare(pkg, registries, options), timeoutMs);
|
|
546
678
|
json(res, result);
|
|
547
679
|
return;
|
|
548
680
|
}
|
|
@@ -554,11 +686,14 @@ function createHandler(opts) {
|
|
|
554
686
|
error(res, "Missing start and end query parameters");
|
|
555
687
|
return;
|
|
556
688
|
}
|
|
557
|
-
const data = await stats.range(registry, pkg, start, end, options);
|
|
689
|
+
const data = await withTimeout(stats.range(registry, pkg, start, end, options), timeoutMs);
|
|
558
690
|
if (format === "csv") {
|
|
691
|
+
const safePkg = sanitizeFilename(pkg);
|
|
692
|
+
const safeStart = sanitizeFilename(start);
|
|
693
|
+
const safeEnd = sanitizeFilename(end);
|
|
559
694
|
res.writeHead(200, {
|
|
560
695
|
"Content-Type": "text/csv",
|
|
561
|
-
"Content-Disposition": `attachment; filename="${
|
|
696
|
+
"Content-Disposition": `attachment; filename="${safePkg}-${safeStart}-${safeEnd}.csv"`
|
|
562
697
|
});
|
|
563
698
|
res.end(calc.toCSV(data));
|
|
564
699
|
return;
|
|
@@ -584,11 +719,13 @@ function createHandler(opts) {
|
|
|
584
719
|
}
|
|
585
720
|
error(res, "Not found", 404);
|
|
586
721
|
} catch (e) {
|
|
587
|
-
if (e
|
|
722
|
+
if (e?.message === "__TIMEOUT__") {
|
|
723
|
+
error(res, "Gateway timeout", 504);
|
|
724
|
+
} else if (e instanceof RegistryError) {
|
|
588
725
|
const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
|
|
589
726
|
error(res, e.message, status);
|
|
590
727
|
} else {
|
|
591
|
-
error(res,
|
|
728
|
+
error(res, "Internal server error", 500);
|
|
592
729
|
}
|
|
593
730
|
}
|
|
594
731
|
};
|
|
@@ -596,7 +733,11 @@ function createHandler(opts) {
|
|
|
596
733
|
function serve(opts) {
|
|
597
734
|
const port = opts?.port ?? 3e3;
|
|
598
735
|
const handler = createHandler({
|
|
599
|
-
cache: opts?.cache !== false ? createCache() : void 0
|
|
736
|
+
cache: opts?.cache !== false ? createCache() : void 0,
|
|
737
|
+
corsOrigin: opts?.corsOrigin,
|
|
738
|
+
rateLimitMax: opts?.rateLimitMax,
|
|
739
|
+
rateLimitWindowSeconds: opts?.rateLimitWindowSeconds,
|
|
740
|
+
requestTimeoutMs: opts?.requestTimeoutMs
|
|
600
741
|
});
|
|
601
742
|
const server = (0, import_node_http.createServer)(handler);
|
|
602
743
|
server.listen(port, () => {
|
|
@@ -612,6 +753,9 @@ Endpoints:`);
|
|
|
612
753
|
}
|
|
613
754
|
|
|
614
755
|
// src/inference.ts
|
|
756
|
+
function sanitize(arr) {
|
|
757
|
+
return arr.filter((v) => Number.isFinite(v));
|
|
758
|
+
}
|
|
615
759
|
function mean(arr) {
|
|
616
760
|
if (arr.length === 0) return 0;
|
|
617
761
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
@@ -619,7 +763,7 @@ function mean(arr) {
|
|
|
619
763
|
function stddev(arr) {
|
|
620
764
|
if (arr.length < 2) return 0;
|
|
621
765
|
const m = mean(arr);
|
|
622
|
-
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
|
|
766
|
+
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
|
623
767
|
return Math.sqrt(variance);
|
|
624
768
|
}
|
|
625
769
|
function linearRegression(ys) {
|
|
@@ -647,6 +791,7 @@ function linearRegression(ys) {
|
|
|
647
791
|
}
|
|
648
792
|
var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
649
793
|
function forecast(series, days = 7) {
|
|
794
|
+
series = sanitize(series);
|
|
650
795
|
if (series.length < 7) return [];
|
|
651
796
|
const window = series.slice(-Math.min(14, series.length));
|
|
652
797
|
const n = window.length;
|
|
@@ -690,6 +835,7 @@ function forecast(series, days = 7) {
|
|
|
690
835
|
return results;
|
|
691
836
|
}
|
|
692
837
|
function detectAnomalies(series, threshold = 2) {
|
|
838
|
+
series = sanitize(series);
|
|
693
839
|
if (series.length < 7) return [];
|
|
694
840
|
const anomalies = [];
|
|
695
841
|
const windowSize = Math.min(14, Math.floor(series.length * 0.7));
|
|
@@ -712,7 +858,11 @@ function detectAnomalies(series, threshold = 2) {
|
|
|
712
858
|
return anomalies;
|
|
713
859
|
}
|
|
714
860
|
function segmentTrends(series, minSegmentLength = 5) {
|
|
861
|
+
series = sanitize(series);
|
|
715
862
|
if (series.length < minSegmentLength) return [];
|
|
863
|
+
if (series.length > 1e3) {
|
|
864
|
+
series = series.slice(-1e3);
|
|
865
|
+
}
|
|
716
866
|
const segments = [];
|
|
717
867
|
let segStart = 0;
|
|
718
868
|
while (segStart < series.length - minSegmentLength + 1) {
|
|
@@ -740,10 +890,11 @@ function segmentTrends(series, minSegmentLength = 5) {
|
|
|
740
890
|
}
|
|
741
891
|
return segments;
|
|
742
892
|
}
|
|
743
|
-
function detectSeasonality(series, startDaysAgo) {
|
|
893
|
+
function detectSeasonality(series, startDaysAgo, referenceDate) {
|
|
894
|
+
series = sanitize(series);
|
|
744
895
|
if (series.length < 14) return null;
|
|
745
896
|
const buckets = [[], [], [], [], [], [], []];
|
|
746
|
-
const today = /* @__PURE__ */ new Date();
|
|
897
|
+
const today = referenceDate ?? /* @__PURE__ */ new Date();
|
|
747
898
|
for (let i = 0; i < series.length; i++) {
|
|
748
899
|
const date = new Date(today);
|
|
749
900
|
date.setDate(date.getDate() - (startDaysAgo - i));
|
|
@@ -762,6 +913,7 @@ function detectSeasonality(series, startDaysAgo) {
|
|
|
762
913
|
};
|
|
763
914
|
}
|
|
764
915
|
function computeMomentum(series) {
|
|
916
|
+
series = sanitize(series);
|
|
765
917
|
if (series.length < 14) return 0;
|
|
766
918
|
const last7 = series.slice(-7);
|
|
767
919
|
const prev7 = series.slice(-14, -7);
|
|
@@ -914,8 +1066,7 @@ function generateActionableAdvice(packages, healthScores, opts = {}) {
|
|
|
914
1066
|
});
|
|
915
1067
|
}
|
|
916
1068
|
const highVolume = packages.filter((p) => {
|
|
917
|
-
|
|
918
|
-
return pkg && pkg.forecast7.length > 0 && pkg.forecast7[6]?.predicted > 100;
|
|
1069
|
+
return p.forecast7.length > 0 && p.forecast7[6]?.predicted > 100;
|
|
919
1070
|
});
|
|
920
1071
|
for (const pkg of highVolume.slice(0, 3)) {
|
|
921
1072
|
const weekSum = pkg.forecast7.reduce((s, f) => s + f.predicted, 0);
|
|
@@ -1087,6 +1238,18 @@ function inferPortfolio(leaderboard, opts = {}) {
|
|
|
1087
1238
|
}
|
|
1088
1239
|
|
|
1089
1240
|
// src/index.ts
|
|
1241
|
+
var VALID_PKG_NAME = /^[@a-zA-Z0-9][\w./@-]*$/;
|
|
1242
|
+
function validatePackageName(pkg, registry) {
|
|
1243
|
+
if (!pkg || typeof pkg !== "string") {
|
|
1244
|
+
throw new RegistryError(registry, 0, `Invalid package name: must be a non-empty string`);
|
|
1245
|
+
}
|
|
1246
|
+
if (pkg.includes("..") || pkg.includes("\\")) {
|
|
1247
|
+
throw new RegistryError(registry, 0, `Invalid package name "${pkg}": path traversal not allowed`);
|
|
1248
|
+
}
|
|
1249
|
+
if (!VALID_PKG_NAME.test(pkg)) {
|
|
1250
|
+
throw new RegistryError(registry, 0, `Invalid package name "${pkg}": contains illegal characters`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1090
1253
|
function createCache() {
|
|
1091
1254
|
const store = /* @__PURE__ */ new Map();
|
|
1092
1255
|
return {
|
|
@@ -1135,6 +1298,7 @@ function registerProvider(provider) {
|
|
|
1135
1298
|
}
|
|
1136
1299
|
var DEFAULT_TTL = 3e5;
|
|
1137
1300
|
async function stats(registry, pkg, options) {
|
|
1301
|
+
validatePackageName(pkg, registry);
|
|
1138
1302
|
const provider = providers[registry];
|
|
1139
1303
|
if (!provider) {
|
|
1140
1304
|
throw new RegistryError(registry, 0, `Unknown registry "${registry}". Use registerProvider() to add custom registries.`);
|
package/dist/index.d.cts
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[]>;
|