@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/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: 400,
64
- // ~2.5 req/s — safe for 54+ scoped packages
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 fetchWithRetry(url, registry, init) {
83
+ async function fetchRetryCore(url, registry, init, preRequest) {
79
84
  let lastError;
80
85
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
81
- await acquireSlot(registry);
82
- const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
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
- let lastError;
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/${pkg}/recent`, "pypi"),
205
- fetchWithRetry(`${API2}/packages/${pkg}/overall?mirrors=false`, "pypi")
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/${pkg}/overall?mirrors=false`,
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
- return JSON.parse(raw);
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("Access-Control-Allow-Origin", "*");
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="${pkg}-${start}-${end}.csv"`
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 instanceof RegistryError) {
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, e.message, 500);
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
- const pkg = packages.find((pp) => pp.name === p.name);
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[]>;