locallytics 0.0.1-beta.1

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.
Files changed (38) hide show
  1. package/README.md +499 -0
  2. package/dist/cli/index.d.ts +3 -0
  3. package/dist/cli/index.d.ts.map +1 -0
  4. package/dist/cli/index.js +71 -0
  5. package/dist/cli/index.js.map +1 -0
  6. package/dist/src/client/index.d.ts +3 -0
  7. package/dist/src/client/index.d.ts.map +1 -0
  8. package/dist/src/client/index.js +41 -0
  9. package/dist/src/client/index.js.map +1 -0
  10. package/dist/src/db/index.d.ts +4 -0
  11. package/dist/src/db/index.d.ts.map +1 -0
  12. package/dist/src/db/index.js +26 -0
  13. package/dist/src/db/index.js.map +1 -0
  14. package/dist/src/db/postgres.d.ts +11 -0
  15. package/dist/src/db/postgres.d.ts.map +1 -0
  16. package/dist/src/db/postgres.js +63 -0
  17. package/dist/src/db/postgres.js.map +1 -0
  18. package/dist/src/db/sqlite.d.ts +11 -0
  19. package/dist/src/db/sqlite.d.ts.map +1 -0
  20. package/dist/src/db/sqlite.js +64 -0
  21. package/dist/src/db/sqlite.js.map +1 -0
  22. package/dist/src/index.d.ts +5 -0
  23. package/dist/src/index.d.ts.map +1 -0
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/index.js.map +1 -0
  26. package/dist/src/server/data.d.ts +3 -0
  27. package/dist/src/server/data.d.ts.map +1 -0
  28. package/dist/src/server/data.js +36 -0
  29. package/dist/src/server/data.js.map +1 -0
  30. package/dist/src/server/handler.d.ts +5 -0
  31. package/dist/src/server/handler.d.ts.map +1 -0
  32. package/dist/src/server/handler.js +40 -0
  33. package/dist/src/server/handler.js.map +1 -0
  34. package/dist/src/types/index.d.ts +34 -0
  35. package/dist/src/types/index.d.ts.map +1 -0
  36. package/dist/src/types/index.js +2 -0
  37. package/dist/src/types/index.js.map +1 -0
  38. package/package.json +62 -0
