statswhatshesaid 0.1.0

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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/bots.ts","../src/config.ts","../src/identity.ts","../src/endpoint.ts","../src/snapshot.ts","../src/hll.ts","../src/store.ts","../src/lifecycle.ts"],"sourcesContent":["import { createMiddleware, trackRequest, type StatsMiddleware } from './middleware.js'\nimport type { StatsOptions } from './types.js'\n\nconst stats = {\n middleware: (options?: StatsOptions): StatsMiddleware => createMiddleware(options),\n track: trackRequest,\n}\n\nexport default stats\nexport type { StatsOptions, StatsResponseBody, DailyCount } from './types.js'\nexport type { StatsMiddleware } from './middleware.js'\nexport type { PersistAdapter, SnapshotV1 } from './snapshot.js'\nexport { FileSnapshotAdapter } from './snapshot.js'\n","import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { isBot } from './bots.js'\nimport { resolveConfig } from './config.js'\nimport { extractIp } from './identity.js'\nimport { handleStatsEndpoint } from './endpoint.js'\nimport { getOrInitRuntime, type StatsRuntime } from './lifecycle.js'\nimport type { StatsOptions } from './types.js'\n\nexport type StatsMiddleware = (req: NextRequest) => NextResponse | Promise<NextResponse>\n\nexport function createMiddleware(options: StatsOptions = {}): StatsMiddleware {\n // Resolution is deferred to first request so a missing STATS_TOKEN doesn't\n // break `next build`. We memoize the resolved config in a closure.\n let resolved: ReturnType<typeof resolveConfig> | null = null\n\n return function statsMiddleware(req: NextRequest): NextResponse {\n if (!resolved) resolved = resolveConfig(options)\n const runtime = getOrInitRuntime(resolved)\n\n // Stats endpoint short-circuit — don't track a visit to the dashboard.\n if (req.nextUrl.pathname === resolved.endpointPath) {\n return handleStatsEndpoint(req, runtime)\n }\n\n trackRequestInternal(req, runtime)\n return NextResponse.next()\n }\n}\n\n/**\n * Standalone tracker for users who can't put the library in middleware.\n * Call from `instrumentation.ts` or a route handler.\n */\nexport function trackRequest(req: NextRequest, options: StatsOptions = {}): void {\n const config = resolveConfig(options)\n const runtime = getOrInitRuntime(config)\n trackRequestInternal(req, runtime)\n}\n\n/**\n * Max number of User-Agent bytes we feed into the visitor hash. Node's HTTP\n * parser already caps header size at ~16 KB, but we truncate defensively so\n * an oversized UA can't cause per-request CPU blow-up.\n */\nconst MAX_UA_LENGTH = 512\n\nfunction trackRequestInternal(req: NextRequest, runtime: StatsRuntime): void {\n const rawUa = req.headers.get('user-agent') ?? ''\n // Truncate BEFORE the bot filter so a 10 KB UA with \"bot\" on the far right\n // is still filtered — the regex only needs to see the prefix.\n const ua = rawUa.length > MAX_UA_LENGTH ? rawUa.slice(0, MAX_UA_LENGTH) : rawUa\n if (runtime.config.filterBots && isBot(ua)) return\n\n const ip = extractIp(req.headers, runtime.config.trustProxy)\n runtime.store.track(ip, ua)\n}\n","export const BOT_UA_RE =\n /bot|crawler|spider|crawling|facebookexternalhit|slurp|mediapartners|ahrefs|semrush|bingpreview|headlesschrome|lighthouse|curl|wget|python-requests|node-fetch|axios|httpclient|java\\//i\n\nexport function isBot(ua: string | null | undefined): boolean {\n if (!ua) return true\n return BOT_UA_RE.test(ua)\n}\n","import type { ResolvedConfig, StatsOptions } from './types.js'\n\nconst DEFAULT_SNAPSHOT_PATH = './.statswhatshesaid.json'\nconst DEFAULT_FLUSH_INTERVAL_MS = 60 * 60 * 1000 // 1 hour\nconst DEFAULT_ENDPOINT_PATH = '/stats'\nconst DEFAULT_HISTORY_DAYS = 90\nconst DEFAULT_MAX_HISTORY_DAYS = 365\nconst DEFAULT_TRUST_PROXY = 1\nconst MIN_RECOMMENDED_TOKEN_LENGTH = 32\nconst MIN_FLUSH_INTERVAL_MS = 1000\n// Match a conservative subset of path-safe characters. No CR/LF, spaces,\n// or shell metacharacters — this is compared against `req.nextUrl.pathname`\n// which is already URL-decoded, so we don't need to allow percent-escapes.\nconst ENDPOINT_PATH_RE = /^\\/[A-Za-z0-9\\-._~/]*$/\nlet weakTokenWarned = false\n\nexport function resolveConfig(options: StatsOptions = {}): ResolvedConfig {\n const env = typeof process !== 'undefined' ? process.env : ({} as NodeJS.ProcessEnv)\n\n const token = options.token ?? env.STATS_TOKEN\n if (!token) {\n throw new Error(\n '[statswhatshesaid] Missing required token. Set the STATS_TOKEN env var or pass `token` to stats.middleware({ token }).',\n )\n }\n // Warn (not throw) if the token is short enough to brute-force.\n // This is advisory — the user may have picked a memorable token on\n // purpose so they can check stats from anywhere without a keychain.\n if (!weakTokenWarned && token.length < MIN_RECOMMENDED_TOKEN_LENGTH) {\n weakTokenWarned = true\n // eslint-disable-next-line no-console\n console.warn(\n `[statswhatshesaid] Warning: the stats token is shorter than ${MIN_RECOMMENDED_TOKEN_LENGTH} characters (${token.length}). ` +\n \"Short tokens are vulnerable to brute-force attacks against the /stats endpoint. \" +\n \"Consider generating a strong token with: `openssl rand -hex 32`. \" +\n \"You can also rate-limit /stats at your reverse proxy or CDN.\",\n )\n }\n\n const snapshotPath =\n options.snapshotPath ?? env.STATS_SNAPSHOT_PATH ?? DEFAULT_SNAPSHOT_PATH\n\n const flushIntervalMs =\n options.flushIntervalMs ??\n parseIntOr(env.STATS_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_INTERVAL_MS)\n requirePositiveInt(flushIntervalMs, 'flushIntervalMs')\n if (flushIntervalMs < MIN_FLUSH_INTERVAL_MS) {\n throw new Error(\n `[statswhatshesaid] flushIntervalMs must be at least ${MIN_FLUSH_INTERVAL_MS} ms to avoid hammering the persist layer; got ${flushIntervalMs}.`,\n )\n }\n\n const rawEndpointPath =\n options.endpointPath ?? env.STATS_ENDPOINT_PATH ?? DEFAULT_ENDPOINT_PATH\n const endpointPath = normalizePath(rawEndpointPath)\n if (!ENDPOINT_PATH_RE.test(endpointPath)) {\n throw new Error(\n `[statswhatshesaid] Invalid endpointPath: ${JSON.stringify(rawEndpointPath)}. Must match /^\\\\/[A-Za-z0-9\\\\-._~/]*$/.`,\n )\n }\n\n const historyDays = options.historyDays ?? DEFAULT_HISTORY_DAYS\n requireNonNegativeInt(historyDays, 'historyDays')\n const maxHistoryDays = options.maxHistoryDays ?? DEFAULT_MAX_HISTORY_DAYS\n requireNonNegativeInt(maxHistoryDays, 'maxHistoryDays')\n const filterBots = options.filterBots ?? true\n const persist = options.persist ?? null\n\n const rawTrustProxy =\n options.trustProxy ?? parseIntOr(env.STATS_TRUST_PROXY, DEFAULT_TRUST_PROXY, true)\n if (!Number.isInteger(rawTrustProxy) || rawTrustProxy < 0) {\n throw new Error(\n `[statswhatshesaid] Invalid trustProxy value: ${rawTrustProxy}. Must be a non-negative integer (0, 1, 2, ...).`,\n )\n }\n\n return {\n token,\n snapshotPath,\n persist,\n flushIntervalMs,\n endpointPath,\n historyDays,\n maxHistoryDays,\n filterBots,\n trustProxy: rawTrustProxy,\n }\n}\n\nfunction requirePositiveInt(value: number, name: string): void {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(\n `[statswhatshesaid] ${name} must be a positive integer; got ${value}.`,\n )\n }\n}\n\nfunction requireNonNegativeInt(value: number, name: string): void {\n if (!Number.isInteger(value) || value < 0) {\n throw new Error(\n `[statswhatshesaid] ${name} must be a non-negative integer; got ${value}.`,\n )\n }\n}\n\nfunction parseIntOr(\n value: string | undefined,\n fallback: number,\n allowZero = false,\n): number {\n if (!value) return fallback\n const n = Number.parseInt(value, 10)\n if (!Number.isFinite(n)) return fallback\n if (allowZero ? n < 0 : n <= 0) return fallback\n return n\n}\n\nfunction normalizePath(p: string): string {\n if (!p.startsWith('/')) return `/${p}`\n return p\n}\n","import { createHash, randomBytes } from 'node:crypto'\n\n/**\n * Stateless identity helpers. Salt lifetime is owned by the runtime in\n * `lifecycle.ts` so it can be persisted in the snapshot file and swapped\n * atomically at the UTC-midnight rollover.\n */\n\nexport function utcDateString(d: Date): string {\n return d.toISOString().slice(0, 10)\n}\n\nconst DATE_RE = /^(\\d{4})-(\\d{2})-(\\d{2})$/\n\n/**\n * True iff `s` is a real UTC calendar date in `YYYY-MM-DD` form. Rejects\n * structurally-valid but calendrically-impossible dates like `2026-02-30`\n * by round-tripping through `Date.UTC`.\n */\nexport function isValidUtcDate(s: string): boolean {\n const m = DATE_RE.exec(s)\n if (!m) return false\n const year = Number(m[1])\n const month = Number(m[2])\n const day = Number(m[3])\n const d = new Date(Date.UTC(year, month - 1, day))\n return (\n d.getUTCFullYear() === year &&\n d.getUTCMonth() === month - 1 &&\n d.getUTCDate() === day\n )\n}\n\n/** Required number of bytes in a daily salt. */\nexport const SALT_BYTES = 32\n\nexport function generateSalt(): Buffer {\n return randomBytes(SALT_BYTES)\n}\n\n/** Peer identifier used when no trusted IP is available. */\nexport const UNKNOWN_PEER = '0.0.0.0'\n\n/**\n * Resolve the client IP from the X-Forwarded-For chain, walking from the\n * RIGHT (server side) of the chain inward, skipping `trustProxy - 1` trusted\n * proxy hops. Returns the first \"untrusted\" entry as the client IP.\n *\n * This is the only safe way to consume XFF: the leftmost entry in the chain\n * is whatever the client sent originally, which is attacker-controlled\n * unless every proxy in front explicitly strips incoming XFF headers.\n *\n * Semantics:\n * - `trustProxy === 0` — never read forwarding headers. All requests\n * collapse to a single constant peer. Safest but breaks visitor\n * counting when the process is behind any kind of proxy.\n * - `trustProxy === N` — pick the Nth entry from the RIGHT of the XFF\n * chain (1-indexed). If the chain is shorter than N, fall back to the\n * constant peer (we can't safely identify the client).\n *\n * Examples with `trustProxy = 1` (default, single trusted proxy in front):\n * XFF: \"1.1.1.1\" → \"1.1.1.1\" (genuine)\n * XFF: \"9.9.9.9, 1.1.1.1\" → \"1.1.1.1\" (attacker forged 9.9.9.9)\n * XFF: (absent) → \"0.0.0.0\" (can't identify)\n *\n * With `trustProxy = 2` (e.g. Cloudflare → nginx → app):\n * XFF: \"1.1.1.1, 2.2.2.2\" → \"1.1.1.1\"\n * XFF: \"9.9.9.9, 1.1.1.1, 2.2.2.2\" → \"1.1.1.1\"\n */\nexport function extractIp(headers: Headers, trustProxy: number): string {\n if (trustProxy < 1) return UNKNOWN_PEER\n\n const xff = headers.get('x-forwarded-for')\n if (!xff) return UNKNOWN_PEER\n\n const entries = xff\n .split(',')\n .map((s) => s.trim())\n .filter((s) => s.length > 0)\n\n if (entries.length < trustProxy) return UNKNOWN_PEER\n\n // Nth-from-right, 1-indexed. trustProxy=1 → entries[length-1], etc.\n return entries[entries.length - trustProxy]!\n}\n\n/**\n * Hash a visitor tuple with the day's salt. Returns the full 32-byte SHA-256\n * digest; callers that only need 64 bits (the HLL) can slice.\n *\n * Length-prefixing: each variable-length component (ip, ua) is preceded by\n * its length as a 4-byte big-endian integer. This makes the pre-image\n * unambiguous — no two distinct `(ip, ua)` pairs can produce the same byte\n * sequence fed into SHA-256. A naive `ip + \":\" + ua` encoding would allow\n * pairs like `(\"1::2\", \"foo\")` and `(\"1\", \":2:foo\")` to collide because of\n * the embedded colons in IPv6 addresses.\n */\nexport function computeVisitorHash(ip: string, ua: string, salt: Buffer): Buffer {\n const ipBuf = Buffer.from(ip, 'utf8')\n const uaBuf = Buffer.from(ua, 'utf8')\n const lenBuf = Buffer.alloc(8)\n lenBuf.writeUInt32BE(ipBuf.length, 0)\n lenBuf.writeUInt32BE(uaBuf.length, 4)\n return createHash('sha256')\n .update(lenBuf)\n .update(ipBuf)\n .update(uaBuf)\n .update(salt)\n .digest()\n}\n\n/**\n * Schedule a one-shot timer for the next UTC midnight (+1s buffer), which on\n * fire chains itself via setInterval every 24h. Returns a cancel function.\n * Callers are responsible for deciding what to do on the rollover — this\n * module doesn't own any state.\n */\nexport function scheduleMidnightTimer(\n onMidnight: () => void,\n now: Date = new Date(),\n): () => void {\n let timer: ReturnType<typeof setTimeout> | null = null\n let interval: ReturnType<typeof setInterval> | null = null\n\n const msUntilNext = msUntilUtcMidnight(now) + 1000\n\n timer = setTimeout(() => {\n try {\n onMidnight()\n } catch {\n /* never propagate out of a timer callback */\n }\n interval = setInterval(\n () => {\n try {\n onMidnight()\n } catch {\n /* swallow */\n }\n },\n 24 * 60 * 60 * 1000,\n )\n interval.unref?.()\n }, msUntilNext)\n timer.unref?.()\n\n return () => {\n if (timer) clearTimeout(timer)\n if (interval) clearInterval(interval)\n timer = null\n interval = null\n }\n}\n\nfunction msUntilUtcMidnight(now: Date): number {\n const next = Date.UTC(\n now.getUTCFullYear(),\n now.getUTCMonth(),\n now.getUTCDate() + 1,\n 0,\n 0,\n 0,\n 0,\n )\n return next - now.getTime()\n}\n","import { createHash, timingSafeEqual } from 'node:crypto'\nimport { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport type { StatsRuntime } from './lifecycle.js'\nimport type { StatsResponseBody } from './types.js'\n\nexport function handleStatsEndpoint(req: NextRequest, runtime: StatsRuntime): NextResponse {\n const provided = extractAuthToken(req)\n if (!provided || !constantTimeEqual(provided, runtime.config.token)) {\n return new NextResponse('Unauthorized', { status: 401 })\n }\n\n // Make sure \"today\" in the response always reflects the current UTC day,\n // even if the rollover timer has drifted (e.g. the machine was asleep).\n runtime.store.rollOverIfNeeded()\n\n const body: StatsResponseBody = {\n today: {\n date: runtime.store.today,\n uniqueVisitors: runtime.store.estimateToday(),\n },\n history: runtime.store.getHistoryDesc(runtime.config.historyDays),\n generatedAt: new Date().toISOString(),\n }\n return NextResponse.json(body, {\n headers: { 'cache-control': 'no-store' },\n })\n}\n\n/**\n * Accept the token via either:\n * - `Authorization: Bearer <token>` header (preferred for production —\n * does not leak to server access logs or browser history)\n * - `?t=<token>` query string (convenient for ad-hoc browser checks)\n *\n * The Authorization header wins if both are present.\n */\nfunction extractAuthToken(req: NextRequest): string | null {\n const auth = req.headers.get('authorization')\n if (auth) {\n const match = /^Bearer\\s+(\\S+)\\s*$/i.exec(auth)\n if (match) return match[1]!\n }\n return req.nextUrl.searchParams.get('t')\n}\n\n/**\n * Constant-time string comparison that does NOT leak the length of either\n * input. We prehash both sides so `timingSafeEqual` always runs over two\n * 32-byte buffers regardless of the original token length.\n */\nfunction constantTimeEqual(a: string, b: string): boolean {\n const ah = createHash('sha256').update(a, 'utf8').digest()\n const bh = createHash('sha256').update(b, 'utf8').digest()\n return timingSafeEqual(ah, bh)\n}\n","import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'\nimport { dirname } from 'node:path'\n\nimport { HLL_REGISTER_COUNT } from './hll.js'\nimport { isValidUtcDate } from './identity.js'\n\n/**\n * Versioned on-disk representation of the entire store.\n *\n * Size budget (base64-encoded):\n * - salt: 32 B → ~44 chars\n * - hllRegisters: 16 KB → ~22 KB of base64\n * - history: ~20 B per day\n *\n * Entire file stays ≤ ~30 KB even after years of operation.\n */\nexport interface SnapshotV1 {\n version: 1\n /** UTC date (YYYY-MM-DD) that the HLL registers currently belong to. */\n today: string\n /** Base64-encoded 32-byte daily salt. Rotated at UTC midnight. */\n salt: string\n /** Base64-encoded 16384-byte HLL register array. */\n hllRegisters: string\n /** Finalized historical daily counts, keyed by UTC date. */\n history: Record<string, number>\n}\n\n/**\n * Pluggable persistence. Synchronous on purpose: the default file adapter\n * is sync (so it can run in the SIGTERM shutdown path), and the data is\n * small enough that any reasonable backend can be wrapped synchronously.\n *\n * If you need to hand this off to an async store (Redis, KV, S3), wrap your\n * client in a thin adapter that blocks during load at startup and best-effort\n * fires-and-forgets during save. For most self-hosted use cases the default\n * file adapter is what you want.\n */\nexport interface PersistAdapter {\n load(): SnapshotV1 | null\n save(snap: SnapshotV1): void\n}\n\n/** Atomic-rename JSON file adapter. Zero dependencies. */\nexport class FileSnapshotAdapter implements PersistAdapter {\n private readonly path: string\n\n constructor(path: string) {\n this.path = path\n mkdirSync(dirname(path), { recursive: true })\n }\n\n load(): SnapshotV1 | null {\n let text: string\n try {\n text = readFileSync(this.path, 'utf8')\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null\n throw err\n }\n\n let parsed: unknown\n try {\n parsed = JSON.parse(text)\n } catch {\n // Corrupt file — treat as no snapshot. Caller will create a fresh one\n // and overwrite on the next flush.\n return null\n }\n\n if (!isValidSnapshot(parsed)) return null\n return parsed\n }\n\n save(snap: SnapshotV1): void {\n const tmp = `${this.path}.tmp`\n // mode 0o600 so the snapshot (which contains the current day's salt\n // — a secret that would make hashes linkable back to their source\n // tuples) is not world-readable. The file is rename-replaced on\n // every flush, so the mode needs to be set on each write.\n writeFileSync(tmp, JSON.stringify(snap), { mode: 0o600 })\n renameSync(tmp, this.path)\n }\n}\n\n/**\n * Cheap structural validation. Does NOT verify that base64 fields decode to\n * the expected byte counts — that's `VisitorStore.fromSnapshot`'s job, where\n * we can wrap the work in try/catch and fall back gracefully. This function\n * only rejects inputs that are obviously not a v1 snapshot.\n */\nfunction isValidSnapshot(x: unknown): x is SnapshotV1 {\n if (!x || typeof x !== 'object') return false\n const o = x as Record<string, unknown>\n if (o.version !== 1) return false\n if (typeof o.today !== 'string' || !isValidUtcDate(o.today)) return false\n if (typeof o.salt !== 'string') return false\n if (typeof o.hllRegisters !== 'string') return false\n // Reject arrays: typeof [] === 'object', which would otherwise pass.\n if (\n typeof o.history !== 'object' ||\n o.history === null ||\n Array.isArray(o.history)\n ) {\n return false\n }\n\n // Sanity-check the base64 string length for the register array.\n // (The exact decoded byte count is re-verified in fromSnapshot.)\n const expectedBase64 = Math.ceil(HLL_REGISTER_COUNT / 3) * 4\n if (o.hllRegisters.length !== expectedBase64) return false\n\n return true\n}\n","/**\n * Pure-JS HyperLogLog sketch for cardinality estimation.\n *\n * Parameters:\n * - p = 14 (precision)\n * - m = 2^14 = 16384 registers (one byte each → 16 KB fixed footprint)\n * - Expected standard error ≈ 1.04 / sqrt(m) ≈ 0.81%\n *\n * The input is the first 8 bytes of a pre-computed hash (we use SHA-256 in\n * `identity.ts`, so we have plenty of bits to work with). The top `P` bits\n * select a register; the remaining `64 - P = 50` bits are scanned for their\n * leading-zero rank.\n *\n * Reference: Flajolet et al., \"HyperLogLog: the analysis of a near-optimal\n * cardinality estimation algorithm\" (2007).\n */\n\nconst P = 14\nexport const HLL_PRECISION = P\nexport const HLL_REGISTER_COUNT = 1 << P // 16384\nconst TAIL_HIGH_BITS = 32 - P // 18\nconst TAIL_HIGH_MASK = (1 << TAIL_HIGH_BITS) - 1 // 0x3FFFF\nconst TAIL_TOTAL_BITS = 64 - P // 50\nconst MAX_RANK = TAIL_TOTAL_BITS + 1 // 51\n\n/**\n * Hand-tuned alpha constant per the HLL paper.\n * For m ≥ 128 the formula below is accurate; our m is always 16384.\n */\nconst ALPHA_M = 0.7213 / (1 + 1.079 / HLL_REGISTER_COUNT)\n\nexport class HyperLogLog {\n readonly registers: Uint8Array\n\n constructor(registers?: Uint8Array) {\n if (registers) {\n if (registers.length !== HLL_REGISTER_COUNT) {\n throw new Error(\n `[statswhatshesaid] HLL registers must be ${HLL_REGISTER_COUNT} bytes, got ${registers.length}`,\n )\n }\n // Take ownership of a copy so external mutation can't corrupt us.\n this.registers = new Uint8Array(registers)\n } else {\n this.registers = new Uint8Array(HLL_REGISTER_COUNT)\n }\n }\n\n /**\n * Add a 64-bit hash (the first 8 bytes of a larger buffer are fine) to the\n * sketch. This is the only mutating call on the hot path.\n */\n addHashBuffer(buf: Buffer): void {\n // Big-endian view of the first 8 bytes.\n const first = buf.readUInt32BE(0)\n const second = buf.readUInt32BE(4)\n\n // Top P=14 bits of the 64-bit hash → register index.\n const idx = first >>> TAIL_HIGH_BITS\n\n // Leading-zero rank of the remaining 50 bits, +1.\n const tailHigh = first & TAIL_HIGH_MASK // 18 bits\n let rank: number\n if (tailHigh !== 0) {\n // clz32 on an 18-bit value returns (14 + leadingZerosIn18BitView),\n // so subtracting 14 gives the 18-bit leading zero count, and +1\n // converts it to the 1-indexed rank.\n rank = Math.clz32(tailHigh) - 14 + 1\n } else if (second !== 0) {\n // All 18 high tail bits were zero; continue in the next 32 bits.\n rank = 18 + Math.clz32(second) + 1\n } else {\n // All 50 tail bits are zero.\n rank = MAX_RANK\n }\n\n if (rank > this.registers[idx]!) {\n this.registers[idx] = rank\n }\n }\n\n /**\n * Estimated number of distinct items inserted.\n * Applies the linear-counting correction for small cardinalities.\n */\n estimate(): number {\n const m = HLL_REGISTER_COUNT\n let sum = 0\n let zeros = 0\n for (let i = 0; i < m; i++) {\n const r = this.registers[i]!\n sum += 2 ** -r\n if (r === 0) zeros++\n }\n let estimate = (ALPHA_M * m * m) / sum\n // Small-range correction: linear counting is more accurate when the\n // raw estimate drops below ~2.5m and we still have empty registers.\n if (estimate <= 2.5 * m && zeros > 0) {\n estimate = m * Math.log(m / zeros)\n }\n return Math.round(estimate)\n }\n\n /** Deep copy the register array for serialization. */\n cloneRegisters(): Uint8Array {\n return new Uint8Array(this.registers)\n }\n\n static fromRegisters(registers: Uint8Array): HyperLogLog {\n return new HyperLogLog(registers)\n }\n}\n","import {\n computeVisitorHash,\n generateSalt,\n isValidUtcDate,\n SALT_BYTES,\n utcDateString,\n} from './identity.js'\nimport { HLL_REGISTER_COUNT, HyperLogLog } from './hll.js'\nimport type { SnapshotV1 } from './snapshot.js'\nimport type { DailyCount } from './types.js'\n\n/**\n * Owns the live state that `/stats` reads from: today's HLL sketch, today's\n * salt, and finalized historical daily counts.\n *\n * All mutating operations are synchronous and allocation-light so they can be\n * called from the middleware hot path.\n */\nexport class VisitorStore {\n private _today: string\n private _salt: Buffer\n private _hll: HyperLogLog\n private _history: Map<string, number>\n private _dirty: boolean\n\n private constructor(args: {\n today: string\n salt: Buffer\n hll: HyperLogLog\n history: Map<string, number>\n dirty: boolean\n }) {\n this._today = args.today\n this._salt = args.salt\n this._hll = args.hll\n this._history = args.history\n this._dirty = args.dirty\n }\n\n static fresh(today: string): VisitorStore {\n return new VisitorStore({\n today,\n salt: generateSalt(),\n hll: new HyperLogLog(),\n history: new Map(),\n dirty: true,\n })\n }\n\n /**\n * Build a store from a persisted snapshot. If the snapshot's `today` no\n * longer matches the current UTC date, the snapshot's HLL is finalized into\n * history and a fresh HLL + salt is created for `currentDate`.\n *\n * This path is the main \"untrusted JSON\" boundary — defensive at every\n * step. Any decode/validation failure degrades gracefully: we keep what\n * we can of history and start today fresh.\n */\n static fromSnapshot(snap: SnapshotV1, currentDate: string): VisitorStore {\n const history = sanitizeHistory(snap.history, currentDate)\n\n if (snap.today === currentDate) {\n // Same-day restore: try to recover the salt + HLL registers so that\n // a returning visitor within the same UTC day doesn't get double-\n // counted. On ANY failure, start fresh — dropping a few minutes of\n // deduped state is better than crashing the app.\n try {\n const salt = decodeSalt(snap.salt)\n const registers = decodeRegisters(snap.hllRegisters)\n const hll = new HyperLogLog(registers)\n return new VisitorStore({\n today: currentDate,\n salt,\n hll,\n history,\n dirty: false,\n })\n } catch {\n return new VisitorStore({\n today: currentDate,\n salt: generateSalt(),\n hll: new HyperLogLog(),\n history,\n dirty: true,\n })\n }\n }\n\n // Day boundary passed while the process was down. Finalize the old\n // sketch's estimate into history, then start today fresh.\n try {\n const registers = decodeRegisters(snap.hllRegisters)\n const oldHll = new HyperLogLog(registers)\n history.set(snap.today, oldHll.estimate())\n } catch {\n /* ignore bad registers; we'd rather lose one day than crash */\n }\n\n return new VisitorStore({\n today: currentDate,\n salt: generateSalt(),\n hll: new HyperLogLog(),\n history,\n dirty: true,\n })\n }\n\n get today(): string {\n return this._today\n }\n\n get dirty(): boolean {\n return this._dirty\n }\n\n /** Estimated unique visitors so far today. */\n estimateToday(): number {\n return this._hll.estimate()\n }\n\n /** Hot path. */\n track(ip: string, ua: string): void {\n const hash = computeVisitorHash(ip, ua, this._salt)\n this._hll.addHashBuffer(hash)\n this._dirty = true\n }\n\n /**\n * If the current UTC date has moved past `this._today`, finalize the\n * previous day into history and start a fresh HLL + salt for the new day.\n * Returns true if a rollover happened.\n */\n rollOverIfNeeded(now: Date = new Date()): boolean {\n const current = utcDateString(now)\n if (current === this._today) return false\n\n this._history.set(this._today, this._hll.estimate())\n this._today = current\n this._salt = generateSalt()\n this._hll = new HyperLogLog()\n this._dirty = true\n return true\n }\n\n /** Drop history entries older than `maxDays` days from today (inclusive). */\n trimHistory(maxDays: number): void {\n if (maxDays <= 0) return\n if (this._history.size <= maxDays) return\n const sortedDesc = [...this._history.keys()].sort().reverse()\n for (let i = maxDays; i < sortedDesc.length; i++) {\n this._history.delete(sortedDesc[i]!)\n }\n this._dirty = true\n }\n\n /** History (excluding today) in descending date order, capped at `limit`. */\n getHistoryDesc(limit: number): DailyCount[] {\n const rows: DailyCount[] = []\n for (const [date, count] of this._history) {\n if (date === this._today) continue\n rows.push({ date, uniqueVisitors: count })\n }\n rows.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))\n return rows.slice(0, limit)\n }\n\n toSnapshot(): SnapshotV1 {\n return {\n version: 1,\n today: this._today,\n salt: this._salt.toString('base64'),\n hllRegisters: Buffer.from(this._hll.cloneRegisters()).toString('base64'),\n history: Object.fromEntries(this._history),\n }\n }\n\n markClean(): void {\n this._dirty = false\n }\n}\n\n/**\n * Decode and validate a base64 salt field. Throws if it doesn't decode to\n * exactly `SALT_BYTES` bytes. Callers should catch and fall back.\n */\nfunction decodeSalt(saltBase64: string): Buffer {\n const salt = Buffer.from(saltBase64, 'base64')\n if (salt.length !== SALT_BYTES) {\n throw new Error(\n `invalid snapshot salt: expected ${SALT_BYTES} bytes, got ${salt.length}`,\n )\n }\n return salt\n}\n\n/**\n * Decode and validate a base64 HLL register array. Throws if it doesn't\n * decode to exactly `HLL_REGISTER_COUNT` bytes. `Buffer.from(x, 'base64')`\n * is lenient and silently ignores malformed characters, so we can't rely on\n * the base64 string length alone — the decoded byte count is the real\n * invariant the HLL constructor cares about.\n */\nfunction decodeRegisters(registersBase64: string): Uint8Array {\n const buf = Buffer.from(registersBase64, 'base64')\n if (buf.length !== HLL_REGISTER_COUNT) {\n throw new Error(\n `invalid snapshot registers: expected ${HLL_REGISTER_COUNT} bytes, got ${buf.length}`,\n )\n }\n return new Uint8Array(buf)\n}\n\n/**\n * Filter a raw `history` object from an untrusted snapshot down to a clean\n * Map, dropping any entry that isn't a real `YYYY-MM-DD` date key mapped\n * to a non-negative integer. Also drops an entry matching `currentDate` —\n * today's count is owned by the live HLL, not history.\n */\nfunction sanitizeHistory(\n raw: Record<string, number>,\n currentDate: string,\n): Map<string, number> {\n const out = new Map<string, number>()\n for (const [date, count] of Object.entries(raw)) {\n if (!isValidUtcDate(date)) continue\n if (date === currentDate) continue\n if (typeof count !== 'number') continue\n if (!Number.isFinite(count) || !Number.isInteger(count)) continue\n if (count < 0) continue\n out.set(date, count)\n }\n return out\n}\n","import { scheduleMidnightTimer, utcDateString } from './identity.js'\nimport { FileSnapshotAdapter, type PersistAdapter } from './snapshot.js'\nimport { VisitorStore } from './store.js'\nimport type { ResolvedConfig } from './types.js'\n\nexport interface StatsRuntime {\n config: ResolvedConfig\n store: VisitorStore\n persist: PersistAdapter\n /** Force a flush of the in-memory state to the snapshot. */\n flush: () => void\n /** Tear everything down. Idempotent. */\n shutdown: () => void\n}\n\ndeclare global {\n // eslint-disable-next-line no-var\n var __statswhatshesaid__: StatsRuntime | undefined\n}\n\n/**\n * Returns the singleton runtime, lazily creating it on first call. Stored on\n * `globalThis` so Next dev-mode HMR doesn't open multiple file handles or\n * duplicate timers.\n */\nexport function getOrInitRuntime(config: ResolvedConfig): StatsRuntime {\n if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__\n\n assertNodeRuntime()\n\n const persist: PersistAdapter =\n config.persist ?? new FileSnapshotAdapter(config.snapshotPath)\n\n const today = utcDateString(new Date())\n const loaded = safeLoad(persist)\n const store = loaded\n ? VisitorStore.fromSnapshot(loaded, today)\n : VisitorStore.fresh(today)\n store.trimHistory(config.maxHistoryDays)\n\n let shuttingDown = false\n let flushTimer: ReturnType<typeof setInterval> | null = null\n let cancelMidnight: (() => void) | null = null\n\n const flush = (): void => {\n if (!store.dirty) return\n try {\n persist.save(store.toSnapshot())\n store.markClean()\n } catch (err) {\n // Never let a flush error take down the process.\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] flush failed:', err)\n }\n }\n\n const tick = (): void => {\n try {\n if (store.rollOverIfNeeded()) {\n store.trimHistory(config.maxHistoryDays)\n }\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] rollover failed:', err)\n }\n flush()\n }\n\n const shutdown = (): void => {\n if (shuttingDown) return\n shuttingDown = true\n if (flushTimer) clearInterval(flushTimer)\n if (cancelMidnight) cancelMidnight()\n // Remove our process signal handlers so they don't leak across\n // repeated init/shutdown cycles (e.g. test suites, dev-mode HMR).\n process.removeListener('SIGTERM', shutdown)\n process.removeListener('SIGINT', shutdown)\n process.removeListener('beforeExit', shutdown)\n try {\n flush()\n } catch {\n /* swallow */\n }\n try {\n if (config.persist == null) {\n // Nothing to close for the default file adapter, but a user\n // adapter might want a close hook later. For now just a no-op.\n }\n } catch {\n /* swallow */\n }\n if (globalThis.__statswhatshesaid__ === runtime) {\n globalThis.__statswhatshesaid__ = undefined\n }\n }\n\n flushTimer = setInterval(tick, config.flushIntervalMs)\n flushTimer.unref?.()\n cancelMidnight = scheduleMidnightTimer(tick)\n\n // `.once` would auto-remove on fire, but we also need to remove them\n // during an explicit `shutdown()` (tests, HMR) — see shutdown() above.\n process.on('SIGTERM', shutdown)\n process.on('SIGINT', shutdown)\n process.on('beforeExit', shutdown)\n\n const runtime: StatsRuntime = { config, store, persist, flush, shutdown }\n globalThis.__statswhatshesaid__ = runtime\n\n // Persist the initial state (which may have been mutated by a restored\n // rollover) so the file exists before the first flush interval fires.\n flush()\n\n return runtime\n}\n\nfunction safeLoad(persist: PersistAdapter) {\n try {\n return persist.load()\n } catch (err) {\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] snapshot load failed:', err)\n return null\n }\n}\n\nfunction assertNodeRuntime(): void {\n if (typeof process === 'undefined' || !process.versions || !process.versions.node) {\n throw new Error(\n \"[statswhatshesaid] This library requires the Node.js runtime. \" +\n \"Set `export const config = { runtime: 'nodejs' }` in your middleware.ts \" +\n '(requires Next.js 15.2 or newer).',\n )\n }\n}\n"],"mappings":"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","names":["import_server","import_node_crypto"]}
@@ -0,0 +1,108 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * Versioned on-disk representation of the entire store.
5
+ *
6
+ * Size budget (base64-encoded):
7
+ * - salt: 32 B → ~44 chars
8
+ * - hllRegisters: 16 KB → ~22 KB of base64
9
+ * - history: ~20 B per day
10
+ *
11
+ * Entire file stays ≤ ~30 KB even after years of operation.
12
+ */
13
+ interface SnapshotV1 {
14
+ version: 1;
15
+ /** UTC date (YYYY-MM-DD) that the HLL registers currently belong to. */
16
+ today: string;
17
+ /** Base64-encoded 32-byte daily salt. Rotated at UTC midnight. */
18
+ salt: string;
19
+ /** Base64-encoded 16384-byte HLL register array. */
20
+ hllRegisters: string;
21
+ /** Finalized historical daily counts, keyed by UTC date. */
22
+ history: Record<string, number>;
23
+ }
24
+ /**
25
+ * Pluggable persistence. Synchronous on purpose: the default file adapter
26
+ * is sync (so it can run in the SIGTERM shutdown path), and the data is
27
+ * small enough that any reasonable backend can be wrapped synchronously.
28
+ *
29
+ * If you need to hand this off to an async store (Redis, KV, S3), wrap your
30
+ * client in a thin adapter that blocks during load at startup and best-effort
31
+ * fires-and-forgets during save. For most self-hosted use cases the default
32
+ * file adapter is what you want.
33
+ */
34
+ interface PersistAdapter {
35
+ load(): SnapshotV1 | null;
36
+ save(snap: SnapshotV1): void;
37
+ }
38
+ /** Atomic-rename JSON file adapter. Zero dependencies. */
39
+ declare class FileSnapshotAdapter implements PersistAdapter {
40
+ private readonly path;
41
+ constructor(path: string);
42
+ load(): SnapshotV1 | null;
43
+ save(snap: SnapshotV1): void;
44
+ }
45
+
46
+ interface StatsOptions {
47
+ /** Secret token required to read /stats. Falls back to STATS_TOKEN env var. */
48
+ token?: string;
49
+ /**
50
+ * Path to the JSON snapshot file. Falls back to STATS_SNAPSHOT_PATH or
51
+ * `./.statswhatshesaid.json`. Ignored if a custom `persist` adapter is
52
+ * provided.
53
+ */
54
+ snapshotPath?: string;
55
+ /** Bring-your-own persistence backend (Redis, KV, S3, ...). */
56
+ persist?: PersistAdapter;
57
+ /** How often (ms) to flush state to the snapshot. Default 1h. */
58
+ flushIntervalMs?: number;
59
+ /** URL path that returns the JSON stats response. Default '/stats'. */
60
+ endpointPath?: string;
61
+ /** Number of historical days to return from /stats. Default 90. */
62
+ historyDays?: number;
63
+ /** Maximum historical days to keep in memory/snapshot. Default 365. */
64
+ maxHistoryDays?: number;
65
+ /** Drop common bot User-Agents instead of counting them. Default true. */
66
+ filterBots?: boolean;
67
+ /**
68
+ * How many reverse-proxy hops to trust at the right end of the
69
+ * `X-Forwarded-For` chain. Default: `1` (one reverse proxy in front of
70
+ * this process, e.g. nginx / Traefik / Caddy / Cloud provider LB).
71
+ *
72
+ * - `0` — ignore all forwarding headers. Every request hashes to the
73
+ * same constant peer, collapsing unique visitor counts. Use this only
74
+ * if the process is directly exposed to untrusted clients AND you'd
75
+ * rather under-count than be spoofable.
76
+ * - `1` — take the rightmost entry of `X-Forwarded-For` (the IP the last
77
+ * trusted proxy observed as its peer). Safe when exactly one trusted
78
+ * proxy is in front of this process.
79
+ * - `N > 1` — take the Nth entry from the right. Use when multiple
80
+ * trusted proxies chain (e.g. Cloudflare → nginx → app = 2).
81
+ *
82
+ * See the Security section of the README for configuration examples.
83
+ */
84
+ trustProxy?: number;
85
+ }
86
+ interface DailyCount {
87
+ date: string;
88
+ uniqueVisitors: number;
89
+ }
90
+ interface StatsResponseBody {
91
+ today: DailyCount;
92
+ history: DailyCount[];
93
+ generatedAt: string;
94
+ }
95
+
96
+ type StatsMiddleware = (req: NextRequest) => NextResponse | Promise<NextResponse>;
97
+ /**
98
+ * Standalone tracker for users who can't put the library in middleware.
99
+ * Call from `instrumentation.ts` or a route handler.
100
+ */
101
+ declare function trackRequest(req: NextRequest, options?: StatsOptions): void;
102
+
103
+ declare const stats: {
104
+ middleware: (options?: StatsOptions) => StatsMiddleware;
105
+ track: typeof trackRequest;
106
+ };
107
+
108
+ export { type DailyCount, FileSnapshotAdapter, type PersistAdapter, type SnapshotV1, type StatsMiddleware, type StatsOptions, type StatsResponseBody, stats as default };
@@ -0,0 +1,108 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * Versioned on-disk representation of the entire store.
5
+ *
6
+ * Size budget (base64-encoded):
7
+ * - salt: 32 B → ~44 chars
8
+ * - hllRegisters: 16 KB → ~22 KB of base64
9
+ * - history: ~20 B per day
10
+ *
11
+ * Entire file stays ≤ ~30 KB even after years of operation.
12
+ */
13
+ interface SnapshotV1 {
14
+ version: 1;
15
+ /** UTC date (YYYY-MM-DD) that the HLL registers currently belong to. */
16
+ today: string;
17
+ /** Base64-encoded 32-byte daily salt. Rotated at UTC midnight. */
18
+ salt: string;
19
+ /** Base64-encoded 16384-byte HLL register array. */
20
+ hllRegisters: string;
21
+ /** Finalized historical daily counts, keyed by UTC date. */
22
+ history: Record<string, number>;
23
+ }
24
+ /**
25
+ * Pluggable persistence. Synchronous on purpose: the default file adapter
26
+ * is sync (so it can run in the SIGTERM shutdown path), and the data is
27
+ * small enough that any reasonable backend can be wrapped synchronously.
28
+ *
29
+ * If you need to hand this off to an async store (Redis, KV, S3), wrap your
30
+ * client in a thin adapter that blocks during load at startup and best-effort
31
+ * fires-and-forgets during save. For most self-hosted use cases the default
32
+ * file adapter is what you want.
33
+ */
34
+ interface PersistAdapter {
35
+ load(): SnapshotV1 | null;
36
+ save(snap: SnapshotV1): void;
37
+ }
38
+ /** Atomic-rename JSON file adapter. Zero dependencies. */
39
+ declare class FileSnapshotAdapter implements PersistAdapter {
40
+ private readonly path;
41
+ constructor(path: string);
42
+ load(): SnapshotV1 | null;
43
+ save(snap: SnapshotV1): void;
44
+ }
45
+
46
+ interface StatsOptions {
47
+ /** Secret token required to read /stats. Falls back to STATS_TOKEN env var. */
48
+ token?: string;
49
+ /**
50
+ * Path to the JSON snapshot file. Falls back to STATS_SNAPSHOT_PATH or
51
+ * `./.statswhatshesaid.json`. Ignored if a custom `persist` adapter is
52
+ * provided.
53
+ */
54
+ snapshotPath?: string;
55
+ /** Bring-your-own persistence backend (Redis, KV, S3, ...). */
56
+ persist?: PersistAdapter;
57
+ /** How often (ms) to flush state to the snapshot. Default 1h. */
58
+ flushIntervalMs?: number;
59
+ /** URL path that returns the JSON stats response. Default '/stats'. */
60
+ endpointPath?: string;
61
+ /** Number of historical days to return from /stats. Default 90. */
62
+ historyDays?: number;
63
+ /** Maximum historical days to keep in memory/snapshot. Default 365. */
64
+ maxHistoryDays?: number;
65
+ /** Drop common bot User-Agents instead of counting them. Default true. */
66
+ filterBots?: boolean;
67
+ /**
68
+ * How many reverse-proxy hops to trust at the right end of the
69
+ * `X-Forwarded-For` chain. Default: `1` (one reverse proxy in front of
70
+ * this process, e.g. nginx / Traefik / Caddy / Cloud provider LB).
71
+ *
72
+ * - `0` — ignore all forwarding headers. Every request hashes to the
73
+ * same constant peer, collapsing unique visitor counts. Use this only
74
+ * if the process is directly exposed to untrusted clients AND you'd
75
+ * rather under-count than be spoofable.
76
+ * - `1` — take the rightmost entry of `X-Forwarded-For` (the IP the last
77
+ * trusted proxy observed as its peer). Safe when exactly one trusted
78
+ * proxy is in front of this process.
79
+ * - `N > 1` — take the Nth entry from the right. Use when multiple
80
+ * trusted proxies chain (e.g. Cloudflare → nginx → app = 2).
81
+ *
82
+ * See the Security section of the README for configuration examples.
83
+ */
84
+ trustProxy?: number;
85
+ }
86
+ interface DailyCount {
87
+ date: string;
88
+ uniqueVisitors: number;
89
+ }
90
+ interface StatsResponseBody {
91
+ today: DailyCount;
92
+ history: DailyCount[];
93
+ generatedAt: string;
94
+ }
95
+
96
+ type StatsMiddleware = (req: NextRequest) => NextResponse | Promise<NextResponse>;
97
+ /**
98
+ * Standalone tracker for users who can't put the library in middleware.
99
+ * Call from `instrumentation.ts` or a route handler.
100
+ */
101
+ declare function trackRequest(req: NextRequest, options?: StatsOptions): void;
102
+
103
+ declare const stats: {
104
+ middleware: (options?: StatsOptions) => StatsMiddleware;
105
+ track: typeof trackRequest;
106
+ };
107
+
108
+ export { type DailyCount, FileSnapshotAdapter, type PersistAdapter, type SnapshotV1, type StatsMiddleware, type StatsOptions, type StatsResponseBody, stats as default };