{"_id":"@aleju03/peeko","name":"@aleju03/peeko","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@aleju03/peeko","version":"0.1.0","description":"Peek at your traffic: a tiny, self-hosted, PostHog-shaped web analytics core on SQLite with live SSE and optional ephemeral presence.","type":"module","license":"MIT","author":{"name":"aleju03"},"repository":{"type":"git","url":"git+https://github.com/aleju03/peeko.git"},"bugs":{"url":"https://github.com/aleju03/peeko/issues"},"homepage":"https://github.com/aleju03/peeko#readme","keywords":["analytics","sqlite","libsql","posthog","presence","self-hosted","sse","web-analytics"],"main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"scripts":{"build":"tsc -p tsconfig.json","test":"vitest run","typecheck":"tsc -p tsconfig.json --noEmit","verify":"vitest run && tsc -p tsconfig.json --noEmit","example":"node --import tsx examples/demo.ts","prepublishOnly":"npm run build"},"dependencies":{"@libsql/client":"^0.17.3"},"devDependencies":{"@types/node":"^22.10.2","tsx":"^4.20.6","typescript":"^5.7.2","vitest":"^3.0.5"},"gitHead":"40a4c88c846147477f3642df2112486b0fc6f93b","_id":"@aleju03/peeko@0.1.0","_nodeVersion":"24.4.0","_npmVersion":"11.10.0","dist":{"integrity":"sha512-cJlN9r6gtmwOb2USNHAZ793rYv30KzrX6QOih2wcHo+vuquXvowEFzLoLe6O19aH2pB1mf+M5ig3OIQvPhFSPQ==","shasum":"52a559a8548df2fd9cfd58c769109520fb6a2874","tarball":"https://registry.npmjs.org/@aleju03/peeko/-/peeko-0.1.0.tgz","fileCount":15,"unpackedSize":69634,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIHOdypX/4oXPkPDs4iiTc9qK4HEMkr9WfnlAVizAlw00AiEAtCMaJPab+WY23gXq3IAfIMQDPEIM1G3f4TjmORjyzS8="}]},"_npmUser":{"name":"aleju03","email":"alejimenezu@gmail.com"},"directories":{},"maintainers":[{"name":"aleju03","email":"alejimenezu@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/peeko_0.1.0_1784838345367_0.6649227848320751"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-23T20:25:45.212Z","0.1.0":"2026-07-23T20:25:45.494Z","modified":"2026-07-23T20:25:45.681Z"},"maintainers":[{"name":"aleju03","email":"alejimenezu@gmail.com"}],"description":"Peek at your traffic: a tiny, self-hosted, PostHog-shaped web analytics core on SQLite with live SSE and optional ephemeral presence.","homepage":"https://github.com/aleju03/peeko#readme","keywords":["analytics","sqlite","libsql","posthog","presence","self-hosted","sse","web-analytics"],"repository":{"type":"git","url":"git+https://github.com/aleju03/peeko.git"},"author":{"name":"aleju03"},"bugs":{"url":"https://github.com/aleju03/peeko/issues"},"license":"MIT","readme":"<p align=\"center\">\n  <img src=\"assets/mascot.png\" alt=\"peeko mascot\" height=\"160\">\n</p>\n\n# peeko\n\nPeek at your traffic. A tiny, self-hosted, **PostHog-shaped web analytics core** built on SQLite.\n\npeeko is the batteries you keep when you rip out a hosted analytics vendor: a capture endpoint that speaks the same wire shape `posthog-js` already sends, a 1 second batched WAL writer so capture volume never touches your serving path, hourly retention pruning, a small read API for a dashboard, and a live SSE feed of events as they land. An optional ephemeral presence layer can also show which pages have active, idle, or hidden tabs without writing heartbeats into analytics storage. You bring the event schema, the GeoIP + bot enrichment, and the dashboard styling.\n\n## Why\n\n- **Drop-in capture.** Point an existing `posthog-js` frontend at peeko's `/capture` and it works. Same `{event, distinct_id, timestamp, properties}` shape, same `$pageview` / `$host` / `$pathname` / `$referring_domain` conventions.\n- **One SQLite file.** No warehouse, no per-event pricing, no rate-limited query API. Every dashboard read is a local index scan. Give it its own DB file so analytics volume can never bloat your app DB.\n- **Batched and bounded.** Writes buffer for 1s and flush in one transaction. Oversized property bags are truncated, the buffer is capped, retention prunes on a timer.\n- **Live.** Subscribe in-process, or expose the built-in SSE feed with short-lived tickets (so a browser never holds an admin token).\n- **Presence when you want it.** Opt into per-tab active/idle/hidden state with TTL expiry, snapshots, and SSE changes. It is process-local, non-durable, and completely absent unless configured.\n\n## Install\n\n```bash\nnpm install @aleju03/peeko\n```\n\nRequires Node 18+ (uses global `fetch` in the examples). Storage is `@libsql/client`, so a `file:` path, `:memory:`, or a remote Turso URL all work.\n\n## Quick start\n\n```ts\nimport { AnalyticsStore, createAnalyticsHandler, createDb } from \"@aleju03/peeko\";\nimport { createServer } from \"node:http\";\n\nconst db = await createDb({ url: \"file:analytics.db\" });\nconst store = new AnalyticsStore(db, {\n  retentionDays: 90,\n  feedHosts: [\"example.com\"],          // count only real site traffic in the feed\n  feedExcludeViewer: \"admin\",          // hide your own logged-in visits\n  feedExcludePathPrefixes: [\"/admin/\"],\n});\nawait store.ensureSchema();\nstore.start();                          // background flush + prune timers\n\nconst handler = createAnalyticsHandler({\n  store,\n  captureToken: process.env.CAPTURE_TOKEN,\n  allowedOrigins: [\"https://dash.example.com\"],\n  // Your GeoIP + bot-verdict enrichment hook. Runs per capture request.\n  enrich: (req) => ({\n    geoCountry: req.headers[\"x-vercel-ip-country\"] as string | undefined,\n    isBot: /bot|crawler/i.test(req.headers[\"user-agent\"] ?? \"\"),\n  }),\n});\n\ncreateServer((req, res) => {\n  handler(req, res).then((handled) => { if (!handled) { res.writeHead(404); res.end(); } });\n}).listen(3000);\n```\n\nThat exposes four core routes under the base path (default `/peeko`):\n\n| Route | Method | Purpose |\n| --- | --- | --- |\n| `/peeko/capture` | POST | Ingest one payload or `{batch:[...]}`. Token-gated. |\n| `/peeko/live-ticket` | GET | Mint a short-lived ticket for the browser. Token-gated. |\n| `/peeko/live?ticket=` | GET | SSE stream of accepted, feed-filtered events. |\n| `/peeko/monitor?rangeHours=24` | GET | JSON rollup (overview, top paths/referrers/countries, bounce, recent). Token-gated. |\n\nPresence routes are disabled unless a `PresenceTracker` is passed to the handler:\n\n| Route | Method | Purpose |\n| --- | --- | --- |\n| `/peeko/presence/heartbeat` | POST | Upsert one visitor tab and refresh its TTL. Capture-token gated. |\n| `/peeko/presence/leave` | POST | Best-effort explicit tab departure. Capture-token gated. |\n| `/peeko/presence/snapshot` | GET | Current non-expired tabs. Token-gated; filter by `country`, `path`, or `state`. |\n| `/peeko/presence/live-ticket` | GET | Mint a short-lived presence-stream ticket. Token-gated. |\n| `/peeko/presence/live?ticket=` | GET | SSE snapshot followed by presence changes and heartbeats. |\n\n## Frontend capture\n\nAny `posthog-js` install works by pointing its host at peeko:\n\n```js\nposthog.init(\"anything\", { api_host: \"https://your-app.com/peeko\" });\n```\n\nOr send it yourself:\n\n```js\nfetch(\"/peeko/capture\", {\n  method: \"POST\",\n  headers: { \"content-type\": \"application/json\" },\n  body: JSON.stringify({\n    event: \"$pageview\",\n    distinct_id: visitorId,\n    timestamp: new Date().toISOString(),\n    properties: { $host: location.host, $pathname: location.pathname, $screen_width: screen.width },\n  }),\n});\n```\n\n## Read API\n\nUse the store directly for a custom dashboard:\n\n```ts\nawait store.getOverview({ rangeHours: 24 });     // active/pageviews/uniques/events\nawait store.getTopPaths({ rangeHours: 24 });\nawait store.getTopReferrers({ rangeHours: 24 });\nawait store.getTopCountries({ rangeHours: 24 });\nawait store.getBounce({ rangeHours: 24 });\nawait store.getRecentFeed({ rangeHours: 24, country: \"US\", limit: 100 });\n\n// The escape hatch for your own event schema: top-N of any property, any event.\nawait store.getBreakdown({ event: \"product_viewed\", prop: \"sku\", rangeHours: 24 });\nawait store.getBreakdown({ event: \"$pageview\", prop: \"$.utm.campaign\", distinct: true, rangeHours: 168 });\n```\n\n`getBreakdown` is how you rebuild panels like \"top products\" or \"top authors\" without baking them into the core: the engine stays schema-free, and your own event fields (kept in `properties`) drive the custom aggregations.\n\n## Optional live presence\n\nPresence is deliberately separate from historical analytics. Heartbeats never enter SQLite, never increment event counts, and disappear on process restart. Enable it only when the product benefits from a live operational view:\n\n```ts\nimport { AnalyticsStore, PresenceTracker, createAnalyticsHandler } from \"@aleju03/peeko\";\n\nconst store = new AnalyticsStore(db);\nconst presence = new PresenceTracker({\n  ttlMs: 60_000,          // expire a silent tab after one minute\n  sweepIntervalMs: 5_000,\n  maxEntries: 5_000,      // bound process memory; stalest tabs are evicted\n});\n\nawait store.ensureSchema();\nstore.start();\npresence.start();\n\nconst handler = createAnalyticsHandler({\n  store,\n  presence,               // omit this and every /presence/* route stays disabled\n  captureToken: process.env.CAPTURE_TOKEN,\n  enrich: (req) => ({\n    geoCountry: req.headers[\"x-vercel-ip-country\"] as string | undefined,\n    isBot: /bot|crawler/i.test(req.headers[\"user-agent\"] ?? \"\"),\n  }),\n});\n```\n\nA heartbeat describes one browser tab. `distinct_id` identifies the visitor; `tab_id` distinguishes multiple open tabs:\n\n```js\nconst tabId = crypto.randomUUID();\nlet lastInteractionAt = Date.now();\n\nfor (const event of [\"pointerdown\", \"keydown\", \"scroll\", \"touchstart\"]) {\n  addEventListener(event, () => { lastInteractionAt = Date.now(); }, { passive: true });\n}\n\nfunction activityState() {\n  if (document.visibilityState !== \"visible\") return \"hidden\";\n  if (!document.hasFocus() || Date.now() - lastInteractionAt > 60_000) return \"idle\";\n  return \"active\";\n}\n\nfunction sendPresence() {\n  return fetch(\"/peeko/presence/heartbeat\", {\n    method: \"POST\",\n    headers: { \"content-type\": \"application/json\" },\n    body: JSON.stringify({\n      distinct_id: visitorId,\n      tab_id: tabId,\n      path: location.pathname,\n      state: activityState(),\n      last_interaction_at: lastInteractionAt,\n    }),\n    keepalive: true,\n  });\n}\n\nsendPresence();\nconst presenceTimer = setInterval(sendPresence, 25_000);\naddEventListener(\"focus\", sendPresence);\naddEventListener(\"blur\", sendPresence);\ndocument.addEventListener(\"visibilitychange\", sendPresence);\n\naddEventListener(\"pagehide\", () => {\n  clearInterval(presenceTimer);\n  navigator.sendBeacon(\n    \"/peeko/presence/leave\",\n    new Blob(\n      [JSON.stringify({ distinct_id: visitorId, tab_id: tabId })],\n      { type: \"application/json\" },\n    ),\n  );\n});\n```\n\nCall `sendPresence()` after SPA navigation so route changes appear immediately. If capture is token-gated, proxy the heartbeat and leave calls through your application just like analytics capture; never put the server token in browser JavaScript.\n\nThe presence SSE stream starts with `presence_snapshot`, then emits `presence_change` frames:\n\n- `upsert` means a tab joined or changed route/state/interaction metadata.\n- `touch` is a compact unchanged heartbeat that refreshes `updatedAt` and `expiresAt`.\n- `leave` with `left` came from the best-effort leave endpoint.\n- `leave` with `expired` is the authoritative TTL timeout.\n- `leave` with `evicted` means the configured in-memory cap was reached.\n- `leave` with `filtered` means a tab moved out of a filtered stream.\n\nRepeated unchanged heartbeats emit only the compact `touch` shape and never write analytics rows. Presence is process-local by design and fits Peeko's single-process SQLite deployment. Multi-instance applications should supply a shared ephemeral presence implementation before treating the view as global.\n\n## What you inject\n\npeeko deliberately ships only the reusable core. Three things stay yours:\n\n1. **Your event schema.** Anything beyond the extracted columns lives in `properties` and is queried with `getBreakdown`.\n2. **The enrichment hook.** GeoIP country and bot verdict are server-side signals; you fill `CaptureMeta` in `enrich`.\n3. **The dashboard.** peeko hands you JSON and SSE events; you style them.\n\n## Run the demo\n\n```bash\nnpm install\nnpm run example   # boots a server, captures events, prints the live feed + rollup\nnpm test          # unit + end-to-end HTTP/SSE tests\n```\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-89d05d4f36e90beb37704d93a84fe862"}