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