[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-83027":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":14,"openIssues":15,"contributorsCount":15,"subscribersCount":15,"size":15,"stars1d":16,"stars7d":17,"stars30d":18,"stars90d":15,"forks30d":15,"starsTrendScore":19,"compositeScore":20,"rankGlobal":10,"rankLanguage":10,"license":21,"archived":22,"fork":22,"defaultBranch":23,"hasWiki":22,"hasPages":22,"topics":24,"createdAt":10,"pushedAt":10,"updatedAt":33,"readmeContent":34,"aiSummary":35,"trendingCount":15,"starSnapshotCount":15,"syncStatus":16,"lastSyncTime":36,"discoverSource":37},83027,"replaya","s2-streamstore\u002Freplaya","s2-streamstore","Self-hosted browser session replay built on S2 with live tailing","https:\u002F\u002Fs2.dev",null,"TypeScript",81,4,55,0,2,9,18,7,52.4,"MIT License",false,"main",[25,26,27,28,29,30,31,32],"observability","rrweb","s2","self-hosted","session-recording","session-replay","streaming","typescript","2026-06-12 04:01:39","\u003Cp align=\"center\">\n  \u003Cimg src=\"public\u002Ffavicon.svg\" alt=\"RePlaya logo\" width=\"96\" height=\"96\">\n\u003C\u002Fp>\n\n# RePlaya\n\nSelf-hosted session replay built on [S2](https:\u002F\u002Fs2.dev\u002F). Each session is stored as one S2 stream, and that stream is the whole backend — there's no separate database, message bus, object store, or search index. Because an S2 stream can be tailed as it's written, RePlaya can replay a session live, while the visitor is still on the page, as well as play back finished ones. Add the recorder snippet to your site and sessions are stored as streams you can replay, live-tail, filter, and export.\n\n## Demo\n\n\u003Cp align=\"center\">\n  \u003Cimg src=\"docs\u002Fdemo.gif\" alt=\"RePlaya dashboard: a new session goes active and is live-tailed as the visitor uses the app\" width=\"900\">\n\u003C\u002Fp>\n\n\u003Cp align=\"center\">\u003Cem>A new session appears in the list and is live-tailed from its S2 stream — the replay and activity feed update as the visitor uses the app. (\u003Ca href=\"docs\u002Fdemo.mp4\">MP4\u003C\u002Fa>)\u003C\u002Fem>\u003C\u002Fp>\n\n## Quickstart\n\nYou'll need an S2 access token and a basin. Put them in `.env.local`:\n\n```bash\nS2_ACCESS_TOKEN=replace-with-an-s2-access-token\nS2_BASIN=replaya-your-name\nPORT=8787\n```\n\nThe basin is created on first use with RePlaya's stream defaults. The dashboard's health pill reports the basin and the effective S2 endpoints so you can confirm what you're pointed at. To use [s2-lite](https:\u002F\u002Fgithub.com\u002Fs2-streamstore\u002Fs2#s2-lite) or another compatible deployment instead of S2 Cloud, set the endpoints explicitly:\n\n```bash\nS2_ACCOUNT_ENDPOINT=http:\u002F\u002Flocalhost:7070\nS2_BASIN_ENDPOINT=http:\u002F\u002Flocalhost:7070\n```\n\nThen install dependencies and start the dev server:\n\n```bash\npnpm install\npnpm dev\n```\n\nThe API runs on `http:\u002F\u002Flocalhost:8787` and Vite serves the dashboard on `http:\u002F\u002Flocalhost:5173`. To create a test recording without instrumenting another app, open `http:\u002F\u002Flocalhost:8787\u002Frecorder-test`; it records through the same hosted recorder script.\n\nFor a production-style local run, build and serve everything from Express on one port:\n\n```bash\npnpm build\npnpm start\n# open http:\u002F\u002Flocalhost:8787\n```\n\n## Drop-in recorder\n\nAdd this to any page to start capturing, pointing it at your RePlaya host:\n\n```html\n\u003Cscript>\n  !function(w,d,s,u){w.replaya=w.replaya||function(){(w.replaya.q=w.replaya.q||[]).push(arguments)};var e=d.createElement(s);e.async=1;e.src=u;d.head.appendChild(e)}(window,document,\"script\",\"https:\u002F\u002Freplaya.example.com\u002Frecorder.js\");\n  replaya(\"init\", {\n    apiHost: \"https:\u002F\u002Freplaya.example.com\",\n    source: \"web-app\"\n  });\n\u003C\u002Fscript>\n```\n\nIn local development that host is `http:\u002F\u002Flocalhost:8787`, and `\u002Frecorder-test` serves a page that records through the same script.\n\n`source` is optional metadata for grouping captures by app, site, environment, or tenant. `distinctId` and `userId` can be passed to tag sessions with application identity.\n\nBy default, the recorder **masks all input, select, and textarea values** (rrweb `maskAllInputs`), so end-user keystrokes — passwords, emails, anything typed — are never sent to the server. To capture raw form control state on a page where that's acceptable (e.g. an internal admin UI), pass `maskAllInputs: false` to `replaya(\"init\", ...)`, or add `data-mask-all-inputs=\"false\"` to the recorder script tag.\n\nMasking covers input values only; text the page renders into the DOM is still recorded. Wrap sensitive regions in the `replaya-block` class to omit them from the recording, or `replaya-ignore` to skip a subtree's changes.\n\nIn production, keep the dashboard and read APIs private and expose only the collector routes publicly; see [Configuration & deployment](#configuration--deployment).\n\n## How it works on S2\n\nA session recording is a log: an append-only, ordered, timestamped sequence of events. RePlaya stores each session as one S2 stream and reads it back the same way, so a single primitive covers what's often split across several systems.\n\n- **Storage.** rrweb events are appended to the tail of the session's stream over the S2 Producer API, which batches with backpressure and acks each batch once it's durable. The stream is the recording — there's no separate blob store, and nothing buffers on the server between ingest and storage. Large rrweb events are framed across multiple S2 records and reconstructed on read.\n- **Timeline.** Session streams use `timestamping.mode: client-require`, so the rrweb capture time is written into each event record's S2 timestamp and read back as the scrub timeline. (Create, stop, and heartbeat records use server wall-clock time.)\n- **Listing.** S2 lists streams in lexicographic order, so each stream is named `sessions\u002F\u003Cinverted timestamp>`; `streams.list({ prefix: \"sessions\u002F\" })` then returns newest-first with `startAfter` paging — no database keeping an order in sync. A best-effort sidecar index stream is tailed to update the list as new sessions start.\n- **Live tail.** `GET \u002Fapi\u002Fsessions\u002F:id\u002Flive` opens an S2 read session from the snapshot tail and bridges new records to the browser over SSE, where they're appended to the mounted player. The same stream serves both the historical scrub and the live edge.\n- **Concurrency.** Stream creation and stop write `active` \u002F `stopped` fencing tokens; event and heartbeat appends are fenced on `active`, so a finished session can't be resurrected by a late writer.\n\nStreams are created on first append (`createStreamOnAppend`), inheriting the basin's default config, so there's no stream provisioning to manage. That leaves one external dependency: point RePlaya at [S2 Cloud](https:\u002F\u002Fs2.dev\u002F), or at a self-hosted [s2-lite](https:\u002F\u002Fgithub.com\u002Fs2-streamstore\u002Fs2#s2-lite) to keep everything in your own infrastructure. Recordings live in your own basin — URI-addressable, with configurable retention and on-demand deletion. The browser never receives the S2 token; all S2 reads and writes go through the RePlaya server.\n\nFor comparison with a typical session-replay backend:\n\n| | Typical replay backend | RePlaya |\n| --- | --- | --- |\n| Services to run | Message bus, analytics store, relational DB, object store, search index | One Node server + S2 |\n| Live sessions | Usually playback after an ingest\u002Fflush delay | Live tail of active sessions, off the same stream |\n| Stored recording | Blobs in object storage; metadata across databases | One ordered S2 stream per session |\n| Self-host footprint | A multi-service cluster, often on Kubernetes | A single process + S2 (or self-hosted s2-lite) |\n\nDashboard search is a client-side filter over the sessions already listed — S2 provides ordering and newest-first listing, not full-text search. See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the full design.\n\n## Configuration & deployment\n\nServer-only S2 configuration lives in `.env.local` (see [Quickstart](#quickstart)). The read side has **no built-in authentication by design** — the deployment boundary *is* the access control. In production, RePlaya treats the collector as public, write-only surface area and the dashboard\u002Fread APIs as private surface area.\n\n1. **Don't expose the read APIs to the internet.** Bind the app to a private interface and put the dashboard, `GET \u002Fapi\u002Fsessions*`, live tailing, and `\u002Fapi\u002Fhealth` behind your SSO\u002Faccess layer (VPN, Tailscale, Cloudflare Access, oauth2-proxy).\n2. **Expose only the collector publicly** — `GET \u002Frecorder.js`, `GET \u002Fvendor\u002Frrweb.min.js`, and the write endpoints (`POST \u002Fapi\u002Fsessions`, `\u002Fevents`, `\u002Fheartbeat`, `\u002Fstop`) — and lock those down with `REPLAYA_ALLOWED_CAPTURE_ORIGINS` + `REPLAYA_PROJECT_KEY`.\n3. **Set `NODE_ENV=production`** so ingest auth is enforced, originless ingest is rejected, and the recorder test fixture is disabled.\n4. **Set `REPLAYA_APPEND_TOKEN_SECRET`** to a stable random value (e.g. `openssl rand -hex 32`). With ingest auth enabled (the production default), the server refuses to start without it. Set `REPLAYA_TRUST_PROXY=true` if you run behind a reverse proxy so per-client rate limits use the real client IP.\n\n```bash\nNODE_ENV=production\nREPLAYA_PROJECT_KEY=pk_live_replace_with_public_write_key\nREPLAYA_APPEND_TOKEN_SECRET=replace-with-a-long-random-secret\nREPLAYA_ALLOWED_CAPTURE_ORIGINS=https:\u002F\u002Fapp.example.com,https:\u002F\u002Fwww.example.com\nREPLAYA_TRUST_PROXY=true\n```\n\nThen pass the public project key in the recorder init:\n\n```js\nreplaya(\"init\", {\n  apiHost: \"https:\u002F\u002Fcollect.example.com\",\n  projectKey: \"pk_live_replace_with_public_write_key\",\n  source: \"web-app\"\n});\n```\n\nSession create returns a short-lived append token that the recorder sends with event, heartbeat, and stop writes. Useful limits and knobs:\n\n- `REPLAYA_SESSION_CREATE_RATE_LIMIT` default `60` per minute per client\u002Fproject.\n- `REPLAYA_SESSION_APPEND_RATE_LIMIT` default `600` per minute per client\u002Fsession.\n- `REPLAYA_MAX_EVENTS_PER_BATCH` default `100`.\n- `REPLAYA_JSON_BODY_LIMIT` default `8mb`.\n- `REPLAYA_APPEND_TOKEN_TTL_MS` default `86400000`.\n- `REPLAYA_LOG_REQUESTS` — access log for every request. Defaults on in development, off in production; failed requests (4xx\u002F5xx) are always logged.\n- `REPLAYA_SHUTDOWN_GRACE_MS` default `10000`. On `SIGTERM`\u002F`SIGINT` the server drains in-flight requests, drops lingering live-tail streams after ~3s, and hard-exits at the grace deadline.\n\nSee [`ARCHITECTURE.md`](ARCHITECTURE.md#security--request-boundary) for how the boundary, ingest auth, and append tokens fit together.\n\n### Docker\n\n```bash\ndocker build -t replaya .\ndocker run --rm -p 8787:8787 \\\n  -e NODE_ENV=production \\\n  -e S2_ACCESS_TOKEN=... -e S2_BASIN=... \\\n  -e REPLAYA_PROJECT_KEY=pk_live_... \\\n  -e REPLAYA_APPEND_TOKEN_SECRET=\"$(openssl rand -hex 32)\" \\\n  -e REPLAYA_ALLOWED_CAPTURE_ORIGINS=https:\u002F\u002Fapp.example.com \\\n  replaya\n```\n\nThe image runs the single compiled server (`node dist-server\u002Fserver\u002Findex.js`) as a non-root user and includes a `HEALTHCHECK` against `\u002Fapi\u002Fhealth`. Only the collector and recorder routes should be publicly reachable.\n\n> Pass secrets with `-e VAR=value` (or a secrets manager), not by reusing a local `.env`. `docker --env-file` does not strip surrounding quotes the way `dotenv` does, so a quoted value like `S2_ACCESS_TOKEN=\"...\"` would reach the container with the quotes included.\n\n## Scripts\n\n- `pnpm dev` starts the API and Vite.\n- `pnpm build` type-checks the client\u002Fserver and builds the frontend.\n- `pnpm lint` runs ESLint.\n- `pnpm test` runs the unit\u002Fsmoke suite (recorder invariants + HTTP smoke). No S2 required.\n- `pnpm test:integration` runs the S2 round-trip tests against a real S2 API.\n- `pnpm start` serves the built frontend and API from Express.\n\n## Testing\n\n`pnpm test` covers recorder invariants and an HTTP smoke of the server (recorder delivery, security headers, ingest-auth rejection) without needing S2.\n\nThe integration tests exercise the real create → append → replay → delete path against [`s2 lite`](https:\u002F\u002Fgithub.com\u002Fs2-streamstore\u002Fs2#s2-lite), the in-memory S2 emulator. They're skipped unless `S2_TEST_ENDPOINT` is set:\n\n```bash\ndocker run -d -p 8080:80 ghcr.io\u002Fs2-streamstore\u002Fs2 lite\nS2_TEST_ENDPOINT=http:\u002F\u002Flocalhost:8080 pnpm test:integration\n```\n\nCI runs both: a build\u002Flint\u002Funit-test job (Node 20 + 24) and an integration job that boots `s2 lite` and runs the round-trip suite.\n","RePlaya 是一个基于 S2 的自托管浏览器会话回放工具，支持实时跟踪。其核心功能包括将每个会话存储为一个 S2 流，并通过该流实现整个后端，无需额外的数据库、消息总线或对象存储。RePlaya 能够在用户仍在页面上活动时进行实时会话回放，同时也能回放已完成的会话。它使用 TypeScript 编写，具备过滤和导出会话的功能。适用于需要增强网站或应用可观测性的场景，如用户体验分析、错误排查等。","2026-06-11 04:09:56","CREATED_QUERY"]