package/README.md ADDED
@@ -0,0 +1,499 @@
1
+ # Locallytics
2
+
3
+ Self-hosted, privacy-first analytics for any JavaScript framework.
4
+
5
+ - **Privacy-first** — No cookies, respects Do Not Track
6
+ - **Self-hosted** — Your data in your database (PostgreSQL or SQLite)
7
+ - **Framework-agnostic** — Works with Next.js, Remix, Express, Hono, etc.
8
+ - **Simple** — Two main functions: `LocallyticsGrabber` and `LocallyticsData`
9
+ - **Multiple formats** — Export analytics as JSON or CSV
10
+
11
+ ## Quick Start
12
+
13
+ ### 1. Install
14
+
15
+ ```bash
16
+ npm install locallytics
17
+ ```
18
+
19
+ ### 2. Migrate Database
20
+
21
+ Set your database URL and run migrations:
22
+
23
+ ```bash
24
+ export DATABASE_URL="postgres://user:pass@localhost/db"
25
+ # or
26
+ export DATABASE_URL="./analytics.db"
27
+
28
+ npx locallytics migrate
29
+ ```
30
+
31
+ ### 3. Create API Handler
32
+
33
+ The handler uses Web Standard `Request`/`Response` - works in any framework.
34
+
35
+ **Next.js:**
36
+ ```typescript
37
+ // app/api/analytics/route.ts
38
+ import { createHandler } from "locallytics";
39
+
40
+ const handler = await createHandler({
41
+ database: process.env.DATABASE_URL!,
42
+ });
43
+
44
+ export const POST = handler.POST;
45
+ ```
46
+
47
+ **Express:**
48
+ ```javascript
49
+ import express from "express";
50
+ import { createHandler } from "locallytics";
51
+
52
+ const app = express();
53
+ const analytics = await createHandler({
54
+ database: process.env.DATABASE_URL,
55
+ });
56
+
57
+ app.post("/api/analytics", async (req, res) => {
58
+ const response = await analytics.POST(req);
59
+ res.status(response.status).send(await response.text());
60
+ });
61
+ ```
62
+
63
+ **Hono:**
64
+ ```typescript
65
+ import { Hono } from "hono";
66
+ import { createHandler } from "locallytics";
67
+
68
+ const app = new Hono();
69
+ const analytics = await createHandler({
70
+ database: process.env.DATABASE_URL!,
71
+ });
72
+
73
+ app.post("/api/analytics", (c) => analytics.POST(c.req.raw));
74
+ ```
75
+
76
+ ### 4. Add Client Tracker
77
+
78
+ Call `LocallyticsGrabber()` on page load to track pageviews.
79
+
80
+ **Next.js (React):**
81
+ ```tsx
82
+ // app/layout.tsx
83
+ "use client";
84
+
85
+ import { LocallyticsGrabber } from "locallytics/client";
86
+ import { useEffect } from "react";
87
+
88
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
89
+ useEffect(() => {
90
+ LocallyticsGrabber();
91
+ }, []);
92
+
93
+ return (
94
+ <html>
95
+ <body>{children}</body>
96
+ </html>
97
+ );
98
+ }
99
+ ```
100
+
101
+ **Vanilla JavaScript:**
102
+ ```html
103
+ <script type="module">
104
+ import { LocallyticsGrabber } from "/node_modules/locallytics/dist/client/index.js";
105
+ LocallyticsGrabber();
106
+ </script>
107
+ ```
108
+
109
+ ### 5. View Analytics
110
+
111
+ Query analytics data on the server:
112
+
113
+ ```typescript
114
+ // app/dashboard/page.tsx (Next.js)
115
+ import { LocallyticsData } from "locallytics";
116
+
117
+ export default async function Dashboard() {
118
+ const data = await LocallyticsData(
119
+ process.env.DATABASE_URL!,
120
+ "last7d" // "last24h" | "last7d" | "last30d"
121
+ );
122
+
123
+ return (
124
+ <div>
125
+ <p>{data.pageviews} pageviews</p>
126
+ <p>{data.uniqueVisitors} visitors</p>
127
+ </div>
128
+ );
129
+ }
130
+ ```
131
+
132
+ ---
133
+
134
+ ## API Reference
135
+
136
+ ### `LocallyticsGrabber(options?)`
137
+
138
+ Client-side function that tracks pageviews in the browser.
139
+
140
+ **Usage:**
141
+ ```typescript
142
+ import { LocallyticsGrabber } from "locallytics/client";
143
+
144
+ LocallyticsGrabber({
145
+ endpoint: "/api/analytics", // default
146
+ });
147
+ ```
148
+
149
+ **Options:**
150
+
151
+ | Option | Type | Default | Description |
152
+ | ---------- | -------- | ------------------ | ------------------------------ |
153
+ | `endpoint` | `string` | `"/api/analytics"` | API endpoint to send data to |
154
+
155
+ **Data Collected:**
156
+
157
+ Each pageview sends:
158
+ - `pageUrl` — Current path
159
+ - `referrer` — Where user came from
160
+ - `sessionId` — Anonymous ID (localStorage)
161
+ - `timestamp` — ISO timestamp
162
+
163
+ **Privacy Features:**
164
+ - **No cookies** — Uses localStorage only
165
+ - **Respects DNT** — Tracking disabled when Do Not Track is on
166
+ - **No PII** — Anonymous session IDs only
167
+
168
+ ---
169
+
170
+ ### `createHandler(config)`
171
+
172
+ Creates API route handlers for receiving tracking data.
173
+
174
+ **Usage:**
175
+ ```typescript
176
+ import { createHandler } from "locallytics";
177
+
178
+ const handler = await createHandler({
179
+ database: process.env.DATABASE_URL!,
180
+ });
181
+
182
+ export const POST = handler.POST;
183
+ ```
184
+
185
+ **Config:**
186
+
187
+ | Option | Type | Required | Description |
188
+ | ---------- | -------- | -------- | ------------------------------ |
189
+ | `database` | `string` | Yes | Database connection string |
190
+
191
+ **Returns:**
192
+
193
+ Object with a `POST` method that accepts Web Standard `Request` and returns `Response`.
194
+
195
+ **Response Codes:**
196
+ - `201` — Pageview recorded successfully
197
+ - `204` — Tracking skipped (DNT enabled)
198
+ - `400` — Invalid payload
199
+ - `500` — Server error
200
+
201
+ ---
202
+
203
+ ### `LocallyticsData(databaseUrl, range, format?)`
204
+
205
+ Queries analytics data from the database.
206
+
207
+ **Usage:**
208
+ ```typescript
209
+ import { LocallyticsData } from "locallytics";
210
+
211
+ // Get JSON data (default)
212
+ const data = await LocallyticsData(
213
+ process.env.DATABASE_URL!,
214
+ "last7d"
215
+ );
216
+
217
+ console.log(data.pageviews, data.uniqueVisitors);
218
+
219
+ // Get CSV data
220
+ const csv = await LocallyticsData(
221
+ process.env.DATABASE_URL!,
222
+ "last7d",
223
+ "csv"
224
+ );
225
+
226
+ console.log(csv); // CSV formatted string
227
+ ```
228
+
229
+ **Parameters:**
230
+
231
+ | Parameter | Type | Required | Description |
232
+ | ------------- | --------------------------------- | -------- | ------------------------------ |
233
+ | `databaseUrl` | `string` | Yes | Database connection string |
234
+ | `range` | `"last24h"` \| `"last7d"` \| `"last30d"` | Yes | Date range to query |
235
+ | `format` | `"json"` \| `"csv"` | No | Output format (default: `"json"`) |
236
+
237
+ **Returns (JSON format):**
238
+
239
+ ```typescript
240
+ {
241
+ pageviews: number; // Total pageviews
242
+ uniqueVisitors: number; // Unique sessions
243
+ topPages: Array<{
244
+ page: string; // Page URL
245
+ count: number; // View count
246
+ }>; // Top 10 pages
247
+ dailyStats: Array<{
248
+ date: string; // YYYY-MM-DD
249
+ views: number; // Views that day
250
+ }>;
251
+ }
252
+ ```
253
+
254
+ **Returns (CSV format):**
255
+
256
+ String with CSV formatted data including summary, top pages, and daily stats.
257
+
258
+ ---
259
+
260
+ ## CLI Commands
261
+
262
+ ### `migrate`
263
+
264
+ Creates or updates database tables.
265
+
266
+ ```bash
267
+ export DATABASE_URL="postgres://user:pass@localhost/db"
268
+ npx locallytics migrate
269
+ ```
270
+
271
+ ### `reset`
272
+
273
+ Drops and recreates all tables. **WARNING: This destroys all data!**
274
+
275
+ ```bash
276
+ export DATABASE_URL="postgres://user:pass@localhost/db"
277
+ npx locallytics reset
278
+ ```
279
+
280
+ ### `help`
281
+
282
+ Shows available commands.
283
+
284
+ ```bash
285
+ npx locallytics help
286
+ ```
287
+
288
+ ---
289
+
290
+ ## Database Support
291
+
292
+ Locallytics supports PostgreSQL and SQLite. The database type is detected automatically from the connection string.
293
+
294
+ ### PostgreSQL
295
+
296
+ **Connection String Format:**
297
+ ```
298
+ postgres://user:password@host:port/database
299
+ postgresql://user:password@host:port/database
300
+ ```
301
+
302
+ **Example:**
303
+ ```bash
304
+ export DATABASE_URL="postgres://myuser:mypass@localhost:5432/analytics"
305
+ npx locallytics migrate
306
+ ```
307
+
308
+ **Requirements:**
309
+ - Install `pg` peer dependency: `npm install pg`
310
+
311
+ ### SQLite
312
+
313
+ **Connection String Format:**
314
+ ```
315
+ ./path/to/database.db
316
+ /absolute/path/to/database.db
317
+ sqlite:///path/to/database.db
318
+ ```
319
+
320
+ **Example:**
321
+ ```bash
322
+ export DATABASE_URL="./analytics.db"
323
+ npx locallytics migrate
324
+ ```
325
+
326
+ **Requirements:**
327
+ - Install `better-sqlite3` peer dependency: `npm install better-sqlite3`
328
+
329
+ ---
330
+
331
+ ## Privacy Features
332
+
333
+ Locallytics is designed with privacy in mind:
334
+
335
+ ### No Cookies
336
+ Uses localStorage for session IDs instead of cookies. No cookie banners required.
337
+
338
+ ### Respects Do Not Track
339
+ When a user has Do Not Track (DNT) enabled in their browser:
340
+ - Client-side tracker won't send data
341
+ - Server-side handler won't record data
342
+
343
+ ### No Personal Information
344
+ Only collects:
345
+ - Page URLs
346
+ - Referrer URLs
347
+ - Anonymous session IDs (random UUIDs)
348
+ - Timestamps
349
+
350
+ No IP addresses, user agents, or any identifying information.
351
+
352
+ ### Self-Hosted
353
+ All data stays in your database. No third-party services involved.
354
+
355
+ ---
356
+
357
+ ## Framework Examples
358
+
359
+ ### Next.js (App Router)
360
+
361
+ See [examples/nextjs](./examples/nextjs) for complete example.
362
+
363
+ **API Route:**
364
+ ```typescript
365
+ // app/api/analytics/route.ts
366
+ import { createHandler } from "locallytics";
367
+
368
+ const handler = await createHandler({
369
+ database: process.env.DATABASE_URL!,
370
+ });
371
+
372
+ export const POST = handler.POST;
373
+ ```
374
+
375
+ **Layout (Client Component):**
376
+ ```tsx
377
+ // app/layout.tsx
378
+ "use client";
379
+
380
+ import { LocallyticsGrabber } from "locallytics/client";
381
+ import { useEffect } from "react";
382
+
383
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
384
+ useEffect(() => {
385
+ LocallyticsGrabber();
386
+ }, []);
387
+
388
+ return (
389
+ <html>
390
+ <body>{children}</body>
391
+ </html>
392
+ );
393
+ }
394
+ ```
395
+
396
+ **Dashboard (Server Component):**
397
+ ```tsx
398
+ // app/dashboard/page.tsx
399
+ import { LocallyticsData } from "locallytics";
400
+
401
+ export default async function Dashboard() {
402
+ const data = await LocallyticsData(process.env.DATABASE_URL!, "last7d");
403
+
404
+ return (
405
+ <div>
406
+ <h1>Analytics</h1>
407
+ <p>Pageviews: {data.pageviews}</p>
408
+ <p>Unique Visitors: {data.uniqueVisitors}</p>
409
+
410
+ <h2>Top Pages</h2>
411
+ <ul>
412
+ {data.topPages.map((page) => (
413
+ <li key={page.page}>
414
+ {page.page}: {page.count} views
415
+ </li>
416
+ ))}
417
+ </ul>
418
+ </div>
419
+ );
420
+ }
421
+ ```
422
+
423
+ ### Express
424
+
425
+ See [examples/express](./examples/express) for complete example.
426
+
427
+ ```javascript
428
+ import express from "express";
429
+ import { createHandler, LocallyticsData } from "locallytics";
430
+
431
+ const app = express();
432
+ const analytics = await createHandler({
433
+ database: process.env.DATABASE_URL,
434
+ });
435
+
436
+ app.post("/api/analytics", async (req, res) => {
437
+ const response = await analytics.POST(req);
438
+ res.status(response.status).send(await response.text());
439
+ });
440
+
441
+ app.get("/dashboard", async (req, res) => {
442
+ const data = await LocallyticsData(process.env.DATABASE_URL, "last7d");
443
+ res.json(data);
444
+ });
445
+
446
+ app.listen(3000);
447
+ ```
448
+
449
+ ### Remix
450
+
451
+ ```typescript
452
+ // app/routes/api.analytics.ts
453
+ import type { ActionFunction } from "@remix-run/node";
454
+ import { createHandler } from "locallytics";
455
+
456
+ const handler = await createHandler({
457
+ database: process.env.DATABASE_URL!,
458
+ });
459
+
460
+ export const action: ActionFunction = async ({ request }) => {
461
+ return handler.POST(request);
462
+ };
463
+ ```
464
+
465
+ ### Hono
466
+
467
+ ```typescript
468
+ import { Hono } from "hono";
469
+ import { createHandler } from "locallytics";
470
+
471
+ const app = new Hono();
472
+ const analytics = await createHandler({
473
+ database: process.env.DATABASE_URL!,
474
+ });
475
+
476
+ app.post("/api/analytics", (c) => analytics.POST(c.req.raw));
477
+ ```
478
+
479
+ ---
480
+
481
+ ## TypeScript
482
+
483
+ Locallytics is written in TypeScript and includes full type definitions.
484
+
485
+ ```typescript
486
+ import type {
487
+ DateRange,
488
+ DataFormat,
489
+ AnalyticsResult,
490
+ GrabberOptions,
491
+ HandlerConfig,
492
+ } from "locallytics";
493
+ ```
494
+
495
+ ---
496
+
497
+ ## License
498
+
499
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../cli/index.ts"],"names":[],"mappings":""}
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { createAdapter } from "../src/db/index.js";
3
+ const commands = {
4
+ migrate: async () => {
5
+ const databaseUrl = process.env.DATABASE_URL;
6
+ if (!databaseUrl) {
7
+ console.error("❌ DATABASE_URL environment variable is required");
8
+ process.exit(1);
9
+ }
10
+ console.log("🔄 Running migration...");
11
+ try {
12
+ const adapter = await createAdapter(databaseUrl);
13
+ await adapter.migrate();
14
+ adapter.close();
15
+ console.log("✅ Migration completed successfully");
16
+ }
17
+ catch (error) {
18
+ console.error("❌ Migration failed:", error);
19
+ process.exit(1);
20
+ }
21
+ },
22
+ reset: async () => {
23
+ const databaseUrl = process.env.DATABASE_URL;
24
+ if (!databaseUrl) {
25
+ console.error("❌ DATABASE_URL environment variable is required");
26
+ process.exit(1);
27
+ }
28
+ console.log("⚠️ WARNING: This will delete all analytics data!");
29
+ console.log("🔄 Resetting database...");
30
+ try {
31
+ const adapter = await createAdapter(databaseUrl);
32
+ await adapter.reset();
33
+ adapter.close();
34
+ console.log("✅ Database reset successfully");
35
+ }
36
+ catch (error) {
37
+ console.error("❌ Reset failed:", error);
38
+ process.exit(1);
39
+ }
40
+ },
41
+ help: () => {
42
+ console.log(`
43
+ Locallytics CLI - Database management tool
44
+
45
+ Usage:
46
+ locallytics <command>
47
+
48
+ Commands:
49
+ migrate Create or update database tables
50
+ reset Drop and recreate all tables (destroys data!)
51
+ help Show this help message
52
+
53
+ Environment Variables:
54
+ DATABASE_URL Database connection string (required)
55
+
56
+ Examples:
57
+ export DATABASE_URL="postgres://user:pass@localhost/db"
58
+ locallytics migrate
59
+
60
+ export DATABASE_URL="./analytics.db"
61
+ locallytics migrate
62
+ `);
63
+ },
64
+ };
65
+ const command = process.argv[2];
66
+ if (!command || !commands[command]) {
67
+ commands.help();
68
+ process.exit(command ? 1 : 0);
69
+ }
70
+ commands[command]();
71
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../cli/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,MAAM,QAAQ,GAAG;IACf,OAAO,EAAE,KAAK,IAAI,EAAE;QAClB,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QAE7C,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;YACjE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;QAEvC,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;YACjD,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;YACxB,OAAO,CAAC,KAAK,EAAE,CAAC;YAChB,OAAO,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAC;QACpD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAC;YAC5C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IAED,KAAK,EAAE,KAAK,IAAI,EAAE;QAChB,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;QAE7C,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;YACjE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,mDAAmD,CAAC,CAAC;QACjE,OAAO,CAAC,GAAG,CAAC,0BAA0B,CAAC,CAAC;QAExC,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;YACjD,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC;YACtB,OAAO,CAAC,KAAK,EAAE,CAAC;YAChB,OAAO,CAAC,GAAG,CAAC,+BAA+B,CAAC,CAAC;QAC/C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,iBAAiB,EAAE,KAAK,CAAC,CAAC;YACxC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IAED,IAAI,EAAE,GAAG,EAAE;QACT,OAAO,CAAC,GAAG,CAAC;;;;;;;;;;;;;;;;;;;;KAoBX,CAAC,CAAC;IACL,CAAC;CACF,CAAC;AAEF,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAA0B,CAAC;AAEzD,IAAI,CAAC,OAAO,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAChB,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAChC,CAAC;AAED,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { GrabberOptions } from "../types/index.js";
2
+ export declare function LocallyticsGrabber(options?: GrabberOptions): void;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAmBxD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,cAAmB,GAAG,IAAI,CA2BrE"}
@@ -0,0 +1,41 @@
1
+ function getSessionId() {
2
+ const key = "_locallytics_sid";
3
+ let sessionId = localStorage.getItem(key);
4
+ if (!sessionId) {
5
+ sessionId = crypto.randomUUID();
6
+ localStorage.setItem(key, sessionId);
7
+ }
8
+ return sessionId;
9
+ }
10
+ function respectsDNT() {
11
+ const dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
12
+ return dnt === "1" || dnt === "yes";
13
+ }
14
+ export function LocallyticsGrabber(options = {}) {
15
+ if (typeof window === "undefined")
16
+ return;
17
+ if (respectsDNT())
18
+ return;
19
+ const endpoint = options.endpoint || "/api/analytics";
20
+ const payload = {
21
+ pageUrl: window.location.pathname + window.location.search,
22
+ referrer: document.referrer || null,
23
+ sessionId: getSessionId(),
24
+ timestamp: new Date().toISOString(),
25
+ };
26
+ const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
27
+ if (navigator.sendBeacon) {
28
+ navigator.sendBeacon(endpoint, blob);
29
+ }
30
+ else {
31
+ fetch(endpoint, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify(payload),
35
+ keepalive: true,
36
+ }).catch(() => {
37
+ // Silently fail if tracking request fails
38
+ });
39
+ }
40
+ }
41
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAEA,SAAS,YAAY;IACnB,MAAM,GAAG,GAAG,kBAAkB,CAAC;IAC/B,IAAI,SAAS,GAAG,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAE1C,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,SAAS,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;QAChC,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;IACvC,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,WAAW;IAClB,MAAM,GAAG,GAAG,SAAS,CAAC,UAAU,IAAK,MAAc,CAAC,UAAU,IAAK,SAAiB,CAAC,YAAY,CAAC;IAClG,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,KAAK,CAAC;AACtC,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,UAA0B,EAAE;IAC7D,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO;IAC1C,IAAI,WAAW,EAAE;QAAE,OAAO;IAE1B,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,gBAAgB,CAAC;IAEtD,MAAM,OAAO,GAAG;QACd,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM;QAC1D,QAAQ,EAAE,QAAQ,CAAC,QAAQ,IAAI,IAAI;QACnC,SAAS,EAAE,YAAY,EAAE;QACzB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACpC,CAAC;IAEF,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAE/E,IAAI,SAAS,CAAC,UAAU,EAAE,CAAC;QACzB,SAAS,CAAC,UAAU,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACvC,CAAC;SAAM,CAAC;QACN,KAAK,CAAC,QAAQ,EAAE;YACd,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;YAC7B,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE;YACZ,0CAA0C;QAC5C,CAAC,CAAC,CAAC;IACL,CAAC;AACH,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { DatabaseAdapter, DateRange } from "../types/index.js";
2
+ export declare function createAdapter(databaseUrl: string): Promise<DatabaseAdapter>;
3
+ export declare function getDateCutoff(range: DateRange): Date;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/db/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEpE,wBAAsB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAejF;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAapD"}
@@ -0,0 +1,26 @@
1
+ export async function createAdapter(databaseUrl) {
2
+ if (databaseUrl.startsWith("postgres://") || databaseUrl.startsWith("postgresql://")) {
3
+ const { PostgresAdapter } = await import("./postgres.js");
4
+ return new PostgresAdapter(databaseUrl);
5
+ }
6
+ if (databaseUrl.endsWith(".db") || databaseUrl.endsWith(".sqlite") || databaseUrl.startsWith("sqlite://")) {
7
+ const { SQLiteAdapter } = await import("./sqlite.js");
8
+ const path = databaseUrl.replace("sqlite://", "");
9
+ return new SQLiteAdapter(path);
10
+ }
11
+ throw new Error(`Unsupported database URL: ${databaseUrl}. Supported formats: postgres://, sqlite:// or .db/.sqlite file path`);
12
+ }
13
+ export function getDateCutoff(range) {
14
+ const now = new Date();
15
+ switch (range) {
16
+ case "last24h":
17
+ return new Date(now.getTime() - 24 * 60 * 60 * 1000);
18
+ case "last7d":
19
+ return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
20
+ case "last30d":
21
+ return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
22
+ default:
23
+ throw new Error(`Invalid date range: ${range}`);
24
+ }
25
+ }
26
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/db/index.ts"],"names":[],"mappings":"AAEA,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,WAAmB;IACrD,IAAI,WAAW,CAAC,UAAU,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,UAAU,CAAC,eAAe,CAAC,EAAE,CAAC;QACrF,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,MAAM,CAAC,eAAe,CAAC,CAAC;QAC1D,OAAO,IAAI,eAAe,CAAC,WAAW,CAAC,CAAC;IAC1C,CAAC;IAED,IAAI,WAAW,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,WAAW,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,WAAW,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;QAC1G,MAAM,EAAE,aAAa,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC;QACtD,MAAM,IAAI,GAAG,WAAW,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;QAClD,OAAO,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;IACjC,CAAC;IAED,MAAM,IAAI,KAAK,CACb,6BAA6B,WAAW,sEAAsE,CAC/G,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,KAAgB;IAC5C,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IAEvB,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,SAAS;YACZ,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;QACvD,KAAK,QAAQ;YACX,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;QAC3D,KAAK,SAAS;YACZ,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;QAC5D;YACE,MAAM,IAAI,KAAK,CAAC,uBAAuB,KAAK,EAAE,CAAC,CAAC;IACpD,CAAC;AACH,CAAC"}
@@ -0,0 +1,11 @@
1
+ import type { DatabaseAdapter, PageviewPayload, AnalyticsResult } from "../types/index.js";
2
+ export declare class PostgresAdapter implements DatabaseAdapter {
3
+ private pool;
4
+ constructor(connectionString: string);
5
+ insert(data: PageviewPayload): Promise<void>;
6
+ query(since: Date): Promise<AnalyticsResult>;
7
+ migrate(): Promise<void>;
8
+ reset(): Promise<void>;
9
+ close(): void;
10
+ }
11
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../../src/db/postgres.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAiB3F,qBAAa,eAAgB,YAAW,eAAe;IACrD,OAAO,CAAC,IAAI,CAAU;gBAEV,gBAAgB,EAAE,MAAM;IAI9B,MAAM,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAO5C,KAAK,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC;IAwC5C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B,KAAK,IAAI,IAAI;CAGd"}
@@ -0,0 +1,63 @@
1
+ import pg from "pg";
2
+ const { Pool } = pg;
3
+ const SCHEMA = `
4
+ CREATE TABLE IF NOT EXISTS pageviews (
5
+ id SERIAL PRIMARY KEY,
6
+ page_url TEXT NOT NULL,
7
+ referrer TEXT,
8
+ session_id TEXT NOT NULL,
9
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
10
+ );
11
+
12
+ CREATE INDEX IF NOT EXISTS idx_pageviews_created_at ON pageviews(created_at);
13
+ CREATE INDEX IF NOT EXISTS idx_pageviews_session_id ON pageviews(session_id);
14
+ `;
15
+ export class PostgresAdapter {
16
+ pool;
17
+ constructor(connectionString) {
18
+ this.pool = new Pool({ connectionString });
19
+ }
20
+ async insert(data) {
21
+ await this.pool.query("INSERT INTO pageviews (page_url, referrer, session_id, created_at) VALUES ($1, $2, $3, $4)", [data.pageUrl, data.referrer, data.sessionId, new Date(data.timestamp)]);
22
+ }
23
+ async query(since) {
24
+ const [pageviewsResult, visitorsResult, topPagesResult, dailyStatsResult] = await Promise.all([
25
+ this.pool.query("SELECT COUNT(*) as count FROM pageviews WHERE created_at >= $1", [since]),
26
+ this.pool.query("SELECT COUNT(DISTINCT session_id) as count FROM pageviews WHERE created_at >= $1", [since]),
27
+ this.pool.query(`SELECT page_url as page, COUNT(*) as count
28
+ FROM pageviews
29
+ WHERE created_at >= $1
30
+ GROUP BY page_url
31
+ ORDER BY count DESC
32
+ LIMIT 10`, [since]),
33
+ this.pool.query(`SELECT DATE(created_at) as date, COUNT(*) as views
34
+ FROM pageviews
35
+ WHERE created_at >= $1
36
+ GROUP BY DATE(created_at)
37
+ ORDER BY date ASC`, [since]),
38
+ ]);
39
+ return {
40
+ pageviews: Number(pageviewsResult.rows[0].count),
41
+ uniqueVisitors: Number(visitorsResult.rows[0].count),
42
+ topPages: topPagesResult.rows.map((row) => ({
43
+ page: row.page,
44
+ count: Number(row.count),
45
+ })),
46
+ dailyStats: dailyStatsResult.rows.map((row) => ({
47
+ date: row.date.toISOString().split("T")[0],
48
+ views: Number(row.views),
49
+ })),
50
+ };
51
+ }
52
+ async migrate() {
53
+ await this.pool.query(SCHEMA);
54
+ }
55
+ async reset() {
56
+ await this.pool.query("DROP TABLE IF EXISTS pageviews CASCADE");
57
+ await this.migrate();
58
+ }
59
+ close() {
60
+ this.pool.end();
61
+ }
62
+ }
63
+ //# sourceMappingURL=postgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.js","sourceRoot":"","sources":["../../../src/db/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,IAAI,CAAC;AAGpB,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;AAEpB,MAAM,MAAM,GAAG;;;;;;;;;;;CAWd,CAAC;AAEF,MAAM,OAAO,eAAe;IAClB,IAAI,CAAU;IAEtB,YAAY,gBAAwB;QAClC,IAAI,CAAC,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAC7C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,IAAqB;QAChC,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CACnB,4FAA4F,EAC5F,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CACxE,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,KAAW;QACrB,MAAM,CAAC,eAAe,EAAE,cAAc,EAAE,cAAc,EAAE,gBAAgB,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAC5F,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,gEAAgE,EAAE,CAAC,KAAK,CAAC,CAAC;YAC1F,IAAI,CAAC,IAAI,CAAC,KAAK,CACb,kFAAkF,EAClF,CAAC,KAAK,CAAC,CACR;YACD,IAAI,CAAC,IAAI,CAAC,KAAK,CACb;;;;;kBAKU,EACV,CAAC,KAAK,CAAC,CACR;YACD,IAAI,CAAC,IAAI,CAAC,KAAK,CACb;;;;2BAImB,EACnB,CAAC,KAAK,CAAC,CACR;SACF,CAAC,CAAC;QAEH,OAAO;YACL,SAAS,EAAE,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YAChD,cAAc,EAAE,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YACpD,QAAQ,EAAE,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBAC1C,IAAI,EAAE,GAAG,CAAC,IAAI;gBACd,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC;aACzB,CAAC,CAAC;YACH,UAAU,EAAE,gBAAgB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBAC9C,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;gBAC1C,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC;aACzB,CAAC,CAAC;SACJ,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,KAAK,CAAC,KAAK;QACT,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,wCAAwC,CAAC,CAAC;QAChE,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACvB,CAAC;IAED,KAAK;QACH,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IAClB,CAAC;CACF"}
@@ -0,0 +1,11 @@
1
+ import type { DatabaseAdapter, PageviewPayload, AnalyticsResult } from "../types/index.js";
2
+ export declare class SQLiteAdapter implements DatabaseAdapter {
3
+ private db;
4
+ constructor(path: string);
5
+ insert(data: PageviewPayload): Promise<void>;
6
+ query(since: Date): Promise<AnalyticsResult>;
7
+ migrate(): Promise<void>;
8
+ reset(): Promise<void>;
9
+ close(): void;
10
+ }
11
+ //# sourceMappingURL=sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../../src/db/sqlite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAe3F,qBAAa,aAAc,YAAW,eAAe;IACnD,OAAO,CAAC,EAAE,CAAoB;gBAElB,IAAI,EAAE,MAAM;IAIlB,MAAM,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAO5C,KAAK,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC;IAwC5C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAIxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B,KAAK,IAAI,IAAI;CAGd"}
@@ -0,0 +1,64 @@
1
+ import Database from "better-sqlite3";
2
+ const SCHEMA = `
3
+ CREATE TABLE IF NOT EXISTS pageviews (
4
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5
+ page_url TEXT NOT NULL,
6
+ referrer TEXT,
7
+ session_id TEXT NOT NULL,
8
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
9
+ );
10
+
11
+ CREATE INDEX IF NOT EXISTS idx_pageviews_created_at ON pageviews(created_at);
12
+ CREATE INDEX IF NOT EXISTS idx_pageviews_session_id ON pageviews(session_id);
13
+ `;
14
+ export class SQLiteAdapter {
15
+ db;
16
+ constructor(path) {
17
+ this.db = new Database(path);
18
+ }
19
+ async insert(data) {
20
+ const stmt = this.db.prepare("INSERT INTO pageviews (page_url, referrer, session_id, created_at) VALUES (?, ?, ?, ?)");
21
+ stmt.run(data.pageUrl, data.referrer, data.sessionId, new Date(data.timestamp).toISOString());
22
+ }
23
+ async query(since) {
24
+ const sinceISO = since.toISOString();
25
+ const pageviews = this.db
26
+ .prepare("SELECT COUNT(*) as count FROM pageviews WHERE created_at >= ?")
27
+ .get(sinceISO);
28
+ const uniqueVisitors = this.db
29
+ .prepare("SELECT COUNT(DISTINCT session_id) as count FROM pageviews WHERE created_at >= ?")
30
+ .get(sinceISO);
31
+ const topPages = this.db
32
+ .prepare(`SELECT page_url as page, COUNT(*) as count
33
+ FROM pageviews
34
+ WHERE created_at >= ?
35
+ GROUP BY page_url
36
+ ORDER BY count DESC
37
+ LIMIT 10`)
38
+ .all(sinceISO);
39
+ const dailyStats = this.db
40
+ .prepare(`SELECT DATE(created_at) as date, COUNT(*) as views
41
+ FROM pageviews
42
+ WHERE created_at >= ?
43
+ GROUP BY DATE(created_at)
44
+ ORDER BY date ASC`)
45
+ .all(sinceISO);
46
+ return {
47
+ pageviews: pageviews.count,
48
+ uniqueVisitors: uniqueVisitors.count,
49
+ topPages,
50
+ dailyStats,
51
+ };
52
+ }
53
+ async migrate() {
54
+ this.db.exec(SCHEMA);
55
+ }
56
+ async reset() {
57
+ this.db.exec("DROP TABLE IF EXISTS pageviews");
58
+ await this.migrate();
59
+ }
60
+ close() {
61
+ this.db.close();
62
+ }
63
+ }
64
+ //# sourceMappingURL=sqlite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../../src/db/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAGtC,MAAM,MAAM,GAAG;;;;;;;;;;;CAWd,CAAC;AAEF,MAAM,OAAO,aAAa;IAChB,EAAE,CAAoB;IAE9B,YAAY,IAAY;QACtB,IAAI,CAAC,EAAE,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC/B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,IAAqB;QAChC,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAC1B,wFAAwF,CACzF,CAAC;QACF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;IAChG,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,KAAW;QACrB,MAAM,QAAQ,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;QAErC,MAAM,SAAS,GAAG,IAAI,CAAC,EAAE;aACtB,OAAO,CAAC,+DAA+D,CAAC;aACxE,GAAG,CAAC,QAAQ,CAAsB,CAAC;QAEtC,MAAM,cAAc,GAAG,IAAI,CAAC,EAAE;aAC3B,OAAO,CAAC,iFAAiF,CAAC;aAC1F,GAAG,CAAC,QAAQ,CAAsB,CAAC;QAEtC,MAAM,QAAQ,GAAG,IAAI,CAAC,EAAE;aACrB,OAAO,CACN;;;;;kBAKU,CACX;aACA,GAAG,CAAC,QAAQ,CAA2C,CAAC;QAE3D,MAAM,UAAU,GAAG,IAAI,CAAC,EAAE;aACvB,OAAO,CACN;;;;2BAImB,CACpB;aACA,GAAG,CAAC,QAAQ,CAA2C,CAAC;QAE3D,OAAO;YACL,SAAS,EAAE,SAAS,CAAC,KAAK;YAC1B,cAAc,EAAE,cAAc,CAAC,KAAK;YACpC,QAAQ;YACR,UAAU;SACX,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,OAAO;QACX,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACvB,CAAC;IAED,KAAK,CAAC,KAAK;QACT,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;QAC/C,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;IACvB,CAAC;IAED,KAAK;QACH,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC;IAClB,CAAC;CACF"}
@@ -0,0 +1,5 @@
1
+ export { LocallyticsGrabber } from "./client/index.js";
2
+ export { LocallyticsData } from "./server/data.js";
3
+ export { createHandler } from "./server/handler.js";
4
+ export type { DateRange, DataFormat, AnalyticsResult, GrabberOptions, HandlerConfig, } from "./types/index.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EACV,SAAS,EACT,UAAU,EACV,eAAe,EACf,cAAc,EACd,aAAa,GACd,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { LocallyticsGrabber } from "./client/index.js";
2
+ export { LocallyticsData } from "./server/data.js";
3
+ export { createHandler } from "./server/handler.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { DateRange, DataFormat, AnalyticsResult } from "../types/index.js";
2
+ export declare function LocallyticsData(databaseUrl: string, range: DateRange, format?: DataFormat): Promise<AnalyticsResult | string>;
3
+ //# sourceMappingURL=data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data.d.ts","sourceRoot":"","sources":["../../../src/server/data.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AA2BhF,wBAAsB,eAAe,CACnC,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,SAAS,EAChB,MAAM,GAAE,UAAmB,GAC1B,OAAO,CAAC,eAAe,GAAG,MAAM,CAAC,CAenC"}
@@ -0,0 +1,36 @@
1
+ import { createAdapter, getDateCutoff } from "../db/index.js";
2
+ function formatAsCSV(data) {
3
+ const lines = [];
4
+ lines.push("# Summary");
5
+ lines.push("Metric,Value");
6
+ lines.push(`Total Pageviews,${data.pageviews}`);
7
+ lines.push(`Unique Visitors,${data.uniqueVisitors}`);
8
+ lines.push("");
9
+ lines.push("# Top Pages");
10
+ lines.push("Page,Views");
11
+ for (const page of data.topPages) {
12
+ lines.push(`${page.page},${page.count}`);
13
+ }
14
+ lines.push("");
15
+ lines.push("# Daily Stats");
16
+ lines.push("Date,Views");
17
+ for (const stat of data.dailyStats) {
18
+ lines.push(`${stat.date},${stat.views}`);
19
+ }
20
+ return lines.join("\n");
21
+ }
22
+ export async function LocallyticsData(databaseUrl, range, format = "json") {
23
+ const adapter = await createAdapter(databaseUrl);
24
+ const since = getDateCutoff(range);
25
+ try {
26
+ const data = await adapter.query(since);
27
+ if (format === "csv") {
28
+ return formatAsCSV(data);
29
+ }
30
+ return data;
31
+ }
32
+ finally {
33
+ adapter.close();
34
+ }
35
+ }
36
+ //# sourceMappingURL=data.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data.js","sourceRoot":"","sources":["../../../src/server/data.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAG9D,SAAS,WAAW,CAAC,IAAqB;IACxC,MAAM,KAAK,GAAa,EAAE,CAAC;IAE3B,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACxB,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,mBAAmB,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;IAChD,KAAK,CAAC,IAAI,CAAC,mBAAmB,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC;IACrD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAEf,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAC1B,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IACzB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QACjC,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAC3C,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAEf,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;IAC5B,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IACzB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;QACnC,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,WAAmB,EACnB,KAAgB,EAChB,SAAqB,MAAM;IAE3B,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,WAAW,CAAC,CAAC;IACjD,MAAM,KAAK,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAExC,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;YACrB,OAAO,WAAW,CAAC,IAAI,CAAC,CAAC;QAC3B,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;YAAS,CAAC;QACT,OAAO,CAAC,KAAK,EAAE,CAAC;IAClB,CAAC;AACH,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { HandlerConfig } from "../types/index.js";
2
+ export declare function createHandler(config: HandlerConfig): Promise<{
3
+ POST: (request: Request) => Promise<Response>;
4
+ }>;
5
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../../src/server/handler.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAcvD,wBAAsB,aAAa,CAAC,MAAM,EAAE,aAAa;oBAG1B,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;GA4BzD"}
@@ -0,0 +1,40 @@
1
+ import { z } from "zod";
2
+ import { createAdapter } from "../db/index.js";
3
+ const PageviewSchema = z.object({
4
+ pageUrl: z.string(),
5
+ referrer: z.string().nullable(),
6
+ sessionId: z.string(),
7
+ timestamp: z.string(),
8
+ });
9
+ function respectsDNT(request) {
10
+ const dnt = request.headers.get("dnt") || request.headers.get("DNT");
11
+ return dnt === "1";
12
+ }
13
+ export async function createHandler(config) {
14
+ const adapter = await createAdapter(config.database);
15
+ const POST = async (request) => {
16
+ if (respectsDNT(request)) {
17
+ return new Response(null, { status: 204 });
18
+ }
19
+ try {
20
+ const body = await request.json();
21
+ const data = PageviewSchema.parse(body);
22
+ await adapter.insert(data);
23
+ return new Response(null, { status: 201 });
24
+ }
25
+ catch (error) {
26
+ if (error instanceof z.ZodError) {
27
+ return new Response(JSON.stringify({ error: "Invalid payload", details: error.errors }), {
28
+ status: 400,
29
+ headers: { "Content-Type": "application/json" },
30
+ });
31
+ }
32
+ return new Response(JSON.stringify({ error: "Internal server error" }), {
33
+ status: 500,
34
+ headers: { "Content-Type": "application/json" },
35
+ });
36
+ }
37
+ };
38
+ return { POST };
39
+ }
40
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../../../src/server/handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAG/C,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9B,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC/B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;IACrB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;CACtB,CAAC,CAAC;AAEH,SAAS,WAAW,CAAC,OAAgB;IACnC,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IACrE,OAAO,GAAG,KAAK,GAAG,CAAC;AACrB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,MAAqB;IACvD,MAAM,OAAO,GAAG,MAAM,aAAa,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAErD,MAAM,IAAI,GAAG,KAAK,EAAE,OAAgB,EAAqB,EAAE;QACzD,IAAI,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;YACzB,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC7C,CAAC;QAED,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,CAAC;YAClC,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAExC,MAAM,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YAE3B,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC7C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,KAAK,YAAY,CAAC,CAAC,QAAQ,EAAE,CAAC;gBAChC,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE;oBACvF,MAAM,EAAE,GAAG;oBACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;iBAChD,CAAC,CAAC;YACL,CAAC;YAED,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,EAAE;gBACtE,MAAM,EAAE,GAAG;gBACX,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;aAChD,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,EAAE,IAAI,EAAE,CAAC;AAClB,CAAC"}
@@ -0,0 +1,34 @@
1
+ export type DateRange = "last24h" | "last7d" | "last30d";
2
+ export type DataFormat = "json" | "csv";
3
+ export interface PageviewPayload {
4
+ pageUrl: string;
5
+ referrer: string | null;
6
+ sessionId: string;
7
+ timestamp: string;
8
+ }
9
+ export interface AnalyticsResult {
10
+ pageviews: number;
11
+ uniqueVisitors: number;
12
+ topPages: Array<{
13
+ page: string;
14
+ count: number;
15
+ }>;
16
+ dailyStats: Array<{
17
+ date: string;
18
+ views: number;
19
+ }>;
20
+ }
21
+ export interface DatabaseAdapter {
22
+ insert(data: PageviewPayload): Promise<void>;
23
+ query(since: Date): Promise<AnalyticsResult>;
24
+ migrate(): Promise<void>;
25
+ reset(): Promise<void>;
26
+ close(): void;
27
+ }
28
+ export interface GrabberOptions {
29
+ endpoint?: string;
30
+ }
31
+ export interface HandlerConfig {
32
+ database: string;
33
+ }
34
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEzD,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,KAAK,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;IACH,UAAU,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;CACJ;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,KAAK,CAAC,KAAK,EAAE,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IAC7C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;CAClB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/index.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "locallytics",
3
+ "version": "0.0.1-beta.1",
4
+ "description": "Self-hosted, privacy-first analytics for any JavaScript framework",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./dist/client/index.d.ts",
15
+ "default": "./dist/client/index.js"
16
+ }
17
+ },
18
+ "bin": {
19
+ "locallytics": "./dist/cli/index.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "dev": "tsc --watch",
28
+ "lint": "biome check .",
29
+ "format": "biome format --write ."
30
+ },
31
+ "keywords": [
32
+ "analytics",
33
+ "privacy",
34
+ "self-hosted",
35
+ "pageviews",
36
+ "tracking"
37
+ ],
38
+ "author": "",
39
+ "license": "MIT",
40
+ "dependencies": {
41
+ "zod": "^3.22.4"
42
+ },
43
+ "peerDependencies": {
44
+ "pg": "^8.11.0",
45
+ "better-sqlite3": "^9.4.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "pg": {
49
+ "optional": true
50
+ },
51
+ "better-sqlite3": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^1.5.3",
57
+ "@types/better-sqlite3": "^7.6.9",
58
+ "@types/node": "^20.11.16",
59
+ "@types/pg": "^8.11.0",
60
+ "typescript": "^5.3.3"
61
+ }
62
+ }