statswhatshesaid 0.2.0 → 0.3.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.
- package/CHANGELOG.md +151 -0
- package/README.md +12 -0
- package/dist/index.cjs +136 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +136 -36
- package/dist/index.js.map +1 -1
- package/package.json +7 -8
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# statswhatshesaid
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a404924: **Library — opt-in shared-salt mode + raw HLL sketch export**
|
|
8
|
+
|
|
9
|
+
Add a new `saltSecret` option (env: `STATS_SALT_SECRET`). When set, the
|
|
10
|
+
daily HLL salt is derived as `HMAC-SHA-256(saltSecret, utcDate)` instead of
|
|
11
|
+
random per-process bytes. Replicas configured with the same secret then
|
|
12
|
+
produce identical daily salts — the mathematical precondition for an
|
|
13
|
+
external tool to merge HLL sketches across replicas. Cross-day
|
|
14
|
+
unlinkability is preserved (the salt still rotates daily).
|
|
15
|
+
|
|
16
|
+
When shared-salt mode is on, `GET /stats?format=raw` additionally returns
|
|
17
|
+
the raw 16,384-byte HLL register array (base64) plus an 8-byte
|
|
18
|
+
`saltFingerprint` so a collector can verify replicas are using the same
|
|
19
|
+
salt before merging. When the secret is unset, behavior is unchanged.
|
|
20
|
+
|
|
21
|
+
This is fully backwards-compatible: existing deployments need no changes.
|
|
22
|
+
|
|
23
|
+
**New package — `statswhatshesaid-collector`**
|
|
24
|
+
|
|
25
|
+
External one-shot CLI (`swhsd-collect`) that polls one or more deployed
|
|
26
|
+
`statswhatshesaid` apps and persists their results to a local SQLite
|
|
27
|
+
database. Solves what the in-memory library deliberately does not:
|
|
28
|
+
|
|
29
|
+
- multi-app aggregation
|
|
30
|
+
- best-effort persistence across app restarts
|
|
31
|
+
- long-term retention beyond the library's in-memory window
|
|
32
|
+
- multi-replica merging (opt-in, requires `STATS_SALT_SECRET`)
|
|
33
|
+
|
|
34
|
+
Schedule it however you like — cron, systemd timer, launchd, GitHub
|
|
35
|
+
Actions. See `packages/collector/README.md` for examples.
|
|
36
|
+
|
|
37
|
+
## 0.2.0
|
|
38
|
+
|
|
39
|
+
### Minor Changes
|
|
40
|
+
|
|
41
|
+
- c5437a3: **One-line drop-in.** statswhatshesaid is now truly one line:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// middleware.ts
|
|
45
|
+
export { default } from "statswhatshesaid";
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
No `runtime: 'nodejs'` config, no `matcher`, no `experimental` flags, no Next.js 15.2+ requirement. It just works.
|
|
49
|
+
|
|
50
|
+
## Breaking changes
|
|
51
|
+
|
|
52
|
+
This is a major architectural change disguised as a `minor` bump because we're still in `0.x`. The headline change: **all persistence is gone**. Counts and history live in process memory only and reset on every restart. This is intentional — see "Why" below.
|
|
53
|
+
|
|
54
|
+
- **Removed** `node:fs` entirely. No more snapshot file. No more `.statswhatshesaid.json`. No more atomic-rename writes.
|
|
55
|
+
- **Removed** `node:crypto`. All hashing now uses Web Crypto (`crypto.subtle.digest`, `crypto.getRandomValues`).
|
|
56
|
+
- **Removed** the `runtime: 'nodejs'` middleware-config requirement. The library runs in **both** Edge and Node runtimes since it only uses Web APIs.
|
|
57
|
+
- **Removed** the `PersistAdapter` interface, `FileSnapshotAdapter`, `SnapshotV1` type, and the `persist`, `snapshotPath`, `flushIntervalMs` options.
|
|
58
|
+
- **Removed** `process.on('SIGTERM' | 'SIGINT' | 'beforeExit')` handlers. There's nothing to flush.
|
|
59
|
+
- **Removed** the periodic flush timer.
|
|
60
|
+
- **Removed** the Node-runtime guard (`assertNodeRuntime`). The library no longer cares which runtime you use.
|
|
61
|
+
- **Lowered** the Next.js peer dependency from `>=15.2.0` to `>=13.0.0`.
|
|
62
|
+
- **Default export** of the main package is now a pre-instantiated middleware function (was previously the `stats` object). To customize options, import `createMiddleware`:
|
|
63
|
+
```ts
|
|
64
|
+
import { createMiddleware } from "statswhatshesaid";
|
|
65
|
+
export default createMiddleware({ filterBots: false });
|
|
66
|
+
```
|
|
67
|
+
- **`createMiddleware` now returns an `async` function** since `crypto.subtle.digest` is async. Next.js middleware natively supports async.
|
|
68
|
+
- **`trackRequest` is now async** for the same reason.
|
|
69
|
+
|
|
70
|
+
## New features
|
|
71
|
+
|
|
72
|
+
- **Self-filters common static paths** before tracking (`/_next/static/*`, `/_next/image/*`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml`, `/manifest.json`, etc.) so users don't need a custom `matcher` to skip static assets.
|
|
73
|
+
- **One-line integration**: `export { default } from 'statswhatshesaid'` is the entire `middleware.ts`.
|
|
74
|
+
|
|
75
|
+
## Why (the short version)
|
|
76
|
+
|
|
77
|
+
The previous version's promise of "drop in" was undermined by the four lines of `export const config = { matcher, runtime: 'nodejs' }` boilerplate users had to write, plus the Next.js 15.2+ requirement and the directory of snapshot/WAL/SHM-equivalent file artifacts. The user explicitly wanted a true drop-in for monitoring freshly launched apps and accepted the trade-off of in-memory-only state.
|
|
78
|
+
|
|
79
|
+
Edge runtime is now a first-class target. You can deploy this on Vercel Edge Middleware, on a Docker scratch image, on Cloudflare Pages — anywhere modern JS runs.
|
|
80
|
+
|
|
81
|
+
## Bundle size
|
|
82
|
+
|
|
83
|
+
ESM bundle: 18.7 KB → **12.2 KB** (smaller because `snapshot.ts`, `FileSnapshotAdapter`, the persist abstraction, and the lifecycle plumbing are all gone).
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
89 unit and integration tests, all passing. The 5-test snapshot suite was deleted along with the file adapter. Persistence-restart and corruption-recovery tests were dropped (no persistence to test). New tests cover the static-path filter, the Web Crypto hash path, and the async constant-time compare.
|
|
88
|
+
|
|
89
|
+
End-to-end smoke tested via the `examples/basic` Next.js app: 2 distinct visitors counted, dedup correct, bot filtered, favicon skipped, both query and `Authorization` token paths working, wrong token rejected.
|
|
90
|
+
|
|
91
|
+
## 0.1.0
|
|
92
|
+
|
|
93
|
+
### Minor Changes
|
|
94
|
+
|
|
95
|
+
- 7880aa7: Initial release of `statswhatshesaid` — a super minimal drop-in unique-visitors-per-day stats library for self-hosted Next.js.
|
|
96
|
+
|
|
97
|
+
**Features:**
|
|
98
|
+
|
|
99
|
+
- One-line integration via Next.js middleware (`export default stats.middleware()`).
|
|
100
|
+
- Single `/stats?t=<token>` endpoint returning JSON (today's estimate + history).
|
|
101
|
+
- Cookieless visitor identification: `SHA-256(ip + ua + dailySalt)`, salt rotates at UTC midnight.
|
|
102
|
+
- HyperLogLog (p=14) cardinality estimation — fixed 16 KB per day, ~0.8% standard error.
|
|
103
|
+
- Single JSON snapshot file (~22 KB) with atomic `.tmp` + rename writes. Default `./.statswhatshesaid.json`.
|
|
104
|
+
- Pluggable `PersistAdapter` for bring-your-own backends (Redis, KV, S3).
|
|
105
|
+
- Edge-runtime guard with a clear, actionable error message.
|
|
106
|
+
- **Zero runtime dependencies.** No native modules, no Docker volume gymnastics. Works on Alpine, slim, distroless.
|
|
107
|
+
- Requires Next.js ≥ 15.2 for the `nodejs` middleware runtime.
|
|
108
|
+
|
|
109
|
+
- 3b295d6: Second-pass hardening found via a targeted re-audit. These fixes all sit inside the existing v0.1.0 window (still unreleased), so they roll into the first published release.
|
|
110
|
+
|
|
111
|
+
**Length-prefixed visitor hash construction.** `computeVisitorHash` now prepends a big-endian length header for each variable-length component (ip, ua) before feeding them into SHA-256. The previous `ip + ":" + ua + ":" + salt` encoding was input-ambiguous: with IPv6 addresses containing colons, two distinct `(ip, ua)` pairs could produce the same pre-image and therefore the same hash. Length-prefixing makes the pre-image unambiguous.
|
|
112
|
+
|
|
113
|
+
**Snapshot load is now crash-proof.** `VisitorStore.fromSnapshot` wraps both the same-day and cross-day branches in try/catch and degrades gracefully:
|
|
114
|
+
|
|
115
|
+
- Decoded salt length is validated (must be exactly 32 bytes) before use.
|
|
116
|
+
- Decoded HLL register length is validated (must be exactly `HLL_REGISTER_COUNT` bytes) before use. `Buffer.from(x, 'base64')` silently ignores malformed characters, so the base64 string-length check in `isValidSnapshot` alone was insufficient.
|
|
117
|
+
- On any decode/validation failure, the store starts fresh for the current day rather than throwing out of init. Up to a few minutes of same-day dedupe state is lost; the process stays up.
|
|
118
|
+
|
|
119
|
+
**Strict history validation.** `sanitizeHistory` drops any entry that isn't a real `YYYY-MM-DD` calendar date (validated via `Date.UTC` round-trip, rejecting `2026-02-30` and `2025-02-29`) mapped to a non-negative integer count. Entries for `currentDate` itself are dropped (today's count is owned by the live HLL). Protects against snapshot files poisoned by whoever has write access.
|
|
120
|
+
|
|
121
|
+
**Snapshot validator rejects arrays.** `isValidSnapshot` now explicitly rejects arrays for the `history` field. Previously `typeof [] === 'object'` let arrays through, which would then be iterated with numeric-string keys.
|
|
122
|
+
|
|
123
|
+
**Config sanity checks.** `resolveConfig` now validates:
|
|
124
|
+
|
|
125
|
+
- `flushIntervalMs` must be a positive integer ≥ 1000 ms (prevents `setInterval(tick, 0)` hot loops from a bad config).
|
|
126
|
+
- `historyDays` and `maxHistoryDays` must be non-negative integers.
|
|
127
|
+
- `endpointPath` must match `^/[A-Za-z0-9\-._~/]*$` — no whitespace, CR/LF, or shell metacharacters.
|
|
128
|
+
|
|
129
|
+
All throw loud, clear errors at config resolution time.
|
|
130
|
+
|
|
131
|
+
**New `isValidUtcDate` helper.** Shared between snapshot validation and history sanitization. Rejects calendrically-impossible dates like `2026-02-30` via `Date.UTC` round-trip, not just via regex.
|
|
132
|
+
|
|
133
|
+
**Tests.** 23 new hardening tests across four describe blocks covering the hash input-ambiguity fix, date validation, config validation, and graceful snapshot-load degradation. Total test count: 76 → 99, all green.
|
|
134
|
+
|
|
135
|
+
- 64b583f: Security hardening pass before the first public release.
|
|
136
|
+
|
|
137
|
+
**New `trustProxy` option** (default: `1`). Determines how many reverse-proxy hops to skip when resolving the client IP from `X-Forwarded-For`. The library now walks the XFF chain from the RIGHT (instead of blindly taking the leftmost entry), which defeats the standard client-side XFF spoofing attack when at least one trusted proxy sits in front of the process. Set to `0` to ignore forwarding headers entirely, or to `N > 1` for chained proxies (e.g. Cloudflare → nginx → app = `2`). Configurable via `STATS_TRUST_PROXY` env var. See the README Security section for recipes.
|
|
138
|
+
|
|
139
|
+
**`/stats` now accepts `Authorization: Bearer <token>`** in addition to the `?t=<token>` query string. The header is preferred in production because it does not leak into access logs, browser history, or Referer headers. If both are provided, the header wins.
|
|
140
|
+
|
|
141
|
+
**Weak-token warning.** The library emits a one-time `console.warn` at init time if the token is shorter than 32 characters, with guidance to run `openssl rand -hex 32`. The library does NOT reject short tokens — you may deliberately pick a memorable one for ad-hoc browser access.
|
|
142
|
+
|
|
143
|
+
**Snapshot file is now written with mode `0o600`** (owner read/write only). The snapshot contains the current day's visitor-hashing salt and should not be world-readable.
|
|
144
|
+
|
|
145
|
+
**User-Agent truncation.** Incoming `User-Agent` headers are truncated to 512 bytes before hashing and bot filtering, bounding per-request CPU cost regardless of the upstream header-size limit.
|
|
146
|
+
|
|
147
|
+
**Constant-time token comparison.** Token validation now prehashes both sides with SHA-256 before `timingSafeEqual`, so the comparison no longer branches on token length.
|
|
148
|
+
|
|
149
|
+
**Process signal handler leak fix.** `shutdown()` now calls `process.removeListener` for its own handlers, fixing a `MaxListenersExceededWarning` that appeared when many init/shutdown cycles ran in the same process (e.g. dev-mode HMR, test suites).
|
|
150
|
+
|
|
151
|
+
**README: new Security section** covering the threat model, `trustProxy` semantics with nginx/Caddy/Cloudflare recipes, token handling, flooding limitations, snapshot file contents, and privacy properties.
|
package/README.md
CHANGED
|
@@ -123,6 +123,18 @@ Configure via env vars (preferred for `STATS_TOKEN`) or by passing options to `c
|
|
|
123
123
|
| `maxHistoryDays` | — | `365` (kept in memory) |
|
|
124
124
|
| `filterBots` | — | `true` |
|
|
125
125
|
| `trustProxy` | `STATS_TRUST_PROXY` | `1` (see [Security](#security) below) |
|
|
126
|
+
| `saltSecret` | `STATS_SALT_SECRET` | unset (see [Multi-replica deployments](#multi-replica-deployments) below) |
|
|
127
|
+
|
|
128
|
+
## Multi-replica deployments
|
|
129
|
+
|
|
130
|
+
The default in-memory design is single-instance: each Next.js worker has its own HyperLogLog sketch. If you run multiple replicas, each replica counts the visitors it serves, with no awareness of the others — visitor numbers across `/stats` will differ from replica to replica.
|
|
131
|
+
|
|
132
|
+
If you want a single consolidated number across replicas, you can opt in to **shared-salt mode** and pair it with an external collector that merges sketches:
|
|
133
|
+
|
|
134
|
+
1. Set `STATS_SALT_SECRET` (any long random string — `openssl rand -hex 32`) to the **same value** on every replica. The daily HLL salt then becomes `HMAC-SHA-256(saltSecret, utcDate)` — deterministic across replicas, still rotating daily, so cross-day unlinkability is preserved.
|
|
135
|
+
2. Run [`statswhatshesaid-collector`](../collector) — an external CLI — on a machine you control. Configure it with the per-replica URLs and the `STATS_TOKEN`. The collector polls `/stats?format=raw` from each replica, fetches the raw HLL register array plus a salt fingerprint, verifies the fingerprints match, merges the sketches register-wise (element-wise max), and stores the merged daily number in a local SQLite database.
|
|
136
|
+
|
|
137
|
+
If you don't set `STATS_SALT_SECRET`, the library behaves exactly as before — random per-process salts, `/stats?format=raw` simply ignored — and you can run a single-replica deployment without any of this.
|
|
126
138
|
|
|
127
139
|
## Security
|
|
128
140
|
|
package/dist/index.cjs
CHANGED
|
@@ -42,8 +42,10 @@ var DEFAULT_HISTORY_DAYS = 90;
|
|
|
42
42
|
var DEFAULT_MAX_HISTORY_DAYS = 365;
|
|
43
43
|
var DEFAULT_TRUST_PROXY = 1;
|
|
44
44
|
var MIN_RECOMMENDED_TOKEN_LENGTH = 32;
|
|
45
|
+
var MIN_RECOMMENDED_SALT_SECRET_LENGTH = 32;
|
|
45
46
|
var ENDPOINT_PATH_RE = /^\/[A-Za-z0-9\-._~/]*$/;
|
|
46
47
|
var weakTokenWarned = false;
|
|
48
|
+
var weakSaltSecretWarned = false;
|
|
47
49
|
function resolveConfig(options = {}) {
|
|
48
50
|
const env = typeof process !== "undefined" && process.env ? process.env : {};
|
|
49
51
|
const token = options.token ?? env.STATS_TOKEN;
|
|
@@ -76,13 +78,22 @@ function resolveConfig(options = {}) {
|
|
|
76
78
|
`[statswhatshesaid] Invalid trustProxy value: ${rawTrustProxy}. Must be a non-negative integer (0, 1, 2, ...).`
|
|
77
79
|
);
|
|
78
80
|
}
|
|
81
|
+
const rawSaltSecret = options.saltSecret ?? env.STATS_SALT_SECRET;
|
|
82
|
+
const saltSecret = rawSaltSecret && rawSaltSecret.length > 0 ? rawSaltSecret : null;
|
|
83
|
+
if (saltSecret && !weakSaltSecretWarned && saltSecret.length < MIN_RECOMMENDED_SALT_SECRET_LENGTH) {
|
|
84
|
+
weakSaltSecretWarned = true;
|
|
85
|
+
console.warn(
|
|
86
|
+
`[statswhatshesaid] Warning: STATS_SALT_SECRET is shorter than ${MIN_RECOMMENDED_SALT_SECRET_LENGTH} characters (${saltSecret.length}). A weak secret makes the daily salt easier to guess, which weakens the privacy guarantee that an attacker cannot rederive \`(ip, ua)\` pairs from their hashes. Generate a strong secret with: \`openssl rand -hex 32\`.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
79
89
|
return {
|
|
80
90
|
token,
|
|
81
91
|
endpointPath,
|
|
82
92
|
historyDays,
|
|
83
93
|
maxHistoryDays,
|
|
84
94
|
filterBots,
|
|
85
|
-
trustProxy: rawTrustProxy
|
|
95
|
+
trustProxy: rawTrustProxy,
|
|
96
|
+
saltSecret
|
|
86
97
|
};
|
|
87
98
|
}
|
|
88
99
|
function requireNonNegativeInt(value, name) {
|
|
@@ -114,6 +125,25 @@ function generateSalt() {
|
|
|
114
125
|
globalThis.crypto.getRandomValues(salt);
|
|
115
126
|
return salt;
|
|
116
127
|
}
|
|
128
|
+
async function deriveDailySalt(secret, utcDate) {
|
|
129
|
+
const enc = new TextEncoder();
|
|
130
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
131
|
+
"raw",
|
|
132
|
+
enc.encode(secret),
|
|
133
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
134
|
+
false,
|
|
135
|
+
["sign"]
|
|
136
|
+
);
|
|
137
|
+
const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(utcDate));
|
|
138
|
+
return new Uint8Array(sig);
|
|
139
|
+
}
|
|
140
|
+
async function computeSaltFingerprint(salt) {
|
|
141
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", salt);
|
|
142
|
+
const bytes = new Uint8Array(digest, 0, 8);
|
|
143
|
+
let hex = "";
|
|
144
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
145
|
+
return hex;
|
|
146
|
+
}
|
|
117
147
|
var UNKNOWN_PEER = "0.0.0.0";
|
|
118
148
|
function extractIp(headers, trustProxy) {
|
|
119
149
|
if (trustProxy < 1) return UNKNOWN_PEER;
|
|
@@ -170,37 +200,8 @@ function isStaticPath(pathname) {
|
|
|
170
200
|
|
|
171
201
|
// src/endpoint.ts
|
|
172
202
|
var import_server = require("next/server");
|
|
173
|
-
async function handleStatsEndpoint(req, runtime) {
|
|
174
|
-
const provided = extractAuthToken(req);
|
|
175
|
-
if (!provided || !await constantTimeStringEqual(provided, runtime.config.token)) {
|
|
176
|
-
return new import_server.NextResponse("Unauthorized", {
|
|
177
|
-
status: 401,
|
|
178
|
-
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
runtime.store.rollOverIfNeeded();
|
|
182
|
-
const body = {
|
|
183
|
-
today: {
|
|
184
|
-
date: runtime.store.today,
|
|
185
|
-
uniqueVisitors: runtime.store.estimateToday()
|
|
186
|
-
},
|
|
187
|
-
history: runtime.store.getHistoryDesc(runtime.config.historyDays),
|
|
188
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
189
|
-
};
|
|
190
|
-
return import_server.NextResponse.json(body, {
|
|
191
|
-
headers: { "cache-control": "no-store" }
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
function extractAuthToken(req) {
|
|
195
|
-
const auth = req.headers.get("authorization");
|
|
196
|
-
if (auth) {
|
|
197
|
-
const match = /^Bearer\s+(\S+)\s*$/i.exec(auth);
|
|
198
|
-
if (match) return match[1];
|
|
199
|
-
}
|
|
200
|
-
return req.nextUrl.searchParams.get("t");
|
|
201
|
-
}
|
|
202
203
|
|
|
203
|
-
// src/
|
|
204
|
+
// ../hll/src/hyperloglog.ts
|
|
204
205
|
var P = 14;
|
|
205
206
|
var HLL_REGISTER_COUNT = 1 << P;
|
|
206
207
|
var TAIL_HIGH_BITS = 32 - P;
|
|
@@ -278,22 +279,88 @@ var HyperLogLog = class _HyperLogLog {
|
|
|
278
279
|
}
|
|
279
280
|
};
|
|
280
281
|
|
|
282
|
+
// ../hll/src/merge.ts
|
|
283
|
+
var ALPHA_M2 = 0.7213 / (1 + 1.079 / HLL_REGISTER_COUNT);
|
|
284
|
+
function assertRegisterLength(arr, label) {
|
|
285
|
+
if (arr.length !== HLL_REGISTER_COUNT) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`[swhsd/hll] ${label} must be ${HLL_REGISTER_COUNT} bytes, got ${arr.length}`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function encodeRegistersBase64(registers) {
|
|
292
|
+
assertRegisterLength(registers, "register array");
|
|
293
|
+
let binary = "";
|
|
294
|
+
const CHUNK = 32768;
|
|
295
|
+
for (let i = 0; i < registers.length; i += CHUNK) {
|
|
296
|
+
const slice = registers.subarray(i, i + CHUNK);
|
|
297
|
+
binary += String.fromCharCode(...slice);
|
|
298
|
+
}
|
|
299
|
+
return btoa(binary);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/endpoint.ts
|
|
303
|
+
async function handleStatsEndpoint(req, runtime) {
|
|
304
|
+
const provided = extractAuthToken(req);
|
|
305
|
+
if (!provided || !await constantTimeStringEqual(provided, runtime.config.token)) {
|
|
306
|
+
return new import_server.NextResponse("Unauthorized", {
|
|
307
|
+
status: 401,
|
|
308
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
runtime.store.rollOverIfNeeded();
|
|
312
|
+
const today = {
|
|
313
|
+
date: runtime.store.today,
|
|
314
|
+
uniqueVisitors: runtime.store.estimateToday()
|
|
315
|
+
};
|
|
316
|
+
if (isRawFormatRequested(req) && runtime.store.isSharedSaltMode()) {
|
|
317
|
+
const sketch = await runtime.store.exposeTodaySketch();
|
|
318
|
+
today.sketch = encodeRegistersBase64(sketch.registers);
|
|
319
|
+
today.saltFingerprint = sketch.saltFingerprint;
|
|
320
|
+
}
|
|
321
|
+
const body = {
|
|
322
|
+
today,
|
|
323
|
+
history: runtime.store.getHistoryDesc(runtime.config.historyDays),
|
|
324
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
325
|
+
};
|
|
326
|
+
return import_server.NextResponse.json(body, {
|
|
327
|
+
headers: { "cache-control": "no-store" }
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function isRawFormatRequested(req) {
|
|
331
|
+
return req.nextUrl.searchParams.get("format") === "raw";
|
|
332
|
+
}
|
|
333
|
+
function extractAuthToken(req) {
|
|
334
|
+
const auth = req.headers.get("authorization");
|
|
335
|
+
if (auth) {
|
|
336
|
+
const match = /^Bearer\s+(\S+)\s*$/i.exec(auth);
|
|
337
|
+
if (match) return match[1];
|
|
338
|
+
}
|
|
339
|
+
return req.nextUrl.searchParams.get("t");
|
|
340
|
+
}
|
|
341
|
+
|
|
281
342
|
// src/store.ts
|
|
282
343
|
var VisitorStore = class _VisitorStore {
|
|
283
344
|
_today;
|
|
284
345
|
_salt;
|
|
346
|
+
_saltSecret;
|
|
285
347
|
_hll;
|
|
286
348
|
_history;
|
|
287
349
|
constructor(args) {
|
|
288
350
|
this._today = args.today;
|
|
289
351
|
this._salt = args.salt;
|
|
352
|
+
this._saltSecret = args.saltSecret;
|
|
290
353
|
this._hll = args.hll;
|
|
291
354
|
this._history = args.history;
|
|
292
355
|
}
|
|
293
|
-
static fresh(today) {
|
|
356
|
+
static fresh(today, saltSecret = null) {
|
|
294
357
|
return new _VisitorStore({
|
|
295
358
|
today,
|
|
296
|
-
salt
|
|
359
|
+
// In shared-salt mode the salt is derived lazily on first track,
|
|
360
|
+
// since HMAC requires `await crypto.subtle.sign` and we want `fresh()`
|
|
361
|
+
// to stay synchronous.
|
|
362
|
+
salt: saltSecret ? null : generateSalt(),
|
|
363
|
+
saltSecret,
|
|
297
364
|
hll: new HyperLogLog(),
|
|
298
365
|
history: /* @__PURE__ */ new Map()
|
|
299
366
|
});
|
|
@@ -301,6 +368,10 @@ var VisitorStore = class _VisitorStore {
|
|
|
301
368
|
get today() {
|
|
302
369
|
return this._today;
|
|
303
370
|
}
|
|
371
|
+
/** Whether the store is using deterministic (shared) salt derivation. */
|
|
372
|
+
isSharedSaltMode() {
|
|
373
|
+
return this._saltSecret !== null;
|
|
374
|
+
}
|
|
304
375
|
/** Estimated unique visitors so far today. */
|
|
305
376
|
estimateToday() {
|
|
306
377
|
return this._hll.estimate();
|
|
@@ -312,7 +383,8 @@ var VisitorStore = class _VisitorStore {
|
|
|
312
383
|
*/
|
|
313
384
|
async track(ip, ua) {
|
|
314
385
|
this.rollOverIfNeeded();
|
|
315
|
-
const
|
|
386
|
+
const salt = await this.getOrDeriveSalt();
|
|
387
|
+
const hash = await computeVisitorHash(ip, ua, salt);
|
|
316
388
|
this._hll.addHashBuffer(hash);
|
|
317
389
|
}
|
|
318
390
|
/**
|
|
@@ -326,7 +398,7 @@ var VisitorStore = class _VisitorStore {
|
|
|
326
398
|
if (current === this._today) return false;
|
|
327
399
|
this._history.set(this._today, this._hll.estimate());
|
|
328
400
|
this._today = current;
|
|
329
|
-
this._salt = generateSalt();
|
|
401
|
+
this._salt = this._saltSecret ? null : generateSalt();
|
|
330
402
|
this._hll = new HyperLogLog();
|
|
331
403
|
return true;
|
|
332
404
|
}
|
|
@@ -349,13 +421,41 @@ var VisitorStore = class _VisitorStore {
|
|
|
349
421
|
rows.sort((a, b) => a.date < b.date ? 1 : a.date > b.date ? -1 : 0);
|
|
350
422
|
return rows.slice(0, limit);
|
|
351
423
|
}
|
|
424
|
+
/**
|
|
425
|
+
* Return today's raw HLL register array plus a fingerprint of the salt,
|
|
426
|
+
* for `/stats?format=raw` consumers. Only meaningful in shared-salt mode
|
|
427
|
+
* — fingerprints from random salts can't be cross-checked across
|
|
428
|
+
* replicas. The caller should gate on `isSharedSaltMode()` before calling.
|
|
429
|
+
*/
|
|
430
|
+
async exposeTodaySketch() {
|
|
431
|
+
this.rollOverIfNeeded();
|
|
432
|
+
const salt = await this.getOrDeriveSalt();
|
|
433
|
+
return {
|
|
434
|
+
registers: this._hll.cloneRegisters(),
|
|
435
|
+
saltFingerprint: await computeSaltFingerprint(salt)
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Returns the salt for the current day, deriving it from the shared
|
|
440
|
+
* secret on first call if necessary. Subsequent calls within the same
|
|
441
|
+
* day return the cached value.
|
|
442
|
+
*/
|
|
443
|
+
async getOrDeriveSalt() {
|
|
444
|
+
if (this._salt) return this._salt;
|
|
445
|
+
if (!this._saltSecret) {
|
|
446
|
+
this._salt = generateSalt();
|
|
447
|
+
return this._salt;
|
|
448
|
+
}
|
|
449
|
+
this._salt = await deriveDailySalt(this._saltSecret, this._today);
|
|
450
|
+
return this._salt;
|
|
451
|
+
}
|
|
352
452
|
};
|
|
353
453
|
|
|
354
454
|
// src/lifecycle.ts
|
|
355
455
|
function getOrInitRuntime(config) {
|
|
356
456
|
if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__;
|
|
357
457
|
const today = utcDateString(/* @__PURE__ */ new Date());
|
|
358
|
-
const store = VisitorStore.fresh(today);
|
|
458
|
+
const store = VisitorStore.fresh(today, config.saltSecret);
|
|
359
459
|
store.trimHistory(config.maxHistoryDays);
|
|
360
460
|
const runtime = { config, store };
|
|
361
461
|
globalThis.__statswhatshesaid__ = runtime;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/bots.ts","../src/config.ts","../src/identity.ts","../src/endpoint.ts","../src/hll.ts","../src/store.ts","../src/lifecycle.ts"],"sourcesContent":["import { createMiddleware, trackRequest } from './middleware.js'\n\n/**\n * Pre-instantiated default middleware. Lets users drop the library in with\n * a single line in their `middleware.ts`:\n *\n * ```ts\n * export { default } from 'statswhatshesaid'\n * ```\n *\n * The default middleware reads its configuration from environment variables\n * (`STATS_TOKEN` is required, the rest have sensible defaults). Config\n * resolution is deferred to the first request, so `next build` works fine\n * without `STATS_TOKEN` set — the error only fires at runtime.\n *\n * For customized configuration, import `createMiddleware` and call it with\n * your options:\n *\n * ```ts\n * import { createMiddleware } from 'statswhatshesaid'\n * export default createMiddleware({ filterBots: false })\n * ```\n */\nconst defaultMiddleware = createMiddleware()\n\nexport default defaultMiddleware\nexport { createMiddleware, trackRequest }\nexport type { StatsOptions, StatsResponseBody, DailyCount } from './types.js'\nexport type { StatsMiddleware } from './middleware.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, isStaticPath } 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) => Promise<NextResponse>\n\n/**\n * Build a Next.js middleware that tracks unique visitors.\n *\n * Returns an `async` function compatible with Next.js's middleware contract.\n * Lazy config resolution: the closure does not call `resolveConfig` until\n * the first request, so module-load (and `next build`) won't fail just\n * because `STATS_TOKEN` isn't set yet.\n */\nexport function createMiddleware(options: StatsOptions = {}): StatsMiddleware {\n let resolved: ReturnType<typeof resolveConfig> | null = null\n\n return async function statsMiddleware(req: NextRequest): Promise<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 // Self-filter common static paths so users don't need their own\n // `matcher` config in middleware.ts.\n if (isStaticPath(req.nextUrl.pathname)) {\n return NextResponse.next()\n }\n\n await trackRequestInternal(req, runtime)\n return NextResponse.next()\n }\n}\n\n/**\n * Standalone tracker for users who want to call from a route handler or\n * `instrumentation.ts` instead of from middleware.\n */\nexport async function trackRequest(\n req: NextRequest,\n options: StatsOptions = {},\n): Promise<void> {\n const config = resolveConfig(options)\n const runtime = getOrInitRuntime(config)\n await 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\nasync function trackRequestInternal(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<void> {\n try {\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 await runtime.store.track(ip, ua)\n } catch (err) {\n // Never let a tracking failure take down the user's request.\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] track failed:', err)\n }\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_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\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 =\n typeof process !== 'undefined' && process.env\n ? process.env\n : ({} as Record<string, string | undefined>)\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 createMiddleware({ token }).',\n )\n }\n // Warn (not throw) if the token is short enough to brute-force.\n // Advisory only — 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 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\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 endpointPath,\n historyDays,\n maxHistoryDays,\n filterBots,\n trustProxy: rawTrustProxy,\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","/**\n * Stateless identity helpers, runtime-agnostic.\n *\n * All crypto here uses the Web Crypto API (`globalThis.crypto.subtle` and\n * `globalThis.crypto.getRandomValues`) so the library can run in both the\n * Next.js Edge runtime and the Node runtime without any conditional code.\n *\n * Web Crypto's `subtle.digest` is async, which makes `computeVisitorHash`\n * async, which in turn makes the middleware hot path async. Next.js\n * supports async middleware natively.\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(): Uint8Array {\n const salt = new Uint8Array(SALT_BYTES)\n globalThis.crypto.getRandomValues(salt)\n return salt\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 * Semantics:\n * - `trustProxy === 0` — never read forwarding headers. All requests\n * collapse to a single constant peer.\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.\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 */\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 32-byte SHA-256\n * digest as a `Uint8Array`. The HLL only consumes the first 8 bytes.\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 async function computeVisitorHash(\n ip: string,\n ua: string,\n salt: Uint8Array,\n): Promise<Uint8Array> {\n const enc = new TextEncoder()\n const ipBuf = enc.encode(ip)\n const uaBuf = enc.encode(ua)\n\n // 8-byte length header (two big-endian u32s) + ipBuf + uaBuf + salt.\n const total = new Uint8Array(8 + ipBuf.length + uaBuf.length + salt.length)\n const dv = new DataView(total.buffer)\n dv.setUint32(0, ipBuf.length, false)\n dv.setUint32(4, uaBuf.length, false)\n total.set(ipBuf, 8)\n total.set(uaBuf, 8 + ipBuf.length)\n total.set(salt, 8 + ipBuf.length + uaBuf.length)\n\n const digest = await globalThis.crypto.subtle.digest('SHA-256', total)\n return new Uint8Array(digest)\n}\n\n/**\n * Constant-time string comparison via SHA-256 prehash. Both inputs are\n * hashed to fixed 32-byte buffers and then XOR-compared in constant time,\n * so neither the length nor the content of either input leaks via timing.\n */\nexport async function constantTimeStringEqual(a: string, b: string): Promise<boolean> {\n const enc = new TextEncoder()\n const [ah, bh] = await Promise.all([\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(a)),\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(b)),\n ])\n const av = new Uint8Array(ah)\n const bv = new Uint8Array(bh)\n let diff = 0\n for (let i = 0; i < av.length; i++) {\n diff |= av[i]! ^ bv[i]!\n }\n return diff === 0\n}\n\n/**\n * Conservative list of paths the middleware should NOT track. Lets the user\n * skip the `matcher` config entirely. Only matches well-known static paths,\n * never extension-based, to avoid false positives on routes like\n * `/api/data.json`.\n */\nexport function isStaticPath(pathname: string): boolean {\n if (pathname.startsWith('/_next/')) return true\n // Common well-known files at the root.\n switch (pathname) {\n case '/favicon.ico':\n case '/favicon.svg':\n case '/robots.txt':\n case '/sitemap.xml':\n case '/manifest.json':\n case '/site.webmanifest':\n case '/apple-touch-icon.png':\n case '/apple-touch-icon-precomposed.png':\n return true\n default:\n return false\n }\n}\n","import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { constantTimeStringEqual } from './identity.js'\nimport type { StatsRuntime } from './lifecycle.js'\nimport type { StatsResponseBody } from './types.js'\n\nexport async function handleStatsEndpoint(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<NextResponse> {\n const provided = extractAuthToken(req)\n if (!provided || !(await constantTimeStringEqual(provided, runtime.config.token))) {\n return new NextResponse('Unauthorized', {\n status: 401,\n headers: { 'content-type': 'text/plain; charset=utf-8' },\n })\n }\n\n // Make sure \"today\" in the response always reflects the current UTC day,\n // even if no track() call has triggered a rollover yet.\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 * 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. Accepts any\n * `Uint8Array` (including Node `Buffer`, which is a subclass).\n */\n addHashBuffer(buf: Uint8Array): void {\n if (buf.length < 8) {\n throw new Error(\n `[statswhatshesaid] HLL hash input must be at least 8 bytes, got ${buf.length}`,\n )\n }\n // Big-endian view of the first 8 bytes. Use DataView so we don't depend\n // on Node's Buffer methods (we want to run in Edge runtime too).\n const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)\n const first = dv.getUint32(0, false)\n const second = dv.getUint32(4, false)\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 { computeVisitorHash, generateSalt, utcDateString } from './identity.js'\nimport { HyperLogLog } from './hll.js'\nimport type { DailyCount } from './types.js'\n\n/**\n * Owns the in-memory live state that `/stats` reads from: today's HLL\n * sketch, today's salt, and finalized historical daily counts.\n *\n * No persistence — counts and history live in process memory only and are\n * lost on process restart. Within a single Edge isolate or Node process,\n * state survives across requests because module-level singletons in Next.js\n * middleware persist for the worker's lifetime.\n *\n * `track` is async because the visitor hash uses Web Crypto's\n * `crypto.subtle.digest`, which has no synchronous counterpart in the Edge\n * runtime. Next.js middleware natively supports async functions.\n */\nexport class VisitorStore {\n private _today: string\n private _salt: Uint8Array\n private _hll: HyperLogLog\n private _history: Map<string, number>\n\n private constructor(args: {\n today: string\n salt: Uint8Array\n hll: HyperLogLog\n history: Map<string, number>\n }) {\n this._today = args.today\n this._salt = args.salt\n this._hll = args.hll\n this._history = args.history\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 })\n }\n\n get today(): string {\n return this._today\n }\n\n /** Estimated unique visitors so far today. */\n estimateToday(): number {\n return this._hll.estimate()\n }\n\n /**\n * Hot path. Lazily rolls over the day if needed (so we don't depend on a\n * background timer that may be unreliable in Edge isolates), then hashes\n * and adds the visitor to the HLL sketch.\n */\n async track(ip: string, ua: string): Promise<void> {\n this.rollOverIfNeeded()\n const hash = await computeVisitorHash(ip, ua, this._salt)\n this._hll.addHashBuffer(hash)\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. Cheap enough to call on every\n * request (one Date allocation, one string compare).\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 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 }\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","import { utcDateString } from './identity.js'\nimport { VisitorStore } from './store.js'\nimport type { ResolvedConfig } from './types.js'\n\nexport interface StatsRuntime {\n config: ResolvedConfig\n store: VisitorStore\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 stores or\n * accumulate state across module reloads.\n *\n * In-memory only — no file handles, no timers, no process signal handlers.\n * The store survives for the lifetime of the worker / Edge isolate.\n * Restarting the process resets all counts.\n */\nexport function getOrInitRuntime(config: ResolvedConfig): StatsRuntime {\n if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__\n\n const today = utcDateString(new Date())\n const store = VisitorStore.fresh(today)\n store.trimHistory(config.maxHistoryDays)\n\n const runtime: StatsRuntime = { config, store }\n globalThis.__statswhatshesaid__ = runtime\n return runtime\n}\n"],"mappings":"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","names":["import_server"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/bots.ts","../src/config.ts","../src/identity.ts","../src/endpoint.ts","../../hll/src/hyperloglog.ts","../../hll/src/merge.ts","../src/store.ts","../src/lifecycle.ts"],"sourcesContent":["import { createMiddleware, trackRequest } from './middleware.js'\n\n/**\n * Pre-instantiated default middleware. Lets users drop the library in with\n * a single line in their `middleware.ts`:\n *\n * ```ts\n * export { default } from 'statswhatshesaid'\n * ```\n *\n * The default middleware reads its configuration from environment variables\n * (`STATS_TOKEN` is required, the rest have sensible defaults). Config\n * resolution is deferred to the first request, so `next build` works fine\n * without `STATS_TOKEN` set — the error only fires at runtime.\n *\n * For customized configuration, import `createMiddleware` and call it with\n * your options:\n *\n * ```ts\n * import { createMiddleware } from 'statswhatshesaid'\n * export default createMiddleware({ filterBots: false })\n * ```\n */\nconst defaultMiddleware = createMiddleware()\n\nexport default defaultMiddleware\nexport { createMiddleware, trackRequest }\nexport type { StatsOptions, StatsResponseBody, DailyCount } from './types.js'\nexport type { StatsMiddleware } from './middleware.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, isStaticPath } 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) => Promise<NextResponse>\n\n/**\n * Build a Next.js middleware that tracks unique visitors.\n *\n * Returns an `async` function compatible with Next.js's middleware contract.\n * Lazy config resolution: the closure does not call `resolveConfig` until\n * the first request, so module-load (and `next build`) won't fail just\n * because `STATS_TOKEN` isn't set yet.\n */\nexport function createMiddleware(options: StatsOptions = {}): StatsMiddleware {\n let resolved: ReturnType<typeof resolveConfig> | null = null\n\n return async function statsMiddleware(req: NextRequest): Promise<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 // Self-filter common static paths so users don't need their own\n // `matcher` config in middleware.ts.\n if (isStaticPath(req.nextUrl.pathname)) {\n return NextResponse.next()\n }\n\n await trackRequestInternal(req, runtime)\n return NextResponse.next()\n }\n}\n\n/**\n * Standalone tracker for users who want to call from a route handler or\n * `instrumentation.ts` instead of from middleware.\n */\nexport async function trackRequest(\n req: NextRequest,\n options: StatsOptions = {},\n): Promise<void> {\n const config = resolveConfig(options)\n const runtime = getOrInitRuntime(config)\n await 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\nasync function trackRequestInternal(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<void> {\n try {\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 await runtime.store.track(ip, ua)\n } catch (err) {\n // Never let a tracking failure take down the user's request.\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] track failed:', err)\n }\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_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_RECOMMENDED_SALT_SECRET_LENGTH = 32\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\nlet weakSaltSecretWarned = false\n\nexport function resolveConfig(options: StatsOptions = {}): ResolvedConfig {\n const env =\n typeof process !== 'undefined' && process.env\n ? process.env\n : ({} as Record<string, string | undefined>)\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 createMiddleware({ token }).',\n )\n }\n // Warn (not throw) if the token is short enough to brute-force.\n // Advisory only — 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 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\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 const rawSaltSecret = options.saltSecret ?? env.STATS_SALT_SECRET\n const saltSecret = rawSaltSecret && rawSaltSecret.length > 0 ? rawSaltSecret : null\n if (\n saltSecret &&\n !weakSaltSecretWarned &&\n saltSecret.length < MIN_RECOMMENDED_SALT_SECRET_LENGTH\n ) {\n weakSaltSecretWarned = true\n // eslint-disable-next-line no-console\n console.warn(\n `[statswhatshesaid] Warning: STATS_SALT_SECRET is shorter than ${MIN_RECOMMENDED_SALT_SECRET_LENGTH} characters (${saltSecret.length}). ` +\n 'A weak secret makes the daily salt easier to guess, which weakens the privacy ' +\n 'guarantee that an attacker cannot rederive `(ip, ua)` pairs from their hashes. ' +\n 'Generate a strong secret with: `openssl rand -hex 32`.',\n )\n }\n\n return {\n token,\n endpointPath,\n historyDays,\n maxHistoryDays,\n filterBots,\n trustProxy: rawTrustProxy,\n saltSecret,\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","/**\n * Stateless identity helpers, runtime-agnostic.\n *\n * All crypto here uses the Web Crypto API (`globalThis.crypto.subtle` and\n * `globalThis.crypto.getRandomValues`) so the library can run in both the\n * Next.js Edge runtime and the Node runtime without any conditional code.\n *\n * Web Crypto's `subtle.digest` is async, which makes `computeVisitorHash`\n * async, which in turn makes the middleware hot path async. Next.js\n * supports async middleware natively.\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(): Uint8Array {\n const salt = new Uint8Array(SALT_BYTES)\n globalThis.crypto.getRandomValues(salt)\n return salt\n}\n\n/**\n * Derive a deterministic 32-byte daily salt from a shared secret and a UTC\n * calendar date. Two replicas running with the same `secret` will produce\n * the same daily salt for the same `utcDate`, which is the mathematical\n * precondition for an external collector to merge HLL sketches across\n * replicas.\n *\n * Implementation: HMAC-SHA-256(secret, utcDate). The salt rotates daily,\n * preserving cross-day unlinkability — yesterday's hash of `(ip, ua)` is\n * unrelated to today's hash of the same tuple.\n */\nexport async function deriveDailySalt(\n secret: string,\n utcDate: string,\n): Promise<Uint8Array> {\n const enc = new TextEncoder()\n const key = await globalThis.crypto.subtle.importKey(\n 'raw',\n enc.encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const sig = await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(utcDate))\n return new Uint8Array(sig)\n}\n\n/**\n * Compact identifier for a given salt: SHA-256(salt), truncated to the\n * first 8 bytes, hex-encoded. Lets the collector confirm two replicas are\n * using the same daily salt before merging their sketches. 64 bits is\n * enough to make accidental collisions astronomically unlikely while\n * staying short on the wire.\n */\nexport async function computeSaltFingerprint(salt: Uint8Array): Promise<string> {\n const digest = await globalThis.crypto.subtle.digest('SHA-256', salt)\n const bytes = new Uint8Array(digest, 0, 8)\n let hex = ''\n for (const b of bytes) hex += b.toString(16).padStart(2, '0')\n return hex\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 * Semantics:\n * - `trustProxy === 0` — never read forwarding headers. All requests\n * collapse to a single constant peer.\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.\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 */\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 32-byte SHA-256\n * digest as a `Uint8Array`. The HLL only consumes the first 8 bytes.\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 async function computeVisitorHash(\n ip: string,\n ua: string,\n salt: Uint8Array,\n): Promise<Uint8Array> {\n const enc = new TextEncoder()\n const ipBuf = enc.encode(ip)\n const uaBuf = enc.encode(ua)\n\n // 8-byte length header (two big-endian u32s) + ipBuf + uaBuf + salt.\n const total = new Uint8Array(8 + ipBuf.length + uaBuf.length + salt.length)\n const dv = new DataView(total.buffer)\n dv.setUint32(0, ipBuf.length, false)\n dv.setUint32(4, uaBuf.length, false)\n total.set(ipBuf, 8)\n total.set(uaBuf, 8 + ipBuf.length)\n total.set(salt, 8 + ipBuf.length + uaBuf.length)\n\n const digest = await globalThis.crypto.subtle.digest('SHA-256', total)\n return new Uint8Array(digest)\n}\n\n/**\n * Constant-time string comparison via SHA-256 prehash. Both inputs are\n * hashed to fixed 32-byte buffers and then XOR-compared in constant time,\n * so neither the length nor the content of either input leaks via timing.\n */\nexport async function constantTimeStringEqual(a: string, b: string): Promise<boolean> {\n const enc = new TextEncoder()\n const [ah, bh] = await Promise.all([\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(a)),\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(b)),\n ])\n const av = new Uint8Array(ah)\n const bv = new Uint8Array(bh)\n let diff = 0\n for (let i = 0; i < av.length; i++) {\n diff |= av[i]! ^ bv[i]!\n }\n return diff === 0\n}\n\n/**\n * Conservative list of paths the middleware should NOT track. Lets the user\n * skip the `matcher` config entirely. Only matches well-known static paths,\n * never extension-based, to avoid false positives on routes like\n * `/api/data.json`.\n */\nexport function isStaticPath(pathname: string): boolean {\n if (pathname.startsWith('/_next/')) return true\n // Common well-known files at the root.\n switch (pathname) {\n case '/favicon.ico':\n case '/favicon.svg':\n case '/robots.txt':\n case '/sitemap.xml':\n case '/manifest.json':\n case '/site.webmanifest':\n case '/apple-touch-icon.png':\n case '/apple-touch-icon-precomposed.png':\n return true\n default:\n return false\n }\n}\n","import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { encodeRegistersBase64 } from '@swhsd/hll'\n\nimport { constantTimeStringEqual } from './identity.js'\nimport type { StatsRuntime } from './lifecycle.js'\nimport type { StatsResponseBody, TodayCount } from './types.js'\n\nexport async function handleStatsEndpoint(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<NextResponse> {\n const provided = extractAuthToken(req)\n if (!provided || !(await constantTimeStringEqual(provided, runtime.config.token))) {\n return new NextResponse('Unauthorized', {\n status: 401,\n headers: { 'content-type': 'text/plain; charset=utf-8' },\n })\n }\n\n // Make sure \"today\" in the response always reflects the current UTC day,\n // even if no track() call has triggered a rollover yet.\n runtime.store.rollOverIfNeeded()\n\n const today: TodayCount = {\n date: runtime.store.today,\n uniqueVisitors: runtime.store.estimateToday(),\n }\n\n // Raw format export — only meaningful when shared-salt mode is on, since\n // sketches built from random per-process salts can't be merged across\n // replicas. If the caller asked for raw without shared-salt mode, we\n // respond as if they hadn't asked. (The collector treats absence of\n // `sketch` as \"this server doesn't support merging\".)\n if (isRawFormatRequested(req) && runtime.store.isSharedSaltMode()) {\n const sketch = await runtime.store.exposeTodaySketch()\n today.sketch = encodeRegistersBase64(sketch.registers)\n today.saltFingerprint = sketch.saltFingerprint\n }\n\n const body: StatsResponseBody = {\n today,\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 * The collector requests the raw sketch with `?format=raw`. Query-string\n * only — middleware sometimes strips or rewrites the Accept header, and we\n * want a single authoritative source.\n */\nfunction isRawFormatRequested(req: NextRequest): boolean {\n return req.nextUrl.searchParams.get('format') === 'raw'\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 * 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. Accepts any\n * `Uint8Array` (including Node `Buffer`, which is a subclass).\n */\n addHashBuffer(buf: Uint8Array): void {\n if (buf.length < 8) {\n throw new Error(\n `[statswhatshesaid] HLL hash input must be at least 8 bytes, got ${buf.length}`,\n )\n }\n // Big-endian view of the first 8 bytes. Use DataView so we don't depend\n // on Node's Buffer methods (we want to run in Edge runtime too).\n const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)\n const first = dv.getUint32(0, false)\n const second = dv.getUint32(4, false)\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","/**\n * Functional helpers operating directly on the 16384-byte register array.\n *\n * These are extracted from the `HyperLogLog` class so the collector can\n * fetch raw register arrays over the wire (base64-encoded), merge them\n * register-wise (element-wise max), and compute a single cardinality\n * estimate — without ever constructing a stateful `HyperLogLog` instance.\n */\n\nimport { HLL_REGISTER_COUNT } from './hyperloglog.js'\n\nconst ALPHA_M = 0.7213 / (1 + 1.079 / HLL_REGISTER_COUNT)\n\nfunction assertRegisterLength(arr: Uint8Array, label: string): void {\n if (arr.length !== HLL_REGISTER_COUNT) {\n throw new Error(\n `[swhsd/hll] ${label} must be ${HLL_REGISTER_COUNT} bytes, got ${arr.length}`,\n )\n }\n}\n\n/**\n * Merge two HLL register arrays by element-wise maximum. Returns a fresh\n * array; inputs are not modified.\n *\n * This is mathematically equivalent to feeding every hash that produced\n * either input into a single sketch, **provided both sketches were built\n * with the same daily salt**. The collector must verify that precondition\n * (via `saltFingerprint` from the wire response) before calling this.\n */\nexport function mergeRegisters(a: Uint8Array, b: Uint8Array): Uint8Array {\n assertRegisterLength(a, 'left register array')\n assertRegisterLength(b, 'right register array')\n const out = new Uint8Array(HLL_REGISTER_COUNT)\n for (let i = 0; i < HLL_REGISTER_COUNT; i++) {\n const av = a[i]!\n const bv = b[i]!\n out[i] = av > bv ? av : bv\n }\n return out\n}\n\n/**\n * Merge N register arrays via repeated pairwise merge. Returns a fresh\n * array. Throws if `sketches` is empty.\n */\nexport function mergeManyRegisters(sketches: readonly Uint8Array[]): Uint8Array {\n if (sketches.length === 0) {\n throw new Error('[swhsd/hll] mergeManyRegisters requires at least one input')\n }\n const first = sketches[0]!\n assertRegisterLength(first, 'register array')\n let acc: Uint8Array = new Uint8Array(HLL_REGISTER_COUNT)\n acc.set(first)\n for (let i = 1; i < sketches.length; i++) {\n acc = mergeRegisters(acc, sketches[i]!)\n }\n return acc\n}\n\n/**\n * Estimate cardinality directly from a register array. Same formula and\n * small-range correction as `HyperLogLog.estimate()`.\n */\nexport function estimateRegisters(registers: Uint8Array): number {\n assertRegisterLength(registers, 'register array')\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 = registers[i]!\n sum += 2 ** -r\n if (r === 0) zeros++\n }\n let estimate = (ALPHA_M * m * m) / sum\n if (estimate <= 2.5 * m && zeros > 0) {\n estimate = m * Math.log(m / zeros)\n }\n return Math.round(estimate)\n}\n\n/**\n * Base64 encoder using Web APIs only — works in both Node and Edge runtimes.\n * Produces a fixed ~21,848-character string for our 16,384-byte register\n * array.\n */\nexport function encodeRegistersBase64(registers: Uint8Array): string {\n assertRegisterLength(registers, 'register array')\n // btoa expects a binary string; build it in 8 KB chunks to avoid blowing\n // the argument list when spreading the typed array.\n let binary = ''\n const CHUNK = 0x8000\n for (let i = 0; i < registers.length; i += CHUNK) {\n const slice = registers.subarray(i, i + CHUNK)\n binary += String.fromCharCode(...slice)\n }\n return btoa(binary)\n}\n\n/**\n * Inverse of `encodeRegistersBase64`. Throws if the decoded length is not\n * exactly `HLL_REGISTER_COUNT`.\n */\nexport function decodeRegistersBase64(s: string): Uint8Array {\n const binary = atob(s)\n const out = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n out[i] = binary.charCodeAt(i)\n }\n assertRegisterLength(out, 'decoded register array')\n return out\n}\n","import {\n computeSaltFingerprint,\n computeVisitorHash,\n deriveDailySalt,\n generateSalt,\n utcDateString,\n} from './identity.js'\nimport { HyperLogLog } from '@swhsd/hll'\nimport type { DailyCount } from './types.js'\n\n/**\n * Owns the in-memory live state that `/stats` reads from: today's HLL\n * sketch, today's salt, and finalized historical daily counts.\n *\n * No persistence — counts and history live in process memory only and are\n * lost on process restart. Within a single Edge isolate or Node process,\n * state survives across requests because module-level singletons in Next.js\n * middleware persist for the worker's lifetime.\n *\n * `track` is async because the visitor hash uses Web Crypto's\n * `crypto.subtle.digest`, which has no synchronous counterpart in the Edge\n * runtime. Next.js middleware natively supports async functions.\n *\n * When `saltSecret` is set, the daily salt is derived as\n * `HMAC-SHA-256(saltSecret, today)`. Derivation is async, so it happens\n * lazily on the first `track()` of each day. When unset, salts are\n * generated synchronously with `crypto.getRandomValues`.\n */\nexport class VisitorStore {\n private _today: string\n private _salt: Uint8Array | null\n private readonly _saltSecret: string | null\n private _hll: HyperLogLog\n private _history: Map<string, number>\n\n private constructor(args: {\n today: string\n salt: Uint8Array | null\n saltSecret: string | null\n hll: HyperLogLog\n history: Map<string, number>\n }) {\n this._today = args.today\n this._salt = args.salt\n this._saltSecret = args.saltSecret\n this._hll = args.hll\n this._history = args.history\n }\n\n static fresh(today: string, saltSecret: string | null = null): VisitorStore {\n return new VisitorStore({\n today,\n // In shared-salt mode the salt is derived lazily on first track,\n // since HMAC requires `await crypto.subtle.sign` and we want `fresh()`\n // to stay synchronous.\n salt: saltSecret ? null : generateSalt(),\n saltSecret,\n hll: new HyperLogLog(),\n history: new Map(),\n })\n }\n\n get today(): string {\n return this._today\n }\n\n /** Whether the store is using deterministic (shared) salt derivation. */\n isSharedSaltMode(): boolean {\n return this._saltSecret !== null\n }\n\n /** Estimated unique visitors so far today. */\n estimateToday(): number {\n return this._hll.estimate()\n }\n\n /**\n * Hot path. Lazily rolls over the day if needed (so we don't depend on a\n * background timer that may be unreliable in Edge isolates), then hashes\n * and adds the visitor to the HLL sketch.\n */\n async track(ip: string, ua: string): Promise<void> {\n this.rollOverIfNeeded()\n const salt = await this.getOrDeriveSalt()\n const hash = await computeVisitorHash(ip, ua, salt)\n this._hll.addHashBuffer(hash)\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. Cheap enough to call on every\n * request (one Date allocation, one string compare).\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 = this._saltSecret ? null : generateSalt()\n this._hll = new HyperLogLog()\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 }\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 /**\n * Return today's raw HLL register array plus a fingerprint of the salt,\n * for `/stats?format=raw` consumers. Only meaningful in shared-salt mode\n * — fingerprints from random salts can't be cross-checked across\n * replicas. The caller should gate on `isSharedSaltMode()` before calling.\n */\n async exposeTodaySketch(): Promise<{\n registers: Uint8Array\n saltFingerprint: string\n }> {\n this.rollOverIfNeeded()\n const salt = await this.getOrDeriveSalt()\n return {\n registers: this._hll.cloneRegisters(),\n saltFingerprint: await computeSaltFingerprint(salt),\n }\n }\n\n /**\n * Returns the salt for the current day, deriving it from the shared\n * secret on first call if necessary. Subsequent calls within the same\n * day return the cached value.\n */\n private async getOrDeriveSalt(): Promise<Uint8Array> {\n if (this._salt) return this._salt\n // In shared-salt mode `_salt` is null until first use.\n if (!this._saltSecret) {\n // Defensive — `fresh()` always populates a random salt in non-shared\n // mode, so this branch should be unreachable.\n this._salt = generateSalt()\n return this._salt\n }\n this._salt = await deriveDailySalt(this._saltSecret, this._today)\n return this._salt\n }\n}\n","import { utcDateString } from './identity.js'\nimport { VisitorStore } from './store.js'\nimport type { ResolvedConfig } from './types.js'\n\nexport interface StatsRuntime {\n config: ResolvedConfig\n store: VisitorStore\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 stores or\n * accumulate state across module reloads.\n *\n * In-memory only — no file handles, no timers, no process signal handlers.\n * The store survives for the lifetime of the worker / Edge isolate.\n * Restarting the process resets all counts.\n */\nexport function getOrInitRuntime(config: ResolvedConfig): StatsRuntime {\n if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__\n\n const today = utcDateString(new Date())\n const store = VisitorStore.fresh(today, config.saltSecret)\n store.trimHistory(config.maxHistoryDays)\n\n const runtime: StatsRuntime = { config, store }\n globalThis.__statswhatshesaid__ = runtime\n return runtime\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,IAAAA,iBAA6B;;;ACAtB,IAAM,YACX;AAEK,SAAS,MAAM,IAAwC;AAC5D,MAAI,CAAC,GAAI,QAAO;AAChB,SAAO,UAAU,KAAK,EAAE;AAC1B;;;ACJA,IAAM,wBAAwB;AAC9B,IAAM,uBAAuB;AAC7B,IAAM,2BAA2B;AACjC,IAAM,sBAAsB;AAC5B,IAAM,+BAA+B;AACrC,IAAM,qCAAqC;AAI3C,IAAM,mBAAmB;AACzB,IAAI,kBAAkB;AACtB,IAAI,uBAAuB;AAEpB,SAAS,cAAc,UAAwB,CAAC,GAAmB;AACxE,QAAM,MACJ,OAAO,YAAY,eAAe,QAAQ,MACtC,QAAQ,MACP,CAAC;AAER,QAAM,QAAQ,QAAQ,SAAS,IAAI;AACnC,MAAI,CAAC,OAAO;AACV,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAIA,MAAI,CAAC,mBAAmB,MAAM,SAAS,8BAA8B;AACnE,sBAAkB;AAElB,YAAQ;AAAA,MACN,+DAA+D,4BAA4B,gBAAgB,MAAM,MAAM;AAAA,IAIzH;AAAA,EACF;AAEA,QAAM,kBACJ,QAAQ,gBAAgB,IAAI,uBAAuB;AACrD,QAAM,eAAe,cAAc,eAAe;AAClD,MAAI,CAAC,iBAAiB,KAAK,YAAY,GAAG;AACxC,UAAM,IAAI;AAAA,MACR,4CAA4C,KAAK,UAAU,eAAe,CAAC;AAAA,IAC7E;AAAA,EACF;AAEA,QAAM,cAAc,QAAQ,eAAe;AAC3C,wBAAsB,aAAa,aAAa;AAChD,QAAM,iBAAiB,QAAQ,kBAAkB;AACjD,wBAAsB,gBAAgB,gBAAgB;AACtD,QAAM,aAAa,QAAQ,cAAc;AAEzC,QAAM,gBACJ,QAAQ,cAAc,WAAW,IAAI,mBAAmB,qBAAqB,IAAI;AACnF,MAAI,CAAC,OAAO,UAAU,aAAa,KAAK,gBAAgB,GAAG;AACzD,UAAM,IAAI;AAAA,MACR,gDAAgD,aAAa;AAAA,IAC/D;AAAA,EACF;AAEA,QAAM,gBAAgB,QAAQ,cAAc,IAAI;AAChD,QAAM,aAAa,iBAAiB,cAAc,SAAS,IAAI,gBAAgB;AAC/E,MACE,cACA,CAAC,wBACD,WAAW,SAAS,oCACpB;AACA,2BAAuB;AAEvB,YAAQ;AAAA,MACN,iEAAiE,kCAAkC,gBAAgB,WAAW,MAAM;AAAA,IAItI;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,YAAY;AAAA,IACZ;AAAA,EACF;AACF;AAEA,SAAS,sBAAsB,OAAe,MAAoB;AAChE,MAAI,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,GAAG;AACzC,UAAM,IAAI;AAAA,MACR,sBAAsB,IAAI,wCAAwC,KAAK;AAAA,IACzE;AAAA,EACF;AACF;AAEA,SAAS,WACP,OACA,UACA,YAAY,OACJ;AACR,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,IAAI,OAAO,SAAS,OAAO,EAAE;AACnC,MAAI,CAAC,OAAO,SAAS,CAAC,EAAG,QAAO;AAChC,MAAI,YAAY,IAAI,IAAI,KAAK,EAAG,QAAO;AACvC,SAAO;AACT;AAEA,SAAS,cAAc,GAAmB;AACxC,MAAI,CAAC,EAAE,WAAW,GAAG,EAAG,QAAO,IAAI,CAAC;AACpC,SAAO;AACT;;;ACvGO,SAAS,cAAc,GAAiB;AAC7C,SAAO,EAAE,YAAY,EAAE,MAAM,GAAG,EAAE;AACpC;AAwBO,IAAM,aAAa;AAEnB,SAAS,eAA2B;AACzC,QAAM,OAAO,IAAI,WAAW,UAAU;AACtC,aAAW,OAAO,gBAAgB,IAAI;AACtC,SAAO;AACT;AAaA,eAAsB,gBACpB,QACA,SACqB;AACrB,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,MAAM,MAAM,WAAW,OAAO,OAAO;AAAA,IACzC;AAAA,IACA,IAAI,OAAO,MAAM;AAAA,IACjB,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,QAAM,MAAM,MAAM,WAAW,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,OAAO,OAAO,CAAC;AAChF,SAAO,IAAI,WAAW,GAAG;AAC3B;AASA,eAAsB,uBAAuB,MAAmC;AAC9E,QAAM,SAAS,MAAM,WAAW,OAAO,OAAO,OAAO,WAAW,IAAI;AACpE,QAAM,QAAQ,IAAI,WAAW,QAAQ,GAAG,CAAC;AACzC,MAAI,MAAM;AACV,aAAW,KAAK,MAAO,QAAO,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAC5D,SAAO;AACT;AAGO,IAAM,eAAe;AAmBrB,SAAS,UAAU,SAAkB,YAA4B;AACtE,MAAI,aAAa,EAAG,QAAO;AAE3B,QAAM,MAAM,QAAQ,IAAI,iBAAiB;AACzC,MAAI,CAAC,IAAK,QAAO;AAEjB,QAAM,UAAU,IACb,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC;AAE7B,MAAI,QAAQ,SAAS,WAAY,QAAO;AAGxC,SAAO,QAAQ,QAAQ,SAAS,UAAU;AAC5C;AAaA,eAAsB,mBACpB,IACA,IACA,MACqB;AACrB,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,QAAQ,IAAI,OAAO,EAAE;AAC3B,QAAM,QAAQ,IAAI,OAAO,EAAE;AAG3B,QAAM,QAAQ,IAAI,WAAW,IAAI,MAAM,SAAS,MAAM,SAAS,KAAK,MAAM;AAC1E,QAAM,KAAK,IAAI,SAAS,MAAM,MAAM;AACpC,KAAG,UAAU,GAAG,MAAM,QAAQ,KAAK;AACnC,KAAG,UAAU,GAAG,MAAM,QAAQ,KAAK;AACnC,QAAM,IAAI,OAAO,CAAC;AAClB,QAAM,IAAI,OAAO,IAAI,MAAM,MAAM;AACjC,QAAM,IAAI,MAAM,IAAI,MAAM,SAAS,MAAM,MAAM;AAE/C,QAAM,SAAS,MAAM,WAAW,OAAO,OAAO,OAAO,WAAW,KAAK;AACrE,SAAO,IAAI,WAAW,MAAM;AAC9B;AAOA,eAAsB,wBAAwB,GAAW,GAA6B;AACpF,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,CAAC,IAAI,EAAE,IAAI,MAAM,QAAQ,IAAI;AAAA,IACjC,WAAW,OAAO,OAAO,OAAO,WAAW,IAAI,OAAO,CAAC,CAAC;AAAA,IACxD,WAAW,OAAO,OAAO,OAAO,WAAW,IAAI,OAAO,CAAC,CAAC;AAAA,EAC1D,CAAC;AACD,QAAM,KAAK,IAAI,WAAW,EAAE;AAC5B,QAAM,KAAK,IAAI,WAAW,EAAE;AAC5B,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,GAAG,QAAQ,KAAK;AAClC,YAAQ,GAAG,CAAC,IAAK,GAAG,CAAC;AAAA,EACvB;AACA,SAAO,SAAS;AAClB;AAQO,SAAS,aAAa,UAA2B;AACtD,MAAI,SAAS,WAAW,SAAS,EAAG,QAAO;AAE3C,UAAQ,UAAU;AAAA,IAChB,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AAAA,IACL,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;;;ACxMA,oBAA6B;;;ACiB7B,IAAM,IAAI;AAEH,IAAM,qBAAqB,KAAK;AACvC,IAAM,iBAAiB,KAAK;AAC5B,IAAM,kBAAkB,KAAK,kBAAkB;AAC/C,IAAM,kBAAkB,KAAK;AAC7B,IAAM,WAAW,kBAAkB;AAMnC,IAAM,UAAU,UAAU,IAAI,QAAQ;AAE/B,IAAM,cAAN,MAAM,aAAY;AAAA,EACd;AAAA,EAET,YAAY,WAAwB;AAClC,QAAI,WAAW;AACb,UAAI,UAAU,WAAW,oBAAoB;AAC3C,cAAM,IAAI;AAAA,UACR,4CAA4C,kBAAkB,eAAe,UAAU,MAAM;AAAA,QAC/F;AAAA,MACF;AAEA,WAAK,YAAY,IAAI,WAAW,SAAS;AAAA,IAC3C,OAAO;AACL,WAAK,YAAY,IAAI,WAAW,kBAAkB;AAAA,IACpD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,cAAc,KAAuB;AACnC,QAAI,IAAI,SAAS,GAAG;AAClB,YAAM,IAAI;AAAA,QACR,mEAAmE,IAAI,MAAM;AAAA,MAC/E;AAAA,IACF;AAGA,UAAM,KAAK,IAAI,SAAS,IAAI,QAAQ,IAAI,YAAY,IAAI,UAAU;AAClE,UAAM,QAAQ,GAAG,UAAU,GAAG,KAAK;AACnC,UAAM,SAAS,GAAG,UAAU,GAAG,KAAK;AAGpC,UAAM,MAAM,UAAU;AAGtB,UAAM,WAAW,QAAQ;AACzB,QAAI;AACJ,QAAI,aAAa,GAAG;AAIlB,aAAO,KAAK,MAAM,QAAQ,IAAI,KAAK;AAAA,IACrC,WAAW,WAAW,GAAG;AAEvB,aAAO,KAAK,KAAK,MAAM,MAAM,IAAI;AAAA,IACnC,OAAO;AAEL,aAAO;AAAA,IACT;AAEA,QAAI,OAAO,KAAK,UAAU,GAAG,GAAI;AAC/B,WAAK,UAAU,GAAG,IAAI;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAmB;AACjB,UAAM,IAAI;AACV,QAAI,MAAM;AACV,QAAI,QAAQ;AACZ,aAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,YAAM,IAAI,KAAK,UAAU,CAAC;AAC1B,aAAO,KAAK,CAAC;AACb,UAAI,MAAM,EAAG;AAAA,IACf;AACA,QAAI,WAAY,UAAU,IAAI,IAAK;AAGnC,QAAI,YAAY,MAAM,KAAK,QAAQ,GAAG;AACpC,iBAAW,IAAI,KAAK,IAAI,IAAI,KAAK;AAAA,IACnC;AACA,WAAO,KAAK,MAAM,QAAQ;AAAA,EAC5B;AAAA;AAAA,EAGA,iBAA6B;AAC3B,WAAO,IAAI,WAAW,KAAK,SAAS;AAAA,EACtC;AAAA,EAEA,OAAO,cAAc,WAAoC;AACvD,WAAO,IAAI,aAAY,SAAS;AAAA,EAClC;AACF;;;AC5GA,IAAMC,WAAU,UAAU,IAAI,QAAQ;AAEtC,SAAS,qBAAqB,KAAiB,OAAqB;AAClE,MAAI,IAAI,WAAW,oBAAoB;AACrC,UAAM,IAAI;AAAA,MACR,eAAe,KAAK,YAAY,kBAAkB,eAAe,IAAI,MAAM;AAAA,IAC7E;AAAA,EACF;AACF;AAmEO,SAAS,sBAAsB,WAA+B;AACnE,uBAAqB,WAAW,gBAAgB;AAGhD,MAAI,SAAS;AACb,QAAM,QAAQ;AACd,WAAS,IAAI,GAAG,IAAI,UAAU,QAAQ,KAAK,OAAO;AAChD,UAAM,QAAQ,UAAU,SAAS,GAAG,IAAI,KAAK;AAC7C,cAAU,OAAO,aAAa,GAAG,KAAK;AAAA,EACxC;AACA,SAAO,KAAK,MAAM;AACpB;;;AFxFA,eAAsB,oBACpB,KACA,SACuB;AACvB,QAAM,WAAW,iBAAiB,GAAG;AACrC,MAAI,CAAC,YAAY,CAAE,MAAM,wBAAwB,UAAU,QAAQ,OAAO,KAAK,GAAI;AACjF,WAAO,IAAI,2BAAa,gBAAgB;AAAA,MACtC,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,4BAA4B;AAAA,IACzD,CAAC;AAAA,EACH;AAIA,UAAQ,MAAM,iBAAiB;AAE/B,QAAM,QAAoB;AAAA,IACxB,MAAM,QAAQ,MAAM;AAAA,IACpB,gBAAgB,QAAQ,MAAM,cAAc;AAAA,EAC9C;AAOA,MAAI,qBAAqB,GAAG,KAAK,QAAQ,MAAM,iBAAiB,GAAG;AACjE,UAAM,SAAS,MAAM,QAAQ,MAAM,kBAAkB;AACrD,UAAM,SAAS,sBAAsB,OAAO,SAAS;AACrD,UAAM,kBAAkB,OAAO;AAAA,EACjC;AAEA,QAAM,OAA0B;AAAA,IAC9B;AAAA,IACA,SAAS,QAAQ,MAAM,eAAe,QAAQ,OAAO,WAAW;AAAA,IAChE,cAAa,oBAAI,KAAK,GAAE,YAAY;AAAA,EACtC;AACA,SAAO,2BAAa,KAAK,MAAM;AAAA,IAC7B,SAAS,EAAE,iBAAiB,WAAW;AAAA,EACzC,CAAC;AACH;AAOA,SAAS,qBAAqB,KAA2B;AACvD,SAAO,IAAI,QAAQ,aAAa,IAAI,QAAQ,MAAM;AACpD;AAUA,SAAS,iBAAiB,KAAiC;AACzD,QAAM,OAAO,IAAI,QAAQ,IAAI,eAAe;AAC5C,MAAI,MAAM;AACR,UAAM,QAAQ,uBAAuB,KAAK,IAAI;AAC9C,QAAI,MAAO,QAAO,MAAM,CAAC;AAAA,EAC3B;AACA,SAAO,IAAI,QAAQ,aAAa,IAAI,GAAG;AACzC;;;AG/CO,IAAM,eAAN,MAAM,cAAa;AAAA,EAChB;AAAA,EACA;AAAA,EACS;AAAA,EACT;AAAA,EACA;AAAA,EAEA,YAAY,MAMjB;AACD,SAAK,SAAS,KAAK;AACnB,SAAK,QAAQ,KAAK;AAClB,SAAK,cAAc,KAAK;AACxB,SAAK,OAAO,KAAK;AACjB,SAAK,WAAW,KAAK;AAAA,EACvB;AAAA,EAEA,OAAO,MAAM,OAAe,aAA4B,MAAoB;AAC1E,WAAO,IAAI,cAAa;AAAA,MACtB;AAAA;AAAA;AAAA;AAAA,MAIA,MAAM,aAAa,OAAO,aAAa;AAAA,MACvC;AAAA,MACA,KAAK,IAAI,YAAY;AAAA,MACrB,SAAS,oBAAI,IAAI;AAAA,IACnB,CAAC;AAAA,EACH;AAAA,EAEA,IAAI,QAAgB;AAClB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,mBAA4B;AAC1B,WAAO,KAAK,gBAAgB;AAAA,EAC9B;AAAA;AAAA,EAGA,gBAAwB;AACtB,WAAO,KAAK,KAAK,SAAS;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,MAAM,IAAY,IAA2B;AACjD,SAAK,iBAAiB;AACtB,UAAM,OAAO,MAAM,KAAK,gBAAgB;AACxC,UAAM,OAAO,MAAM,mBAAmB,IAAI,IAAI,IAAI;AAClD,SAAK,KAAK,cAAc,IAAI;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,iBAAiB,MAAY,oBAAI,KAAK,GAAY;AAChD,UAAM,UAAU,cAAc,GAAG;AACjC,QAAI,YAAY,KAAK,OAAQ,QAAO;AAEpC,SAAK,SAAS,IAAI,KAAK,QAAQ,KAAK,KAAK,SAAS,CAAC;AACnD,SAAK,SAAS;AACd,SAAK,QAAQ,KAAK,cAAc,OAAO,aAAa;AACpD,SAAK,OAAO,IAAI,YAAY;AAC5B,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,YAAY,SAAuB;AACjC,QAAI,WAAW,EAAG;AAClB,QAAI,KAAK,SAAS,QAAQ,QAAS;AACnC,UAAM,aAAa,CAAC,GAAG,KAAK,SAAS,KAAK,CAAC,EAAE,KAAK,EAAE,QAAQ;AAC5D,aAAS,IAAI,SAAS,IAAI,WAAW,QAAQ,KAAK;AAChD,WAAK,SAAS,OAAO,WAAW,CAAC,CAAE;AAAA,IACrC;AAAA,EACF;AAAA;AAAA,EAGA,eAAe,OAA6B;AAC1C,UAAM,OAAqB,CAAC;AAC5B,eAAW,CAAC,MAAM,KAAK,KAAK,KAAK,UAAU;AACzC,UAAI,SAAS,KAAK,OAAQ;AAC1B,WAAK,KAAK,EAAE,MAAM,gBAAgB,MAAM,CAAC;AAAA,IAC3C;AACA,SAAK,KAAK,CAAC,GAAG,MAAO,EAAE,OAAO,EAAE,OAAO,IAAI,EAAE,OAAO,EAAE,OAAO,KAAK,CAAE;AACpE,WAAO,KAAK,MAAM,GAAG,KAAK;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,oBAGH;AACD,SAAK,iBAAiB;AACtB,UAAM,OAAO,MAAM,KAAK,gBAAgB;AACxC,WAAO;AAAA,MACL,WAAW,KAAK,KAAK,eAAe;AAAA,MACpC,iBAAiB,MAAM,uBAAuB,IAAI;AAAA,IACpD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAc,kBAAuC;AACnD,QAAI,KAAK,MAAO,QAAO,KAAK;AAE5B,QAAI,CAAC,KAAK,aAAa;AAGrB,WAAK,QAAQ,aAAa;AAC1B,aAAO,KAAK;AAAA,IACd;AACA,SAAK,QAAQ,MAAM,gBAAgB,KAAK,aAAa,KAAK,MAAM;AAChE,WAAO,KAAK;AAAA,EACd;AACF;;;AC1IO,SAAS,iBAAiB,QAAsC;AACrE,MAAI,WAAW,qBAAsB,QAAO,WAAW;AAEvD,QAAM,QAAQ,cAAc,oBAAI,KAAK,CAAC;AACtC,QAAM,QAAQ,aAAa,MAAM,OAAO,OAAO,UAAU;AACzD,QAAM,YAAY,OAAO,cAAc;AAEvC,QAAM,UAAwB,EAAE,QAAQ,MAAM;AAC9C,aAAW,uBAAuB;AAClC,SAAO;AACT;;;ARbO,SAAS,iBAAiB,UAAwB,CAAC,GAAoB;AAC5E,MAAI,WAAoD;AAExD,SAAO,eAAe,gBAAgB,KAAyC;AAC7E,QAAI,CAAC,SAAU,YAAW,cAAc,OAAO;AAC/C,UAAM,UAAU,iBAAiB,QAAQ;AAGzC,QAAI,IAAI,QAAQ,aAAa,SAAS,cAAc;AAClD,aAAO,oBAAoB,KAAK,OAAO;AAAA,IACzC;AAIA,QAAI,aAAa,IAAI,QAAQ,QAAQ,GAAG;AACtC,aAAO,4BAAa,KAAK;AAAA,IAC3B;AAEA,UAAM,qBAAqB,KAAK,OAAO;AACvC,WAAO,4BAAa,KAAK;AAAA,EAC3B;AACF;AAMA,eAAsB,aACpB,KACA,UAAwB,CAAC,GACV;AACf,QAAM,SAAS,cAAc,OAAO;AACpC,QAAM,UAAU,iBAAiB,MAAM;AACvC,QAAM,qBAAqB,KAAK,OAAO;AACzC;AAOA,IAAM,gBAAgB;AAEtB,eAAe,qBACb,KACA,SACe;AACf,MAAI;AACF,UAAM,QAAQ,IAAI,QAAQ,IAAI,YAAY,KAAK;AAG/C,UAAM,KAAK,MAAM,SAAS,gBAAgB,MAAM,MAAM,GAAG,aAAa,IAAI;AAC1E,QAAI,QAAQ,OAAO,cAAc,MAAM,EAAE,EAAG;AAE5C,UAAM,KAAK,UAAU,IAAI,SAAS,QAAQ,OAAO,UAAU;AAC3D,UAAM,QAAQ,MAAM,MAAM,IAAI,EAAE;AAAA,EAClC,SAAS,KAAK;AAGZ,YAAQ,MAAM,oCAAoC,GAAG;AAAA,EACvD;AACF;;;AD1DA,IAAM,oBAAoB,iBAAiB;AAE3C,IAAO,gBAAQ;","names":["import_server","ALPHA_M"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -24,13 +24,40 @@ interface StatsOptions {
|
|
|
24
24
|
* See the Security section of the README for configuration examples.
|
|
25
25
|
*/
|
|
26
26
|
trustProxy?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Shared secret used to derive the daily HLL salt deterministically.
|
|
29
|
+
*
|
|
30
|
+
* When set, the daily salt becomes `HMAC-SHA-256(saltSecret, utcDate)`
|
|
31
|
+
* instead of a random per-process value. Two replicas running with the
|
|
32
|
+
* same `saltSecret` will derive identical daily salts — the mathematical
|
|
33
|
+
* precondition for an external collector to merge HLL sketches across
|
|
34
|
+
* replicas and report a correct union cardinality.
|
|
35
|
+
*
|
|
36
|
+
* When unset, salts remain random per-process (the previous behavior).
|
|
37
|
+
* Falls back to the `STATS_SALT_SECRET` env var.
|
|
38
|
+
*
|
|
39
|
+
* Cross-day unlinkability is preserved either way — the salt still
|
|
40
|
+
* rotates daily.
|
|
41
|
+
*/
|
|
42
|
+
saltSecret?: string;
|
|
27
43
|
}
|
|
28
44
|
interface DailyCount {
|
|
29
45
|
date: string;
|
|
30
46
|
uniqueVisitors: number;
|
|
31
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* `today` in the /stats response. Has the same `date` + `uniqueVisitors`
|
|
50
|
+
* fields as a historical day. When the request asks for raw format AND
|
|
51
|
+
* shared-salt mode is active, the response also includes the raw HLL
|
|
52
|
+
* register array (base64) and a fingerprint of the salt — enough for the
|
|
53
|
+
* external collector to merge sketches across replicas.
|
|
54
|
+
*/
|
|
55
|
+
interface TodayCount extends DailyCount {
|
|
56
|
+
sketch?: string;
|
|
57
|
+
saltFingerprint?: string;
|
|
58
|
+
}
|
|
32
59
|
interface StatsResponseBody {
|
|
33
|
-
today:
|
|
60
|
+
today: TodayCount;
|
|
34
61
|
history: DailyCount[];
|
|
35
62
|
generatedAt: string;
|
|
36
63
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -24,13 +24,40 @@ interface StatsOptions {
|
|
|
24
24
|
* See the Security section of the README for configuration examples.
|
|
25
25
|
*/
|
|
26
26
|
trustProxy?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Shared secret used to derive the daily HLL salt deterministically.
|
|
29
|
+
*
|
|
30
|
+
* When set, the daily salt becomes `HMAC-SHA-256(saltSecret, utcDate)`
|
|
31
|
+
* instead of a random per-process value. Two replicas running with the
|
|
32
|
+
* same `saltSecret` will derive identical daily salts — the mathematical
|
|
33
|
+
* precondition for an external collector to merge HLL sketches across
|
|
34
|
+
* replicas and report a correct union cardinality.
|
|
35
|
+
*
|
|
36
|
+
* When unset, salts remain random per-process (the previous behavior).
|
|
37
|
+
* Falls back to the `STATS_SALT_SECRET` env var.
|
|
38
|
+
*
|
|
39
|
+
* Cross-day unlinkability is preserved either way — the salt still
|
|
40
|
+
* rotates daily.
|
|
41
|
+
*/
|
|
42
|
+
saltSecret?: string;
|
|
27
43
|
}
|
|
28
44
|
interface DailyCount {
|
|
29
45
|
date: string;
|
|
30
46
|
uniqueVisitors: number;
|
|
31
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* `today` in the /stats response. Has the same `date` + `uniqueVisitors`
|
|
50
|
+
* fields as a historical day. When the request asks for raw format AND
|
|
51
|
+
* shared-salt mode is active, the response also includes the raw HLL
|
|
52
|
+
* register array (base64) and a fingerprint of the salt — enough for the
|
|
53
|
+
* external collector to merge sketches across replicas.
|
|
54
|
+
*/
|
|
55
|
+
interface TodayCount extends DailyCount {
|
|
56
|
+
sketch?: string;
|
|
57
|
+
saltFingerprint?: string;
|
|
58
|
+
}
|
|
32
59
|
interface StatsResponseBody {
|
|
33
|
-
today:
|
|
60
|
+
today: TodayCount;
|
|
34
61
|
history: DailyCount[];
|
|
35
62
|
generatedAt: string;
|
|
36
63
|
}
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,10 @@ var DEFAULT_HISTORY_DAYS = 90;
|
|
|
14
14
|
var DEFAULT_MAX_HISTORY_DAYS = 365;
|
|
15
15
|
var DEFAULT_TRUST_PROXY = 1;
|
|
16
16
|
var MIN_RECOMMENDED_TOKEN_LENGTH = 32;
|
|
17
|
+
var MIN_RECOMMENDED_SALT_SECRET_LENGTH = 32;
|
|
17
18
|
var ENDPOINT_PATH_RE = /^\/[A-Za-z0-9\-._~/]*$/;
|
|
18
19
|
var weakTokenWarned = false;
|
|
20
|
+
var weakSaltSecretWarned = false;
|
|
19
21
|
function resolveConfig(options = {}) {
|
|
20
22
|
const env = typeof process !== "undefined" && process.env ? process.env : {};
|
|
21
23
|
const token = options.token ?? env.STATS_TOKEN;
|
|
@@ -48,13 +50,22 @@ function resolveConfig(options = {}) {
|
|
|
48
50
|
`[statswhatshesaid] Invalid trustProxy value: ${rawTrustProxy}. Must be a non-negative integer (0, 1, 2, ...).`
|
|
49
51
|
);
|
|
50
52
|
}
|
|
53
|
+
const rawSaltSecret = options.saltSecret ?? env.STATS_SALT_SECRET;
|
|
54
|
+
const saltSecret = rawSaltSecret && rawSaltSecret.length > 0 ? rawSaltSecret : null;
|
|
55
|
+
if (saltSecret && !weakSaltSecretWarned && saltSecret.length < MIN_RECOMMENDED_SALT_SECRET_LENGTH) {
|
|
56
|
+
weakSaltSecretWarned = true;
|
|
57
|
+
console.warn(
|
|
58
|
+
`[statswhatshesaid] Warning: STATS_SALT_SECRET is shorter than ${MIN_RECOMMENDED_SALT_SECRET_LENGTH} characters (${saltSecret.length}). A weak secret makes the daily salt easier to guess, which weakens the privacy guarantee that an attacker cannot rederive \`(ip, ua)\` pairs from their hashes. Generate a strong secret with: \`openssl rand -hex 32\`.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
51
61
|
return {
|
|
52
62
|
token,
|
|
53
63
|
endpointPath,
|
|
54
64
|
historyDays,
|
|
55
65
|
maxHistoryDays,
|
|
56
66
|
filterBots,
|
|
57
|
-
trustProxy: rawTrustProxy
|
|
67
|
+
trustProxy: rawTrustProxy,
|
|
68
|
+
saltSecret
|
|
58
69
|
};
|
|
59
70
|
}
|
|
60
71
|
function requireNonNegativeInt(value, name) {
|
|
@@ -86,6 +97,25 @@ function generateSalt() {
|
|
|
86
97
|
globalThis.crypto.getRandomValues(salt);
|
|
87
98
|
return salt;
|
|
88
99
|
}
|
|
100
|
+
async function deriveDailySalt(secret, utcDate) {
|
|
101
|
+
const enc = new TextEncoder();
|
|
102
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
103
|
+
"raw",
|
|
104
|
+
enc.encode(secret),
|
|
105
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
106
|
+
false,
|
|
107
|
+
["sign"]
|
|
108
|
+
);
|
|
109
|
+
const sig = await globalThis.crypto.subtle.sign("HMAC", key, enc.encode(utcDate));
|
|
110
|
+
return new Uint8Array(sig);
|
|
111
|
+
}
|
|
112
|
+
async function computeSaltFingerprint(salt) {
|
|
113
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", salt);
|
|
114
|
+
const bytes = new Uint8Array(digest, 0, 8);
|
|
115
|
+
let hex = "";
|
|
116
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
117
|
+
return hex;
|
|
118
|
+
}
|
|
89
119
|
var UNKNOWN_PEER = "0.0.0.0";
|
|
90
120
|
function extractIp(headers, trustProxy) {
|
|
91
121
|
if (trustProxy < 1) return UNKNOWN_PEER;
|
|
@@ -142,37 +172,8 @@ function isStaticPath(pathname) {
|
|
|
142
172
|
|
|
143
173
|
// src/endpoint.ts
|
|
144
174
|
import { NextResponse } from "next/server";
|
|
145
|
-
async function handleStatsEndpoint(req, runtime) {
|
|
146
|
-
const provided = extractAuthToken(req);
|
|
147
|
-
if (!provided || !await constantTimeStringEqual(provided, runtime.config.token)) {
|
|
148
|
-
return new NextResponse("Unauthorized", {
|
|
149
|
-
status: 401,
|
|
150
|
-
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
runtime.store.rollOverIfNeeded();
|
|
154
|
-
const body = {
|
|
155
|
-
today: {
|
|
156
|
-
date: runtime.store.today,
|
|
157
|
-
uniqueVisitors: runtime.store.estimateToday()
|
|
158
|
-
},
|
|
159
|
-
history: runtime.store.getHistoryDesc(runtime.config.historyDays),
|
|
160
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
161
|
-
};
|
|
162
|
-
return NextResponse.json(body, {
|
|
163
|
-
headers: { "cache-control": "no-store" }
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
function extractAuthToken(req) {
|
|
167
|
-
const auth = req.headers.get("authorization");
|
|
168
|
-
if (auth) {
|
|
169
|
-
const match = /^Bearer\s+(\S+)\s*$/i.exec(auth);
|
|
170
|
-
if (match) return match[1];
|
|
171
|
-
}
|
|
172
|
-
return req.nextUrl.searchParams.get("t");
|
|
173
|
-
}
|
|
174
175
|
|
|
175
|
-
// src/
|
|
176
|
+
// ../hll/src/hyperloglog.ts
|
|
176
177
|
var P = 14;
|
|
177
178
|
var HLL_REGISTER_COUNT = 1 << P;
|
|
178
179
|
var TAIL_HIGH_BITS = 32 - P;
|
|
@@ -250,22 +251,88 @@ var HyperLogLog = class _HyperLogLog {
|
|
|
250
251
|
}
|
|
251
252
|
};
|
|
252
253
|
|
|
254
|
+
// ../hll/src/merge.ts
|
|
255
|
+
var ALPHA_M2 = 0.7213 / (1 + 1.079 / HLL_REGISTER_COUNT);
|
|
256
|
+
function assertRegisterLength(arr, label) {
|
|
257
|
+
if (arr.length !== HLL_REGISTER_COUNT) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`[swhsd/hll] ${label} must be ${HLL_REGISTER_COUNT} bytes, got ${arr.length}`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function encodeRegistersBase64(registers) {
|
|
264
|
+
assertRegisterLength(registers, "register array");
|
|
265
|
+
let binary = "";
|
|
266
|
+
const CHUNK = 32768;
|
|
267
|
+
for (let i = 0; i < registers.length; i += CHUNK) {
|
|
268
|
+
const slice = registers.subarray(i, i + CHUNK);
|
|
269
|
+
binary += String.fromCharCode(...slice);
|
|
270
|
+
}
|
|
271
|
+
return btoa(binary);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/endpoint.ts
|
|
275
|
+
async function handleStatsEndpoint(req, runtime) {
|
|
276
|
+
const provided = extractAuthToken(req);
|
|
277
|
+
if (!provided || !await constantTimeStringEqual(provided, runtime.config.token)) {
|
|
278
|
+
return new NextResponse("Unauthorized", {
|
|
279
|
+
status: 401,
|
|
280
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
runtime.store.rollOverIfNeeded();
|
|
284
|
+
const today = {
|
|
285
|
+
date: runtime.store.today,
|
|
286
|
+
uniqueVisitors: runtime.store.estimateToday()
|
|
287
|
+
};
|
|
288
|
+
if (isRawFormatRequested(req) && runtime.store.isSharedSaltMode()) {
|
|
289
|
+
const sketch = await runtime.store.exposeTodaySketch();
|
|
290
|
+
today.sketch = encodeRegistersBase64(sketch.registers);
|
|
291
|
+
today.saltFingerprint = sketch.saltFingerprint;
|
|
292
|
+
}
|
|
293
|
+
const body = {
|
|
294
|
+
today,
|
|
295
|
+
history: runtime.store.getHistoryDesc(runtime.config.historyDays),
|
|
296
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
297
|
+
};
|
|
298
|
+
return NextResponse.json(body, {
|
|
299
|
+
headers: { "cache-control": "no-store" }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
function isRawFormatRequested(req) {
|
|
303
|
+
return req.nextUrl.searchParams.get("format") === "raw";
|
|
304
|
+
}
|
|
305
|
+
function extractAuthToken(req) {
|
|
306
|
+
const auth = req.headers.get("authorization");
|
|
307
|
+
if (auth) {
|
|
308
|
+
const match = /^Bearer\s+(\S+)\s*$/i.exec(auth);
|
|
309
|
+
if (match) return match[1];
|
|
310
|
+
}
|
|
311
|
+
return req.nextUrl.searchParams.get("t");
|
|
312
|
+
}
|
|
313
|
+
|
|
253
314
|
// src/store.ts
|
|
254
315
|
var VisitorStore = class _VisitorStore {
|
|
255
316
|
_today;
|
|
256
317
|
_salt;
|
|
318
|
+
_saltSecret;
|
|
257
319
|
_hll;
|
|
258
320
|
_history;
|
|
259
321
|
constructor(args) {
|
|
260
322
|
this._today = args.today;
|
|
261
323
|
this._salt = args.salt;
|
|
324
|
+
this._saltSecret = args.saltSecret;
|
|
262
325
|
this._hll = args.hll;
|
|
263
326
|
this._history = args.history;
|
|
264
327
|
}
|
|
265
|
-
static fresh(today) {
|
|
328
|
+
static fresh(today, saltSecret = null) {
|
|
266
329
|
return new _VisitorStore({
|
|
267
330
|
today,
|
|
268
|
-
salt
|
|
331
|
+
// In shared-salt mode the salt is derived lazily on first track,
|
|
332
|
+
// since HMAC requires `await crypto.subtle.sign` and we want `fresh()`
|
|
333
|
+
// to stay synchronous.
|
|
334
|
+
salt: saltSecret ? null : generateSalt(),
|
|
335
|
+
saltSecret,
|
|
269
336
|
hll: new HyperLogLog(),
|
|
270
337
|
history: /* @__PURE__ */ new Map()
|
|
271
338
|
});
|
|
@@ -273,6 +340,10 @@ var VisitorStore = class _VisitorStore {
|
|
|
273
340
|
get today() {
|
|
274
341
|
return this._today;
|
|
275
342
|
}
|
|
343
|
+
/** Whether the store is using deterministic (shared) salt derivation. */
|
|
344
|
+
isSharedSaltMode() {
|
|
345
|
+
return this._saltSecret !== null;
|
|
346
|
+
}
|
|
276
347
|
/** Estimated unique visitors so far today. */
|
|
277
348
|
estimateToday() {
|
|
278
349
|
return this._hll.estimate();
|
|
@@ -284,7 +355,8 @@ var VisitorStore = class _VisitorStore {
|
|
|
284
355
|
*/
|
|
285
356
|
async track(ip, ua) {
|
|
286
357
|
this.rollOverIfNeeded();
|
|
287
|
-
const
|
|
358
|
+
const salt = await this.getOrDeriveSalt();
|
|
359
|
+
const hash = await computeVisitorHash(ip, ua, salt);
|
|
288
360
|
this._hll.addHashBuffer(hash);
|
|
289
361
|
}
|
|
290
362
|
/**
|
|
@@ -298,7 +370,7 @@ var VisitorStore = class _VisitorStore {
|
|
|
298
370
|
if (current === this._today) return false;
|
|
299
371
|
this._history.set(this._today, this._hll.estimate());
|
|
300
372
|
this._today = current;
|
|
301
|
-
this._salt = generateSalt();
|
|
373
|
+
this._salt = this._saltSecret ? null : generateSalt();
|
|
302
374
|
this._hll = new HyperLogLog();
|
|
303
375
|
return true;
|
|
304
376
|
}
|
|
@@ -321,13 +393,41 @@ var VisitorStore = class _VisitorStore {
|
|
|
321
393
|
rows.sort((a, b) => a.date < b.date ? 1 : a.date > b.date ? -1 : 0);
|
|
322
394
|
return rows.slice(0, limit);
|
|
323
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Return today's raw HLL register array plus a fingerprint of the salt,
|
|
398
|
+
* for `/stats?format=raw` consumers. Only meaningful in shared-salt mode
|
|
399
|
+
* — fingerprints from random salts can't be cross-checked across
|
|
400
|
+
* replicas. The caller should gate on `isSharedSaltMode()` before calling.
|
|
401
|
+
*/
|
|
402
|
+
async exposeTodaySketch() {
|
|
403
|
+
this.rollOverIfNeeded();
|
|
404
|
+
const salt = await this.getOrDeriveSalt();
|
|
405
|
+
return {
|
|
406
|
+
registers: this._hll.cloneRegisters(),
|
|
407
|
+
saltFingerprint: await computeSaltFingerprint(salt)
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Returns the salt for the current day, deriving it from the shared
|
|
412
|
+
* secret on first call if necessary. Subsequent calls within the same
|
|
413
|
+
* day return the cached value.
|
|
414
|
+
*/
|
|
415
|
+
async getOrDeriveSalt() {
|
|
416
|
+
if (this._salt) return this._salt;
|
|
417
|
+
if (!this._saltSecret) {
|
|
418
|
+
this._salt = generateSalt();
|
|
419
|
+
return this._salt;
|
|
420
|
+
}
|
|
421
|
+
this._salt = await deriveDailySalt(this._saltSecret, this._today);
|
|
422
|
+
return this._salt;
|
|
423
|
+
}
|
|
324
424
|
};
|
|
325
425
|
|
|
326
426
|
// src/lifecycle.ts
|
|
327
427
|
function getOrInitRuntime(config) {
|
|
328
428
|
if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__;
|
|
329
429
|
const today = utcDateString(/* @__PURE__ */ new Date());
|
|
330
|
-
const store = VisitorStore.fresh(today);
|
|
430
|
+
const store = VisitorStore.fresh(today, config.saltSecret);
|
|
331
431
|
store.trimHistory(config.maxHistoryDays);
|
|
332
432
|
const runtime = { config, store };
|
|
333
433
|
globalThis.__statswhatshesaid__ = runtime;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/middleware.ts","../src/bots.ts","../src/config.ts","../src/identity.ts","../src/endpoint.ts","../src/hll.ts","../src/store.ts","../src/lifecycle.ts","../src/index.ts"],"sourcesContent":["import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { isBot } from './bots.js'\nimport { resolveConfig } from './config.js'\nimport { extractIp, isStaticPath } 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) => Promise<NextResponse>\n\n/**\n * Build a Next.js middleware that tracks unique visitors.\n *\n * Returns an `async` function compatible with Next.js's middleware contract.\n * Lazy config resolution: the closure does not call `resolveConfig` until\n * the first request, so module-load (and `next build`) won't fail just\n * because `STATS_TOKEN` isn't set yet.\n */\nexport function createMiddleware(options: StatsOptions = {}): StatsMiddleware {\n let resolved: ReturnType<typeof resolveConfig> | null = null\n\n return async function statsMiddleware(req: NextRequest): Promise<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 // Self-filter common static paths so users don't need their own\n // `matcher` config in middleware.ts.\n if (isStaticPath(req.nextUrl.pathname)) {\n return NextResponse.next()\n }\n\n await trackRequestInternal(req, runtime)\n return NextResponse.next()\n }\n}\n\n/**\n * Standalone tracker for users who want to call from a route handler or\n * `instrumentation.ts` instead of from middleware.\n */\nexport async function trackRequest(\n req: NextRequest,\n options: StatsOptions = {},\n): Promise<void> {\n const config = resolveConfig(options)\n const runtime = getOrInitRuntime(config)\n await 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\nasync function trackRequestInternal(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<void> {\n try {\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 await runtime.store.track(ip, ua)\n } catch (err) {\n // Never let a tracking failure take down the user's request.\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] track failed:', err)\n }\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_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\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 =\n typeof process !== 'undefined' && process.env\n ? process.env\n : ({} as Record<string, string | undefined>)\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 createMiddleware({ token }).',\n )\n }\n // Warn (not throw) if the token is short enough to brute-force.\n // Advisory only — 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 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\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 endpointPath,\n historyDays,\n maxHistoryDays,\n filterBots,\n trustProxy: rawTrustProxy,\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","/**\n * Stateless identity helpers, runtime-agnostic.\n *\n * All crypto here uses the Web Crypto API (`globalThis.crypto.subtle` and\n * `globalThis.crypto.getRandomValues`) so the library can run in both the\n * Next.js Edge runtime and the Node runtime without any conditional code.\n *\n * Web Crypto's `subtle.digest` is async, which makes `computeVisitorHash`\n * async, which in turn makes the middleware hot path async. Next.js\n * supports async middleware natively.\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(): Uint8Array {\n const salt = new Uint8Array(SALT_BYTES)\n globalThis.crypto.getRandomValues(salt)\n return salt\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 * Semantics:\n * - `trustProxy === 0` — never read forwarding headers. All requests\n * collapse to a single constant peer.\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.\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 */\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 32-byte SHA-256\n * digest as a `Uint8Array`. The HLL only consumes the first 8 bytes.\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 async function computeVisitorHash(\n ip: string,\n ua: string,\n salt: Uint8Array,\n): Promise<Uint8Array> {\n const enc = new TextEncoder()\n const ipBuf = enc.encode(ip)\n const uaBuf = enc.encode(ua)\n\n // 8-byte length header (two big-endian u32s) + ipBuf + uaBuf + salt.\n const total = new Uint8Array(8 + ipBuf.length + uaBuf.length + salt.length)\n const dv = new DataView(total.buffer)\n dv.setUint32(0, ipBuf.length, false)\n dv.setUint32(4, uaBuf.length, false)\n total.set(ipBuf, 8)\n total.set(uaBuf, 8 + ipBuf.length)\n total.set(salt, 8 + ipBuf.length + uaBuf.length)\n\n const digest = await globalThis.crypto.subtle.digest('SHA-256', total)\n return new Uint8Array(digest)\n}\n\n/**\n * Constant-time string comparison via SHA-256 prehash. Both inputs are\n * hashed to fixed 32-byte buffers and then XOR-compared in constant time,\n * so neither the length nor the content of either input leaks via timing.\n */\nexport async function constantTimeStringEqual(a: string, b: string): Promise<boolean> {\n const enc = new TextEncoder()\n const [ah, bh] = await Promise.all([\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(a)),\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(b)),\n ])\n const av = new Uint8Array(ah)\n const bv = new Uint8Array(bh)\n let diff = 0\n for (let i = 0; i < av.length; i++) {\n diff |= av[i]! ^ bv[i]!\n }\n return diff === 0\n}\n\n/**\n * Conservative list of paths the middleware should NOT track. Lets the user\n * skip the `matcher` config entirely. Only matches well-known static paths,\n * never extension-based, to avoid false positives on routes like\n * `/api/data.json`.\n */\nexport function isStaticPath(pathname: string): boolean {\n if (pathname.startsWith('/_next/')) return true\n // Common well-known files at the root.\n switch (pathname) {\n case '/favicon.ico':\n case '/favicon.svg':\n case '/robots.txt':\n case '/sitemap.xml':\n case '/manifest.json':\n case '/site.webmanifest':\n case '/apple-touch-icon.png':\n case '/apple-touch-icon-precomposed.png':\n return true\n default:\n return false\n }\n}\n","import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { constantTimeStringEqual } from './identity.js'\nimport type { StatsRuntime } from './lifecycle.js'\nimport type { StatsResponseBody } from './types.js'\n\nexport async function handleStatsEndpoint(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<NextResponse> {\n const provided = extractAuthToken(req)\n if (!provided || !(await constantTimeStringEqual(provided, runtime.config.token))) {\n return new NextResponse('Unauthorized', {\n status: 401,\n headers: { 'content-type': 'text/plain; charset=utf-8' },\n })\n }\n\n // Make sure \"today\" in the response always reflects the current UTC day,\n // even if no track() call has triggered a rollover yet.\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 * 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. Accepts any\n * `Uint8Array` (including Node `Buffer`, which is a subclass).\n */\n addHashBuffer(buf: Uint8Array): void {\n if (buf.length < 8) {\n throw new Error(\n `[statswhatshesaid] HLL hash input must be at least 8 bytes, got ${buf.length}`,\n )\n }\n // Big-endian view of the first 8 bytes. Use DataView so we don't depend\n // on Node's Buffer methods (we want to run in Edge runtime too).\n const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)\n const first = dv.getUint32(0, false)\n const second = dv.getUint32(4, false)\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 { computeVisitorHash, generateSalt, utcDateString } from './identity.js'\nimport { HyperLogLog } from './hll.js'\nimport type { DailyCount } from './types.js'\n\n/**\n * Owns the in-memory live state that `/stats` reads from: today's HLL\n * sketch, today's salt, and finalized historical daily counts.\n *\n * No persistence — counts and history live in process memory only and are\n * lost on process restart. Within a single Edge isolate or Node process,\n * state survives across requests because module-level singletons in Next.js\n * middleware persist for the worker's lifetime.\n *\n * `track` is async because the visitor hash uses Web Crypto's\n * `crypto.subtle.digest`, which has no synchronous counterpart in the Edge\n * runtime. Next.js middleware natively supports async functions.\n */\nexport class VisitorStore {\n private _today: string\n private _salt: Uint8Array\n private _hll: HyperLogLog\n private _history: Map<string, number>\n\n private constructor(args: {\n today: string\n salt: Uint8Array\n hll: HyperLogLog\n history: Map<string, number>\n }) {\n this._today = args.today\n this._salt = args.salt\n this._hll = args.hll\n this._history = args.history\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 })\n }\n\n get today(): string {\n return this._today\n }\n\n /** Estimated unique visitors so far today. */\n estimateToday(): number {\n return this._hll.estimate()\n }\n\n /**\n * Hot path. Lazily rolls over the day if needed (so we don't depend on a\n * background timer that may be unreliable in Edge isolates), then hashes\n * and adds the visitor to the HLL sketch.\n */\n async track(ip: string, ua: string): Promise<void> {\n this.rollOverIfNeeded()\n const hash = await computeVisitorHash(ip, ua, this._salt)\n this._hll.addHashBuffer(hash)\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. Cheap enough to call on every\n * request (one Date allocation, one string compare).\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 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 }\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","import { utcDateString } from './identity.js'\nimport { VisitorStore } from './store.js'\nimport type { ResolvedConfig } from './types.js'\n\nexport interface StatsRuntime {\n config: ResolvedConfig\n store: VisitorStore\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 stores or\n * accumulate state across module reloads.\n *\n * In-memory only — no file handles, no timers, no process signal handlers.\n * The store survives for the lifetime of the worker / Edge isolate.\n * Restarting the process resets all counts.\n */\nexport function getOrInitRuntime(config: ResolvedConfig): StatsRuntime {\n if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__\n\n const today = utcDateString(new Date())\n const store = VisitorStore.fresh(today)\n store.trimHistory(config.maxHistoryDays)\n\n const runtime: StatsRuntime = { config, store }\n globalThis.__statswhatshesaid__ = runtime\n return runtime\n}\n","import { createMiddleware, trackRequest } from './middleware.js'\n\n/**\n * Pre-instantiated default middleware. Lets users drop the library in with\n * a single line in their `middleware.ts`:\n *\n * ```ts\n * export { default } from 'statswhatshesaid'\n * ```\n *\n * The default middleware reads its configuration from environment variables\n * (`STATS_TOKEN` is required, the rest have sensible defaults). Config\n * resolution is deferred to the first request, so `next build` works fine\n * without `STATS_TOKEN` set — the error only fires at runtime.\n *\n * For customized configuration, import `createMiddleware` and call it with\n * your options:\n *\n * ```ts\n * import { createMiddleware } from 'statswhatshesaid'\n * export default createMiddleware({ filterBots: false })\n * ```\n */\nconst defaultMiddleware = createMiddleware()\n\nexport default defaultMiddleware\nexport { createMiddleware, trackRequest }\nexport type { StatsOptions, StatsResponseBody, DailyCount } from './types.js'\nexport type { StatsMiddleware } from './middleware.js'\n"],"mappings":"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","names":["NextResponse","NextResponse"]}
|
|
1
|
+
{"version":3,"sources":["../src/middleware.ts","../src/bots.ts","../src/config.ts","../src/identity.ts","../src/endpoint.ts","../../hll/src/hyperloglog.ts","../../hll/src/merge.ts","../src/store.ts","../src/lifecycle.ts","../src/index.ts"],"sourcesContent":["import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { isBot } from './bots.js'\nimport { resolveConfig } from './config.js'\nimport { extractIp, isStaticPath } 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) => Promise<NextResponse>\n\n/**\n * Build a Next.js middleware that tracks unique visitors.\n *\n * Returns an `async` function compatible with Next.js's middleware contract.\n * Lazy config resolution: the closure does not call `resolveConfig` until\n * the first request, so module-load (and `next build`) won't fail just\n * because `STATS_TOKEN` isn't set yet.\n */\nexport function createMiddleware(options: StatsOptions = {}): StatsMiddleware {\n let resolved: ReturnType<typeof resolveConfig> | null = null\n\n return async function statsMiddleware(req: NextRequest): Promise<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 // Self-filter common static paths so users don't need their own\n // `matcher` config in middleware.ts.\n if (isStaticPath(req.nextUrl.pathname)) {\n return NextResponse.next()\n }\n\n await trackRequestInternal(req, runtime)\n return NextResponse.next()\n }\n}\n\n/**\n * Standalone tracker for users who want to call from a route handler or\n * `instrumentation.ts` instead of from middleware.\n */\nexport async function trackRequest(\n req: NextRequest,\n options: StatsOptions = {},\n): Promise<void> {\n const config = resolveConfig(options)\n const runtime = getOrInitRuntime(config)\n await 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\nasync function trackRequestInternal(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<void> {\n try {\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 await runtime.store.track(ip, ua)\n } catch (err) {\n // Never let a tracking failure take down the user's request.\n // eslint-disable-next-line no-console\n console.error('[statswhatshesaid] track failed:', err)\n }\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_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_RECOMMENDED_SALT_SECRET_LENGTH = 32\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\nlet weakSaltSecretWarned = false\n\nexport function resolveConfig(options: StatsOptions = {}): ResolvedConfig {\n const env =\n typeof process !== 'undefined' && process.env\n ? process.env\n : ({} as Record<string, string | undefined>)\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 createMiddleware({ token }).',\n )\n }\n // Warn (not throw) if the token is short enough to brute-force.\n // Advisory only — 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 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\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 const rawSaltSecret = options.saltSecret ?? env.STATS_SALT_SECRET\n const saltSecret = rawSaltSecret && rawSaltSecret.length > 0 ? rawSaltSecret : null\n if (\n saltSecret &&\n !weakSaltSecretWarned &&\n saltSecret.length < MIN_RECOMMENDED_SALT_SECRET_LENGTH\n ) {\n weakSaltSecretWarned = true\n // eslint-disable-next-line no-console\n console.warn(\n `[statswhatshesaid] Warning: STATS_SALT_SECRET is shorter than ${MIN_RECOMMENDED_SALT_SECRET_LENGTH} characters (${saltSecret.length}). ` +\n 'A weak secret makes the daily salt easier to guess, which weakens the privacy ' +\n 'guarantee that an attacker cannot rederive `(ip, ua)` pairs from their hashes. ' +\n 'Generate a strong secret with: `openssl rand -hex 32`.',\n )\n }\n\n return {\n token,\n endpointPath,\n historyDays,\n maxHistoryDays,\n filterBots,\n trustProxy: rawTrustProxy,\n saltSecret,\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","/**\n * Stateless identity helpers, runtime-agnostic.\n *\n * All crypto here uses the Web Crypto API (`globalThis.crypto.subtle` and\n * `globalThis.crypto.getRandomValues`) so the library can run in both the\n * Next.js Edge runtime and the Node runtime without any conditional code.\n *\n * Web Crypto's `subtle.digest` is async, which makes `computeVisitorHash`\n * async, which in turn makes the middleware hot path async. Next.js\n * supports async middleware natively.\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(): Uint8Array {\n const salt = new Uint8Array(SALT_BYTES)\n globalThis.crypto.getRandomValues(salt)\n return salt\n}\n\n/**\n * Derive a deterministic 32-byte daily salt from a shared secret and a UTC\n * calendar date. Two replicas running with the same `secret` will produce\n * the same daily salt for the same `utcDate`, which is the mathematical\n * precondition for an external collector to merge HLL sketches across\n * replicas.\n *\n * Implementation: HMAC-SHA-256(secret, utcDate). The salt rotates daily,\n * preserving cross-day unlinkability — yesterday's hash of `(ip, ua)` is\n * unrelated to today's hash of the same tuple.\n */\nexport async function deriveDailySalt(\n secret: string,\n utcDate: string,\n): Promise<Uint8Array> {\n const enc = new TextEncoder()\n const key = await globalThis.crypto.subtle.importKey(\n 'raw',\n enc.encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n )\n const sig = await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(utcDate))\n return new Uint8Array(sig)\n}\n\n/**\n * Compact identifier for a given salt: SHA-256(salt), truncated to the\n * first 8 bytes, hex-encoded. Lets the collector confirm two replicas are\n * using the same daily salt before merging their sketches. 64 bits is\n * enough to make accidental collisions astronomically unlikely while\n * staying short on the wire.\n */\nexport async function computeSaltFingerprint(salt: Uint8Array): Promise<string> {\n const digest = await globalThis.crypto.subtle.digest('SHA-256', salt)\n const bytes = new Uint8Array(digest, 0, 8)\n let hex = ''\n for (const b of bytes) hex += b.toString(16).padStart(2, '0')\n return hex\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 * Semantics:\n * - `trustProxy === 0` — never read forwarding headers. All requests\n * collapse to a single constant peer.\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.\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 */\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 32-byte SHA-256\n * digest as a `Uint8Array`. The HLL only consumes the first 8 bytes.\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 async function computeVisitorHash(\n ip: string,\n ua: string,\n salt: Uint8Array,\n): Promise<Uint8Array> {\n const enc = new TextEncoder()\n const ipBuf = enc.encode(ip)\n const uaBuf = enc.encode(ua)\n\n // 8-byte length header (two big-endian u32s) + ipBuf + uaBuf + salt.\n const total = new Uint8Array(8 + ipBuf.length + uaBuf.length + salt.length)\n const dv = new DataView(total.buffer)\n dv.setUint32(0, ipBuf.length, false)\n dv.setUint32(4, uaBuf.length, false)\n total.set(ipBuf, 8)\n total.set(uaBuf, 8 + ipBuf.length)\n total.set(salt, 8 + ipBuf.length + uaBuf.length)\n\n const digest = await globalThis.crypto.subtle.digest('SHA-256', total)\n return new Uint8Array(digest)\n}\n\n/**\n * Constant-time string comparison via SHA-256 prehash. Both inputs are\n * hashed to fixed 32-byte buffers and then XOR-compared in constant time,\n * so neither the length nor the content of either input leaks via timing.\n */\nexport async function constantTimeStringEqual(a: string, b: string): Promise<boolean> {\n const enc = new TextEncoder()\n const [ah, bh] = await Promise.all([\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(a)),\n globalThis.crypto.subtle.digest('SHA-256', enc.encode(b)),\n ])\n const av = new Uint8Array(ah)\n const bv = new Uint8Array(bh)\n let diff = 0\n for (let i = 0; i < av.length; i++) {\n diff |= av[i]! ^ bv[i]!\n }\n return diff === 0\n}\n\n/**\n * Conservative list of paths the middleware should NOT track. Lets the user\n * skip the `matcher` config entirely. Only matches well-known static paths,\n * never extension-based, to avoid false positives on routes like\n * `/api/data.json`.\n */\nexport function isStaticPath(pathname: string): boolean {\n if (pathname.startsWith('/_next/')) return true\n // Common well-known files at the root.\n switch (pathname) {\n case '/favicon.ico':\n case '/favicon.svg':\n case '/robots.txt':\n case '/sitemap.xml':\n case '/manifest.json':\n case '/site.webmanifest':\n case '/apple-touch-icon.png':\n case '/apple-touch-icon-precomposed.png':\n return true\n default:\n return false\n }\n}\n","import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nimport { encodeRegistersBase64 } from '@swhsd/hll'\n\nimport { constantTimeStringEqual } from './identity.js'\nimport type { StatsRuntime } from './lifecycle.js'\nimport type { StatsResponseBody, TodayCount } from './types.js'\n\nexport async function handleStatsEndpoint(\n req: NextRequest,\n runtime: StatsRuntime,\n): Promise<NextResponse> {\n const provided = extractAuthToken(req)\n if (!provided || !(await constantTimeStringEqual(provided, runtime.config.token))) {\n return new NextResponse('Unauthorized', {\n status: 401,\n headers: { 'content-type': 'text/plain; charset=utf-8' },\n })\n }\n\n // Make sure \"today\" in the response always reflects the current UTC day,\n // even if no track() call has triggered a rollover yet.\n runtime.store.rollOverIfNeeded()\n\n const today: TodayCount = {\n date: runtime.store.today,\n uniqueVisitors: runtime.store.estimateToday(),\n }\n\n // Raw format export — only meaningful when shared-salt mode is on, since\n // sketches built from random per-process salts can't be merged across\n // replicas. If the caller asked for raw without shared-salt mode, we\n // respond as if they hadn't asked. (The collector treats absence of\n // `sketch` as \"this server doesn't support merging\".)\n if (isRawFormatRequested(req) && runtime.store.isSharedSaltMode()) {\n const sketch = await runtime.store.exposeTodaySketch()\n today.sketch = encodeRegistersBase64(sketch.registers)\n today.saltFingerprint = sketch.saltFingerprint\n }\n\n const body: StatsResponseBody = {\n today,\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 * The collector requests the raw sketch with `?format=raw`. Query-string\n * only — middleware sometimes strips or rewrites the Accept header, and we\n * want a single authoritative source.\n */\nfunction isRawFormatRequested(req: NextRequest): boolean {\n return req.nextUrl.searchParams.get('format') === 'raw'\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 * 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. Accepts any\n * `Uint8Array` (including Node `Buffer`, which is a subclass).\n */\n addHashBuffer(buf: Uint8Array): void {\n if (buf.length < 8) {\n throw new Error(\n `[statswhatshesaid] HLL hash input must be at least 8 bytes, got ${buf.length}`,\n )\n }\n // Big-endian view of the first 8 bytes. Use DataView so we don't depend\n // on Node's Buffer methods (we want to run in Edge runtime too).\n const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)\n const first = dv.getUint32(0, false)\n const second = dv.getUint32(4, false)\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","/**\n * Functional helpers operating directly on the 16384-byte register array.\n *\n * These are extracted from the `HyperLogLog` class so the collector can\n * fetch raw register arrays over the wire (base64-encoded), merge them\n * register-wise (element-wise max), and compute a single cardinality\n * estimate — without ever constructing a stateful `HyperLogLog` instance.\n */\n\nimport { HLL_REGISTER_COUNT } from './hyperloglog.js'\n\nconst ALPHA_M = 0.7213 / (1 + 1.079 / HLL_REGISTER_COUNT)\n\nfunction assertRegisterLength(arr: Uint8Array, label: string): void {\n if (arr.length !== HLL_REGISTER_COUNT) {\n throw new Error(\n `[swhsd/hll] ${label} must be ${HLL_REGISTER_COUNT} bytes, got ${arr.length}`,\n )\n }\n}\n\n/**\n * Merge two HLL register arrays by element-wise maximum. Returns a fresh\n * array; inputs are not modified.\n *\n * This is mathematically equivalent to feeding every hash that produced\n * either input into a single sketch, **provided both sketches were built\n * with the same daily salt**. The collector must verify that precondition\n * (via `saltFingerprint` from the wire response) before calling this.\n */\nexport function mergeRegisters(a: Uint8Array, b: Uint8Array): Uint8Array {\n assertRegisterLength(a, 'left register array')\n assertRegisterLength(b, 'right register array')\n const out = new Uint8Array(HLL_REGISTER_COUNT)\n for (let i = 0; i < HLL_REGISTER_COUNT; i++) {\n const av = a[i]!\n const bv = b[i]!\n out[i] = av > bv ? av : bv\n }\n return out\n}\n\n/**\n * Merge N register arrays via repeated pairwise merge. Returns a fresh\n * array. Throws if `sketches` is empty.\n */\nexport function mergeManyRegisters(sketches: readonly Uint8Array[]): Uint8Array {\n if (sketches.length === 0) {\n throw new Error('[swhsd/hll] mergeManyRegisters requires at least one input')\n }\n const first = sketches[0]!\n assertRegisterLength(first, 'register array')\n let acc: Uint8Array = new Uint8Array(HLL_REGISTER_COUNT)\n acc.set(first)\n for (let i = 1; i < sketches.length; i++) {\n acc = mergeRegisters(acc, sketches[i]!)\n }\n return acc\n}\n\n/**\n * Estimate cardinality directly from a register array. Same formula and\n * small-range correction as `HyperLogLog.estimate()`.\n */\nexport function estimateRegisters(registers: Uint8Array): number {\n assertRegisterLength(registers, 'register array')\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 = registers[i]!\n sum += 2 ** -r\n if (r === 0) zeros++\n }\n let estimate = (ALPHA_M * m * m) / sum\n if (estimate <= 2.5 * m && zeros > 0) {\n estimate = m * Math.log(m / zeros)\n }\n return Math.round(estimate)\n}\n\n/**\n * Base64 encoder using Web APIs only — works in both Node and Edge runtimes.\n * Produces a fixed ~21,848-character string for our 16,384-byte register\n * array.\n */\nexport function encodeRegistersBase64(registers: Uint8Array): string {\n assertRegisterLength(registers, 'register array')\n // btoa expects a binary string; build it in 8 KB chunks to avoid blowing\n // the argument list when spreading the typed array.\n let binary = ''\n const CHUNK = 0x8000\n for (let i = 0; i < registers.length; i += CHUNK) {\n const slice = registers.subarray(i, i + CHUNK)\n binary += String.fromCharCode(...slice)\n }\n return btoa(binary)\n}\n\n/**\n * Inverse of `encodeRegistersBase64`. Throws if the decoded length is not\n * exactly `HLL_REGISTER_COUNT`.\n */\nexport function decodeRegistersBase64(s: string): Uint8Array {\n const binary = atob(s)\n const out = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n out[i] = binary.charCodeAt(i)\n }\n assertRegisterLength(out, 'decoded register array')\n return out\n}\n","import {\n computeSaltFingerprint,\n computeVisitorHash,\n deriveDailySalt,\n generateSalt,\n utcDateString,\n} from './identity.js'\nimport { HyperLogLog } from '@swhsd/hll'\nimport type { DailyCount } from './types.js'\n\n/**\n * Owns the in-memory live state that `/stats` reads from: today's HLL\n * sketch, today's salt, and finalized historical daily counts.\n *\n * No persistence — counts and history live in process memory only and are\n * lost on process restart. Within a single Edge isolate or Node process,\n * state survives across requests because module-level singletons in Next.js\n * middleware persist for the worker's lifetime.\n *\n * `track` is async because the visitor hash uses Web Crypto's\n * `crypto.subtle.digest`, which has no synchronous counterpart in the Edge\n * runtime. Next.js middleware natively supports async functions.\n *\n * When `saltSecret` is set, the daily salt is derived as\n * `HMAC-SHA-256(saltSecret, today)`. Derivation is async, so it happens\n * lazily on the first `track()` of each day. When unset, salts are\n * generated synchronously with `crypto.getRandomValues`.\n */\nexport class VisitorStore {\n private _today: string\n private _salt: Uint8Array | null\n private readonly _saltSecret: string | null\n private _hll: HyperLogLog\n private _history: Map<string, number>\n\n private constructor(args: {\n today: string\n salt: Uint8Array | null\n saltSecret: string | null\n hll: HyperLogLog\n history: Map<string, number>\n }) {\n this._today = args.today\n this._salt = args.salt\n this._saltSecret = args.saltSecret\n this._hll = args.hll\n this._history = args.history\n }\n\n static fresh(today: string, saltSecret: string | null = null): VisitorStore {\n return new VisitorStore({\n today,\n // In shared-salt mode the salt is derived lazily on first track,\n // since HMAC requires `await crypto.subtle.sign` and we want `fresh()`\n // to stay synchronous.\n salt: saltSecret ? null : generateSalt(),\n saltSecret,\n hll: new HyperLogLog(),\n history: new Map(),\n })\n }\n\n get today(): string {\n return this._today\n }\n\n /** Whether the store is using deterministic (shared) salt derivation. */\n isSharedSaltMode(): boolean {\n return this._saltSecret !== null\n }\n\n /** Estimated unique visitors so far today. */\n estimateToday(): number {\n return this._hll.estimate()\n }\n\n /**\n * Hot path. Lazily rolls over the day if needed (so we don't depend on a\n * background timer that may be unreliable in Edge isolates), then hashes\n * and adds the visitor to the HLL sketch.\n */\n async track(ip: string, ua: string): Promise<void> {\n this.rollOverIfNeeded()\n const salt = await this.getOrDeriveSalt()\n const hash = await computeVisitorHash(ip, ua, salt)\n this._hll.addHashBuffer(hash)\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. Cheap enough to call on every\n * request (one Date allocation, one string compare).\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 = this._saltSecret ? null : generateSalt()\n this._hll = new HyperLogLog()\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 }\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 /**\n * Return today's raw HLL register array plus a fingerprint of the salt,\n * for `/stats?format=raw` consumers. Only meaningful in shared-salt mode\n * — fingerprints from random salts can't be cross-checked across\n * replicas. The caller should gate on `isSharedSaltMode()` before calling.\n */\n async exposeTodaySketch(): Promise<{\n registers: Uint8Array\n saltFingerprint: string\n }> {\n this.rollOverIfNeeded()\n const salt = await this.getOrDeriveSalt()\n return {\n registers: this._hll.cloneRegisters(),\n saltFingerprint: await computeSaltFingerprint(salt),\n }\n }\n\n /**\n * Returns the salt for the current day, deriving it from the shared\n * secret on first call if necessary. Subsequent calls within the same\n * day return the cached value.\n */\n private async getOrDeriveSalt(): Promise<Uint8Array> {\n if (this._salt) return this._salt\n // In shared-salt mode `_salt` is null until first use.\n if (!this._saltSecret) {\n // Defensive — `fresh()` always populates a random salt in non-shared\n // mode, so this branch should be unreachable.\n this._salt = generateSalt()\n return this._salt\n }\n this._salt = await deriveDailySalt(this._saltSecret, this._today)\n return this._salt\n }\n}\n","import { utcDateString } from './identity.js'\nimport { VisitorStore } from './store.js'\nimport type { ResolvedConfig } from './types.js'\n\nexport interface StatsRuntime {\n config: ResolvedConfig\n store: VisitorStore\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 stores or\n * accumulate state across module reloads.\n *\n * In-memory only — no file handles, no timers, no process signal handlers.\n * The store survives for the lifetime of the worker / Edge isolate.\n * Restarting the process resets all counts.\n */\nexport function getOrInitRuntime(config: ResolvedConfig): StatsRuntime {\n if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__\n\n const today = utcDateString(new Date())\n const store = VisitorStore.fresh(today, config.saltSecret)\n store.trimHistory(config.maxHistoryDays)\n\n const runtime: StatsRuntime = { config, store }\n globalThis.__statswhatshesaid__ = runtime\n return runtime\n}\n","import { createMiddleware, trackRequest } from './middleware.js'\n\n/**\n * Pre-instantiated default middleware. Lets users drop the library in with\n * a single line in their `middleware.ts`:\n *\n * ```ts\n * export { default } from 'statswhatshesaid'\n * ```\n *\n * The default middleware reads its configuration from environment variables\n * (`STATS_TOKEN` is required, the rest have sensible defaults). Config\n * resolution is deferred to the first request, so `next build` works fine\n * without `STATS_TOKEN` set — the error only fires at runtime.\n *\n * For customized configuration, import `createMiddleware` and call it with\n * your options:\n *\n * ```ts\n * import { createMiddleware } from 'statswhatshesaid'\n * export default createMiddleware({ filterBots: false })\n * ```\n */\nconst defaultMiddleware = createMiddleware()\n\nexport default defaultMiddleware\nexport { createMiddleware, trackRequest }\nexport type { StatsOptions, StatsResponseBody, DailyCount } from './types.js'\nexport type { StatsMiddleware } from './middleware.js'\n"],"mappings":"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","names":["NextResponse","ALPHA_M","NextResponse"]}
|
package/package.json
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "statswhatshesaid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A super minimal one-line drop-in unique-visitors-per-day stats library for Next.js. In-memory, zero dependencies, runs in both Edge and Node runtimes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Les Kleuver <les.kleuver@gmail.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/lkleuver/statswhatshesaid.git",
|
|
10
|
-
"directory": "
|
|
10
|
+
"directory": "packages/lib"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/lkleuver/statswhatshesaid/issues"
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://github.com/lkleuver/statswhatshesaid#readme",
|
|
16
16
|
"publishConfig": {
|
|
17
|
-
"provenance": true
|
|
17
|
+
"provenance": true,
|
|
18
|
+
"access": "public"
|
|
18
19
|
},
|
|
19
20
|
"engines": {
|
|
20
21
|
"node": ">=18.17.0"
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"files": [
|
|
34
35
|
"dist",
|
|
35
36
|
"README.md",
|
|
36
|
-
"LICENSE"
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"CHANGELOG.md"
|
|
37
39
|
],
|
|
38
40
|
"scripts": {
|
|
39
41
|
"build": "tsup",
|
|
@@ -41,9 +43,6 @@
|
|
|
41
43
|
"test:watch": "vitest",
|
|
42
44
|
"typecheck": "tsc --noEmit",
|
|
43
45
|
"verify": "npm run typecheck && npm run test && npm run build",
|
|
44
|
-
"changeset": "changeset",
|
|
45
|
-
"version": "changeset version",
|
|
46
|
-
"release": "npm run verify && changeset publish",
|
|
47
46
|
"prepublishOnly": "npm run verify"
|
|
48
47
|
},
|
|
49
48
|
"keywords": [
|
|
@@ -60,7 +59,7 @@
|
|
|
60
59
|
"next": ">=13.0.0"
|
|
61
60
|
},
|
|
62
61
|
"devDependencies": {
|
|
63
|
-
"@
|
|
62
|
+
"@swhsd/hll": "*",
|
|
64
63
|
"@types/node": "^20.14.0",
|
|
65
64
|
"next": "^15.2.0",
|
|
66
65
|
"tsup": "^8.3.0",
|