statswhatshesaid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/dist/index.cjs +641 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +108 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +614 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
FileSnapshotAdapter: () => FileSnapshotAdapter,
|
|
24
|
+
default: () => index_default
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/middleware.ts
|
|
29
|
+
var import_server2 = require("next/server");
|
|
30
|
+
|
|
31
|
+
// src/bots.ts
|
|
32
|
+
var BOT_UA_RE = /bot|crawler|spider|crawling|facebookexternalhit|slurp|mediapartners|ahrefs|semrush|bingpreview|headlesschrome|lighthouse|curl|wget|python-requests|node-fetch|axios|httpclient|java\//i;
|
|
33
|
+
function isBot(ua) {
|
|
34
|
+
if (!ua) return true;
|
|
35
|
+
return BOT_UA_RE.test(ua);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/config.ts
|
|
39
|
+
var DEFAULT_SNAPSHOT_PATH = "./.statswhatshesaid.json";
|
|
40
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 60 * 60 * 1e3;
|
|
41
|
+
var DEFAULT_ENDPOINT_PATH = "/stats";
|
|
42
|
+
var DEFAULT_HISTORY_DAYS = 90;
|
|
43
|
+
var DEFAULT_MAX_HISTORY_DAYS = 365;
|
|
44
|
+
var DEFAULT_TRUST_PROXY = 1;
|
|
45
|
+
var MIN_RECOMMENDED_TOKEN_LENGTH = 32;
|
|
46
|
+
var MIN_FLUSH_INTERVAL_MS = 1e3;
|
|
47
|
+
var ENDPOINT_PATH_RE = /^\/[A-Za-z0-9\-._~/]*$/;
|
|
48
|
+
var weakTokenWarned = false;
|
|
49
|
+
function resolveConfig(options = {}) {
|
|
50
|
+
const env = typeof process !== "undefined" ? process.env : {};
|
|
51
|
+
const token = options.token ?? env.STATS_TOKEN;
|
|
52
|
+
if (!token) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"[statswhatshesaid] Missing required token. Set the STATS_TOKEN env var or pass `token` to stats.middleware({ token })."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!weakTokenWarned && token.length < MIN_RECOMMENDED_TOKEN_LENGTH) {
|
|
58
|
+
weakTokenWarned = true;
|
|
59
|
+
console.warn(
|
|
60
|
+
`[statswhatshesaid] Warning: the stats token is shorter than ${MIN_RECOMMENDED_TOKEN_LENGTH} characters (${token.length}). Short tokens are vulnerable to brute-force attacks against the /stats endpoint. Consider generating a strong token with: \`openssl rand -hex 32\`. You can also rate-limit /stats at your reverse proxy or CDN.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const snapshotPath = options.snapshotPath ?? env.STATS_SNAPSHOT_PATH ?? DEFAULT_SNAPSHOT_PATH;
|
|
64
|
+
const flushIntervalMs = options.flushIntervalMs ?? parseIntOr(env.STATS_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_INTERVAL_MS);
|
|
65
|
+
requirePositiveInt(flushIntervalMs, "flushIntervalMs");
|
|
66
|
+
if (flushIntervalMs < MIN_FLUSH_INTERVAL_MS) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[statswhatshesaid] flushIntervalMs must be at least ${MIN_FLUSH_INTERVAL_MS} ms to avoid hammering the persist layer; got ${flushIntervalMs}.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const rawEndpointPath = options.endpointPath ?? env.STATS_ENDPOINT_PATH ?? DEFAULT_ENDPOINT_PATH;
|
|
72
|
+
const endpointPath = normalizePath(rawEndpointPath);
|
|
73
|
+
if (!ENDPOINT_PATH_RE.test(endpointPath)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[statswhatshesaid] Invalid endpointPath: ${JSON.stringify(rawEndpointPath)}. Must match /^\\/[A-Za-z0-9\\-._~/]*$/.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const historyDays = options.historyDays ?? DEFAULT_HISTORY_DAYS;
|
|
79
|
+
requireNonNegativeInt(historyDays, "historyDays");
|
|
80
|
+
const maxHistoryDays = options.maxHistoryDays ?? DEFAULT_MAX_HISTORY_DAYS;
|
|
81
|
+
requireNonNegativeInt(maxHistoryDays, "maxHistoryDays");
|
|
82
|
+
const filterBots = options.filterBots ?? true;
|
|
83
|
+
const persist = options.persist ?? null;
|
|
84
|
+
const rawTrustProxy = options.trustProxy ?? parseIntOr(env.STATS_TRUST_PROXY, DEFAULT_TRUST_PROXY, true);
|
|
85
|
+
if (!Number.isInteger(rawTrustProxy) || rawTrustProxy < 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[statswhatshesaid] Invalid trustProxy value: ${rawTrustProxy}. Must be a non-negative integer (0, 1, 2, ...).`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
token,
|
|
92
|
+
snapshotPath,
|
|
93
|
+
persist,
|
|
94
|
+
flushIntervalMs,
|
|
95
|
+
endpointPath,
|
|
96
|
+
historyDays,
|
|
97
|
+
maxHistoryDays,
|
|
98
|
+
filterBots,
|
|
99
|
+
trustProxy: rawTrustProxy
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function requirePositiveInt(value, name) {
|
|
103
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`[statswhatshesaid] ${name} must be a positive integer; got ${value}.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function requireNonNegativeInt(value, name) {
|
|
110
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`[statswhatshesaid] ${name} must be a non-negative integer; got ${value}.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function parseIntOr(value, fallback, allowZero = false) {
|
|
117
|
+
if (!value) return fallback;
|
|
118
|
+
const n = Number.parseInt(value, 10);
|
|
119
|
+
if (!Number.isFinite(n)) return fallback;
|
|
120
|
+
if (allowZero ? n < 0 : n <= 0) return fallback;
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
function normalizePath(p) {
|
|
124
|
+
if (!p.startsWith("/")) return `/${p}`;
|
|
125
|
+
return p;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/identity.ts
|
|
129
|
+
var import_node_crypto = require("crypto");
|
|
130
|
+
function utcDateString(d) {
|
|
131
|
+
return d.toISOString().slice(0, 10);
|
|
132
|
+
}
|
|
133
|
+
var DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
134
|
+
function isValidUtcDate(s) {
|
|
135
|
+
const m = DATE_RE.exec(s);
|
|
136
|
+
if (!m) return false;
|
|
137
|
+
const year = Number(m[1]);
|
|
138
|
+
const month = Number(m[2]);
|
|
139
|
+
const day = Number(m[3]);
|
|
140
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
141
|
+
return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
|
|
142
|
+
}
|
|
143
|
+
var SALT_BYTES = 32;
|
|
144
|
+
function generateSalt() {
|
|
145
|
+
return (0, import_node_crypto.randomBytes)(SALT_BYTES);
|
|
146
|
+
}
|
|
147
|
+
var UNKNOWN_PEER = "0.0.0.0";
|
|
148
|
+
function extractIp(headers, trustProxy) {
|
|
149
|
+
if (trustProxy < 1) return UNKNOWN_PEER;
|
|
150
|
+
const xff = headers.get("x-forwarded-for");
|
|
151
|
+
if (!xff) return UNKNOWN_PEER;
|
|
152
|
+
const entries = xff.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
153
|
+
if (entries.length < trustProxy) return UNKNOWN_PEER;
|
|
154
|
+
return entries[entries.length - trustProxy];
|
|
155
|
+
}
|
|
156
|
+
function computeVisitorHash(ip, ua, salt) {
|
|
157
|
+
const ipBuf = Buffer.from(ip, "utf8");
|
|
158
|
+
const uaBuf = Buffer.from(ua, "utf8");
|
|
159
|
+
const lenBuf = Buffer.alloc(8);
|
|
160
|
+
lenBuf.writeUInt32BE(ipBuf.length, 0);
|
|
161
|
+
lenBuf.writeUInt32BE(uaBuf.length, 4);
|
|
162
|
+
return (0, import_node_crypto.createHash)("sha256").update(lenBuf).update(ipBuf).update(uaBuf).update(salt).digest();
|
|
163
|
+
}
|
|
164
|
+
function scheduleMidnightTimer(onMidnight, now = /* @__PURE__ */ new Date()) {
|
|
165
|
+
let timer = null;
|
|
166
|
+
let interval = null;
|
|
167
|
+
const msUntilNext = msUntilUtcMidnight(now) + 1e3;
|
|
168
|
+
timer = setTimeout(() => {
|
|
169
|
+
try {
|
|
170
|
+
onMidnight();
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
interval = setInterval(
|
|
174
|
+
() => {
|
|
175
|
+
try {
|
|
176
|
+
onMidnight();
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
24 * 60 * 60 * 1e3
|
|
181
|
+
);
|
|
182
|
+
interval.unref?.();
|
|
183
|
+
}, msUntilNext);
|
|
184
|
+
timer.unref?.();
|
|
185
|
+
return () => {
|
|
186
|
+
if (timer) clearTimeout(timer);
|
|
187
|
+
if (interval) clearInterval(interval);
|
|
188
|
+
timer = null;
|
|
189
|
+
interval = null;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function msUntilUtcMidnight(now) {
|
|
193
|
+
const next = Date.UTC(
|
|
194
|
+
now.getUTCFullYear(),
|
|
195
|
+
now.getUTCMonth(),
|
|
196
|
+
now.getUTCDate() + 1,
|
|
197
|
+
0,
|
|
198
|
+
0,
|
|
199
|
+
0,
|
|
200
|
+
0
|
|
201
|
+
);
|
|
202
|
+
return next - now.getTime();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/endpoint.ts
|
|
206
|
+
var import_node_crypto2 = require("crypto");
|
|
207
|
+
var import_server = require("next/server");
|
|
208
|
+
function handleStatsEndpoint(req, runtime) {
|
|
209
|
+
const provided = extractAuthToken(req);
|
|
210
|
+
if (!provided || !constantTimeEqual(provided, runtime.config.token)) {
|
|
211
|
+
return new import_server.NextResponse("Unauthorized", { status: 401 });
|
|
212
|
+
}
|
|
213
|
+
runtime.store.rollOverIfNeeded();
|
|
214
|
+
const body = {
|
|
215
|
+
today: {
|
|
216
|
+
date: runtime.store.today,
|
|
217
|
+
uniqueVisitors: runtime.store.estimateToday()
|
|
218
|
+
},
|
|
219
|
+
history: runtime.store.getHistoryDesc(runtime.config.historyDays),
|
|
220
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
221
|
+
};
|
|
222
|
+
return import_server.NextResponse.json(body, {
|
|
223
|
+
headers: { "cache-control": "no-store" }
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function extractAuthToken(req) {
|
|
227
|
+
const auth = req.headers.get("authorization");
|
|
228
|
+
if (auth) {
|
|
229
|
+
const match = /^Bearer\s+(\S+)\s*$/i.exec(auth);
|
|
230
|
+
if (match) return match[1];
|
|
231
|
+
}
|
|
232
|
+
return req.nextUrl.searchParams.get("t");
|
|
233
|
+
}
|
|
234
|
+
function constantTimeEqual(a, b) {
|
|
235
|
+
const ah = (0, import_node_crypto2.createHash)("sha256").update(a, "utf8").digest();
|
|
236
|
+
const bh = (0, import_node_crypto2.createHash)("sha256").update(b, "utf8").digest();
|
|
237
|
+
return (0, import_node_crypto2.timingSafeEqual)(ah, bh);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/snapshot.ts
|
|
241
|
+
var import_node_fs = require("fs");
|
|
242
|
+
var import_node_path = require("path");
|
|
243
|
+
|
|
244
|
+
// src/hll.ts
|
|
245
|
+
var P = 14;
|
|
246
|
+
var HLL_REGISTER_COUNT = 1 << P;
|
|
247
|
+
var TAIL_HIGH_BITS = 32 - P;
|
|
248
|
+
var TAIL_HIGH_MASK = (1 << TAIL_HIGH_BITS) - 1;
|
|
249
|
+
var TAIL_TOTAL_BITS = 64 - P;
|
|
250
|
+
var MAX_RANK = TAIL_TOTAL_BITS + 1;
|
|
251
|
+
var ALPHA_M = 0.7213 / (1 + 1.079 / HLL_REGISTER_COUNT);
|
|
252
|
+
var HyperLogLog = class _HyperLogLog {
|
|
253
|
+
registers;
|
|
254
|
+
constructor(registers) {
|
|
255
|
+
if (registers) {
|
|
256
|
+
if (registers.length !== HLL_REGISTER_COUNT) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`[statswhatshesaid] HLL registers must be ${HLL_REGISTER_COUNT} bytes, got ${registers.length}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
this.registers = new Uint8Array(registers);
|
|
262
|
+
} else {
|
|
263
|
+
this.registers = new Uint8Array(HLL_REGISTER_COUNT);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Add a 64-bit hash (the first 8 bytes of a larger buffer are fine) to the
|
|
268
|
+
* sketch. This is the only mutating call on the hot path.
|
|
269
|
+
*/
|
|
270
|
+
addHashBuffer(buf) {
|
|
271
|
+
const first = buf.readUInt32BE(0);
|
|
272
|
+
const second = buf.readUInt32BE(4);
|
|
273
|
+
const idx = first >>> TAIL_HIGH_BITS;
|
|
274
|
+
const tailHigh = first & TAIL_HIGH_MASK;
|
|
275
|
+
let rank;
|
|
276
|
+
if (tailHigh !== 0) {
|
|
277
|
+
rank = Math.clz32(tailHigh) - 14 + 1;
|
|
278
|
+
} else if (second !== 0) {
|
|
279
|
+
rank = 18 + Math.clz32(second) + 1;
|
|
280
|
+
} else {
|
|
281
|
+
rank = MAX_RANK;
|
|
282
|
+
}
|
|
283
|
+
if (rank > this.registers[idx]) {
|
|
284
|
+
this.registers[idx] = rank;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Estimated number of distinct items inserted.
|
|
289
|
+
* Applies the linear-counting correction for small cardinalities.
|
|
290
|
+
*/
|
|
291
|
+
estimate() {
|
|
292
|
+
const m = HLL_REGISTER_COUNT;
|
|
293
|
+
let sum = 0;
|
|
294
|
+
let zeros = 0;
|
|
295
|
+
for (let i = 0; i < m; i++) {
|
|
296
|
+
const r = this.registers[i];
|
|
297
|
+
sum += 2 ** -r;
|
|
298
|
+
if (r === 0) zeros++;
|
|
299
|
+
}
|
|
300
|
+
let estimate = ALPHA_M * m * m / sum;
|
|
301
|
+
if (estimate <= 2.5 * m && zeros > 0) {
|
|
302
|
+
estimate = m * Math.log(m / zeros);
|
|
303
|
+
}
|
|
304
|
+
return Math.round(estimate);
|
|
305
|
+
}
|
|
306
|
+
/** Deep copy the register array for serialization. */
|
|
307
|
+
cloneRegisters() {
|
|
308
|
+
return new Uint8Array(this.registers);
|
|
309
|
+
}
|
|
310
|
+
static fromRegisters(registers) {
|
|
311
|
+
return new _HyperLogLog(registers);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/snapshot.ts
|
|
316
|
+
var FileSnapshotAdapter = class {
|
|
317
|
+
path;
|
|
318
|
+
constructor(path) {
|
|
319
|
+
this.path = path;
|
|
320
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
|
|
321
|
+
}
|
|
322
|
+
load() {
|
|
323
|
+
let text;
|
|
324
|
+
try {
|
|
325
|
+
text = (0, import_node_fs.readFileSync)(this.path, "utf8");
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (err.code === "ENOENT") return null;
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
let parsed;
|
|
331
|
+
try {
|
|
332
|
+
parsed = JSON.parse(text);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
if (!isValidSnapshot(parsed)) return null;
|
|
337
|
+
return parsed;
|
|
338
|
+
}
|
|
339
|
+
save(snap) {
|
|
340
|
+
const tmp = `${this.path}.tmp`;
|
|
341
|
+
(0, import_node_fs.writeFileSync)(tmp, JSON.stringify(snap), { mode: 384 });
|
|
342
|
+
(0, import_node_fs.renameSync)(tmp, this.path);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function isValidSnapshot(x) {
|
|
346
|
+
if (!x || typeof x !== "object") return false;
|
|
347
|
+
const o = x;
|
|
348
|
+
if (o.version !== 1) return false;
|
|
349
|
+
if (typeof o.today !== "string" || !isValidUtcDate(o.today)) return false;
|
|
350
|
+
if (typeof o.salt !== "string") return false;
|
|
351
|
+
if (typeof o.hllRegisters !== "string") return false;
|
|
352
|
+
if (typeof o.history !== "object" || o.history === null || Array.isArray(o.history)) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
const expectedBase64 = Math.ceil(HLL_REGISTER_COUNT / 3) * 4;
|
|
356
|
+
if (o.hllRegisters.length !== expectedBase64) return false;
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/store.ts
|
|
361
|
+
var VisitorStore = class _VisitorStore {
|
|
362
|
+
_today;
|
|
363
|
+
_salt;
|
|
364
|
+
_hll;
|
|
365
|
+
_history;
|
|
366
|
+
_dirty;
|
|
367
|
+
constructor(args) {
|
|
368
|
+
this._today = args.today;
|
|
369
|
+
this._salt = args.salt;
|
|
370
|
+
this._hll = args.hll;
|
|
371
|
+
this._history = args.history;
|
|
372
|
+
this._dirty = args.dirty;
|
|
373
|
+
}
|
|
374
|
+
static fresh(today) {
|
|
375
|
+
return new _VisitorStore({
|
|
376
|
+
today,
|
|
377
|
+
salt: generateSalt(),
|
|
378
|
+
hll: new HyperLogLog(),
|
|
379
|
+
history: /* @__PURE__ */ new Map(),
|
|
380
|
+
dirty: true
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Build a store from a persisted snapshot. If the snapshot's `today` no
|
|
385
|
+
* longer matches the current UTC date, the snapshot's HLL is finalized into
|
|
386
|
+
* history and a fresh HLL + salt is created for `currentDate`.
|
|
387
|
+
*
|
|
388
|
+
* This path is the main "untrusted JSON" boundary — defensive at every
|
|
389
|
+
* step. Any decode/validation failure degrades gracefully: we keep what
|
|
390
|
+
* we can of history and start today fresh.
|
|
391
|
+
*/
|
|
392
|
+
static fromSnapshot(snap, currentDate) {
|
|
393
|
+
const history = sanitizeHistory(snap.history, currentDate);
|
|
394
|
+
if (snap.today === currentDate) {
|
|
395
|
+
try {
|
|
396
|
+
const salt = decodeSalt(snap.salt);
|
|
397
|
+
const registers = decodeRegisters(snap.hllRegisters);
|
|
398
|
+
const hll = new HyperLogLog(registers);
|
|
399
|
+
return new _VisitorStore({
|
|
400
|
+
today: currentDate,
|
|
401
|
+
salt,
|
|
402
|
+
hll,
|
|
403
|
+
history,
|
|
404
|
+
dirty: false
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
return new _VisitorStore({
|
|
408
|
+
today: currentDate,
|
|
409
|
+
salt: generateSalt(),
|
|
410
|
+
hll: new HyperLogLog(),
|
|
411
|
+
history,
|
|
412
|
+
dirty: true
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const registers = decodeRegisters(snap.hllRegisters);
|
|
418
|
+
const oldHll = new HyperLogLog(registers);
|
|
419
|
+
history.set(snap.today, oldHll.estimate());
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
return new _VisitorStore({
|
|
423
|
+
today: currentDate,
|
|
424
|
+
salt: generateSalt(),
|
|
425
|
+
hll: new HyperLogLog(),
|
|
426
|
+
history,
|
|
427
|
+
dirty: true
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
get today() {
|
|
431
|
+
return this._today;
|
|
432
|
+
}
|
|
433
|
+
get dirty() {
|
|
434
|
+
return this._dirty;
|
|
435
|
+
}
|
|
436
|
+
/** Estimated unique visitors so far today. */
|
|
437
|
+
estimateToday() {
|
|
438
|
+
return this._hll.estimate();
|
|
439
|
+
}
|
|
440
|
+
/** Hot path. */
|
|
441
|
+
track(ip, ua) {
|
|
442
|
+
const hash = computeVisitorHash(ip, ua, this._salt);
|
|
443
|
+
this._hll.addHashBuffer(hash);
|
|
444
|
+
this._dirty = true;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* If the current UTC date has moved past `this._today`, finalize the
|
|
448
|
+
* previous day into history and start a fresh HLL + salt for the new day.
|
|
449
|
+
* Returns true if a rollover happened.
|
|
450
|
+
*/
|
|
451
|
+
rollOverIfNeeded(now = /* @__PURE__ */ new Date()) {
|
|
452
|
+
const current = utcDateString(now);
|
|
453
|
+
if (current === this._today) return false;
|
|
454
|
+
this._history.set(this._today, this._hll.estimate());
|
|
455
|
+
this._today = current;
|
|
456
|
+
this._salt = generateSalt();
|
|
457
|
+
this._hll = new HyperLogLog();
|
|
458
|
+
this._dirty = true;
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
/** Drop history entries older than `maxDays` days from today (inclusive). */
|
|
462
|
+
trimHistory(maxDays) {
|
|
463
|
+
if (maxDays <= 0) return;
|
|
464
|
+
if (this._history.size <= maxDays) return;
|
|
465
|
+
const sortedDesc = [...this._history.keys()].sort().reverse();
|
|
466
|
+
for (let i = maxDays; i < sortedDesc.length; i++) {
|
|
467
|
+
this._history.delete(sortedDesc[i]);
|
|
468
|
+
}
|
|
469
|
+
this._dirty = true;
|
|
470
|
+
}
|
|
471
|
+
/** History (excluding today) in descending date order, capped at `limit`. */
|
|
472
|
+
getHistoryDesc(limit) {
|
|
473
|
+
const rows = [];
|
|
474
|
+
for (const [date, count] of this._history) {
|
|
475
|
+
if (date === this._today) continue;
|
|
476
|
+
rows.push({ date, uniqueVisitors: count });
|
|
477
|
+
}
|
|
478
|
+
rows.sort((a, b) => a.date < b.date ? 1 : a.date > b.date ? -1 : 0);
|
|
479
|
+
return rows.slice(0, limit);
|
|
480
|
+
}
|
|
481
|
+
toSnapshot() {
|
|
482
|
+
return {
|
|
483
|
+
version: 1,
|
|
484
|
+
today: this._today,
|
|
485
|
+
salt: this._salt.toString("base64"),
|
|
486
|
+
hllRegisters: Buffer.from(this._hll.cloneRegisters()).toString("base64"),
|
|
487
|
+
history: Object.fromEntries(this._history)
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
markClean() {
|
|
491
|
+
this._dirty = false;
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
function decodeSalt(saltBase64) {
|
|
495
|
+
const salt = Buffer.from(saltBase64, "base64");
|
|
496
|
+
if (salt.length !== SALT_BYTES) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`invalid snapshot salt: expected ${SALT_BYTES} bytes, got ${salt.length}`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return salt;
|
|
502
|
+
}
|
|
503
|
+
function decodeRegisters(registersBase64) {
|
|
504
|
+
const buf = Buffer.from(registersBase64, "base64");
|
|
505
|
+
if (buf.length !== HLL_REGISTER_COUNT) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`invalid snapshot registers: expected ${HLL_REGISTER_COUNT} bytes, got ${buf.length}`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
return new Uint8Array(buf);
|
|
511
|
+
}
|
|
512
|
+
function sanitizeHistory(raw, currentDate) {
|
|
513
|
+
const out = /* @__PURE__ */ new Map();
|
|
514
|
+
for (const [date, count] of Object.entries(raw)) {
|
|
515
|
+
if (!isValidUtcDate(date)) continue;
|
|
516
|
+
if (date === currentDate) continue;
|
|
517
|
+
if (typeof count !== "number") continue;
|
|
518
|
+
if (!Number.isFinite(count) || !Number.isInteger(count)) continue;
|
|
519
|
+
if (count < 0) continue;
|
|
520
|
+
out.set(date, count);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/lifecycle.ts
|
|
526
|
+
function getOrInitRuntime(config) {
|
|
527
|
+
if (globalThis.__statswhatshesaid__) return globalThis.__statswhatshesaid__;
|
|
528
|
+
assertNodeRuntime();
|
|
529
|
+
const persist = config.persist ?? new FileSnapshotAdapter(config.snapshotPath);
|
|
530
|
+
const today = utcDateString(/* @__PURE__ */ new Date());
|
|
531
|
+
const loaded = safeLoad(persist);
|
|
532
|
+
const store = loaded ? VisitorStore.fromSnapshot(loaded, today) : VisitorStore.fresh(today);
|
|
533
|
+
store.trimHistory(config.maxHistoryDays);
|
|
534
|
+
let shuttingDown = false;
|
|
535
|
+
let flushTimer = null;
|
|
536
|
+
let cancelMidnight = null;
|
|
537
|
+
const flush = () => {
|
|
538
|
+
if (!store.dirty) return;
|
|
539
|
+
try {
|
|
540
|
+
persist.save(store.toSnapshot());
|
|
541
|
+
store.markClean();
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error("[statswhatshesaid] flush failed:", err);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
const tick = () => {
|
|
547
|
+
try {
|
|
548
|
+
if (store.rollOverIfNeeded()) {
|
|
549
|
+
store.trimHistory(config.maxHistoryDays);
|
|
550
|
+
}
|
|
551
|
+
} catch (err) {
|
|
552
|
+
console.error("[statswhatshesaid] rollover failed:", err);
|
|
553
|
+
}
|
|
554
|
+
flush();
|
|
555
|
+
};
|
|
556
|
+
const shutdown = () => {
|
|
557
|
+
if (shuttingDown) return;
|
|
558
|
+
shuttingDown = true;
|
|
559
|
+
if (flushTimer) clearInterval(flushTimer);
|
|
560
|
+
if (cancelMidnight) cancelMidnight();
|
|
561
|
+
process.removeListener("SIGTERM", shutdown);
|
|
562
|
+
process.removeListener("SIGINT", shutdown);
|
|
563
|
+
process.removeListener("beforeExit", shutdown);
|
|
564
|
+
try {
|
|
565
|
+
flush();
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
if (config.persist == null) {
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
if (globalThis.__statswhatshesaid__ === runtime) {
|
|
574
|
+
globalThis.__statswhatshesaid__ = void 0;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
flushTimer = setInterval(tick, config.flushIntervalMs);
|
|
578
|
+
flushTimer.unref?.();
|
|
579
|
+
cancelMidnight = scheduleMidnightTimer(tick);
|
|
580
|
+
process.on("SIGTERM", shutdown);
|
|
581
|
+
process.on("SIGINT", shutdown);
|
|
582
|
+
process.on("beforeExit", shutdown);
|
|
583
|
+
const runtime = { config, store, persist, flush, shutdown };
|
|
584
|
+
globalThis.__statswhatshesaid__ = runtime;
|
|
585
|
+
flush();
|
|
586
|
+
return runtime;
|
|
587
|
+
}
|
|
588
|
+
function safeLoad(persist) {
|
|
589
|
+
try {
|
|
590
|
+
return persist.load();
|
|
591
|
+
} catch (err) {
|
|
592
|
+
console.error("[statswhatshesaid] snapshot load failed:", err);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function assertNodeRuntime() {
|
|
597
|
+
if (typeof process === "undefined" || !process.versions || !process.versions.node) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
"[statswhatshesaid] This library requires the Node.js runtime. Set `export const config = { runtime: 'nodejs' }` in your middleware.ts (requires Next.js 15.2 or newer)."
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/middleware.ts
|
|
605
|
+
function createMiddleware(options = {}) {
|
|
606
|
+
let resolved = null;
|
|
607
|
+
return function statsMiddleware(req) {
|
|
608
|
+
if (!resolved) resolved = resolveConfig(options);
|
|
609
|
+
const runtime = getOrInitRuntime(resolved);
|
|
610
|
+
if (req.nextUrl.pathname === resolved.endpointPath) {
|
|
611
|
+
return handleStatsEndpoint(req, runtime);
|
|
612
|
+
}
|
|
613
|
+
trackRequestInternal(req, runtime);
|
|
614
|
+
return import_server2.NextResponse.next();
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function trackRequest(req, options = {}) {
|
|
618
|
+
const config = resolveConfig(options);
|
|
619
|
+
const runtime = getOrInitRuntime(config);
|
|
620
|
+
trackRequestInternal(req, runtime);
|
|
621
|
+
}
|
|
622
|
+
var MAX_UA_LENGTH = 512;
|
|
623
|
+
function trackRequestInternal(req, runtime) {
|
|
624
|
+
const rawUa = req.headers.get("user-agent") ?? "";
|
|
625
|
+
const ua = rawUa.length > MAX_UA_LENGTH ? rawUa.slice(0, MAX_UA_LENGTH) : rawUa;
|
|
626
|
+
if (runtime.config.filterBots && isBot(ua)) return;
|
|
627
|
+
const ip = extractIp(req.headers, runtime.config.trustProxy);
|
|
628
|
+
runtime.store.track(ip, ua);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/index.ts
|
|
632
|
+
var stats = {
|
|
633
|
+
middleware: (options) => createMiddleware(options),
|
|
634
|
+
track: trackRequest
|
|
635
|
+
};
|
|
636
|
+
var index_default = stats;
|
|
637
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
638
|
+
0 && (module.exports = {
|
|
639
|
+
FileSnapshotAdapter
|
|
640
|
+
});
|
|
641
|
+
//# sourceMappingURL=index.cjs.map
|