@mcptoolshop/registry-stats 3.2.1 → 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/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { writeFileSync, existsSync as existsSync2 } from "fs";
5
- import { resolve as resolve2 } from "path";
4
+ import { writeFileSync, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
5
+ import { resolve as resolve2, dirname as dirname2, join } from "path";
6
+ import { fileURLToPath } from "url";
6
7
 
7
8
  // src/types.ts
8
9
  var RegistryError = class extends Error {
@@ -20,9 +21,10 @@ var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
20
21
  var MAX_RETRIES = 3;
21
22
  var BASE_DELAY = 1e3;
22
23
  var registryLocks = /* @__PURE__ */ new Map();
24
+ var MAX_LOCK_ENTRIES = 50;
23
25
  var REGISTRY_DELAYS = {
24
- npm: 400,
25
- // ~2.5 req/s — safe for 54+ scoped packages
26
+ npm: 800,
27
+ // ~1.25 req/s — safe for 91+ scoped packages (429s at 400ms)
26
28
  pypi: 2200,
27
29
  // 30 req/60s = 1 per 2s, with headroom
28
30
  docker: 4e3
@@ -34,13 +36,27 @@ function acquireSlot(registry) {
34
36
  const prev = registryLocks.get(registry) ?? Promise.resolve();
35
37
  const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
36
38
  registryLocks.set(registry, slot);
39
+ if (registryLocks.size > MAX_LOCK_ENTRIES) {
40
+ const oldest = registryLocks.keys().next().value;
41
+ if (oldest !== void 0) registryLocks.delete(oldest);
42
+ }
37
43
  return prev;
38
44
  }
39
- async function fetchWithRetry(url, registry, init) {
45
+ async function fetchRetryCore(url, registry, init, preRequest) {
40
46
  let lastError;
41
47
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
42
- await acquireSlot(registry);
43
- const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
48
+ if (preRequest) await preRequest();
49
+ let res;
50
+ try {
51
+ res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
52
+ } catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ lastError = new RegistryError(registry, 0, `Network error: ${message} \u2014 ${url}`);
55
+ if (attempt === MAX_RETRIES) break;
56
+ const backoff2 = BASE_DELAY * Math.pow(2, attempt);
57
+ await new Promise((r) => setTimeout(r, backoff2));
58
+ continue;
59
+ }
44
60
  if (res.status === 404) return null;
45
61
  if (res.ok) return res.json();
46
62
  const retryAfter = res.headers.get("retry-after");
@@ -57,41 +73,25 @@ async function fetchWithRetry(url, registry, init) {
57
73
  const delay = Math.max(backoff, retryAfterMs);
58
74
  await new Promise((r) => setTimeout(r, delay));
59
75
  }
60
- throw lastError;
76
+ throw lastError ?? new RegistryError(registry, 0, `Fetch failed after ${MAX_RETRIES} retries: ${url}`);
77
+ }
78
+ async function fetchWithRetry(url, registry, init) {
79
+ return fetchRetryCore(url, registry, init, () => acquireSlot(registry));
61
80
  }
62
81
  async function fetchDirect(url, registry, init) {
63
- let lastError;
64
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
65
- const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
66
- if (res.status === 404) return null;
67
- if (res.ok) return res.json();
68
- const retryAfter = res.headers.get("retry-after");
69
- const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
70
- lastError = new RegistryError(
71
- registry,
72
- res.status,
73
- `${res.statusText}: ${url}`,
74
- retryAfterSeconds
75
- );
76
- if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
77
- const backoff = BASE_DELAY * Math.pow(2, attempt);
78
- const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
79
- const delay = Math.max(backoff, retryAfterMs);
80
- await new Promise((r) => setTimeout(r, delay));
81
- }
82
- throw lastError;
82
+ return fetchRetryCore(url, registry, init);
83
83
  }
84
84
 
85
85
  // src/providers/npm.ts
86
86
  var API = "https://api.npmjs.org/downloads";
87
87
  var npm = {
88
88
  name: "npm",
89
- async getStats(pkg) {
89
+ async getStats(pkg2) {
90
90
  const end = /* @__PURE__ */ new Date();
91
91
  const start = new Date(end);
92
92
  start.setDate(start.getDate() - 30);
93
93
  const data = await fetchWithRetry(
94
- `${API}/range/${fmt(start)}:${fmt(end)}/${pkg}`,
94
+ `${API}/range/${fmt(start)}:${fmt(end)}/${encodeNpmPackage(pkg2)}`,
95
95
  "npm"
96
96
  );
97
97
  if (!data || !data.downloads || data.downloads.length === 0) return null;
@@ -101,12 +101,12 @@ var npm = {
101
101
  const lastMonth = days.reduce((s, d) => s + d.downloads, 0);
102
102
  return {
103
103
  registry: "npm",
104
- package: pkg,
104
+ package: pkg2,
105
105
  downloads: { lastDay, lastWeek, lastMonth },
106
106
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
107
107
  };
108
108
  },
109
- async getRange(pkg, start, end) {
109
+ async getRange(pkg2, start, end) {
110
110
  const startDate = new Date(start);
111
111
  const endDate = new Date(end);
112
112
  const maxDays = 549;
@@ -118,7 +118,7 @@ var npm = {
118
118
  const actualEnd = chunkEnd > endDate ? endDate : chunkEnd;
119
119
  const s = fmt(cursor);
120
120
  const e = fmt(actualEnd);
121
- const data = await fetchWithRetry(`${API}/range/${s}:${e}/${pkg}`, "npm");
121
+ const data = await fetchWithRetry(`${API}/range/${s}:${e}/${encodeNpmPackage(pkg2)}`, "npm");
122
122
  if (data) {
123
123
  for (const d of data.downloads) {
124
124
  chunks.push({ date: d.day, downloads: d.downloads });
@@ -154,22 +154,26 @@ async function npmBulkPoint(packages, period = "last-month") {
154
154
  function fmt(d) {
155
155
  return d.toISOString().slice(0, 10);
156
156
  }
157
+ function encodeNpmPackage(pkg2) {
158
+ return encodeURIComponent(pkg2);
159
+ }
157
160
 
158
161
  // src/providers/pypi.ts
159
162
  var API2 = "https://pypistats.org/api";
160
163
  var pypi = {
161
164
  name: "pypi",
162
165
  rateLimit: { maxRequests: 30, windowSeconds: 60, authRaisesLimit: false },
163
- async getStats(pkg) {
166
+ async getStats(pkg2) {
167
+ const safePkg = encodeURIComponent(pkg2);
164
168
  const [recent, overall] = await Promise.all([
165
- fetchWithRetry(`${API2}/packages/${pkg}/recent`, "pypi"),
166
- fetchWithRetry(`${API2}/packages/${pkg}/overall?mirrors=false`, "pypi")
169
+ fetchWithRetry(`${API2}/packages/${safePkg}/recent`, "pypi"),
170
+ fetchWithRetry(`${API2}/packages/${safePkg}/overall?mirrors=false`, "pypi")
167
171
  ]);
168
172
  if (!recent && !overall) return null;
169
173
  const total = overall?.data?.filter((d) => d.category === "without_mirrors")?.reduce((sum, d) => sum + d.downloads, 0);
170
174
  return {
171
175
  registry: "pypi",
172
- package: pkg,
176
+ package: pkg2,
173
177
  downloads: {
174
178
  total: total || void 0,
175
179
  lastDay: recent?.data.last_day,
@@ -179,9 +183,10 @@ var pypi = {
179
183
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
180
184
  };
181
185
  },
182
- async getRange(pkg, start, end) {
186
+ async getRange(pkg2, start, end) {
187
+ const safePkg = encodeURIComponent(pkg2);
183
188
  const data = await fetchWithRetry(
184
- `${API2}/packages/${pkg}/overall?mirrors=false`,
189
+ `${API2}/packages/${safePkg}/overall?mirrors=false`,
185
190
  "pypi"
186
191
  );
187
192
  if (!data) return [];
@@ -199,12 +204,12 @@ var pypi = {
199
204
  var SEARCH_API = "https://azuresearch-usnc.nuget.org/query";
200
205
  var nuget = {
201
206
  name: "nuget",
202
- async getStats(pkg) {
203
- const url = `${SEARCH_API}?q=packageid:${encodeURIComponent(pkg)}&take=1`;
207
+ async getStats(pkg2) {
208
+ const url = `${SEARCH_API}?q=packageid:${encodeURIComponent(pkg2)}&take=1`;
204
209
  const json2 = await fetchWithRetry(url, "nuget");
205
210
  if (!json2) return null;
206
211
  const match = json2.data.find(
207
- (d) => d.id.toLowerCase() === pkg.toLowerCase()
212
+ (d) => d.id.toLowerCase() === pkg2.toLowerCase()
208
213
  );
209
214
  if (!match) return null;
210
215
  return {
@@ -228,7 +233,7 @@ function getStat(stats2, name) {
228
233
  }
229
234
  var vscode = {
230
235
  name: "vscode",
231
- async getStats(pkg) {
236
+ async getStats(pkg2) {
232
237
  let json2;
233
238
  try {
234
239
  json2 = await fetchWithRetry(API3, "vscode", {
@@ -240,7 +245,7 @@ var vscode = {
240
245
  body: JSON.stringify({
241
246
  filters: [
242
247
  {
243
- criteria: [{ filterType: 7, value: pkg }]
248
+ criteria: [{ filterType: 7, value: pkg2 }]
244
249
  }
245
250
  ],
246
251
  flags: 256
@@ -279,12 +284,12 @@ var API4 = "https://hub.docker.com/v2/repositories";
279
284
  var docker = {
280
285
  name: "docker",
281
286
  rateLimit: { maxRequests: 10, windowSeconds: 3600, authRaisesLimit: true },
282
- async getStats(pkg, options) {
287
+ async getStats(pkg2, options) {
283
288
  const headers = {};
284
289
  if (options?.dockerToken) {
285
290
  headers["Authorization"] = `Bearer ${options.dockerToken}`;
286
291
  }
287
- const safePkg = pkg.split("/").map((s) => encodeURIComponent(s)).join("/");
292
+ const safePkg = pkg2.split("/").map((s) => encodeURIComponent(s)).join("/");
288
293
  const json2 = await fetchWithRetry(`${API4}/${safePkg}`, "docker", { headers });
289
294
  if (!json2 || !json2.name || !json2.namespace) return null;
290
295
  return {
@@ -304,13 +309,16 @@ var docker = {
304
309
 
305
310
  // src/calc.ts
306
311
  var calc = {
312
+ /** Sum all downloads in the given records. */
307
313
  total(records) {
308
314
  return records.reduce((sum, r) => sum + r.downloads, 0);
309
315
  },
316
+ /** Compute average daily downloads. Returns 0 for empty input. */
310
317
  avg(records) {
311
318
  if (records.length === 0) return 0;
312
319
  return calc.total(records) / records.length;
313
320
  },
321
+ /** Group records by a custom key function. */
314
322
  group(records, fn) {
315
323
  const groups = {};
316
324
  for (const r of records) {
@@ -319,12 +327,15 @@ var calc = {
319
327
  }
320
328
  return groups;
321
329
  },
330
+ /** Group records by month (YYYY-MM keys). */
322
331
  monthly(records) {
323
332
  return calc.group(records, (r) => r.date.slice(0, 7));
324
333
  },
334
+ /** Group records by year (YYYY keys). */
325
335
  yearly(records) {
326
336
  return calc.group(records, (r) => r.date.slice(0, 4));
327
337
  },
338
+ /** Sum downloads within each group. */
328
339
  groupTotals(grouped) {
329
340
  const result = {};
330
341
  for (const [key, records] of Object.entries(grouped)) {
@@ -332,6 +343,7 @@ var calc = {
332
343
  }
333
344
  return result;
334
345
  },
346
+ /** Average downloads within each group. */
335
347
  groupAvgs(grouped) {
336
348
  const result = {};
337
349
  for (const [key, records] of Object.entries(grouped)) {
@@ -339,6 +351,7 @@ var calc = {
339
351
  }
340
352
  return result;
341
353
  },
354
+ /** Detect trend direction (up/down/flat) by comparing recent vs previous window averages. */
342
355
  trend(records, windowDays = 7) {
343
356
  if (records.length < windowDays * 2) {
344
357
  return { slope: 0, direction: "flat", changePercent: 0 };
@@ -354,6 +367,7 @@ var calc = {
354
367
  const direction = slope > threshold ? "up" : slope < -threshold ? "down" : "flat";
355
368
  return { slope: Math.round(slope * 100) / 100, direction, changePercent: Math.round(changePercent * 100) / 100 };
356
369
  },
370
+ /** Compute a simple moving average over a sliding window. */
357
371
  movingAvg(records, windowDays = 7) {
358
372
  if (records.length < windowDays) return [];
359
373
  const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
@@ -370,6 +384,7 @@ var calc = {
370
384
  }
371
385
  return result;
372
386
  },
387
+ /** Compute a 0-100 popularity score based on log-scaled recent daily downloads. */
373
388
  popularity(records) {
374
389
  if (records.length === 0) return 0;
375
390
  const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
@@ -379,6 +394,7 @@ var calc = {
379
394
  const score = Math.max(0, Math.min(100, Math.log10(Math.max(1, avgDaily)) / 6 * 100));
380
395
  return Math.round(score * 10) / 10;
381
396
  },
397
+ /** Convert records to CSV string with date,downloads columns. */
382
398
  toCSV(records) {
383
399
  const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
384
400
  const lines = ["date,downloads"];
@@ -387,6 +403,7 @@ var calc = {
387
403
  }
388
404
  return lines.join("\n");
389
405
  },
406
+ /** Convert records to a ChartData object suitable for chart libraries. */
390
407
  toChartData(records, label = "downloads") {
391
408
  const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
392
409
  return {
@@ -406,7 +423,14 @@ function loadConfig(startDir) {
406
423
  const configPath = resolve(dir, CONFIG_NAME);
407
424
  if (existsSync(configPath)) {
408
425
  const raw = readFileSync(configPath, "utf-8");
409
- return JSON.parse(raw);
426
+ let parsed;
427
+ try {
428
+ parsed = JSON.parse(raw);
429
+ } catch (err) {
430
+ const message = err instanceof Error ? err.message : String(err);
431
+ throw new Error(`Invalid JSON in ${configPath}: ${message}`);
432
+ }
433
+ return validateConfig(parsed, configPath);
410
434
  }
411
435
  const parent = dirname(dir);
412
436
  if (parent === dir) break;
@@ -414,6 +438,50 @@ function loadConfig(startDir) {
414
438
  }
415
439
  return null;
416
440
  }
441
+ function validateConfig(raw, source) {
442
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
443
+ throw new Error(`Config in ${source} must be a JSON object, got ${Array.isArray(raw) ? "array" : typeof raw}`);
444
+ }
445
+ const obj = raw;
446
+ const config = {};
447
+ if (obj.registries !== void 0) {
448
+ if (!Array.isArray(obj.registries) || !obj.registries.every((r) => typeof r === "string")) {
449
+ throw new Error(`Config "registries" must be an array of strings in ${source}`);
450
+ }
451
+ config.registries = obj.registries;
452
+ }
453
+ if (obj.packages !== void 0) {
454
+ if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
455
+ throw new Error(`Config "packages" must be an object in ${source}`);
456
+ }
457
+ config.packages = obj.packages;
458
+ }
459
+ if (obj.cache !== void 0) {
460
+ if (typeof obj.cache !== "boolean") {
461
+ throw new Error(`Config "cache" must be a boolean in ${source}`);
462
+ }
463
+ config.cache = obj.cache;
464
+ }
465
+ if (obj.cacheTtlMs !== void 0) {
466
+ if (typeof obj.cacheTtlMs !== "number" || obj.cacheTtlMs <= 0 || !Number.isFinite(obj.cacheTtlMs)) {
467
+ throw new Error(`Config "cacheTtlMs" must be a positive number in ${source}`);
468
+ }
469
+ config.cacheTtlMs = obj.cacheTtlMs;
470
+ }
471
+ if (obj.concurrency !== void 0) {
472
+ if (typeof obj.concurrency !== "number" || obj.concurrency < 1 || !Number.isInteger(obj.concurrency)) {
473
+ throw new Error(`Config "concurrency" must be a positive integer in ${source}`);
474
+ }
475
+ config.concurrency = obj.concurrency;
476
+ }
477
+ if (obj.dockerToken !== void 0) {
478
+ if (typeof obj.dockerToken !== "string") {
479
+ throw new Error(`Config "dockerToken" must be a string in ${source}`);
480
+ }
481
+ config.dockerToken = obj.dockerToken;
482
+ }
483
+ return config;
484
+ }
417
485
  var STARTER = `{
418
486
  "registries": ["npm", "pypi", "nuget", "vscode", "docker"],
419
487
  "packages": {
@@ -433,6 +501,36 @@ function starterConfig() {
433
501
 
434
502
  // src/server.ts
435
503
  import { createServer as httpCreateServer } from "http";
504
+ function createRateLimiter(maxRequests, windowSeconds) {
505
+ const buckets = /* @__PURE__ */ new Map();
506
+ const cleanup = setInterval(() => {
507
+ const now = Date.now();
508
+ for (const [ip, bucket] of buckets) {
509
+ if (now > bucket.resetAt) buckets.delete(ip);
510
+ }
511
+ }, 6e4);
512
+ cleanup.unref();
513
+ return {
514
+ /** Returns true if the request is allowed, false if rate-limited. */
515
+ allow(ip) {
516
+ const now = Date.now();
517
+ const bucket = buckets.get(ip);
518
+ if (!bucket || now > bucket.resetAt) {
519
+ buckets.set(ip, { count: 1, resetAt: now + windowSeconds * 1e3 });
520
+ return true;
521
+ }
522
+ bucket.count++;
523
+ return bucket.count <= maxRequests;
524
+ },
525
+ /** Exposed for testing — clear all buckets. */
526
+ reset() {
527
+ buckets.clear();
528
+ }
529
+ };
530
+ }
531
+ function sanitizeFilename(value) {
532
+ return value.replace(/[^a-zA-Z0-9._@/-]/g, "_").slice(0, 200);
533
+ }
436
534
  function json(res, data, status = 200) {
437
535
  res.writeHead(status, { "Content-Type": "application/json" });
438
536
  res.end(JSON.stringify(data));
@@ -452,13 +550,43 @@ function parseUrl(url) {
452
550
  }
453
551
  return { path, query };
454
552
  }
553
+ function withTimeout(promise, ms) {
554
+ return new Promise((resolve3, reject) => {
555
+ const timer = setTimeout(() => reject(new Error("__TIMEOUT__")), ms);
556
+ promise.then(
557
+ (v) => {
558
+ clearTimeout(timer);
559
+ resolve3(v);
560
+ },
561
+ (e) => {
562
+ clearTimeout(timer);
563
+ reject(e);
564
+ }
565
+ );
566
+ });
567
+ }
568
+ function getClientIp(req) {
569
+ const forwarded = req.headers["x-forwarded-for"];
570
+ if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
571
+ return req.socket.remoteAddress ?? "0.0.0.0";
572
+ }
455
573
  function createHandler(opts) {
456
574
  const options = { ...opts };
457
575
  if (!options.cache) {
458
576
  options.cache = createCache();
459
577
  }
578
+ const corsOrigin = opts?.corsOrigin ?? "*";
579
+ const timeoutMs = opts?.requestTimeoutMs ?? 3e4;
580
+ const limiter = createRateLimiter(
581
+ opts?.rateLimitMax ?? 60,
582
+ opts?.rateLimitWindowSeconds ?? 60
583
+ );
460
584
  return async (req, res) => {
461
- res.setHeader("Access-Control-Allow-Origin", "*");
585
+ res.setHeader("X-Content-Type-Options", "nosniff");
586
+ res.setHeader("X-Frame-Options", "DENY");
587
+ res.setHeader("X-XSS-Protection", "1; mode=block");
588
+ res.setHeader("Cache-Control", "no-store");
589
+ res.setHeader("Access-Control-Allow-Origin", corsOrigin);
462
590
  res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
463
591
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
464
592
  if (req.method === "OPTIONS") {
@@ -466,6 +594,11 @@ function createHandler(opts) {
466
594
  res.end();
467
595
  return;
468
596
  }
597
+ const clientIp = getClientIp(req);
598
+ if (!limiter.allow(clientIp)) {
599
+ error(res, "Too many requests", 429);
600
+ return;
601
+ }
469
602
  if (req.method !== "GET") {
470
603
  error(res, "Method not allowed", 405);
471
604
  return;
@@ -474,17 +607,17 @@ function createHandler(opts) {
474
607
  try {
475
608
  if (path[0] === "stats") {
476
609
  if (path.length === 2) {
477
- const pkg = decodeURIComponent(path[1]);
478
- const results = await stats.all(pkg, options);
610
+ const pkg2 = decodeURIComponent(path[1]);
611
+ const results = await withTimeout(stats.all(pkg2, options), timeoutMs);
479
612
  json(res, results);
480
613
  return;
481
614
  }
482
615
  if (path.length >= 3) {
483
616
  const registry = path[1];
484
- const pkg = path.slice(2).join("/");
485
- const result = await stats(registry, pkg, options);
617
+ const pkg2 = path.slice(2).join("/");
618
+ const result = await withTimeout(stats(registry, pkg2, options), timeoutMs);
486
619
  if (!result) {
487
- error(res, `Package "${pkg}" not found on ${registry}`, 404);
620
+ error(res, `Package "${pkg2}" not found on ${registry}`, 404);
488
621
  return;
489
622
  }
490
623
  json(res, result);
@@ -492,31 +625,34 @@ function createHandler(opts) {
492
625
  }
493
626
  }
494
627
  if (path[0] === "compare" && path.length >= 2) {
495
- const pkg = decodeURIComponent(path[1]);
628
+ const pkg2 = decodeURIComponent(path[1]);
496
629
  const registries = query.registries ? query.registries.split(",") : void 0;
497
- const result = await stats.compare(pkg, registries, options);
630
+ const result = await withTimeout(stats.compare(pkg2, registries, options), timeoutMs);
498
631
  json(res, result);
499
632
  return;
500
633
  }
501
634
  if (path[0] === "range" && path.length >= 3) {
502
635
  const registry = path[1];
503
- const pkg = path.slice(2).join("/");
636
+ const pkg2 = path.slice(2).join("/");
504
637
  const { start, end, format } = query;
505
638
  if (!start || !end) {
506
639
  error(res, "Missing start and end query parameters");
507
640
  return;
508
641
  }
509
- const data = await stats.range(registry, pkg, start, end, options);
642
+ const data = await withTimeout(stats.range(registry, pkg2, start, end, options), timeoutMs);
510
643
  if (format === "csv") {
644
+ const safePkg = sanitizeFilename(pkg2);
645
+ const safeStart = sanitizeFilename(start);
646
+ const safeEnd = sanitizeFilename(end);
511
647
  res.writeHead(200, {
512
648
  "Content-Type": "text/csv",
513
- "Content-Disposition": `attachment; filename="${pkg}-${start}-${end}.csv"`
649
+ "Content-Disposition": `attachment; filename="${safePkg}-${safeStart}-${safeEnd}.csv"`
514
650
  });
515
651
  res.end(calc.toCSV(data));
516
652
  return;
517
653
  }
518
654
  if (format === "chart") {
519
- json(res, calc.toChartData(data, `${pkg} (${registry})`));
655
+ json(res, calc.toChartData(data, `${pkg2} (${registry})`));
520
656
  return;
521
657
  }
522
658
  json(res, data);
@@ -536,11 +672,13 @@ function createHandler(opts) {
536
672
  }
537
673
  error(res, "Not found", 404);
538
674
  } catch (e) {
539
- if (e instanceof RegistryError) {
675
+ if (e?.message === "__TIMEOUT__") {
676
+ error(res, "Gateway timeout", 504);
677
+ } else if (e instanceof RegistryError) {
540
678
  const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
541
679
  error(res, e.message, status);
542
680
  } else {
543
- error(res, e.message, 500);
681
+ error(res, "Internal server error", 500);
544
682
  }
545
683
  }
546
684
  };
@@ -548,7 +686,11 @@ function createHandler(opts) {
548
686
  function serve(opts) {
549
687
  const port = opts?.port ?? 3e3;
550
688
  const handler = createHandler({
551
- cache: opts?.cache !== false ? createCache() : void 0
689
+ cache: opts?.cache !== false ? createCache() : void 0,
690
+ corsOrigin: opts?.corsOrigin,
691
+ rateLimitMax: opts?.rateLimitMax,
692
+ rateLimitWindowSeconds: opts?.rateLimitWindowSeconds,
693
+ requestTimeoutMs: opts?.requestTimeoutMs
552
694
  });
553
695
  const server = httpCreateServer(handler);
554
696
  server.listen(port, () => {
@@ -564,6 +706,18 @@ Endpoints:`);
564
706
  }
565
707
 
566
708
  // src/index.ts
709
+ var VALID_PKG_NAME = /^[@a-zA-Z0-9][\w./@-]*$/;
710
+ function validatePackageName(pkg2, registry) {
711
+ if (!pkg2 || typeof pkg2 !== "string") {
712
+ throw new RegistryError(registry, 0, `Invalid package name: must be a non-empty string`);
713
+ }
714
+ if (pkg2.includes("..") || pkg2.includes("\\")) {
715
+ throw new RegistryError(registry, 0, `Invalid package name "${pkg2}": path traversal not allowed`);
716
+ }
717
+ if (!VALID_PKG_NAME.test(pkg2)) {
718
+ throw new RegistryError(registry, 0, `Invalid package name "${pkg2}": contains illegal characters`);
719
+ }
720
+ }
567
721
  function createCache() {
568
722
  const store = /* @__PURE__ */ new Map();
569
723
  return {
@@ -608,25 +762,26 @@ var providers = {
608
762
  docker
609
763
  };
610
764
  var DEFAULT_TTL = 3e5;
611
- async function stats(registry, pkg, options) {
765
+ async function stats(registry, pkg2, options) {
766
+ validatePackageName(pkg2, registry);
612
767
  const provider = providers[registry];
613
768
  if (!provider) {
614
769
  throw new RegistryError(registry, 0, `Unknown registry "${registry}". Use registerProvider() to add custom registries.`);
615
770
  }
616
771
  const cache = options?.cache;
617
772
  if (cache) {
618
- const key = `stats:${registry}:${pkg}`;
773
+ const key = `stats:${registry}:${pkg2}`;
619
774
  const cached = cache.get(key);
620
775
  if (cached) return cached;
621
- const result = await provider.getStats(pkg, options);
776
+ const result = await provider.getStats(pkg2, options);
622
777
  if (result) cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
623
778
  return result;
624
779
  }
625
- return provider.getStats(pkg, options);
780
+ return provider.getStats(pkg2, options);
626
781
  }
627
- stats.all = async function all(pkg, options) {
782
+ stats.all = async function all(pkg2, options) {
628
783
  const results = await Promise.allSettled(
629
- Object.values(providers).map((p) => p.getStats(pkg, options))
784
+ Object.values(providers).map((p) => p.getStats(pkg2, options))
630
785
  );
631
786
  return results.filter(
632
787
  (r) => r.status === "fulfilled" && r.value !== null
@@ -642,32 +797,32 @@ stats.bulk = async function bulk(registry, packages, options) {
642
797
  }
643
798
  const concurrency = options?.concurrency ?? 5;
644
799
  const limit = pLimit(concurrency);
645
- return Promise.all(packages.map((pkg) => limit(() => stats(registry, pkg, options))));
800
+ return Promise.all(packages.map((pkg2) => limit(() => stats(registry, pkg2, options))));
646
801
  };
647
802
  async function npmBulkStats(packages, options) {
648
803
  const scoped = [];
649
804
  const unscoped = [];
650
- for (const pkg of packages) {
651
- if (pkg.startsWith("@")) {
652
- scoped.push(pkg);
805
+ for (const pkg2 of packages) {
806
+ if (pkg2.startsWith("@")) {
807
+ scoped.push(pkg2);
653
808
  } else {
654
- unscoped.push(pkg);
809
+ unscoped.push(pkg2);
655
810
  }
656
811
  }
657
812
  const bulkMonth = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-month") : /* @__PURE__ */ new Map();
658
813
  const bulkWeek = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-week") : /* @__PURE__ */ new Map();
659
814
  const bulkDay = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-day") : /* @__PURE__ */ new Map();
660
815
  const unscopedResults = /* @__PURE__ */ new Map();
661
- for (const pkg of unscoped) {
662
- const month = bulkMonth.get(pkg);
663
- const week = bulkWeek.get(pkg);
664
- const day = bulkDay.get(pkg);
816
+ for (const pkg2 of unscoped) {
817
+ const month = bulkMonth.get(pkg2);
818
+ const week = bulkWeek.get(pkg2);
819
+ const day = bulkDay.get(pkg2);
665
820
  if (month === void 0 && week === void 0 && day === void 0) {
666
- unscopedResults.set(pkg, null);
821
+ unscopedResults.set(pkg2, null);
667
822
  } else {
668
- unscopedResults.set(pkg, {
823
+ unscopedResults.set(pkg2, {
669
824
  registry: "npm",
670
- package: pkg,
825
+ package: pkg2,
671
826
  downloads: {
672
827
  lastDay: day,
673
828
  lastWeek: week,
@@ -679,13 +834,13 @@ async function npmBulkStats(packages, options) {
679
834
  }
680
835
  const limit = pLimit(1);
681
836
  const scopedResults = await Promise.all(
682
- scoped.map((pkg) => limit(() => stats("npm", pkg, options)))
837
+ scoped.map((pkg2) => limit(() => stats("npm", pkg2, options)))
683
838
  );
684
839
  const scopedMap = /* @__PURE__ */ new Map();
685
- scoped.forEach((pkg, i) => scopedMap.set(pkg, scopedResults[i]));
686
- return packages.map((pkg) => unscopedResults.get(pkg) ?? scopedMap.get(pkg) ?? null);
840
+ scoped.forEach((pkg2, i) => scopedMap.set(pkg2, scopedResults[i]));
841
+ return packages.map((pkg2) => unscopedResults.get(pkg2) ?? scopedMap.get(pkg2) ?? null);
687
842
  }
688
- stats.range = async function range(registry, pkg, start, end, options) {
843
+ stats.range = async function range(registry, pkg2, start, end, options) {
689
844
  const provider = providers[registry];
690
845
  if (!provider) {
691
846
  throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
@@ -699,20 +854,20 @@ stats.range = async function range(registry, pkg, start, end, options) {
699
854
  }
700
855
  const cache = options?.cache;
701
856
  if (cache) {
702
- const key = `range:${registry}:${pkg}:${start}:${end}`;
857
+ const key = `range:${registry}:${pkg2}:${start}:${end}`;
703
858
  const cached = cache.get(key);
704
859
  if (cached) return cached;
705
- const result = await provider.getRange(pkg, start, end);
860
+ const result = await provider.getRange(pkg2, start, end);
706
861
  cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
707
862
  return result;
708
863
  }
709
- return provider.getRange(pkg, start, end);
864
+ return provider.getRange(pkg2, start, end);
710
865
  };
711
- stats.compare = async function compare(pkg, registries, options) {
866
+ stats.compare = async function compare(pkg2, registries, options) {
712
867
  const regs = registries ?? Object.keys(providers);
713
868
  const results = await Promise.allSettled(
714
869
  regs.map(async (reg) => {
715
- const result = await stats(reg, pkg, options);
870
+ const result = await stats(reg, pkg2, options);
716
871
  return result ? { reg, result } : null;
717
872
  })
718
873
  );
@@ -723,7 +878,7 @@ stats.compare = async function compare(pkg, registries, options) {
723
878
  }
724
879
  }
725
880
  return {
726
- package: pkg,
881
+ package: pkg2,
727
882
  registries: registryMap,
728
883
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
729
884
  };
@@ -755,6 +910,9 @@ stats.mine = async function mine(maintainer, options) {
755
910
  };
756
911
 
757
912
  // src/cli.ts
913
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
914
+ var pkg = JSON.parse(readFileSync2(join(__dirname, "..", "package.json"), "utf-8"));
915
+ var VERSION = pkg.version;
758
916
  function usage() {
759
917
  console.log(`
760
918
  Usage: registry-stats [package] [options]
@@ -771,7 +929,9 @@ Options:
771
929
  Only npm and pypi support this
772
930
  --compare Compare package across registries side-by-side
773
931
  --format Output format: table (default), json, csv, chart
932
+ --json Shorthand for --format json
774
933
  --init Create a starter registry-stats.config.json
934
+ --version, -V Show version
775
935
  --help, -h Show this help
776
936
 
777
937
  Subcommands:
@@ -902,7 +1062,8 @@ async function runConfigPackages(config, format) {
902
1062
  try {
903
1063
  const result = await stats(registry, pkgId, opts);
904
1064
  if (result) results.push(result);
905
- } catch {
1065
+ } catch (e) {
1066
+ console.error(`Warning: failed to fetch ${registry} for ${displayName}: ${e.message}`);
906
1067
  }
907
1068
  });
908
1069
  await Promise.all(fetches);
@@ -931,8 +1092,8 @@ async function runMine(maintainer, format, config) {
931
1092
  process.stderr.write(` Discovering packages for ${maintainer}...`);
932
1093
  const results = await stats.mine(maintainer, {
933
1094
  ...opts,
934
- onProgress(done, total, pkg) {
935
- process.stderr.write(`\r Fetching stats... ${done}/${total} (${pkg})${"".padEnd(20)}`);
1095
+ onProgress(done, total, pkg2) {
1096
+ process.stderr.write(`\r Fetching stats... ${done}/${total} (${pkg2})${"".padEnd(20)}`);
936
1097
  }
937
1098
  });
938
1099
  process.stderr.write("\r" + " ".repeat(80) + "\r");
@@ -948,6 +1109,10 @@ async function runMine(maintainer, format, config) {
948
1109
  }
949
1110
  async function main() {
950
1111
  const args = process.argv.slice(2);
1112
+ if (args.includes("--version") || args.includes("-V")) {
1113
+ console.log(`registry-stats ${VERSION}`);
1114
+ process.exit(0);
1115
+ }
951
1116
  if (args.includes("--help") || args.includes("-h")) {
952
1117
  usage();
953
1118
  process.exit(0);
@@ -967,17 +1132,22 @@ async function main() {
967
1132
  for (let i = 1; i < args.length; i++) {
968
1133
  if (args[i] === "--port" && args[i + 1]) {
969
1134
  port = parseInt(args[++i], 10);
1135
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
1136
+ console.error("Error: --port must be a number between 1 and 65535");
1137
+ process.exit(1);
1138
+ }
970
1139
  }
971
1140
  }
972
1141
  serve({ port });
973
1142
  return;
974
1143
  }
975
- let pkg;
1144
+ let pkg2;
976
1145
  let registry;
977
1146
  let range2;
978
1147
  let format = "table";
979
1148
  let compare2 = false;
980
1149
  let mineUser;
1150
+ const unknownFlags = [];
981
1151
  for (let i = 0; i < args.length; i++) {
982
1152
  if ((args[i] === "--registry" || args[i] === "-r") && args[i + 1]) {
983
1153
  registry = args[++i];
@@ -991,16 +1161,25 @@ async function main() {
991
1161
  compare2 = true;
992
1162
  } else if (args[i] === "--mine" && args[i + 1]) {
993
1163
  mineUser = args[++i];
994
- } else if (!args[i].startsWith("-") && !pkg) {
995
- pkg = args[i];
1164
+ } else if (!args[i].startsWith("-") && !pkg2) {
1165
+ pkg2 = args[i];
1166
+ } else if (args[i].startsWith("-")) {
1167
+ unknownFlags.push(args[i]);
996
1168
  }
997
1169
  }
1170
+ if (unknownFlags.length > 0) {
1171
+ console.error(`Warning: unknown option(s): ${unknownFlags.join(", ")}`);
1172
+ }
998
1173
  const config = loadConfig();
1174
+ if ((format === "csv" || format === "chart") && !range2) {
1175
+ console.error(`Warning: --format ${format} only produces meaningful output with --range. Falling back to table.`);
1176
+ format = "table";
1177
+ }
999
1178
  if (mineUser) {
1000
1179
  await runMine(mineUser, format, config);
1001
1180
  return;
1002
1181
  }
1003
- if (!pkg) {
1182
+ if (!pkg2) {
1004
1183
  if (!config) {
1005
1184
  usage();
1006
1185
  process.exit(0);
@@ -1012,7 +1191,7 @@ async function main() {
1012
1191
  try {
1013
1192
  if (compare2) {
1014
1193
  const registries = registry ? [registry] : void 0;
1015
- const result = await stats.compare(pkg, registries, opts);
1194
+ const result = await stats.compare(pkg2, registries, opts);
1016
1195
  if (format === "json") {
1017
1196
  console.log(JSON.stringify(result, null, 2));
1018
1197
  } else {
@@ -1027,18 +1206,18 @@ async function main() {
1027
1206
  console.error("Error: --range must be start:end (e.g. 2025-01-01:2025-06-30)");
1028
1207
  process.exit(1);
1029
1208
  }
1030
- const data = await stats.range(reg, pkg, start, end, opts);
1209
+ const data = await stats.range(reg, pkg2, start, end, opts);
1031
1210
  if (format === "json") {
1032
1211
  console.log(JSON.stringify(data, null, 2));
1033
1212
  } else if (format === "csv") {
1034
1213
  console.log(calc.toCSV(data));
1035
1214
  } else if (format === "chart") {
1036
- console.log(JSON.stringify(calc.toChartData(data, `${pkg} (${reg})`), null, 2));
1215
+ console.log(JSON.stringify(calc.toChartData(data, `${pkg2} (${reg})`), null, 2));
1037
1216
  } else {
1038
1217
  const monthly = calc.groupTotals(calc.monthly(data));
1039
1218
  const t = calc.trend(data);
1040
1219
  console.log(`
1041
- ${pkg} (${reg}) \u2014 ${start} to ${end}
1220
+ ${pkg2} (${reg}) \u2014 ${start} to ${end}
1042
1221
  `);
1043
1222
  for (const [month, total] of Object.entries(monthly)) {
1044
1223
  console.log(` ${month} ${formatNumber(total)}`);
@@ -1047,9 +1226,9 @@ ${pkg} (${reg}) \u2014 ${start} to ${end}
1047
1226
  Total: ${formatNumber(calc.total(data))} Avg/day: ${formatNumber(Math.round(calc.avg(data)))} Trend: ${t.direction} (${t.changePercent > 0 ? "+" : ""}${t.changePercent}%)`);
1048
1227
  }
1049
1228
  } else if (registry) {
1050
- const result = await stats(registry, pkg, opts);
1229
+ const result = await stats(registry, pkg2, opts);
1051
1230
  if (!result) {
1052
- console.error(`Package "${pkg}" not found on ${registry}`);
1231
+ console.error(`Package "${pkg2}" not found on ${registry}`);
1053
1232
  process.exit(1);
1054
1233
  }
1055
1234
  if (format === "json") {
@@ -1059,9 +1238,9 @@ ${pkg} (${reg}) \u2014 ${start} to ${end}
1059
1238
  printStats(result);
1060
1239
  }
1061
1240
  } else {
1062
- const results = await stats.all(pkg, opts);
1241
+ const results = await stats.all(pkg2, opts);
1063
1242
  if (results.length === 0) {
1064
- console.error(`Package "${pkg}" not found on any registry`);
1243
+ console.error(`Package "${pkg2}" not found on any registry`);
1065
1244
  process.exit(1);
1066
1245
  }
1067
1246
  if (format === "json") {
@@ -1079,4 +1258,7 @@ ${pkg} (${reg}) \u2014 ${start} to ${end}
1079
1258
  process.exit(1);
1080
1259
  }
1081
1260
  }
1082
- main();
1261
+ main().catch((e) => {
1262
+ console.error(`Error: ${e.message}`);
1263
+ process.exit(1);
1264
+ });