{"_id":"@aleju03/replayd","_rev":"2-c86c9e1f8c92bfbc8d7b09a43c82731d","name":"@aleju03/replayd","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@aleju03/replayd","version":"0.1.0","keywords":["sse","server-sent-events","eventsource","sqlite","libsql","resumable","event-log","last-event-id"],"author":{"name":"aleju03"},"license":"MIT","_id":"@aleju03/replayd@0.1.0","maintainers":[{"name":"aleju03","email":"alejimenezu@gmail.com"}],"homepage":"https://github.com/aleju03/replayd#readme","bugs":{"url":"https://github.com/aleju03/replayd/issues"},"dist":{"shasum":"7ea2c791ea04b33232b39470a8c1050aa8a25171","tarball":"https://registry.npmjs.org/@aleju03/replayd/-/replayd-0.1.0.tgz","fileCount":15,"integrity":"sha512-TxZGvKMXqI/vigeI/gGXoW8kMuuST/Abc2nzh/wUFfhH+x/EmkVL7X3WpUYfbFQIpJnLXyr0YiTKkyknfkZNHw==","signatures":[{"sig":"MEUCIQD5SWeomrndZZkkWc8vAm2Ul/QMh2cW4XNs5FRv6Jn/WwIgaytkG6NCGBuLEqCUOLV9/Xj+MfSnNWnZYQWJgojUtbg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":31408},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"gitHead":"8f58cc88d211a09edc56a5ca3a2b3da78c742e94","scripts":{"test":"vitest run","build":"tsc -p tsconfig.json","verify":"vitest run && tsc -p tsconfig.json --noEmit","example":"node --import tsx examples/demo.ts","typecheck":"tsc -p tsconfig.json --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"aleju03","email":"alejimenezu@gmail.com"},"repository":{"url":"git+https://github.com/aleju03/replayd.git","type":"git"},"_npmVersion":"11.10.0","description":"Resumable Server-Sent Events on SQLite: a durable event log, Last-Event-ID replay on reconnect, and per-topic client tracking.","directories":{},"_nodeVersion":"24.4.0","dependencies":{"@libsql/client":"^0.17.3"},"_hasShrinkwrap":false,"devDependencies":{"tsx":"^4.20.6","vitest":"^3.0.5","typescript":"^5.7.2","@types/node":"^22.10.2"},"_npmOperationalInternal":{"tmp":"tmp/replayd_0.1.0_1784838378090_0.6389181186658506","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@aleju03/replayd","version":"0.2.0","description":"Resumable Server-Sent Events on SQLite: a durable event log, Last-Event-ID replay on reconnect, explicit gap signalling, and per-topic client tracking.","type":"module","license":"MIT","author":{"name":"aleju03"},"repository":{"type":"git","url":"git+https://github.com/aleju03/replayd.git"},"bugs":{"url":"https://github.com/aleju03/replayd/issues"},"homepage":"https://github.com/aleju03/replayd#readme","keywords":["sse","server-sent-events","eventsource","sqlite","libsql","resumable","event-log","last-event-id"],"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":"3783dd3d2f28d997657edb8d171492db9e09dc02","_id":"@aleju03/replayd@0.2.0","_nodeVersion":"24.18.0","_npmVersion":"12.0.1","dist":{"integrity":"sha512-xlefPDj6fODjDKjSKRsHr3XKMaujm556f7rqUbKii8NYWbuud/POCPcsEnw4Xn3+v6pB2auRSJEA8UhnhFQbGg==","shasum":"78ce81d4061d312f0540dafea6bc6796a9227873","tarball":"https://registry.npmjs.org/@aleju03/replayd/-/replayd-0.2.0.tgz","fileCount":15,"unpackedSize":46668,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aleju03%2freplayd@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIB5dle24/Co5uO2GNdGYUXyujCLW7Tkg+jHGItcguKAHAiEAiRS+m05HHlfLa30w8zvO9xtcZqmvBvLG3E/AgyO1AdE="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:d559146d-b446-442b-9e5c-6b0835d92da0"}},"directories":{},"maintainers":[{"name":"aleju03","email":"alejimenezu@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/replayd_0.2.0_1785043556495_0.2929358829932942"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-23T20:26:17.848Z","modified":"2026-07-26T05:25:57.087Z","0.1.0":"2026-07-23T20:26:18.299Z","0.2.0":"2026-07-26T05:25:56.641Z"},"bugs":{"url":"https://github.com/aleju03/replayd/issues"},"author":{"name":"aleju03"},"license":"MIT","homepage":"https://github.com/aleju03/replayd#readme","keywords":["sse","server-sent-events","eventsource","sqlite","libsql","resumable","event-log","last-event-id"],"repository":{"type":"git","url":"git+https://github.com/aleju03/replayd.git"},"description":"Resumable Server-Sent Events on SQLite: a durable event log, Last-Event-ID replay on reconnect, explicit gap signalling, and per-topic client tracking.","maintainers":[{"name":"aleju03","email":"alejimenezu@gmail.com"}],"readme":"<p align=\"center\">\n  <img src=\"assets/mascot.png\" alt=\"replayd mascot\" height=\"160\">\n</p>\n\n# replayd\n\nResumable Server-Sent Events on SQLite. Every event appends to a log table whose row sequence becomes the SSE `id`, so a browser that reconnects with `Last-Event-ID` gets exactly the events it missed, in order, before the live stream resumes.\n\nThe name is \"replay daemon\". It's the small piece you keep when you want browser SSE that survives a dropped connection without putting a broker in the middle. You bring the payloads and the HTTP server; replayd owns the log, the replay, and the per-topic fan-out.\n\n## What you actually get\n\nThe browser's `EventSource` already does the hard part: it reconnects on its own and sends `Last-Event-ID` when it does. Most servers throw that header away. replayd treats it as a cursor into a durable log, which turns the platform's built-in reconnect into real resume semantics with zero client bookkeeping.\n\nThe log is one append-only SQLite table with an autoincrement `sequence` and a unique `event_id`, so appends are idempotent and there's no Redis in the picture, and no per-message pricing either. Keep it bounded with a [retention policy](#retention).\n\nThe delivery guarantee is contiguity *or* an explicit gap — never a silent hole. A client that reconnects gets every event it missed, or a `gap` event naming exactly what it will never receive. It is never handed a partial history that looks complete.\n\nTopics scope delivery. Publish to `\"room-42\"` and only that topic's subscribers see it; publish to `null` and everyone does. A subscriber with no topic gets the global aggregate.\n\nWhen ingest and serving live in different processes, `startTail()` lets the serving process poll the table and fan out rows a worker wrote elsewhere. A write from any process reaches every reader exactly once.\n\n## Install\n\n```bash\nnpm install @aleju03/replayd\n```\n\nRequires Node 18+ (the SSE handler and examples use global `fetch` / `http`). Storage is `@libsql/client`, which means a `file:` path, `:memory:`, or a remote Turso URL all work. Use a `file:` URL if replay should survive a restart.\n\n## Quick start\n\n```ts\nimport { EventHub, createDb } from \"@aleju03/replayd\";\nimport { createServer } from \"node:http\";\n\nconst db = await createDb({ url: \"file:events.db\" });\nconst hub = new EventHub(db, {\n  defaultType: \"message\",              // SSE event type when publish() is not given one\n  sse: { path: \"/events\", allowedOrigins: [\"https://app.example.com\"] },\n});\nawait hub.ensureSchema();\n\n// Mount the SSE endpoint. handleSse returns true when it handled the request.\ncreateServer((req, res) => {\n  hub.handleSse(req, res).then((handled) => { if (!handled) { res.writeHead(404); res.end(); } });\n}).listen(3000);\n\n// Publish from anywhere: append to the durable log and fan out to live clients.\nawait hub.publish(\"room-42\", { text: \"hello\" });          // only room-42 subscribers\nawait hub.publish(null, { notice: \"maintenance in 10m\" }); // broadcast to everyone\n```\n\nIn the browser it's just `EventSource`; reconnect and replay are handled by the platform and the server:\n\n```js\nconst es = new EventSource(\"https://app.example.com/events?topic=room-42\");\nes.addEventListener(\"message\", (e) => {\n  console.log(e.lastEventId, JSON.parse(e.data));\n});\n// Fell further behind than the server's replayLimit: the history is incomplete,\n// so re-sync from a snapshot instead of patching what you have.\nes.addEventListener(\"gap\", (e) => {\n  console.warn(\"missed events, resyncing\", JSON.parse(e.data));\n});\n// On a dropped connection the browser reconnects automatically, sending\n// Last-Event-ID; replayd replays the events published while it was gone.\n```\n\n## The resume story\n\nEach event goes out as an SSE frame:\n\n```\nid: 128\nevent: message\ndata: {\"text\":\"hello\"}\n```\n\nThe `id` is the row's `sequence`. When a client connects:\n\n1. It is subscribed to the live fan-out **first**, into a buffer. Anything published during the handshake below is held rather than lost in the window between the replay query and the subscription.\n2. A `hello` frame reports the resume sequence.\n3. If the request carries `Last-Event-ID` (a header the browser sets automatically, or `?lastEventId=` for manual clients), replayd replays every missed event in `sequence` order, capped at `replayLimit` (default 100).\n4. The buffer is flushed — deduplicated against what replay already wrote — and the client goes live.\n\nHeartbeats (default every 15s) are sent with no `id` line, and that's deliberate: an id on a heartbeat would overwrite the browser's `Last-Event-ID` cursor with a heartbeat marker and quietly break replay on the next reconnect. Heartbeats carry liveness only, never a resume point. Pass an optional `heartbeatData` hook to attach your own freshness signal to each one.\n\n### When the backlog is too big: the gap event\n\nA client gone longer than `replayLimit` events cannot be handed its whole backlog. Truncating silently would be the worst possible answer — it hands back a partial history that *looks* complete, which is exactly the failure this library exists to prevent. So instead:\n\n```\nevent: gap\ndata: {\"resumedFrom\":3,\"missedFrom\":4,\"missedThrough\":4,\"missed\":1,\"replayLimit\":2}\n```\n\nThen replayd delivers the **newest** `replayLimit` events rather than the oldest. Two consequences worth knowing:\n\n- Your client learns its state is incomplete and can re-sync from a snapshot. Listen for `gap` and treat it as \"refetch, don't patch\".\n- The cursor ends up at the head, so the client is current. Replaying the oldest window instead would leave it permanently behind, hitting the same gap on every reconnect and never catching up.\n\nThe gap frame carries no `id` line — it's a notice, not a resume point.\n\n`handleSse` options: `path` (default `/events`), `topicParam` (default `topic`), `allowedOrigins` (`string[] | \"*\"`), `replayLimit` (default 100), `heartbeatMs` (default 15000), `heartbeatData(topic)`, `gapEventType` (default `gap`), `retryMs` (sent as the SSE `retry:` field to set the browser's reconnect delay; omit for its default of ~3s), and `maxBufferedBytes` (default 1 MiB).\n\nThat last one is the slow-consumer guard: a client that reads slower than you publish would otherwise buffer the backlog in your process's memory. Past the cap replayd drops the connection, which is safe precisely because the log is durable — the browser reconnects and resumes from its `Last-Event-ID`, with a gap event if it fell far enough behind. Set `0` to disable.\n\n## Server/worker split with startTail\n\nWhen ingest runs in a different process from the one serving SSE, the serving process can't dispatch in memory because the append happened elsewhere. So it tails the log instead:\n\n```ts\n// worker process: appends only, never serves SSE\nconst worker = new EventHub(db);\nawait worker.publish(\"room-42\", { text: \"from the worker\" });\n\n// server process: serves SSE, tails the table for rows written by the worker\nconst server = new EventHub(db);\nconst stop = server.startTail(250); // poll every 250ms, forward new rows to SSE clients\n```\n\n`startTail` pins its cursor at the current head, so it forwards only events created after it starts; reconnecting clients get their backlog via replay-on-connect, not from the tailer. While a hub is tailing, `publish()` skips its in-process dispatch so the poller stays the single delivery path and nothing arrives twice. Call the returned function to stop.\n\nStarting a second tail on a log that is already tailing throws. It is always a wiring mistake: two pollers would deliver every row to every client twice, and stopping either one would re-enable `publish()`'s direct dispatch for the other.\n\nPass `maxIntervalMs` to back off when the log is quiet — `startTail(250, { maxIntervalMs: 2000 })` polls every 250ms under load and decays toward 2s while idle, trading worst-case delivery latency for far fewer wasted queries. Without it the cadence is fixed.\n\n## Retention\n\nAn append-only log that nothing trims grows forever, so bound it explicitly:\n\n```ts\nawait hub.prune({ maxEvents: 50_000 });                  // once\nconst stop = hub.startRetention({ maxAgeMs: 86_400_000 }, 3_600_000); // hourly, keep 24h\n```\n\nBoth limits may be combined, and `prune` returns how many events it deleted. Retention and replay are the same dial seen from two sides: the history you keep is exactly the window a disconnected client can resume through. Prune below where a client's cursor sits and its next reconnect gets a gap event instead of a clean replay — which is the honest outcome, and why the gap event exists.\n\n## API\n\nEverything is reachable through the `EventHub` facade, or use the pieces directly.\n\n```ts\nhub.ensureSchema();                        // create the table + index (once)\nhub.publish(topic, payload, opts?);        // append + fan out; opts: { type?, eventId? }\nhub.subscribe((event) => { ... });         // in-process live sink; returns an unsubscribe fn\nhub.replay(topic, sinceSequence, limit?);  // the oldest events after a sequence (topic + broadcasts)\nhub.latestSequence();                      // current head\nhub.startTail(intervalMs, opts?);          // server/worker poller; returns a stop fn\nhub.prune(policy);                         // { maxEvents?, maxAgeMs? }; returns events deleted\nhub.startRetention(policy, intervalMs?);   // periodic prune (default hourly); returns a stop fn\nhub.stats();                               // { latestSequence, topics: [{ topic, activeClients, lastActiveAt }] }\nhub.handleSse(req, res);                   // the bound SSE endpoint\n\nhub.log;      // the underlying EventLog\nhub.clients;  // the underlying TopicClientTracker\n```\n\n`EventLog` adds two reads the SSE handler uses for gap detection, useful if you build your own transport: `replayLatest(topic, since, limit)` (the newest window after a cursor, still ascending) and `countBetween(topic, after, before)` (how many visible events fall in an exclusive range).\n\n`publish` is idempotent by `eventId`: a retried publish with the same id neither duplicates the row nor re-delivers to live clients. Omit it and a stable id is generated for you.\n\nA `ReplayEvent` is `{ sequence, event_id, type, topic, payload, created_at }`.\n\n## Run the demo\n\n```bash\nnpm install\nnpm run example   # boots a server, streams live events, then reconnects and replays the gap\nnpm test          # unit + end-to-end HTTP/SSE tests (including the resume path)\n```\n\n## License\n\nMIT\n","readmeFilename":"README.md"}