From c04045957a6171d865879ea5dbc9d5504f6b9f2f Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:54:38 +0800 Subject: [PATCH] feat: add measurable P2P screen sharing PoC (#2) --- .env.example | 18 + .github/workflows/repository-hygiene.yml | 15 +- .node-version | 1 + CONTRIBUTING.md | 2 +- README.md | 23 +- deploy/coturn/turnserver.conf.example | 63 + docs/README.md | 2 + docs/adr/0001-p2p-first-media-topology.md | 2 +- docs/deployment.md | 183 ++ docs/project-memory.md | 15 +- docs/research/webrtc-p2p-screen-sharing.md | 31 +- docs/status.md | 35 +- docs/方案设计.md | 243 +++ docs/需求理解.md | 6 +- index.html | 14 + package-lock.json | 1922 ++++++++++++++++++++ package.json | 40 + scripts/required-project-paths.txt | 8 + src/client/App.tsx | 9 + src/client/components/AppHeader.tsx | 16 + src/client/components/StatsGrid.tsx | 103 ++ src/client/components/StatusBadge.tsx | 85 + src/client/lib/api.ts | 55 + src/client/lib/session.ts | 69 + src/client/lib/signaling.ts | 252 +++ src/client/main.tsx | 15 + src/client/media/quality.ts | 65 + src/client/pages/HostPage.tsx | 723 ++++++++ src/client/pages/ViewerPage.tsx | 392 ++++ src/client/styles.css | 743 ++++++++ src/client/types.ts | 60 + src/client/webrtc/host-peer.ts | 247 +++ src/client/webrtc/stats.ts | 204 +++ src/client/webrtc/viewer-peer.ts | 331 ++++ src/server/app.ts | 283 +++ src/server/config.ts | 200 ++ src/server/index.ts | 42 + src/server/room-store.ts | 350 ++++ src/server/signaling.ts | 651 +++++++ src/server/turn.ts | 108 ++ src/shared/protocol.ts | 203 +++ tests/client-state.test.ts | 153 ++ tests/protocol.test.ts | 94 + tests/room-store.test.ts | 215 +++ tests/server-config.test.ts | 93 + tests/server-http.test.ts | 113 ++ tests/server-signal.test.ts | 680 +++++++ tests/turn.test.ts | 63 + tsconfig.app.json | 23 + tsconfig.json | 8 + tsconfig.server.json | 21 + tsconfig.tools.json | 19 + vite.config.ts | 10 + vitest.config.ts | 11 + 54 files changed, 9305 insertions(+), 27 deletions(-) create mode 100644 .env.example create mode 100644 .node-version create mode 100644 deploy/coturn/turnserver.conf.example create mode 100644 docs/deployment.md create mode 100644 docs/方案设计.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/client/App.tsx create mode 100644 src/client/components/AppHeader.tsx create mode 100644 src/client/components/StatsGrid.tsx create mode 100644 src/client/components/StatusBadge.tsx create mode 100644 src/client/lib/api.ts create mode 100644 src/client/lib/session.ts create mode 100644 src/client/lib/signaling.ts create mode 100644 src/client/main.tsx create mode 100644 src/client/media/quality.ts create mode 100644 src/client/pages/HostPage.tsx create mode 100644 src/client/pages/ViewerPage.tsx create mode 100644 src/client/styles.css create mode 100644 src/client/types.ts create mode 100644 src/client/webrtc/host-peer.ts create mode 100644 src/client/webrtc/stats.ts create mode 100644 src/client/webrtc/viewer-peer.ts create mode 100644 src/server/app.ts create mode 100644 src/server/config.ts create mode 100644 src/server/index.ts create mode 100644 src/server/room-store.ts create mode 100644 src/server/signaling.ts create mode 100644 src/server/turn.ts create mode 100644 src/shared/protocol.ts create mode 100644 tests/client-state.test.ts create mode 100644 tests/protocol.test.ts create mode 100644 tests/room-store.test.ts create mode 100644 tests/server-config.test.ts create mode 100644 tests/server-http.test.ts create mode 100644 tests/server-signal.test.ts create mode 100644 tests/turn.test.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.server.json create mode 100644 tsconfig.tools.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c758c6 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Application +PORT=8787 +PUBLIC_BASE_URL=http://localhost:8787 +ALLOWED_ORIGINS=http://localhost:8787 +ROOM_CREATION_TOKEN= +ROOM_TTL_SECONDS=14400 +MAX_ROOMS=1000 + +# ICE servers. Separate multiple URLs with commas. +STUN_URLS= +TURN_URLS= +TURN_SHARED_SECRET= +TURN_CREDENTIAL_TTL_SECONDS=3600 + +# Production example (use independent untracked secrets of at least 32 bytes): +# ROOM_CREATION_TOKEN=replace-with-at-least-32-random-bytes +# TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp,turns:turn.example.com:443?transport=tcp +# TURN_SHARED_SECRET=replace-with-at-least-32-random-bytes diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index 34f51b7..e91ba1e 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -1,4 +1,4 @@ -name: Repository hygiene +name: CI on: pull_request: @@ -10,14 +10,23 @@ permissions: contents: read jobs: - project-state: + validate: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Validate tracked project state run: sh scripts/check-project-state.sh env: SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: npm + - name: Install dependencies + run: npm ci + - name: Type-check, test, and build + run: npm run check diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..60ade1a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.19.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 080ed5d..75f8ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ 3. For non-trivial design, implementation, or bug fixing, inspect the repository and research current primary sources before changing code. Record durable findings under `docs/research/`. 4. Implement the smallest complete change. Add tests and concise rationale comments in proportion to risk. 5. Update requirements, ADRs, project memory, status, and operational docs when their source facts changed. Replace stale text instead of appending a diary. -6. Run `sh scripts/check-project-state.sh` on macOS/Linux/Git Bash or `./scripts/check-project-state.ps1` on PowerShell, plus the relevant project tests. +6. Run `sh scripts/check-project-state.sh` on macOS/Linux/Git Bash or `./scripts/check-project-state.ps1` on PowerShell, plus `npm run check` and any relevant manual browser/network checks. 7. Make focused commits using `type(scope): summary` where practical, then push the branch. 8. Open a pull request using the repository template. Resolve review comments and required checks before merging. 9. Prefer squash merge for a single coherent change; preserve separate commits when they carry independently useful history. Delete the merged branch. diff --git a/README.md b/README.md index 6f0f585..58663b7 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,32 @@ Screener is a private, low-latency screen-sharing project for one game player and a small group of friends. Its media path is WebRTC P2P-first, with authenticated TURN fallback for network pairs that cannot connect directly. Viewers should be able to join from a desktop or mobile browser without installing the sender application. -The repository is currently in discovery and technical-design preparation. No application runtime has been selected or implemented yet. +The first measurable Web proof of concept is implemented. A host captures a screen, window, or browser tab and creates an expiring invitation for up to three viewers. Each viewer receives an independent WebRTC connection, so direct and TURN-relayed paths can coexist in one room. The Node.js service carries only room, authentication, ICE credential, and signaling traffic. + +This is not yet a production release. Real cross-network TURN behavior, game audio, mobile browser lifecycle behavior, and latency targets still require the documented manual test matrix. + +## Run locally + +Node.js 24 and npm 11 are required. + +```sh +npm ci +npm run dev +``` + +Open `http://localhost:8787`. The default development configuration uses a localhost origin and has no STUN or TURN server, so it is suitable only for same-machine checks. Multi-device or cross-network use requires HTTPS and the production-shaped ICE configuration documented separately. + +Run the complete automated validation with: + +```sh +npm run check +``` Start here: - [Requirements](./docs/需求理解.md) +- [WebRTC PoC design](./docs/方案设计.md) +- [Deployment and TURN setup](./docs/deployment.md) - [Feasibility research](./docs/research/webrtc-p2p-screen-sharing.md) - [Current project memory](./docs/project-memory.md) - [Current status](./docs/status.md) diff --git a/deploy/coturn/turnserver.conf.example b/deploy/coturn/turnserver.conf.example new file mode 100644 index 0000000..12ca8b7 --- /dev/null +++ b/deploy/coturn/turnserver.conf.example @@ -0,0 +1,63 @@ +# Screener minimal production coturn configuration. +# Target: coturn 4.17.2 or newer patched release. +# Copy this file outside the repository, replace every <...> placeholder, and +# restrict the resulting file to the coturn service account. + +# Client-facing TURN/STUN listeners. +listening-port=3478 +tls-listening-port=443 + +# Keep the automatic address selection on a single-homed public host. For a +# multi-homed host or a host behind 1:1 NAT, uncomment and replace these values. +# The NAT must preserve every relay port (external port equals internal port). +#listening-ip= +#relay-ip= +#external-ip=/ + +# A deliberately small relay range for the friend-group PoC. Open the entire +# range bidirectionally for UDP and monitor allocation failures before changing it. +min-port=49152 +max-port=49251 + +fingerprint +realm= +server-name= + +# TURN REST API credentials. This value must exactly match TURN_SHARED_SECRET +# in the application environment. Never use a static user/password or no-auth. +use-auth-secret +static-auth-secret= +stale-nonce=600 +max-allocate-lifetime=3600 + +# Initial abuse controls. max-bps and bps-capacity use bytes per second, not bits. +# 2,000,000 bytes/s permits roughly 16 Mbit/s in each direction per allocation. +user-quota=8 +total-quota=100 +max-bps=2000000 + +# Do not enable allow-loopback-peers: coturn 4.17.2 denies loopback by default. +# Prevent a TURN allocation from reaching common private or link-local targets. +denied-peer-ip=0.0.0.0-0.255.255.255 +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=100.64.0.0-100.127.255.255 +denied-peer-ip=127.0.0.0-127.255.255.255 +denied-peer-ip=169.254.0.0-169.254.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=198.18.0.0-198.19.255.255 +denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff +no-multicast-peers + +# A certificate for TURN_FQDN is required by turns: URLs. Use absolute paths in +# the deployed copy and reload/restart coturn after certificate renewal. +cert= +pkey= + +# Let the service supervisor apply bounded retention. Do not enable verbose or +# log-binding in normal production operation; coturn logs can contain client IPs. +log-file=stdout +log-min-level=info +new-log-timestamp +no-cli diff --git a/docs/README.md b/docs/README.md index 7af092b..52bf76b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,10 @@ # Project Documentation - [需求理解](./需求理解.md): current product scope, priorities, constraints, and acceptance criteria. +- [方案设计](./方案设计.md): accepted, code-ready design for the first measurable WebRTC proof of concept. - [Project memory](./project-memory.md): durable decisions and unresolved questions to read at the start of future work. - [Current status](./status.md): bounded snapshot of the current phase, completed baseline, next step, and blockers. +- [Deployment](./deployment.md): minimal Node, HTTPS/WSS, coturn, firewall, secret, and network-verification procedure. - [Maintenance guide](./maintenance.md): Git workflow, context hygiene, document lifecycle, research policy, and automation. - [P2P WebRTC research](./research/webrtc-p2p-screen-sharing.md): evidence, bandwidth model, browser constraints, reference implementations, and feasibility assessment. - [Agent context governance research](./research/agent-context-governance.md): official Codex, Claude Code, Hermes Agent, and GitHub practices adopted by this repository. diff --git a/docs/adr/0001-p2p-first-media-topology.md b/docs/adr/0001-p2p-first-media-topology.md index f4af1d1..f16d1ef 100644 --- a/docs/adr/0001-p2p-first-media-topology.md +++ b/docs/adr/0001-p2p-first-media-topology.md @@ -16,7 +16,7 @@ Use separate control and media planes: - ICE attempts a direct UDP path for every broadcaster-viewer pair, using STUN to discover candidates. - Only pairs that cannot connect directly use authenticated TURN, preferring UDP and falling back to TCP and TLS on port 443. TURN is required production infrastructure, not an optional enhancement. - Candidate selection is independent per pair. A room may simultaneously contain direct and relayed viewers without moving working peers onto the server. -- The broadcaster initially creates one peer connection per viewer. The provisional default envelope is one to three viewers, with a measured fourth-viewer admission check. +- The broadcaster creates one peer connection per viewer. The PoC hard limit is three viewers; a later release may reconsider it only after publisher upload and encoder measurements. - No SFU is planned for the normal product envelope. It is only a future reconsideration point if the small-room scope changes or measured relay/upload/encoder pressure makes the chosen envelope unworkable. ## Consequences diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..14f0ee2 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,183 @@ +# Minimal Deployment + +Last verified against upstream documentation: 2026-08-18. + +This is the first production-shaped deployment for the WebRTC PoC: one Node.js +process provides the built Web client, room API, and WebSocket signaling behind +Caddy or nginx; coturn is the separate STUN/TURN service. Normal media remains +browser-to-browser. Only an ICE pair that cannot connect directly consumes TURN +bandwidth. + +This workstation does not have coturn installed, so the coturn configuration and +public-network procedures below have not been run locally. Treat deployment as +unverified until the direct, relay, and mixed-network checks at the end pass. + +## Topology and prerequisites + +Use two DNS names, for example `share.example.com` and `turn.example.com`. +The simplest deployment gives the TURN name a separate public IP so coturn can +bind TCP 443; otherwise use an explicitly validated layer-4/SNI gateway: + +```text +browser -- HTTPS/WSS --> Caddy or nginx :443 --> Node.js :8787 +browser <------------ DTLS-SRTP P2P ------------> browser +browser -- TURN only for failed ICE pairs --> coturn +``` + +The minimum runtime is Node.js 24 LTS and coturn 4.17.2 or a newer patched +release. Provision a valid TLS certificate for each public name, enable operating +system time synchronization, and keep the Node application port reachable only +from its reverse proxy. A single Node process is intentional: rooms are held in +memory, so restarting it ends signaling and invalidates room state. + +Build and validate the exact revision before starting it: + +```sh +npm ci +npm run check +npm start +``` + +Run `npm start` under a service supervisor that injects the environment, restarts +on failure, and applies bounded logs. For a simple untracked environment file, +the equivalent direct launch is +`node --env-file=.env.production dist/server/server/index.js`. Do not expose port +8787 to the Internet. + +## Production application environment + +Keep the real values in the process secret store or an untracked, access-restricted +environment file. Node.js 24 can load such a file with `--env-file`; the tracked +`.env.example` is only a schema. Generate independent secrets with a password +manager or a cross-platform Node command such as: + +```sh +node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))" +``` + +Use this production baseline: + +```dotenv +NODE_ENV=production +PORT=8787 +PUBLIC_BASE_URL=https://share.example.com +ALLOWED_ORIGINS=https://share.example.com +ROOM_CREATION_TOKEN= +ROOM_TTL_SECONDS=14400 +MAX_ROOMS=1000 + +STUN_URLS=stun:turn.example.com:3478 +TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp,turns:turn.example.com:443?transport=tcp +TURN_SHARED_SECRET= +TURN_CREDENTIAL_TTL_SECONDS=3600 +``` + +`ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`. +`ROOM_CREATION_TOKEN` and `TURN_SHARED_SECRET` must each contain at least 32 +bytes. They must be independently generated rather than reused. +`TURN_SHARED_SECRET` stays only in the Node environment and coturn configuration; +browsers receive HMAC-SHA1-derived, time-limited credentials after room +authentication. Keep host clocks synchronized because the credential username +contains its Unix expiry time. + +## HTTPS and WSS ingress + +Terminate public TLS at Caddy or nginx and proxy every application path to the +single Node process. `/signal` must preserve the WebSocket upgrade, `Host`, and +`Origin`, and its idle/read timeout must be longer than a normal sharing session. +Do not cache `/api/*` responses. Caddy's minimal same-host form is: + +```caddyfile +share.example.com { + reverse_proxy 127.0.0.1:8787 +} +``` + +An equivalent nginx location is: + +```nginx +location / { + proxy_pass http://127.0.0.1:8787; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1h; +} +``` + +Keep the proxy's access-log retention bounded and access controlled. Viewer +tokens are URL fragments and therefore are not sent in HTTP requests, but logs +still contain network metadata and must not be treated as public artifacts. + +## coturn + +Copy [`deploy/coturn/turnserver.conf.example`](../deploy/coturn/turnserver.conf.example) +to an untracked service-owned location. Replace the FQDN, certificate paths, and +secret. The `static-auth-secret` value must exactly match the application's +`TURN_SHARED_SECRET`; do not configure `no-auth` or permanent browser users. + +On a host with a public address directly on its interface, coturn can select the +single listen/relay address automatically. On a multi-homed host, set +`listening-ip` and `relay-ip` explicitly. Behind 1:1 NAT, also set +`external-ip=/` and forward the complete relay range +without port translation. A normal consumer NAT that changes relay port numbers +is not a supported TURN-host topology. + +The example binds TURN/TLS to 443 because that is the required restrictive-network +fallback. It requires a separate public IP for `turn.example.com` or an ingress +that explicitly supports TURN at layer 4/SNI. A normal HTTPS reverse proxy and +coturn cannot both bind the same IP and port. + +Open only these public listeners: + +| Destination | Protocol | Purpose | +| --- | --- | --- | +| `share.example.com:443` | TCP | HTTPS and WSS | +| `turn.example.com:3478` | UDP and TCP | STUN, TURN/UDP, and TURN/TCP | +| `turn.example.com:443` | TCP | TURN/TLS on a dedicated IP or L4/SNI route | +| `turn.example.com:49152-49251` | UDP, bidirectional | Relayed media endpoints | + +The 100-port relay range is an initial small-room limit, not a universal sizing +rule. Monitor 508/allocation failures and concurrent allocations before widening +it. The example also sets `user-quota=8`, `total-quota=100`, and a per-allocation +`max-bps` of 2,000,000 bytes/s. Tune these from measured 1080p60 traffic and the +purchased egress capacity; never remove all quotas as a shortcut. + +coturn 4.17.2 denies loopback peers unless `allow-loopback-peers` is enabled; the +example deliberately does not enable it and additionally denies common private, +carrier-grade NAT, link-local, and benchmark ranges. Keep `no-multicast-peers` and +`no-cli`. If the TURN host must intentionally reach an internal peer, narrow an +exception to that exact address instead of removing the deny list. + +The example writes informational logs to stdout. Configure the supervisor to +rotate by size and time with a finite retention, such as seven days initially, +then reduce or extend it only for a documented operational need. coturn and the +reverse proxy necessarily see client IP addresses; restrict log access, do not +enable `verbose` or `log-binding` continuously, and never log credentials, SDP, +ICE candidates, or the shared secret. Reload or restart coturn after certificate +renewal and confirm time synchronization remains healthy. + +## Verification + +Run these checks from real external networks before calling the deployment usable: + +1. Open the official [Trickle ICE sample](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/) + and test each configured TURN URL separately with a short-lived credential. + Each UDP, TCP, and TLS test must produce a `relay` candidate; a successful + STUN-only `srflx` candidate does not prove TURN works. +2. Create a normal Screener room on two different networks. Confirm media flows + and the selected-pair stats report a non-relay path when direct ICE succeeds. +3. Open the host at `https://share.example.com/?relay=1`, create a new room, and + connect a viewer. This diagnostic flag sets `iceTransportPolicy=relay`; media + must still flow and the stats panel must report `relay`. +4. Block UDP on a test client while leaving the TURN/TLS endpoint reachable. + Confirm a new session succeeds through the configured `turns:` URL. +5. Repeat with three viewers and a mixed direct/restrictive-network cohort. Record + the selected path, RTT, bitrate, frame rate, packet loss, and coturn egress for + each viewer. Verify a direct viewer does not start consuming relay bandwidth + merely because another viewer needs TURN. + +References: [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2), +[turnserver documentation](https://github.com/coturn/coturn/blob/master/README.turnserver), +and the [official example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf). diff --git a/docs/project-memory.md b/docs/project-memory.md index d8d15f9..0fd67c5 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -21,14 +21,21 @@ Last updated: 2026-08-18 ## Current Recommendation - Start with a Windows Chrome/Edge sharing MVP and a responsive Web viewer using one `RTCPeerConnection` per viewer. Validate current Android Chrome and iOS Safari as viewing endpoints. -- Initial operating envelope: one broadcaster and one to three viewers; allow a fourth only after publisher upload and encoder load pass a preflight check. +- The current PoC hard limit is one broadcaster and three viewers. Do not add a fourth viewer until publisher upload and encoder measurements justify changing the tested envelope. - Use HTTPS/WSS signaling, trickle ICE, STUN, and authenticated coturn candidates. Prefer direct UDP, then relay UDP, while keeping TURN/TCP and TURN/TLS on port 443 available for restrictive networks. - Allow mixed connectivity in one room: direct viewers stay direct while only incompatible network pairs consume TURN bandwidth. - Treat 1080p60 as a best-effort quality profile, not a universal guarantee. Provide 720p60 and 720p30 fallbacks. -- Prefer a runtime-confirmed hardware codec, normally H.264 first with VP8 fallback. Set game capture to `contentHint = "motion"` and preserve framerate before resolution under congestion. +- Leave codec order at the browser default in the first PoC and record the negotiated codec, encoder implementation, and power efficiency. Prefer H.264 only after target-machine measurements show that it is the hardware-efficient path; retain VP8 compatibility. - Add an Electron or native Windows sender only after browser measurements identify capture, application-audio, or encode bottlenecks. - Do not plan an SFU for the normal small-room product path. Reconsider it only if the product scope or real telemetry later invalidates the P2P envelope; never implement peer forwarding trees in the MVP. +## Current Implementation + +- The repository contains a single npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and a separate coturn deployment. +- The Web PoC implements capture-before-room creation, expiring role tokens, one independent peer connection per viewer, stable signaling reconnect identities, explicit ICE restart or peer rebuild, host generation isolation, short-lived TURN credentials, three manual quality profiles, and local WebRTC statistics. +- Automated checks and same-machine synthetic-media Chromium recovery tests pass, including lost offer/answer, one transient offer failure, signaling-only viewer reconnect, viewer-tab replacement with old-peer cleanup, and cancelled-room cleanup. Real screen/game audio, public TURN/NAT behavior, mobile lifecycle handling, and latency or quality targets remain unverified. +- The next milestone is a staging coturn deployment and the manual matrix in `docs/status.md`, not additional product surface or a native sender. + ## Provisional Quality Targets - Under a controlled direct path with RTT at or below 40 ms and packet loss at or below 1%: glass-to-glass latency p50 at or below 150 ms and p95 at or below 250 ms. @@ -39,7 +46,7 @@ Last updated: 2026-08-18 ## Open Decisions -- Exact MVP viewer cap and minimum supported publisher upload speed. +- Minimum supported publisher upload speed and whether a later release should remain capped at three viewers. - Exact mobile browser support matrix and the required behavior around autoplay, backgrounding, orientation changes, and network handoff. - Whether the first release is open source, source-available, or proprietary; this affects whether GPL/AGPL projects can be reused rather than only studied. - Account model versus expiring room links, and whether friends require an allowlist. @@ -50,6 +57,8 @@ Last updated: 2026-08-18 ## Source Of Truth - Requirements: `docs/需求理解.md` +- First PoC technical design: `docs/方案设计.md` +- Minimal production-shaped deployment: `docs/deployment.md` - Research and feasibility: `docs/research/webrtc-p2p-screen-sharing.md` - Topology decision: `docs/adr/0001-p2p-first-media-topology.md` - Current phase and next step: `docs/status.md` diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md index 18f3c14..88cd711 100644 --- a/docs/research/webrtc-p2p-screen-sharing.md +++ b/docs/research/webrtc-p2p-screen-sharing.md @@ -110,14 +110,14 @@ IETF 对 mesh/SFU 的拓扑说明见 [RFC 7667](https://www.rfc-editor.org/rfc/r MVP(也是预期的正常产品形态): - 一至三名观看者默认 P2P。 -- 第四名观看者加入前测量可用上行、当前发送码率、`qualityLimitationReason`、编码耗时和发送队列;不足时先降档。 +- 当前 PoC 明确拒绝第四名观看者;收集可用上行、实际发送码率、`qualityLimitationReason`、编码耗时和发送队列后,再决定后续版本是否放宽。 - 每条链路独立使用 ICE;只有失败的链路走 TURN。 - 若一开始就有多条 `relay`,应提示服务器带宽正在增加。 - 桌面和手机观看者使用同一个 Web 播放端;分享者不要求朋友安装完整客户端。 本项目不以 SFU 作为正常扩容路径;超过小房间上限时直接建议使用外部直播服务。只有以后改变产品范围时,才根据遥测重新评估房间级 SFU,观察项包括: -- 观看者多于三或四名。 +- 正常工作负载需要多于三名观看者。 - 第一名或多名观看者已使用 TURN,继续 P2P 会重复占用服务器上行。 - 分享者上行安全余量不足。 - 分享者因 CPU/encoder 限制降质。 @@ -219,6 +219,8 @@ Electron 可以固定 Chromium 版本,枚举屏幕/窗口,改善选源、热 - `qualityLimitationReason` 和各原因累计时长 - jitter buffer delay、decode time 和 total packet send delay +`RTCIceCandidateStats.protocol` 表示 ICE candidate 的 UDP/TCP;只有本地 relay candidate 的 `relayProtocol` 才表示本端到 TURN 的 UDP/TCP/TLS。规范要求远端 candidate 不暴露 `relayProtocol`,所以本端只能确认远端使用 relay,不能从 `remoteCandidate.protocol` 推断其 TURN 传输。首版分别展示 ICE protocol 与本地 TURN protocol,不为补齐远端字段扩展信令或遥测。 + WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件,并使用画面时间码或高速摄像机测量玻璃到玻璃延迟。60 fps 的单帧周期是 16.7 ms,端到端延迟还包含采集等待、编码、单程网络、jitter buffer、解码和显示。 建议原型目标:受控 direct/RTT <= 40 ms/丢包 <= 1% 时 p50 <= 150 ms、p95 <= 250 ms;区域 TURN/UDP p95 <= 350 ms。先测量,再决定是否需要原生或 SFU。 @@ -248,6 +250,29 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 不建议新项目以休眠的 `ion-sfu` 为底座。Janus 和 Galene 均可用,但分别偏底层网关和完整会议系统,不如上述项目贴合当前边界。 +## 2026-08-18 实施基线复核 + +进入 PoC 实现前再次核对当前官方版本和参考代码: + +- 本机原 Node 20 已结束维护;当前实现基线改为 Node 24 LTS。Vite 8 要求 Node `^20.19.0 || >=22.12.0`,仓库应通过 `engines`、`.node-version` 和 CI 固定受支持运行时。 +- 使用 React + TypeScript 的官方 Vite SPA 模板,不引入 SSR 或全栈框架。生产环境不能使用 `vite preview`,由同一个 Node HTTP 服务提供静态构建、房间 API 与 WebSocket。 +- `ws` 只用于服务端,浏览器使用原生 `WebSocket`。小信令禁用 `perMessageDeflate`、收紧 `maxPayload`,并采用官方 ping/pong heartbeat 模式。 +- `ws.close()` 默认会等待关闭握手,不能用“升级成功后发送 close frame”实现公网连接硬上限。总连接和未鉴权连接容量应在 `handleUpgrade()` 前检查,超限直接返回 HTTP 503 并销毁底层 socket。 +- Node 的 `IncomingMessage.url` 是未经应用路由解析的 request-target,而 WHATWG `URL` 构造器会拒绝部分输入。upgrade handler 必须捕获解析失败并关闭 socket,不能让未鉴权输入抛到 EventEmitter 顶层。 +- coturn 使用 `use-auth-secret` 支持的 TURN REST 短期凭据:`base64(HMAC-SHA1(secret, expiry + ":" + subject))`。coturn 不提供 HTTP 凭据接口,必须由已鉴权的应用服务生成。 +- 截至本次复核,coturn 应使用 4.17.2 或更新补丁版本;4.17.2 修复了此前补丁版本的 UDP TTL 回归。 +- MiroTalk BRO 当前 P2P 模式仍是 broadcaster 对每位 viewer 建独立连接;Screego 也采用独立 session 和 HMAC TURN 凭据。这验证了拓扑,但两者的静态/长时凭据与轻量恢复策略不直接照搬。 + +新增来源,访问日期 2026-08-18: + +- [Node.js release status](https://nodejs.org/en/about/previous-releases) +- [Vite Getting Started](https://vite.dev/guide/) +- [Vite static deployment](https://vite.dev/guide/static-deploy.html) +- [ws official README](https://github.com/websockets/ws/blob/master/README.md) +- [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2) +- [coturn turnserver documentation](https://github.com/coturn/coturn/blob/master/README.turnserver) +- [coturn example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf) + ## 实施难度与预估 以下以一名熟悉 Web/TypeScript、具备基本网络经验的全职工程师为基准,只用于量级判断: @@ -263,7 +288,7 @@ WebRTC 标准没有承诺固定毫秒延迟。工程目标必须带网络条件 ## 最终建议 -1. 首版坚持 P2P-first,但明确只服务小房间,暂定一至三名观看者、条件允许时四名。 +1. 首版坚持 P2P-first,但明确只服务小房间,硬上限为三名观看者;测量完成前不开放第四名。 2. Web 先行,目标 Windows Chrome/Edge;把 1080p60 写成 best effort,同时提供降档。 3. 从第一天部署 coturn,并验证 direct、TURN/UDP、TURN/TCP、TURN/TLS 443 以及移动网络切换;这是避免“部分好友永远看不了”的必要条件。 4. 观看端优先做成免安装响应式 Web;分享端先 Web 验证,再按捕获/音频实测升级 Electron。 diff --git a/docs/status.md b/docs/status.md index ab0bfd2..a961830 100644 --- a/docs/status.md +++ b/docs/status.md @@ -4,29 +4,40 @@ Last updated: 2026-08-18 ## Phase -Discovery is complete enough to begin technical design and a measured WebRTC proof of concept. No application code or production infrastructure exists yet. +The first measurable WebRTC Web proof of concept is implemented. Automated checks and a same-machine Chromium test with a synthetic video source pass. Real display capture, game audio, public-network TURN, mobile lifecycle behavior, and performance targets remain unverified. ## Established Baseline -- Product scope: private game sharing for one broadcaster and one to three normal viewers, with a measured fourth-viewer decision. -- Client shape: Windows Chrome/Edge sharing MVP; responsive desktop and mobile Web viewer; Electron/native sender only after measurements justify it. -- Media topology: independent WebRTC P2P connections with STUN discovery and mandatory authenticated TURN/UDP, TURN/TCP, and TURN/TLS fallback. +- Runtime: Node.js 24, React, TypeScript, Vite, native browser WebRTC, `ws`, Zod, and a separate coturn deployment. +- Product scope: private game sharing for one broadcaster and a hard PoC limit of three viewers. +- Client behavior: screen capture precedes room creation; expiring fragment-token invitations open in desktop or mobile browsers; every viewer has an independent `RTCPeerConnection`. +- Control plane: in-memory rooms, role-bound signaling, stable reconnect identities, short-lived coturn credentials, Origin and payload checks, and explicit room expiry/closure. +- Media topology: direct ICE is preferred per viewer, with authenticated TURN/UDP, TURN/TCP, and TURN/TLS candidates available in production. Mixed direct and relay paths are supported by design. +- Diagnostics: per-peer path, candidate types, ICE protocol, local TURN protocol, RTT, bitrate, frame rate, dimensions, loss, jitter, codec, and quality-limitation fields are read locally from WebRTC stats. - Large public broadcasts are out of scope and should use OBS/Twitch-class services. -- Research, requirements, current memory, agent instructions, and repository governance are stored in Git. + +## Verified In This Revision + +- `npm run check` passes type checking, 39 Vitest tests, the client production build, and the server production build. +- Headless Chromium 151 created real local peer connections from animated canvas streams. The viewer reached `connected`, received a live video track, and reported a direct UDP path. +- Browser fault injection recovered after dropping the first offer, dropping the first answer, and failing the first `createOffer`. A viewer-only signaling reconnect preserved the existing healthy media peer; replacing that viewer tab rebuilt media, released the old peer, and did not enter a reconnect loop. +- Cancelling a delayed room A and immediately starting room B left B live, stopped only A's capture, and invalidated A's invitation after bounded cleanup. +- Desktop and 390 px responsive layouts were smoke-checked without horizontal overflow. +- Production configuration fails closed when HTTPS, TURN, or sufficiently strong secrets are missing. +- coturn is not installed on this workstation; its configuration has only been checked against upstream documentation and local application tests. ## Next Milestone -Write the technical design and build a narrow prototype that validates: +Deploy one staging application and coturn instance, then execute and record the manual browser/network matrix: -- screen and game-audio capture behavior; -- one broadcaster with three heterogeneous viewers; -- direct and mixed direct/TURN rooms; -- mobile browser playback and network handoff; -- actual codec implementation, encode load, bitrate, frame rate, and glass-to-glass latency. +- real screen/window capture and available game or system audio on Windows Chrome and Edge; +- one broadcaster with three heterogeneous viewers for 30 minutes; +- same-LAN direct, cross-network direct, forced relay, mixed direct/relay, and UDP-blocked TURN/TLS paths; +- Android Chrome and iOS Safari playback, orientation, backgrounding, and network handoff; +- actual codec implementation, encode load, publisher upload, bitrate, frame rate, first picture, and glass-to-glass latency. ## Blocking Decisions -- Three versus four as the hard MVP viewer limit. - Whole-system versus selected-game audio for the first release. - Initial deployment region and network test cohort. - Project license and intended distribution model. diff --git a/docs/方案设计.md b/docs/方案设计.md new file mode 100644 index 0000000..aac311d --- /dev/null +++ b/docs/方案设计.md @@ -0,0 +1,243 @@ +# 屏幕共享 WebRTC PoC 方案设计 + +## 2026-08-18 14:45 + +状态:已接受,可进入实现。本文约束首个可测 PoC,不代表全部 P0 已完成。 + +## 目标与边界 + +首个 PoC 验证一名分享者向最多三名朋友进行私密、低延迟游戏画面分享。观看端使用普通桌面或手机浏览器;媒体对每名观看者独立进行 ICE,能直连时走 P2P,只有失败的链路使用 coturn。 + +本阶段必须形成完整的最小纵向链路: + +- 分享者主动选择屏幕、窗口或标签页,并请求可用音频; +- 创建有时限的私密房间,生成分离的分享者和观看者权限; +- 每名观看者拥有独立的 `RTCPeerConnection`; +- 支持 STUN、TURN/UDP、TURN/TCP 和 TURN/TLS 配置; +- 展示连接状态、direct/relay、RTT、实际码率、帧率和丢包等数据; +- 第四名观看者被明确拒绝,不静默超载; +- 在代码、测试和真实浏览器验证完成前,不宣称达到延迟或画质目标。 + +本阶段不实现账号、数据库、Redis、SFU、simulcast/SVC、观看者转发树、语音聊天、Electron、原生捕获、PWA、录制、遥测平台或多地区调度。 + +## 技术栈决策 + +| 层 | 选择 | 原因 | +| --- | --- | --- | +| Web 客户端 | React 19、TypeScript、Vite 8 | 同一套响应式界面覆盖分享者与桌面/移动观看者;官方模板成熟 | +| 媒体 | 浏览器原生 WebRTC 与 Screen Capture API | 不引入 PeerJS/simple-peer,保留对 ICE、sender 参数和 stats 的直接控制 | +| 应用服务 | Node.js 24 LTS、TypeScript、原生 HTTP、`ws` | 服务只负责小流量控制面;前后端可共享协议类型和校验 | +| 运行时校验 | Zod | WebSocket 和 HTTP 输入不可信,需要与 TypeScript 类型一致的运行时边界 | +| 静态文件 | `sirv` | 生产环境由同一 Node 进程提供前端构建结果,避免引入完整 Web 框架 | +| NAT 中继 | coturn 4.17.2 或更新补丁版本 | 成熟的 STUN/TURN 实现,支持 WebRTC 短期凭据 | +| 测试 | Vitest | 覆盖纯逻辑和 Node 集成测试;真实媒体能力另做浏览器/网络矩阵 | +| TLS 入口 | Caddy 或 nginx,由部署者选择 | 统一 HTTPS/WSS;不把证书管理写进应用进程 | + +不选 Go 的原因是 Node 不承载正常情况下的媒体流量,换语言不会减少分享者上行或 TURN 中继成本。不选 C++ 的原因是浏览器能力尚未被实测证明不足;只有捕获、应用音频或硬件编码出现明确瓶颈时才增加原生 helper。 + +## 系统结构 + +```mermaid +flowchart LR + H[分享者 Web] -->|HTTPS: 建房| A[Node 应用服务] + H <-->|WSS: 鉴权与信令| A + V1[观看者 1 Web] <-->|WSS: 鉴权与信令| A + V2[观看者 2 Web] <-->|WSS: 鉴权与信令| A + V3[观看者 3 Web] <-->|WSS: 鉴权与信令| A + H <-->|优先 DTLS-SRTP P2P| V1 + H <-->|优先 DTLS-SRTP P2P| V2 + H <-->|优先 DTLS-SRTP P2P| V3 + H -.仅失败链路.-> T[coturn] + T -.加密媒体中继.-> V2 +``` + +每个观看者的候选检查和最终路径互不影响。同一房间可以同时存在 direct 和 relay 连接。中心房间服务不复制、解码或转发媒体。 + +## 代码布局 + +```text +src/ + client/ React 页面、捕获、PeerConnection、stats 和样式 + server/ HTTP/WS 入口、配置、房间和 TURN 凭据 + shared/ 信令 schema 与跨端类型 +tests/ RoomStore、TURN、协议和 WebSocket 集成测试 +``` + +保持单个 npm package。出现第二个可独立发布的程序前,不建立 monorepo 或 workspace。 + +## 房间与访问控制 + +`POST /api/rooms` 创建内存房间。生产部署必须配置 `ROOM_CREATION_TOKEN`;分享者在首屏手工输入,前端只在当前页面内存中保存并通过 `Authorization: Bearer ...` 提交,开发模式允许省略。该 token 绝不能编入前端 bundle 或持久化到浏览器存储。服务生成: + +- 128-bit 随机 `roomId`; +- 256-bit 随机 `hostToken`; +- 256-bit 随机、可由三名朋友复用的 `viewerToken`; +- 默认四小时、由部署配置限制的房间过期时间。 + +服务端只保存 token 的 SHA-256 摘要。邀请形式为 `/r/{roomId}#token={viewerToken}`;fragment 不进入 HTTP 请求和常规代理日志。客户端读取后写入 `sessionStorage`,立即用 `history.replaceState` 清除地址栏中的 token。分享者 token 只保留在当前标签页内存中,刷新即结束该次主持会话。 + +复用观看 token 是可信朋友 PoC 的有意简化:不能单独撤销某位朋友,泄露后需关闭并重建房间。账号、逐人邀请和撤销属于后续产品决策。 + +房间规则: + +- 一名在线分享者、最多三名在线观看者; +- 房间过期、分享者主动关闭或捕获结束时拒绝新连接并通知现有观看者; +- 定时清理过期房间,并设置全局房间上限防止匿名内存耗尽; +- 不记录 token、SDP、ICE candidate、IP 地址或 TURN 密钥。 + +客户端用 `sessionStorage` 保存随机的 128-bit `clientId`,作为当前标签页内稳定的参与者 key。同一 role/clientId 的新 WebSocket 会原子替换旧 socket;观看者断线后保留五秒 grace,期间不发送 `peer-left` 且 `peerId` 不变,重新鉴权时重发幂等的 `peer-joined`,超时才释放名额。host 鉴权或重连后,`authenticated` 携带包含 grace 成员的权威 viewer ID 列表供客户端删除已离开的 peer,服务端再重放当前在线观看者的 `peer-joined`,因此观看者可以先于 host 打开邀请,host 离线期间的离开也不会遗留幽灵连接。 + +## 信令协议 + +WebSocket 使用同源 `/signal`。连接后五秒内第一条消息必须是 `authenticate`,token 不放 query string。服务端设置 64 KiB `maxPayload`、关闭 `perMessageDeflate`,并以 ping/pong 清理失效连接。单进程固定限制 2048 条总连接和 256 条未鉴权连接,容量在 WebSocket upgrade 前预留,超限直接返回 HTTP 503 并销毁 socket;这只是 PoC 的资源边界,不是容量承诺。 + +主要消息: + +| 方向 | 消息 | 作用 | +| --- | --- | --- | +| client -> server | `authenticate` | 以 room、role、token 和 clientId 建立会话 | +| server -> client | `authenticated` | 返回角色、peerId、房间期限、容量、host 状态、该 viewer 的历史 connectionId、host 的权威 viewer 列表和 ICE 配置 | +| server -> host | `peer-joined` / `peer-left` | 建立或关闭该观看者的独立连接 | +| host -> viewer | `offer` / `candidate` | 带 `peerId` 与 `connectionId` 的定向信令 | +| viewer -> host | `answer` / `candidate` | 只能回给本房间分享者 | +| viewer -> host | `restart-request` | 以 `rebuild` 标志请求 ICE restart 或完整重建该 peer | +| client -> server | `refresh-ice` | 鉴权会话内刷新即将到期的 TURN 凭据 | +| host -> server | `close-room` | 结束房间并使邀请失效 | + +Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate 结构和越权目标。服务端根据已认证会话决定路由:观看者不能给另一名观看者发信令,分享者也不能向本房间以外的 peerId 发送。 + +## WebRTC 协商 + +分享者是唯一 offerer。每名观看者对应一个 `RTCPeerConnection`,以独立 `connectionId` 区分重建代次: + +1. 分享者在开始按钮的同一用户手势内先成功调用 `getDisplayMedia()`。 +2. 只有捕获 track 已存在时才请求创建房间、连接 host WebSocket 并显示邀请;若用户在建房响应返回前取消,迟到的房间由独立、限时的鉴权连接立即关闭,且不得覆盖后续分享会话。 +3. 观看者认证后,服务端向在线分享者发送 `peer-joined`;host 晚上线或重连时收到完整观看者快照。 +4. 分享者创建连接、加入当前屏幕和音频 track,设置 sender 参数,然后创建 offer。 +5. 观看者收到 offer 后创建连接、设置 remote description、创建 answer。 +6. 双方持续通过信令服务交换 trickle ICE candidates。 +7. remote description 尚未设置时,客户端仅缓存有界数量的 candidates;旧 `connectionId` 消息直接丢弃。 + +这种单一 offerer 模式满足当前单向媒体需求,不引入 perfect-negotiation 状态机。同一页面中的不健康连接发送 `rebuild: false`,host 先尝试 `restartIce()`,非稳定信令状态或恢复失败时重建该 peer。新标签页没有旧 `RTCPeerConnection`,或页面 peer 与 `authenticated.connectionId` 代次不一致时,viewer 发送 `rebuild: true`,host 直接换代。健康且代次一致的媒体在单纯信令重连时保持不变;所有异步启动结果以 host 会话 generation 隔离,旧房间不得改写新房间状态。 + +信令 WebSocket 暂时中断时,不立即关闭仍健康的 P2P 媒体;客户端指数退避恢复控制通道。明确不可重连的会话替换会释放旧页面的 peer、统计定时器和媒体引用,host 也停止旧捕获。首版只保证同一页面生命周期内恢复,不承诺浏览器进程被系统挂起后的连续播放。 + +## 捕获、编码与质量 + +捕获只能由按钮产生的用户手势调用 `getDisplayMedia()`。三档目标为: + +| 档位 | 约束目标 | 初始码率上限 | +| --- | --- | ---: | +| 1080p60 | 1920x1080、60 fps | 8 Mbps/peer | +| 720p60 | 1280x720、60 fps | 5 Mbps/peer | +| 720p30 | 1280x720、30 fps | 3 Mbps/peer | + +浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际结果。视频 track 设置 `contentHint = "motion"`;sender 尝试设置 `maxBitrate`、`maxFramerate` 与 `degradationPreference = "maintain-framerate"`,不支持时只记录警告。 + +PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、`encoderImplementation` 和 `powerEfficientEncoder`,再依据目标机器测量决定是否优先 H.264。这样避免静态 codec 偏好意外触发软件编码。 + +`audio: true` 只是请求。若 `getAudioTracks()` 为空,分享者界面必须显示“当前来源没有可共享音频”。纯 Web 首版接受浏览器提供的标签页或系统音频,不承诺仅捕获某个游戏进程。 + +自动全房间降档需要可信的聚合策略,放在测量后的下一阶段;本 PoC 提供手动档位和明确的受限原因,不伪装成已经解决自适应问题。 + +## TURN 配置与轮换 + +应用服务在房间鉴权后生成 coturn REST 凭据: + +```text +expiry = floor(unix_seconds) + ttl +username = expiry + ":" + opaque_room_and_peer_subject +credential = base64(HMAC-SHA1(shared_secret, username)) +``` + +`TURN_SHARED_SECRET` 只存在于应用服务和 coturn。浏览器只收到短期 username/credential、STUN/TURN URLs 和 `expiresAt`。生产环境缺少 TURN secret 或 TURN URLs 时应用启动失败;开发环境允许 STUN-only,但界面必须标记 TURN 未配置。TURN/TLS 443 需要独立公网 IP,或由明确支持该协议的 L4/SNI 入口分流,不能与普通 HTTPS 进程直接争用同一 IP:443。 + +凭据过期前,客户端通过已认证 WebSocket 请求新配置,并对现有连接调用 `setConfiguration()`。已有 TURN allocation 通常不会因凭据时间点到达而立即终止,但后续 ICE restart 必须使用新凭据。 + +候选由 ICE 按优先级和连通性检查选择,不承诺 JavaScript 严格串行执行 UDP、TCP、TLS 三次尝试。界面报告最终选中的 candidate type、ICE protocol,以及本地为 relay 时的 TURN protocol,而不是推测回退步骤。 + +## 可观测性 + +每两秒在本地采集一次 `getStats()`。首版不上传原始统计,也不展示候选地址。 + +分享者逐观看者显示: + +- `connectionState` / `iceConnectionState`; +- 选中候选对的 local/remote candidate type 与 ICE `protocol`,任一端为 `relay` 即标记中继; +- 本地 relay candidate 的 `relayProtocol`;规范禁止远端 candidate 暴露该字段,因此不推断远端使用 TURN/UDP、TCP 还是 TLS; +- `currentRoundTripTime`、发送码率和 `availableOutgoingBitrate`; +- 实际帧率、分辨率和丢包; +- codec、编码实现、每帧平均编码耗时和 `qualityLimitationReason`。 + +观看者显示接收码率、帧率、分辨率、丢包、jitter、解码/丢帧和当前路径。字段不可用时显示“未知”。RTT 只标为网络往返时间,不能冒充玻璃到玻璃延迟。 + +## 界面结构 + +首屏就是工具,不做营销页。视觉采用中性深灰工作台,绿色表示 direct、琥珀色表示 relay/警告、红色表示失败,避免单一蓝紫色主题。 + +- 分享者:顶部房间状态;主区域为 16:9 本地预览;底部为开始/停止、质量档位和复制邀请;侧栏以紧凑列表显示最多三名观看者及统计。 +- 观看者:主区域最大化远端视频;顶部显示房间与连接状态;提供声音、全屏和重试控制。 +- 移动端:视频保持稳定宽高比,控制与状态移到视频下方,任何文字和按钮不得溢出或遮挡。 + +按钮使用 `lucide-react` 图标并提供可访问名称和 tooltip。动态状态区域固定最小尺寸,避免连接状态变化导致布局跳动。 + +## 安全与故障边界 + +- 生产仅允许 HTTPS/WSS,并校验 WebSocket `Origin` allowlist。 +- 建房 token 使用常量时间比较,房间 token 只保存摘要并以常量时间验证;TURN secret 仅作为 HMAC key 存在于服务端;响应设置 `Cache-Control: no-store`。 +- HTTP body、WebSocket payload、总连接数、未鉴权连接数、输出缓冲、房间总数、观看人数和 candidate 缓存都有硬上限;无法解析的 upgrade request-target 在未鉴权边界返回 HTTP 400,不允许异常逃逸到进程顶层。 +- 应用日志不得主动记录 IP;反向代理和 coturn 为提供网络服务必然可见源地址,部署时需设置最短必要留存与访问控制。 +- TURN 应配置 `use-auth-secret`、`realm`、`fingerprint`、`no-multicast-peers`、私网/loopback peer 拒绝、配额和出口监控;禁止匿名 relay。 +- 分享者直连会向可信观看者暴露网络地址。需要隐藏 IP 时才提供强制 relay 诊断选项,并明确其服务器带宽成本。 + +## 验证计划 + +自动测试: + +- RoomStore 的创建、token 摘要验证、过期、关闭、三人上限和第四人拒绝; +- TURN HMAC 固定向量、TTL、未配置生产环境启动失败; +- 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制; +- WebSocket 集成:一名 host、三名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和关闭房间; +- 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知; +- TypeScript 类型检查、Vite production build 和仓库 hygiene。 + +人工矩阵: + +- 同机双标签页与同 LAN direct; +- 真实跨网络、强制 relay、同房间 direct + relay; +- 封 UDP 后验证 TURN/TLS 443; +- Windows Chrome/Edge 的音轨有/无; +- Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换; +- 一名分享者、三名观看者连续 30 分钟,记录实际上行、编码负载和首帧时间。 + +真实矩阵未完成前,状态文档必须把这些列为待验证,而不是把设计目标写成产品能力。 + +## 实施顺序 + +1. 建立单 package 工程、共享 schema、配置与 CI。 +2. 完成 RoomStore、建房 API、WebSocket 鉴权和定向信令。 +3. 完成屏幕捕获、1:3 PeerConnection、观看播放和手动质量档。 +4. 完成 TURN 短期凭据、ICE 配置刷新、连接恢复和 stats 面板。 +5. 完成自动测试、真实浏览器烟测、部署说明、状态与记忆更新。 + +任何一步只实现当前消费者需要的接口。不得为未来账号、SFU 或原生 sender 预建抽象层。 + +## 官方与参考资料 + +访问日期均为 2026-08-18: + +- [Vite Getting Started](https://vite.dev/guide/) +- [React: Build a React app from Scratch](https://react.dev/learn/build-a-react-app-from-scratch) +- [Node.js release status](https://nodejs.org/en/about/previous-releases) +- [ws official README](https://github.com/websockets/ws/blob/master/README.md) +- [Screen Capture specification](https://www.w3.org/TR/screen-capture/) +- [WebRTC specification](https://www.w3.org/TR/webrtc/) +- [WebRTC Stats](https://www.w3.org/TR/webrtc-stats/) +- [WebRTC official samples](https://webrtc.github.io/samples/) +- [coturn TURN server documentation](https://github.com/coturn/coturn/blob/master/README.turnserver) +- [coturn example configuration](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf) +- [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00) + +MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。项目许可证确定前不复制其代码。 + +--- diff --git a/docs/需求理解.md b/docs/需求理解.md index c65c781..7bf26ce 100644 --- a/docs/需求理解.md +++ b/docs/需求理解.md @@ -6,7 +6,7 @@ 构建一个面向熟人游戏场景的低延迟屏幕共享工具。分享者可以在私密房间内分享游戏、应用窗口或显示器画面及声音,多名朋友可以即时观看。系统采用类似 Photon Host Mode 的产品抽象:中心服务负责房间、身份和建立连接,媒体默认通过 WebRTC 在客户端间 P2P 传输;STUN 协助打洞,直连失败的单条链路才使用 TURN。分享端 Web 优先并允许后续 Electron/原生增强;观看端必须可通过桌面或手机浏览器打开邀请链接,不绑定专用客户端。 -当前规模假设为一名分享者和一至三名常规观看者,第四名观看者需要通过上行带宽和编码负载预检。产品不承担公开直播或大规模观看,相关需求交给 OBS/Twitch 等成熟直播服务。该人数是待原型验证的工程边界,不是未经测量的承诺。 +当前 PoC 的硬上限为一名分享者和三名观看者,第四名观看者会被明确拒绝。只有在上行带宽和编码负载完成测量后,后续版本才重新评估是否放宽。产品不承担公开直播或大规模观看,相关需求交给 OBS/Twitch 等成熟直播服务。 ## 功能需求 @@ -51,7 +51,7 @@ ## 边界与约束 - 首版不包含远程键鼠控制、公开/大规模直播、录制、社区系统或完整语音聊天;大规模分享直接使用现有直播服务。 -- 首版不以超过四名观看者为正常工作负载,也不承诺跨洲网络下的固定低延迟。 +- 首版不以超过三名观看者为正常工作负载,也不承诺跨洲网络下的固定低延迟。 - 浏览器规范要求每次由用户主动选择分享源,权限不能永久保存;纯网页不能保证指定游戏窗口或始终获得系统音频。 - 1080p60 是能力目标,实际分辨率、帧率和硬件编码由浏览器、GPU、驱动、画面复杂度及网络共同决定。 - 首版不实现 SFU,不从零实现编解码器或自定义 UDP 视频协议;产品范围以后改变时重新立项,而不是预留复杂框架。 @@ -88,7 +88,7 @@ ## 待确认事项 -- MVP 的硬上限应为三名还是四名观看者。 +- 后续版本是否应在测量通过后放宽三名观看者的硬上限。 - 首版是否必须只捕获所选游戏的声音,还是允许整机系统音频。 - 项目许可证及是否计划闭源商用。 - 首批用户主要位于香港、内地还是跨地区;该信息决定 TURN 部署位置和测试矩阵。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..41c5580 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + Screener + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..73f295b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1922 @@ +{ + "name": "screener", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "screener", + "version": "0.1.0", + "dependencies": { + "lucide-react": "1.31.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "sirv": "3.0.2", + "ws": "8.21.3", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@types/ws": "8.18.1", + "@vitejs/plugin-react": "6.0.5", + "tsx": "4.23.12", + "typescript": "~6.0.3", + "vite": "8.2.1", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=24.0.0 <27" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d813263 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "screener", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24.0.0 <27" + }, + "packageManager": "npm@11.17.0", + "scripts": { + "dev": "tsx watch src/server/index.ts", + "build": "npm run build:client && npm run build:server", + "build:client": "vite build", + "build:server": "tsc -p tsconfig.server.json", + "start": "node dist/server/server/index.js", + "typecheck": "tsc -b --pretty false", + "test": "vitest run", + "test:watch": "vitest", + "check": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { + "lucide-react": "1.31.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "sirv": "3.0.2", + "ws": "8.21.3", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@types/ws": "8.18.1", + "@vitejs/plugin-react": "6.0.5", + "tsx": "4.23.12", + "typescript": "~6.0.3", + "vite": "8.2.1", + "vitest": "4.1.10" + } +} diff --git a/scripts/required-project-paths.txt b/scripts/required-project-paths.txt index 31b953f..3226192 100644 --- a/scripts/required-project-paths.txt +++ b/scripts/required-project-paths.txt @@ -4,6 +4,11 @@ CONTRIBUTING.md README.md .gitattributes .gitignore +.env.example +.node-version +package.json +package-lock.json +tsconfig.json .codex/config.toml .codex/README.md .githooks/pre-commit @@ -14,6 +19,8 @@ docs/project-memory.md docs/status.md docs/maintenance.md docs/需求理解.md +docs/方案设计.md +docs/deployment.md docs/adr/0001-p2p-first-media-topology.md docs/research/agent-context-governance.md docs/research/webrtc-p2p-screen-sharing.md @@ -22,3 +29,4 @@ scripts/check-project-state.sh scripts/check-project-state.ps1 scripts/install-hooks.sh scripts/install-hooks.ps1 +deploy/coturn/turnserver.conf.example diff --git a/src/client/App.tsx b/src/client/App.tsx new file mode 100644 index 0000000..cc9c289 --- /dev/null +++ b/src/client/App.tsx @@ -0,0 +1,9 @@ +import { readViewerRoute } from "./lib/session"; +import { HostPage } from "./pages/HostPage"; +import { ViewerPage } from "./pages/ViewerPage"; + +const viewerRoute = readViewerRoute(); + +export function App() { + return viewerRoute ? : ; +} diff --git a/src/client/components/AppHeader.tsx b/src/client/components/AppHeader.tsx new file mode 100644 index 0000000..8eaea5f --- /dev/null +++ b/src/client/components/AppHeader.tsx @@ -0,0 +1,16 @@ +import { MonitorUp } from "lucide-react"; +import type { ReactNode } from "react"; + +export function AppHeader({ status }: { status: ReactNode }) { + return ( +
+ + + Screener + +
{status}
+
+ ); +} diff --git a/src/client/components/StatsGrid.tsx b/src/client/components/StatsGrid.tsx new file mode 100644 index 0000000..b4a3f44 --- /dev/null +++ b/src/client/components/StatsGrid.tsx @@ -0,0 +1,103 @@ +import type { ConnectionMetrics } from "../types"; + +function readableNumber(value: number | null, digits = 0): string { + return value === null || !Number.isFinite(value) ? "未知" : value.toFixed(digits); +} + +function qualityReason(value: string | null): string { + const labels: Record = { + none: "正常", + bandwidth: "带宽受限", + cpu: "编码受限", + other: "其他限制", + }; + return value ? (labels[value] ?? value) : "未知"; +} + +function Metric({ label, value, title }: { label: string; value: string; title?: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function StatsGrid({ + metrics, + direction, + compact = false, +}: { + metrics: ConnectionMetrics; + direction: "send" | "receive"; + compact?: boolean; +}) { + const encoder = metrics.encoderImplementation + ? `${metrics.encoderImplementation}${ + metrics.powerEfficientEncoder === true + ? " · 节能" + : metrics.powerEfficientEncoder === false + ? " · 非节能" + : "" + }` + : "未知"; + + return ( +
+ + + + + + + {!compact && ( + <> + + {metrics.localCandidateType === "relay" && ( + + )} + + + {direction === "send" ? ( + <> + + + + + ) : ( + <> + + + + )} + + )} +
+ ); +} diff --git a/src/client/components/StatusBadge.tsx b/src/client/components/StatusBadge.tsx new file mode 100644 index 0000000..8766e09 --- /dev/null +++ b/src/client/components/StatusBadge.tsx @@ -0,0 +1,85 @@ +import { CircleAlert, Radio, Wifi, WifiOff } from "lucide-react"; +import type { + MediaPath, + SignalConnectionState, +} from "../types"; + +interface BadgeProps { + tone: "good" | "warning" | "danger" | "neutral"; + label: string; + title?: string; +} + +function Badge({ tone, label, title }: BadgeProps) { + return ( + + + ); +} + +export function SignalStatusBadge({ + state, +}: { + state: SignalConnectionState; +}) { + const values: Record = { + connecting: { tone: "neutral", label: "正在连接" }, + connected: { tone: "good", label: "信令在线" }, + reconnecting: { tone: "warning", label: "正在恢复" }, + offline: { tone: "neutral", label: "信令离线" }, + }; + return ; +} + +export function PeerStatusBadge({ + state, +}: { + state: RTCPeerConnectionState | "waiting"; +}) { + const labels: Record = { + waiting: { tone: "neutral", label: "等待画面" }, + new: { tone: "neutral", label: "准备中" }, + connecting: { tone: "neutral", label: "正在连接" }, + connected: { tone: "good", label: "已连接" }, + disconnected: { tone: "warning", label: "连接中断" }, + failed: { tone: "danger", label: "连接失败" }, + closed: { tone: "neutral", label: "已关闭" }, + }; + return ; +} + +export function PathBadge({ path }: { path: MediaPath }) { + if (path === "direct") { + return ( + + + ); + } + if (path === "relay") { + return ( + + + ); + } + return ( + + + ); +} + +export function WarningBanner({ children }: { children: React.ReactNode }) { + return ( +
+
+ ); +} diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts new file mode 100644 index 0000000..77def67 --- /dev/null +++ b/src/client/lib/api.ts @@ -0,0 +1,55 @@ +import { + createRoomResponseSchema, + type CreateRoomResponse, +} from "../../shared/protocol"; + +function errorMessage(value: unknown): string | null { + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record; + if (typeof record.message === "string") { + return record.message; + } + if (typeof record.error === "string") { + return record.error; + } + if (record.error && typeof record.error === "object") { + const nested = record.error as Record; + return typeof nested.message === "string" ? nested.message : null; + } + return null; +} + +export async function createRoom( + creationToken: string, +): Promise { + const headers = new Headers({ Accept: "application/json" }); + const trimmedToken = creationToken.trim(); + if (trimmedToken) { + headers.set("Authorization", `Bearer ${trimmedToken}`); + } + + const response = await fetch("/api/rooms", { + method: "POST", + headers, + }); + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new Error("建房服务返回了无法识别的响应"); + } + + if (!response.ok) { + throw new Error(errorMessage(body) ?? `建房失败 (${response.status})`); + } + + const parsed = createRoomResponseSchema.safeParse(body); + if (!parsed.success) { + throw new Error("建房服务返回的数据格式不正确"); + } + return parsed.data; +} diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts new file mode 100644 index 0000000..d656b82 --- /dev/null +++ b/src/client/lib/session.ts @@ -0,0 +1,69 @@ +const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; +const ROOM_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; + +export interface ViewerRoute { + roomId: string; + token: string | null; +} + +function readSessionValue(key: string): string | null { + try { + return window.sessionStorage.getItem(key); + } catch { + return null; + } +} + +function writeSessionValue(key: string, value: string): void { + try { + window.sessionStorage.setItem(key, value); + } catch { + // The current page can still use the in-memory value in restricted browsers. + } +} + +export function readViewerRoute(): ViewerRoute | null { + const match = window.location.pathname.match(/^\/r\/([A-Za-z0-9_-]+)\/?$/); + if (!match || !ROOM_PATTERN.test(match[1])) { + return null; + } + + const roomId = match[1]; + const storageKey = `screener:viewer-token:${roomId}`; + const fragmentToken = new URLSearchParams(window.location.hash.slice(1)).get( + "token", + ); + const token = + fragmentToken && TOKEN_PATTERN.test(fragmentToken) + ? fragmentToken + : readSessionValue(storageKey); + + if (fragmentToken && TOKEN_PATTERN.test(fragmentToken)) { + writeSessionValue(storageKey, fragmentToken); + } + + if (window.location.hash) { + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${window.location.search}`, + ); + } + + return { + roomId, + token: token && TOKEN_PATTERN.test(token) ? token : null, + }; +} + +export function getStableClientId(role: "host" | "viewer", roomId: string): string { + const storageKey = `screener:client-id:${role}:${roomId}`; + const existing = readSessionValue(storageKey); + if (existing && ROOM_PATTERN.test(existing)) { + return existing; + } + + const clientId = crypto.randomUUID(); + writeSessionValue(storageKey, clientId); + return clientId; +} diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts new file mode 100644 index 0000000..e330fbd --- /dev/null +++ b/src/client/lib/signaling.ts @@ -0,0 +1,252 @@ +import { + decodeServerMessage, + type ClientMessage, + type Role, + type ServerMessage, +} from "../../shared/protocol"; +import type { SignalConnectionState } from "../types"; + +interface SignalingIdentity { + roomId: string; + role: Role; + token: string; + clientId: string; +} + +interface SignalingEvents { + onMessage: (message: ServerMessage) => void; + onStatus: (status: SignalConnectionState) => void; + onProtocolError: (message: string) => void; + onTerminated: (message: string) => void; +} + +const FATAL_SIGNAL_ERRORS = new Set([ + "AUTH_REQUIRED", + "INVALID_TOKEN", + "ROOM_CLOSED", + "ROOM_EXPIRED", + "ROOM_FULL", + "HOST_ALREADY_CONNECTED", +]); +const SESSION_REPLACED_CLOSE_CODE = 4001; +const TERMINAL_SEND_TIMEOUT_MS = 15_000; + +export function shouldReconnectSignaling(code: number): boolean { + return code !== SESSION_REPLACED_CLOSE_CODE; +} + +function signalUrl(): string { + const url = new URL("/signal", window.location.href); + url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +export class SignalingClient { + private socket: WebSocket | null = null; + private stopped = false; + private authenticated = false; + private reconnectAttempt = 0; + private reconnectTimer: number | null = null; + private authenticationTimer: number | null = null; + private iceRefreshTimer: number | null = null; + private terminalTimer: number | null = null; + private terminalMessage: ClientMessage | null = null; + + constructor( + private readonly identity: SignalingIdentity, + private readonly events: SignalingEvents, + ) {} + + start(): void { + if (this.socket || this.stopped) { + return; + } + this.events.onStatus(this.reconnectAttempt === 0 ? "connecting" : "reconnecting"); + this.connect(); + } + + stop(): void { + this.stopped = true; + this.authenticated = false; + this.clearTimers(); + this.terminalMessage = null; + const socket = this.socket; + this.socket = null; + if (socket && socket.readyState < WebSocket.CLOSING) { + socket.close(1000, "client closed"); + } + this.events.onStatus("offline"); + } + + send(message: ClientMessage): boolean { + if ( + !this.authenticated || + !this.socket || + this.socket.readyState !== WebSocket.OPEN + ) { + return false; + } + this.socket.send(JSON.stringify(message)); + return true; + } + + sendThenStop(message: ClientMessage): void { + if (this.stopped) { + return; + } + if (this.send(message)) { + this.stop(); + return; + } + + this.terminalMessage = message; + if (this.terminalTimer === null) { + this.terminalTimer = window.setTimeout(() => { + this.terminalTimer = null; + this.stop(); + }, TERMINAL_SEND_TIMEOUT_MS); + } + } + + private connect(): void { + if (this.stopped) { + return; + } + + const socket = new WebSocket(signalUrl()); + this.socket = socket; + + socket.addEventListener("open", () => { + if (this.socket !== socket || this.stopped) { + return; + } + const authenticate: ClientMessage = { + type: "authenticate", + ...this.identity, + }; + socket.send(JSON.stringify(authenticate)); + this.authenticationTimer = window.setTimeout(() => { + if (!this.authenticated && this.socket === socket) { + socket.close(4000, "authentication timeout"); + } + }, 8_000); + }); + + socket.addEventListener("message", (event) => { + if (this.socket !== socket || typeof event.data !== "string") { + return; + } + let message: ServerMessage; + try { + message = decodeServerMessage(event.data); + } catch { + this.events.onProtocolError("信令服务返回了无法识别的消息"); + return; + } + + if (message.type === "error" && FATAL_SIGNAL_ERRORS.has(message.code)) { + this.stop(); + this.events.onMessage(message); + return; + } + + if (message.type === "authenticated") { + this.authenticated = true; + this.reconnectAttempt = 0; + this.clearAuthenticationTimer(); + if (this.terminalMessage) { + const terminalMessage = this.terminalMessage; + this.terminalMessage = null; + if (this.send(terminalMessage)) { + this.stop(); + return; + } + } + this.scheduleIceRefresh(message.iceConfig.expiresAt); + this.events.onStatus("connected"); + } else if (message.type === "ice-config") { + this.scheduleIceRefresh(message.iceConfig.expiresAt); + } + this.events.onMessage(message); + }); + + socket.addEventListener("close", (event) => { + if (this.socket !== socket) { + return; + } + this.socket = null; + this.authenticated = false; + this.clearAuthenticationTimer(); + if (!this.stopped && shouldReconnectSignaling(event.code)) { + this.scheduleReconnect(); + } else if (!this.stopped) { + this.stopped = true; + this.clearTimers(); + this.events.onStatus("offline"); + this.events.onTerminated( + event.reason === "Session replaced" + ? "此页面的会话已被另一个标签页接管" + : "信令会话已终止,请刷新后重试", + ); + } + }); + + socket.addEventListener("error", () => { + // The close event owns reconnect scheduling and user-visible state. + }); + } + + private scheduleReconnect(): void { + this.events.onStatus("reconnecting"); + const exponent = Math.min(this.reconnectAttempt, 4); + const delay = Math.min(8_000, 500 * 2 ** exponent) + Math.random() * 250; + this.reconnectAttempt += 1; + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private scheduleIceRefresh(expiresAt: string | null): void { + if (this.iceRefreshTimer !== null) { + window.clearTimeout(this.iceRefreshTimer); + this.iceRefreshTimer = null; + } + if (!expiresAt) { + return; + } + + const expiresAtMs = Date.parse(expiresAt); + if (!Number.isFinite(expiresAtMs)) { + return; + } + const delay = Math.max(5_000, expiresAtMs - Date.now() - 60_000); + this.iceRefreshTimer = window.setTimeout(() => { + this.iceRefreshTimer = null; + this.send({ type: "refresh-ice" }); + }, delay); + } + + private clearAuthenticationTimer(): void { + if (this.authenticationTimer !== null) { + window.clearTimeout(this.authenticationTimer); + this.authenticationTimer = null; + } + } + + private clearTimers(): void { + this.clearAuthenticationTimer(); + if (this.reconnectTimer !== null) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.iceRefreshTimer !== null) { + window.clearTimeout(this.iceRefreshTimer); + this.iceRefreshTimer = null; + } + if (this.terminalTimer !== null) { + window.clearTimeout(this.terminalTimer); + this.terminalTimer = null; + } + } +} diff --git a/src/client/main.tsx b/src/client/main.tsx new file mode 100644 index 0000000..a4358cf --- /dev/null +++ b/src/client/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("Application root was not found"); +} + +createRoot(root).render( + + + , +); diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts new file mode 100644 index 0000000..00c759d --- /dev/null +++ b/src/client/media/quality.ts @@ -0,0 +1,65 @@ +export const QUALITY_PROFILES = { + "1080p60": { + label: "1080p 60", + width: 1920, + height: 1080, + frameRate: 60, + maxBitrate: 8_000_000, + }, + "720p60": { + label: "720p 60", + width: 1280, + height: 720, + frameRate: 60, + maxBitrate: 5_000_000, + }, + "720p30": { + label: "720p 30", + width: 1280, + height: 720, + frameRate: 30, + maxBitrate: 3_000_000, + }, +} as const; + +export type QualityProfileId = keyof typeof QUALITY_PROFILES; +export type QualityProfile = (typeof QUALITY_PROFILES)[QualityProfileId]; + +export async function captureDisplay( + profile: QualityProfile, +): Promise { + if (!navigator.mediaDevices?.getDisplayMedia) { + throw new Error("当前浏览器不支持屏幕共享"); + } + + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { + width: { ideal: profile.width }, + height: { ideal: profile.height }, + frameRate: { ideal: profile.frameRate, max: profile.frameRate }, + }, + audio: true, + }); + + const videoTrack = stream.getVideoTracks()[0]; + if (!videoTrack) { + stream.getTracks().forEach((track) => track.stop()); + throw new Error("浏览器没有返回可分享的视频轨道"); + } + videoTrack.contentHint = "motion"; + return stream; +} + +export async function configureVideoSender( + sender: RTCRtpSender, + profile: QualityProfile, +): Promise { + const parameters = sender.getParameters(); + if (parameters.encodings.length === 0) { + parameters.encodings = [{}]; + } + parameters.encodings[0].maxBitrate = profile.maxBitrate; + parameters.encodings[0].maxFramerate = profile.frameRate; + parameters.degradationPreference = "maintain-framerate"; + await sender.setParameters(parameters); +} diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx new file mode 100644 index 0000000..74375ff --- /dev/null +++ b/src/client/pages/HostPage.tsx @@ -0,0 +1,723 @@ +import { + Check, + Copy, + KeyRound, + MonitorUp, + Square, + Users, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { + CreateRoomResponse, + IceConfig, + ServerMessage, +} from "../../shared/protocol"; +import { AppHeader } from "../components/AppHeader"; +import { + PathBadge, + PeerStatusBadge, + SignalStatusBadge, + WarningBanner, +} from "../components/StatusBadge"; +import { StatsGrid } from "../components/StatsGrid"; +import { createRoom } from "../lib/api"; +import { getStableClientId } from "../lib/session"; +import { SignalingClient } from "../lib/signaling"; +import { + captureDisplay, + QUALITY_PROFILES, + type QualityProfileId, +} from "../media/quality"; +import type { + PeerSnapshot, + SignalConnectionState, +} from "../types"; +import { HostPeer } from "../webrtc/host-peer"; + +type HostPhase = "idle" | "starting" | "live" | "ended" | "error"; + +interface CaptureDetails { + resolution: string; + frameRate: number | null; + hasAudio: boolean; +} + +function captureDetails(stream: MediaStream): CaptureDetails { + const settings = stream.getVideoTracks()[0]?.getSettings(); + return { + resolution: + settings?.width && settings.height + ? `${settings.width}x${settings.height}` + : "未知", + frameRate: settings?.frameRate ?? null, + hasAudio: stream.getAudioTracks().length > 0, + }; +} + +function readableError(error: unknown): string { + if (error instanceof DOMException && error.name === "NotAllowedError") { + return "屏幕选择已取消或没有共享权限"; + } + return error instanceof Error && error.message ? error.message : "启动分享失败"; +} + +function closeAbandonedRoom(room: CreateRoomResponse): void { + try { + const signal = new SignalingClient( + { + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: getStableClientId("host", room.roomId), + }, + { + onMessage: () => undefined, + onStatus: () => undefined, + onProtocolError: () => undefined, + onTerminated: () => undefined, + }, + ); + signal.start(); + signal.sendThenStop({ type: "close-room" }); + } catch { + // The room will still expire naturally if the bounded cleanup cannot start. + } +} + +export function HostPage() { + const forceRelay = useMemo( + () => new URLSearchParams(window.location.search).get("relay") === "1", + [], + ); + const [qualityId, setQualityId] = useState("1080p60"); + const [creationToken, setCreationToken] = useState(""); + const [phase, setPhase] = useState("idle"); + const [signalStatus, setSignalStatus] = + useState("offline"); + const [stream, setStream] = useState(null); + const [details, setDetails] = useState(null); + const [room, setRoom] = useState(null); + const [relayAvailable, setRelayAvailable] = useState(false); + const [peerSnapshots, setPeerSnapshots] = useState>( + () => new Map(), + ); + const [notice, setNotice] = useState(null); + const [copied, setCopied] = useState(false); + + const videoRef = useRef(null); + const streamRef = useRef(null); + const signalRef = useRef(null); + const iceConfigRef = useRef(null); + const peersRef = useRef(new Map()); + const generationRef = useRef(0); + const activeGenerationRef = useRef(null); + + const viewers = useMemo( + () => Array.from(peerSnapshots.values()), + [peerSnapshots], + ); + + useEffect(() => { + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + }, [stream]); + + useEffect( + () => () => { + activeGenerationRef.current = null; + generationRef.current += 1; + signalRef.current?.stop(); + peersRef.current.forEach((peer) => peer.dispose()); + peersRef.current.clear(); + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + }, + [], + ); + + function isCurrentGeneration(generation: number): boolean { + return ( + generationRef.current === generation && + activeGenerationRef.current === generation + ); + } + + function disposeResources(notifyServer: boolean): void { + const signal = signalRef.current; + if (signal) { + if (notifyServer) { + signal.sendThenStop({ type: "close-room" }); + } else { + signal.stop(); + } + } + signalRef.current = null; + peersRef.current.forEach((peer) => peer.dispose()); + peersRef.current.clear(); + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + iceConfigRef.current = null; + setStream(null); + setRoom(null); + setPeerSnapshots(new Map()); + setSignalStatus("offline"); + } + + function endSharing(message: string, notifyServer = true): void { + const generation = activeGenerationRef.current; + if (generation === null || generationRef.current !== generation) { + return; + } + activeGenerationRef.current = null; + generationRef.current += 1; + disposeResources(notifyServer); + setNotice(message); + setPhase("ended"); + } + + function updatePeerSnapshot(snapshot: PeerSnapshot): void { + setPeerSnapshots((current) => { + const next = new Map(current); + next.set(snapshot.peerId, snapshot); + return next; + }); + } + + function removePeer(peerId: string): void { + peersRef.current.get(peerId)?.dispose(); + peersRef.current.delete(peerId); + setPeerSnapshots((current) => { + const next = new Map(current); + next.delete(peerId); + return next; + }); + } + + async function startPeer( + peerId: string, + generation: number, + attempt = 0, + ): Promise { + if (!isCurrentGeneration(generation)) { + return; + } + // Preserve healthy media across control reconnects, but replace a stalled + // negotiation whose offer or answer may have been lost with the WebSocket. + const existing = peersRef.current.get(peerId); + if (existing) { + if (existing.isConnected()) { + return; + } + removePeer(peerId); + } + const activeStream = streamRef.current; + const iceConfig = iceConfigRef.current; + const signal = signalRef.current; + if (!activeStream || !iceConfig || !signal) { + return; + } + + let peer: HostPeer; + peer = new HostPeer( + peerId, + iceConfig, + activeStream, + QUALITY_PROFILES[qualityId], + { + sendSignal: (targetPeerId, payload) => + isCurrentGeneration(generation) && signalRef.current === signal + ? signal.send({ type: "signal", targetPeerId, payload }) + : false, + onUpdate: (snapshot) => { + if ( + isCurrentGeneration(generation) && + peersRef.current.get(peerId) === peer + ) { + updatePeerSnapshot(snapshot); + } + }, + }, + forceRelay, + ); + peersRef.current.set(peerId, peer); + let started: boolean; + try { + started = await peer.start(); + } catch (error) { + if (peersRef.current.get(peerId) === peer) { + removePeer(peerId); + } + throw error; + } + if (started || peersRef.current.get(peerId) !== peer) { + return; + } + + removePeer(peerId); + if (!isCurrentGeneration(generation) || attempt >= 1) { + return; + } + window.setTimeout(() => { + if ( + isCurrentGeneration(generation) && + !peersRef.current.has(peerId) + ) { + void startPeer(peerId, generation, attempt + 1).catch( + (error: unknown) => { + if (isCurrentGeneration(generation)) { + setNotice(readableError(error)); + } + }, + ); + } + }, 500); + } + + async function recoverPeer( + peerId: string, + connectionId: string, + rebuild: boolean, + generation: number, + ): Promise { + const peer = peersRef.current.get(peerId); + if (rebuild) { + if (peer && peer.connectionId !== connectionId) { + return; + } + if (peer) { + removePeer(peerId); + } + await startPeer(peerId, generation); + return; + } + if ( + !isCurrentGeneration(generation) || + !peer || + peer.connectionId !== connectionId + ) { + return; + } + if (await peer.restartIce()) { + return; + } + if (!isCurrentGeneration(generation)) { + return; + } + if (peersRef.current.get(peerId) !== peer) { + return; + } + removePeer(peerId); + await startPeer(peerId, generation); + } + + function handleSignalMessage(message: ServerMessage, generation: number): void { + if (!isCurrentGeneration(generation)) { + return; + } + if (message.type === "authenticated" && message.role === "host") { + const currentViewerIds = new Set(message.viewerPeerIds); + for (const peerId of peersRef.current.keys()) { + if (!currentViewerIds.has(peerId)) { + removePeer(peerId); + } + } + return; + } + if (message.type === "peer-joined") { + void startPeer(message.peerId, generation).catch((error: unknown) => { + if (isCurrentGeneration(generation)) { + setNotice(readableError(error)); + } + }); + return; + } + if (message.type === "peer-left") { + removePeer(message.peerId); + return; + } + if (message.type === "signal") { + void peersRef.current.get(message.fromPeerId)?.acceptSignal(message.payload); + return; + } + if (message.type === "restart-request") { + void recoverPeer( + message.fromPeerId, + message.connectionId, + message.rebuild, + generation, + ).catch((error: unknown) => { + if (isCurrentGeneration(generation)) { + setNotice(readableError(error)); + } + }); + return; + } + if (message.type === "ice-config") { + iceConfigRef.current = message.iceConfig; + setRelayAvailable(message.iceConfig.relayAvailable); + peersRef.current.forEach((peer) => peer.updateIceConfig(message.iceConfig)); + return; + } + if (message.type === "room-closed") { + endSharing( + message.reason === "expired" ? "房间已过期" : "房间已关闭", + false, + ); + return; + } + if (message.type === "error") { + if ( + [ + "AUTH_REQUIRED", + "INVALID_TOKEN", + "ROOM_CLOSED", + "ROOM_EXPIRED", + "HOST_ALREADY_CONNECTED", + ].includes(message.code) + ) { + endSharing(message.message, false); + return; + } + setNotice(message.message); + } + } + + async function startSharing(): Promise { + if ( + phase === "starting" || + phase === "live" || + activeGenerationRef.current !== null + ) { + return; + } + const generation = generationRef.current + 1; + generationRef.current = generation; + activeGenerationRef.current = generation; + setNotice(null); + setCopied(false); + setPhase("starting"); + + let captured: MediaStream; + try { + // This must remain the first awaited operation in the button gesture. + captured = await captureDisplay(QUALITY_PROFILES[qualityId]); + } catch (error) { + if (!isCurrentGeneration(generation)) { + return; + } + activeGenerationRef.current = null; + setNotice(readableError(error)); + setPhase("error"); + return; + } + + if (!isCurrentGeneration(generation)) { + captured.getTracks().forEach((track) => track.stop()); + return; + } + + streamRef.current = captured; + setStream(captured); + setDetails(captureDetails(captured)); + captured.getVideoTracks()[0]?.addEventListener( + "ended", + () => { + if (isCurrentGeneration(generation)) { + endSharing("屏幕分享已结束"); + } + }, + { once: true }, + ); + + let createdRoom: CreateRoomResponse | null = null; + try { + createdRoom = await createRoom(creationToken); + if (!isCurrentGeneration(generation)) { + captured.getTracks().forEach((track) => track.stop()); + closeAbandonedRoom(createdRoom); + return; + } + iceConfigRef.current = createdRoom.iceConfig; + setRelayAvailable(createdRoom.iceConfig.relayAvailable); + + const signal = new SignalingClient( + { + roomId: createdRoom.roomId, + role: "host", + token: createdRoom.hostToken, + clientId: getStableClientId("host", createdRoom.roomId), + }, + { + onStatus: (status) => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + setSignalStatus(status); + } + }, + onProtocolError: (message) => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + setNotice(message); + } + }, + onTerminated: (message) => { + if ( + isCurrentGeneration(generation) && + signalRef.current === signal + ) { + endSharing(message, false); + } + }, + onMessage: (message) => { + if ( + !isCurrentGeneration(generation) || + signalRef.current !== signal + ) { + return; + } + if (message.type === "authenticated") { + iceConfigRef.current = message.iceConfig; + setRelayAvailable(message.iceConfig.relayAvailable); + setCreationToken(""); + setRoom(createdRoom); + setPhase("live"); + } + handleSignalMessage(message, generation); + }, + }, + ); + signalRef.current = signal; + signal.start(); + } catch (error) { + captured.getTracks().forEach((track) => track.stop()); + if (!isCurrentGeneration(generation)) { + if (createdRoom) { + closeAbandonedRoom(createdRoom); + } + return; + } + activeGenerationRef.current = null; + if (createdRoom) { + closeAbandonedRoom(createdRoom); + } + if (streamRef.current === captured) { + streamRef.current = null; + setStream(null); + } + setNotice(readableError(error)); + setPhase("error"); + } + } + + async function copyInvite(): Promise { + if (!room) { + return; + } + const generation = activeGenerationRef.current; + if (generation === null) { + return; + } + try { + await navigator.clipboard.writeText(room.inviteUrl); + if (!isCurrentGeneration(generation)) { + return; + } + setCopied(true); + window.setTimeout(() => { + if (isCurrentGeneration(generation)) { + setCopied(false); + } + }, 1_500); + } catch { + if (isCurrentGeneration(generation)) { + setNotice("无法写入剪贴板,请手动复制邀请链接"); + } + } + } + + const expiresAt = room + ? new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(room.expiresAt)) + : null; + + return ( +
+ } /> + +
+
+
+
+

屏幕分享

+

+ {phase === "live" + ? `${viewers.length}/3 人正在观看` + : phase === "starting" + ? "正在建立房间" + : "尚未开始"} +

+
+ {forceRelay && 强制中继} + {(phase === "live" || phase === "starting") && ( + + )} +
+ +
+ {stream ? ( +
+ + {details && stream && ( +
+ {details.resolution} + {details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"} + {details.hasAudio ? "含音频" : "无音频"} +
+ )} + + {!details?.hasAudio && stream && ( + 当前来源没有可共享音频 + )} + {room && !relayAvailable && ( + TURN 未配置,严格网络可能无法连接 + )} + {notice && ( +
+ {notice} +
+ )} + + {phase !== "live" ? ( +
+
+ 画质 +
+ {(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map( + (id) => ( + + ), + )} +
+
+ + + + +
+ ) : ( + room && ( +
+
+ 邀请链接 · {expiresAt} 过期 + + {room.inviteUrl} + +
+ +
+ ) + )} +
+ + +
+
+ ); +} diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx new file mode 100644 index 0000000..767cad6 --- /dev/null +++ b/src/client/pages/ViewerPage.tsx @@ -0,0 +1,392 @@ +import { + Maximize2, + Play, + RefreshCw, + VideoOff, + Volume2, + VolumeX, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { IceConfig, ServerMessage } from "../../shared/protocol"; +import { AppHeader } from "../components/AppHeader"; +import { + PathBadge, + PeerStatusBadge, + SignalStatusBadge, + WarningBanner, +} from "../components/StatusBadge"; +import { StatsGrid } from "../components/StatsGrid"; +import { getStableClientId } from "../lib/session"; +import { SignalingClient } from "../lib/signaling"; +import type { + PeerSnapshot, + SignalConnectionState, +} from "../types"; +import { ViewerPeer } from "../webrtc/viewer-peer"; + +interface ViewerPageProps { + roomId: string; + token: string | null; +} + +type ViewerRoomState = "waiting" | "active" | "closed" | "error"; + +export function ViewerPage({ roomId, token }: ViewerPageProps) { + const forceRelay = useMemo( + () => new URLSearchParams(window.location.search).get("relay") === "1", + [], + ); + const [signalStatus, setSignalStatus] = + useState("offline"); + const [roomState, setRoomState] = useState( + token ? "waiting" : "error", + ); + const [statusText, setStatusText] = useState( + token ? "正在进入房间" : "邀请链接无效或已失效", + ); + const [hostOnline, setHostOnline] = useState(false); + const [relayAvailable, setRelayAvailable] = useState(false); + const [remoteStream, setRemoteStream] = useState(null); + const [peerSnapshot, setPeerSnapshot] = useState(null); + const [playbackBlocked, setPlaybackBlocked] = useState(false); + const [muted, setMuted] = useState(false); + + const videoRef = useRef(null); + const peerRef = useRef(null); + const signalRef = useRef(null); + + useEffect(() => { + if (!token) { + return; + } + + let active = true; + let currentIceConfig: IceConfig | null = null; + + const signal = new SignalingClient( + { + roomId, + role: "viewer", + token, + clientId: getStableClientId("viewer", roomId), + }, + { + onStatus: (status) => { + if (active) { + setSignalStatus(status); + } + }, + onProtocolError: (message) => { + if (active) { + setStatusText(message); + } + }, + onTerminated: (message) => { + if (!active) { + return; + } + peerRef.current?.dispose(); + peerRef.current = null; + setRemoteStream(null); + setPeerSnapshot(null); + setPlaybackBlocked(false); + setRoomState("error"); + setStatusText(message); + }, + onMessage: (message) => { + if (!active) { + return; + } + void handleMessage(message); + }, + }, + ); + + function ensurePeer(): ViewerPeer | null { + if (peerRef.current) { + return peerRef.current; + } + if (!currentIceConfig) { + return null; + } + const peer = new ViewerPeer( + currentIceConfig, + { + sendSignal: (payload) => signal.send({ type: "signal", payload }), + sendRestartRequest: (connectionId, rebuild) => + signal.send({ type: "restart-request", connectionId, rebuild }), + onStream: (nextStream) => { + if (active) { + setRemoteStream(nextStream); + setRoomState("active"); + setStatusText("正在播放"); + } + }, + onUpdate: (snapshot) => { + if (active) { + setPeerSnapshot(snapshot); + if (snapshot.connectionState === "connected") { + setRoomState("active"); + setStatusText("已连接"); + } else if ( + snapshot.connectionState === "failed" || + snapshot.connectionState === "disconnected" + ) { + setStatusText("正在恢复媒体连接"); + } + } + }, + }, + forceRelay, + ); + peerRef.current = peer; + return peer; + } + + async function handleMessage(message: ServerMessage): Promise { + if (message.type === "authenticated") { + currentIceConfig = message.iceConfig; + setRelayAvailable(message.iceConfig.relayAvailable); + setHostOnline(message.hostOnline); + setRoomState(peerRef.current?.isConnected() ? "active" : "waiting"); + setStatusText(message.hostOnline ? "等待分享画面" : "等待分享者上线"); + const peer = peerRef.current; + peer?.updateIceConfig(message.iceConfig); + if ( + message.connectionId && + !peer?.hasConnectionId(message.connectionId) + ) { + signal.send({ + type: "restart-request", + connectionId: message.connectionId, + rebuild: true, + }); + } else if (peer?.hasConnection() && !peer.isConnected()) { + peer.requestRecovery(); + } + return; + } + if (message.type === "signal") { + const peer = ensurePeer(); + if (!peer) { + setStatusText("尚未收到可用的 ICE 配置"); + return; + } + await peer.acceptSignal(message.fromPeerId, message.payload); + return; + } + if (message.type === "ice-config") { + currentIceConfig = message.iceConfig; + setRelayAvailable(message.iceConfig.relayAvailable); + peerRef.current?.updateIceConfig(message.iceConfig); + return; + } + if (message.type === "host-status") { + setHostOnline(message.online); + if (!message.online && !peerRef.current?.isConnected()) { + setStatusText("等待分享者上线"); + } + return; + } + if (message.type === "room-closed") { + peerRef.current?.dispose(); + peerRef.current = null; + setRemoteStream(null); + setPeerSnapshot(null); + setRoomState("closed"); + setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束"); + signal.stop(); + return; + } + if (message.type === "error") { + if ( + [ + "AUTH_REQUIRED", + "INVALID_TOKEN", + "ROOM_CLOSED", + "ROOM_EXPIRED", + "ROOM_FULL", + ].includes(message.code) + ) { + peerRef.current?.dispose(); + peerRef.current = null; + setRemoteStream(null); + setPeerSnapshot(null); + } + setRoomState("error"); + setStatusText(message.message); + } + } + + signalRef.current = signal; + signal.start(); + return () => { + active = false; + signal.stop(); + signalRef.current = null; + peerRef.current?.dispose(); + peerRef.current = null; + }; + }, [forceRelay, roomId, token]); + + useEffect(() => { + const video = videoRef.current; + if (!video) { + return; + } + video.srcObject = remoteStream; + if (remoteStream) { + void video.play().then( + () => setPlaybackBlocked(false), + () => setPlaybackBlocked(true), + ); + } + }, [remoteStream]); + + async function playVideo(): Promise { + if (!videoRef.current) { + return; + } + try { + await videoRef.current.play(); + setPlaybackBlocked(false); + } catch { + setPlaybackBlocked(true); + } + } + + function toggleMuted(): void { + const nextMuted = !muted; + setMuted(nextMuted); + if (videoRef.current) { + videoRef.current.muted = nextMuted; + if (!nextMuted) { + void playVideo(); + } + } + } + + async function enterFullscreen(): Promise { + const video = videoRef.current as + | (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) + | null; + if (!video) { + return; + } + try { + if (video.requestFullscreen) { + await video.requestFullscreen(); + } else { + video.webkitEnterFullscreen?.(); + } + } catch { + setStatusText("当前浏览器无法进入全屏"); + } + } + + function retryConnection(): void { + if (!peerRef.current?.requestRecovery()) { + setStatusText(hostOnline ? "等待分享画面" : "等待分享者上线"); + } else { + setStatusText("正在恢复媒体连接"); + } + } + + return ( +
+ } /> + +
+
+
+

好友屏幕

+

房间 {roomId.slice(0, 8)}

+
+
+ {forceRelay && 强制中继} + + +
+
+ +
+
+ +
+
+ {statusText} +
+
+ + + +
+
+ + {!relayAvailable && signalStatus === "connected" && ( + TURN 未配置,严格网络可能无法连接 + )} + {peerSnapshot?.error && ( +
+ {peerSnapshot.error} +
+ )} + {peerSnapshot && ( +
+

连接数据

+ +
+ )} +
+
+ ); +} diff --git a/src/client/styles.css b/src/client/styles.css new file mode 100644 index 0000000..c77d52a --- /dev/null +++ b/src/client/styles.css @@ -0,0 +1,743 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + color: #f1f3f1; + background: #101310; + font-synthesis: none; + text-rendering: optimizeLegibility; + letter-spacing: 0; + --surface: #171b18; + --surface-raised: #1d221e; + --surface-hover: #252b26; + --border: #343a35; + --border-strong: #4a514b; + --text: #f1f3f1; + --muted: #a8afa9; + --faint: #747c75; + --green: #45d483; + --green-strong: #2fbd70; + --green-soft: #173526; + --amber: #efb84e; + --amber-soft: #3b2e16; + --red: #f06b6b; + --red-soft: #3c1d1d; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +body { + min-height: 100vh; + min-height: 100dvh; + background: #101310; +} + +button, +input { + font: inherit; + letter-spacing: 0; +} + +button { + color: inherit; +} + +button:focus-visible, +input:focus-visible, +a:focus-visible { + outline: 2px solid var(--green); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.app-shell { + min-height: 100vh; + min-height: 100dvh; + background: #101310; +} + +.app-header { + position: sticky; + z-index: 10; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 58px; + padding: 9px clamp(16px, 3vw, 40px); + border-bottom: 1px solid var(--border); + background: rgba(16, 19, 16, 0.96); + backdrop-filter: blur(12px); +} + +.brand { + display: inline-flex; + gap: 10px; + align-items: center; + color: var(--text); + font-size: 16px; + font-weight: 700; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid #326645; + border-radius: 6px; + color: var(--green); + background: #14271b; +} + +.header-status { + display: flex; + min-width: 112px; + justify-content: flex-end; +} + +.host-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 370px; + width: min(1500px, 100%); + min-height: calc(100dvh - 58px); + margin: 0 auto; +} + +.broadcast-area { + min-width: 0; + padding: 28px clamp(18px, 3vw, 42px) 40px; +} + +.section-heading, +.viewer-title-row { + display: flex; + min-height: 48px; + gap: 12px; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 16px; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 22px; + line-height: 1.25; +} + +h2 { + font-size: 15px; + line-height: 1.3; +} + +h3 { + font-size: 14px; + line-height: 1.3; +} + +.section-meta { + min-height: 20px; + margin-top: 4px; + color: var(--muted); + font-size: 13px; + line-height: 20px; +} + +.video-stage { + position: relative; + width: 100%; + overflow: hidden; + aspect-ratio: 16 / 9; + border: 1px solid var(--border); + border-radius: 6px; + background: #050706; +} + +.video-stage video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + background: #050706; +} + +.stage-placeholder, +.stage-overlay { + position: absolute; + inset: 0; + display: grid; + gap: 10px; + place-content: center; + justify-items: center; + color: var(--faint); + font-size: 14px; +} + +.stage-overlay { + color: var(--text); + background: rgba(5, 7, 6, 0.72); +} + +.capture-strip { + display: flex; + min-height: 34px; + gap: 8px 18px; + align-items: center; + padding: 7px 2px; + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.notice { + display: flex; + min-height: 38px; + align-items: center; + margin-top: 10px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted); + background: var(--surface); + font-size: 13px; + line-height: 1.4; +} + +.notice-warning { + gap: 8px; + border-color: #6a5224; + color: #f3c86f; + background: var(--amber-soft); +} + +.notice-error { + border-color: #713838; + color: #ff9797; + background: var(--red-soft); +} + +.setup-controls { + display: grid; + grid-template-columns: minmax(310px, 1fr) minmax(220px, 0.75fr) auto; + gap: 18px; + align-items: end; + margin-top: 22px; +} + +.control-group { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.control-group legend, +.token-field > span:first-child, +.field-label { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.segmented-control { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + height: 42px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.segmented-control button { + min-width: 0; + padding: 0 10px; + border: 0; + border-right: 1px solid var(--border); + color: var(--muted); + background: transparent; + cursor: pointer; + white-space: nowrap; +} + +.segmented-control button:last-child { + border-right: 0; +} + +.segmented-control button:hover:not(:disabled) { + color: var(--text); + background: var(--surface-hover); +} + +.segmented-control button.is-selected { + color: #dff9e9; + background: var(--green-soft); + box-shadow: inset 0 -2px var(--green); +} + +.token-field { + min-width: 0; +} + +.input-with-icon { + display: flex; + height: 42px; + align-items: center; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--faint); + background: var(--surface); +} + +.input-with-icon:focus-within { + border-color: var(--green); +} + +.input-with-icon input { + min-width: 0; + flex: 1; + height: 100%; + padding: 0 0 0 9px; + border: 0; + outline: 0; + color: var(--text); + background: transparent; +} + +.button, +.icon-button, +.play-overlay { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} + +.button { + min-height: 42px; + gap: 8px; + padding: 0 15px; + font-size: 13px; + font-weight: 700; + white-space: nowrap; +} + +.button-primary { + border-color: var(--green-strong); + color: #06140c; + background: var(--green); +} + +.button-primary:hover:not(:disabled) { + background: #59df91; +} + +.button-danger { + border-color: #7c3d3d; + color: #ffaaaa; + background: var(--red-soft); +} + +.button-danger:hover:not(:disabled) { + border-color: var(--red); +} + +.icon-button { + width: 40px; + height: 40px; + flex: 0 0 40px; + padding: 0; + color: var(--muted); + background: var(--surface-raised); +} + +.icon-button:hover:not(:disabled) { + border-color: var(--border-strong); + color: var(--text); + background: var(--surface-hover); +} + +.invite-bar { + display: flex; + min-width: 0; + gap: 12px; + align-items: center; + margin-top: 18px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.invite-copy { + min-width: 0; + flex: 1; +} + +.invite-copy .field-label { + margin-bottom: 3px; +} + +.invite-url { + display: block; + overflow: hidden; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-panel { + min-width: 0; + padding: 28px 18px 40px; + border-left: 1px solid var(--border); + background: #131613; +} + +.viewer-panel-heading, +.viewer-panel-heading > div, +.viewer-item-heading, +.viewer-item-heading > div { + display: flex; + align-items: center; + justify-content: space-between; +} + +.viewer-panel-heading { + height: 34px; + margin-bottom: 12px; + color: var(--muted); +} + +.viewer-panel-heading > div { + gap: 8px; +} + +.viewer-panel-heading span { + color: var(--faint); + font-size: 12px; +} + +.viewer-list { + display: grid; + gap: 10px; +} + +.viewer-item { + min-width: 0; + padding: 13px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); +} + +.viewer-item-heading { + min-height: 30px; + gap: 8px; + margin-bottom: 12px; +} + +.viewer-item-heading > div { + min-width: 0; + gap: 8px; +} + +.empty-viewers { + display: grid; + min-height: 150px; + gap: 9px; + place-content: center; + justify-items: center; + border: 1px dashed var(--border); + border-radius: 6px; + color: var(--faint); + font-size: 13px; +} + +.status-badge, +.path-badge, +.diagnostic-badge { + display: inline-flex; + min-height: 24px; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 8px; + font-size: 11px; + font-weight: 650; + line-height: 1; + white-space: nowrap; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.status-good, +.path-direct { + border-color: #2d6844; + color: #78e6a4; + background: var(--green-soft); +} + +.status-warning, +.path-relay, +.diagnostic-badge { + border-color: #6a5224; + color: #f3c86f; + background: var(--amber-soft); +} + +.status-danger { + border-color: #713838; + color: #ff9797; + background: var(--red-soft); +} + +.status-neutral, +.path-unknown { + color: var(--muted); + background: var(--surface-raised); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + margin: 0; + font-variant-numeric: tabular-nums; +} + +.metric { + min-width: 0; +} + +.metric dt { + overflow: hidden; + margin-bottom: 2px; + color: var(--faint); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric dd { + overflow: hidden; + margin: 0; + color: #dfe3df; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-error { + margin-top: 10px; + overflow-wrap: anywhere; + color: #ff9797; + font-size: 11px; + line-height: 1.4; +} + +.viewer-workspace { + width: min(1380px, 100%); + margin: 0 auto; + padding: 26px clamp(16px, 3vw, 40px) 42px; +} + +.viewer-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.remote-stage { + max-height: calc(100dvh - 250px); + margin: 0 auto; +} + +.play-overlay { + position: absolute; + inset: 50% auto auto 50%; + min-height: 44px; + gap: 8px; + padding: 0 18px; + color: #06140c; + background: var(--green); + border-color: var(--green-strong); + font-weight: 700; + transform: translate(-50%, -50%); +} + +.viewer-toolbar { + display: flex; + min-height: 58px; + gap: 16px; + align-items: center; + justify-content: space-between; + padding: 9px 0; + border-bottom: 1px solid var(--border); +} + +.toolbar-status { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} + +.viewer-stats { + margin-top: 22px; +} + +.viewer-stats h2 { + margin-bottom: 12px; + color: var(--muted); +} + +.viewer-stats .stats-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); + padding: 14px 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +@media (max-width: 1050px) { + .host-workspace { + grid-template-columns: minmax(0, 1fr) 330px; + } + + .setup-controls { + grid-template-columns: 1fr 1fr; + } + + .start-button { + grid-column: 1 / -1; + } + + .viewer-stats .stats-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 780px) { + .app-header { + position: static; + padding: 9px 14px; + } + + .host-workspace { + display: block; + } + + .broadcast-area, + .viewer-workspace { + padding: 20px 14px 28px; + } + + .viewer-panel { + padding: 20px 14px 28px; + border-top: 1px solid var(--border); + border-left: 0; + } + + .setup-controls { + grid-template-columns: 1fr; + gap: 14px; + } + + .start-button { + grid-column: auto; + } + + .remote-stage { + max-height: none; + } + + .viewer-stats .stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .section-heading, + .viewer-title-row { + flex-wrap: wrap; + } + + .section-heading .button-danger { + width: 100%; + } + + .viewer-badges { + width: 100%; + justify-content: flex-start; + } + + .segmented-control { + height: auto; + min-height: 42px; + } + + .segmented-control button { + min-height: 42px; + padding: 6px; + white-space: normal; + } + + .invite-bar { + align-items: flex-end; + } + + .viewer-stats .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + } +} diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..8ebfb31 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,60 @@ +export type SignalConnectionState = + | "connecting" + | "connected" + | "reconnecting" + | "offline"; + +export type MediaPath = "direct" | "relay" | "unknown"; + +export interface ConnectionMetrics { + path: MediaPath; + iceProtocol: string | null; + localRelayProtocol: string | null; + localCandidateType: string | null; + remoteCandidateType: string | null; + rttMs: number | null; + bitrateKbps: number | null; + availableOutgoingKbps: number | null; + framesPerSecond: number | null; + resolution: string | null; + packetsLost: number | null; + jitterMs: number | null; + framesDropped: number | null; + codec: string | null; + encoderImplementation: string | null; + powerEfficientEncoder: boolean | null; + averageEncodeMs: number | null; + averageDecodeMs: number | null; + qualityLimitationReason: string | null; +} + +export interface PeerSnapshot { + peerId: string; + connectionId: string; + connectionState: RTCPeerConnectionState; + iceConnectionState: RTCIceConnectionState; + metrics: ConnectionMetrics; + error: string | null; +} + +export const EMPTY_METRICS: ConnectionMetrics = { + path: "unknown", + iceProtocol: null, + localRelayProtocol: null, + localCandidateType: null, + remoteCandidateType: null, + rttMs: null, + bitrateKbps: null, + availableOutgoingKbps: null, + framesPerSecond: null, + resolution: null, + packetsLost: null, + jitterMs: null, + framesDropped: null, + codec: null, + encoderImplementation: null, + powerEfficientEncoder: null, + averageEncodeMs: null, + averageDecodeMs: null, + qualityLimitationReason: null, +}; diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts new file mode 100644 index 0000000..09f6dcb --- /dev/null +++ b/src/client/webrtc/host-peer.ts @@ -0,0 +1,247 @@ +import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import type { QualityProfile } from "../media/quality"; +import { configureVideoSender } from "../media/quality"; +import { + EMPTY_METRICS, + type PeerSnapshot, +} from "../types"; +import { + collectConnectionMetrics, + createStatsAccumulator, +} from "./stats"; + +const MAX_PENDING_CANDIDATES = 64; +type SignalCandidate = Extract< + SignalPayload, + { kind: "candidate" } +>["candidate"]; + +interface HostPeerEvents { + sendSignal: (peerId: string, payload: SignalPayload) => boolean; + onUpdate: (snapshot: PeerSnapshot) => void; +} + +export class HostPeer { + readonly connectionId = crypto.randomUUID(); + + private readonly connection: RTCPeerConnection; + private readonly pendingCandidates: SignalCandidate[] = []; + private readonly statsAccumulator = createStatsAccumulator(); + private statsTimer: number | null = null; + private disposed = false; + private negotiating = false; + private snapshot: PeerSnapshot; + + constructor( + readonly peerId: string, + iceConfig: IceConfig, + private readonly stream: MediaStream, + private readonly profile: QualityProfile, + private readonly events: HostPeerEvents, + private readonly forceRelay = false, + ) { + this.connection = new RTCPeerConnection({ + iceServers: iceConfig.iceServers, + iceTransportPolicy: forceRelay ? "relay" : "all", + }); + this.snapshot = { + peerId, + connectionId: this.connectionId, + connectionState: this.connection.connectionState, + iceConnectionState: this.connection.iceConnectionState, + metrics: { ...EMPTY_METRICS }, + error: null, + }; + this.bindConnectionEvents(); + } + + async start(): Promise { + for (const track of this.stream.getTracks()) { + const sender = this.connection.addTrack(track, this.stream); + if (track.kind === "video") { + try { + await configureVideoSender(sender, this.profile); + } catch (error) { + console.warn("Browser rejected preferred sender parameters", error); + } + } + } + if (!(await this.createOffer(false)) || this.disposed) { + return false; + } + this.statsTimer = window.setInterval(() => { + void this.updateStats(); + }, 2_000); + this.emit(); + return true; + } + + async acceptSignal(payload: SignalPayload): Promise { + if (this.disposed || payload.connectionId !== this.connectionId) { + return; + } + + try { + if (payload.kind === "description") { + if (payload.description.type !== "answer") { + return; + } + await this.connection.setRemoteDescription(payload.description); + await this.flushCandidates(); + } else if (this.connection.remoteDescription) { + await this.connection.addIceCandidate(payload.candidate); + } else if (this.pendingCandidates.length < MAX_PENDING_CANDIDATES) { + this.pendingCandidates.push(payload.candidate); + } + } catch (error) { + this.setError(error, "处理观看端信令失败"); + } + } + + async restartIce(): Promise { + if (this.disposed) { + return false; + } + if (this.negotiating) { + return true; + } + if (this.connection.signalingState !== "stable") { + return false; + } + return this.createOffer(true); + } + + isConnected(): boolean { + return this.connection.connectionState === "connected"; + } + + updateIceConfig(iceConfig: IceConfig): void { + if (this.disposed) { + return; + } + try { + this.connection.setConfiguration({ + iceServers: iceConfig.iceServers, + iceTransportPolicy: this.forceRelay ? "relay" : "all", + }); + } catch (error) { + this.setError(error, "更新 TURN 配置失败"); + } + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.statsTimer !== null) { + window.clearInterval(this.statsTimer); + this.statsTimer = null; + } + this.connection.close(); + } + + private bindConnectionEvents(): void { + this.connection.addEventListener("icecandidate", (event) => { + this.events.sendSignal(this.peerId, { + kind: "candidate", + connectionId: this.connectionId, + candidate: event.candidate + ? { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + usernameFragment: event.candidate.usernameFragment, + } + : null, + }); + }); + this.connection.addEventListener("connectionstatechange", () => this.emit()); + this.connection.addEventListener("iceconnectionstatechange", () => this.emit()); + } + + private async createOffer(restart: boolean): Promise { + if (this.disposed) { + return false; + } + this.negotiating = true; + try { + if (restart) { + this.connection.restartIce(); + } + const offer = await this.connection.createOffer(); + if (this.disposed) { + return false; + } + await this.connection.setLocalDescription(offer); + if (this.disposed || !this.connection.localDescription) { + return false; + } + if ( + !this.events.sendSignal(this.peerId, { + kind: "description", + connectionId: this.connectionId, + description: { + type: "offer", + sdp: this.connection.localDescription.sdp, + }, + }) + ) { + throw new Error("信令暂时离线,等待重新协商"); + } + this.snapshot = { ...this.snapshot, error: null }; + this.emit(); + return true; + } catch (error) { + this.setError(error, restart ? "恢复连接失败" : "创建连接失败"); + return false; + } finally { + this.negotiating = false; + } + } + + private async flushCandidates(): Promise { + const candidates = this.pendingCandidates.splice(0); + for (const candidate of candidates) { + await this.connection.addIceCandidate(candidate); + } + } + + private async updateStats(): Promise { + if (this.disposed || this.connection.connectionState === "closed") { + return; + } + try { + const metrics = await collectConnectionMetrics( + this.connection, + "send", + this.statsAccumulator, + ); + this.snapshot = { ...this.snapshot, metrics }; + this.emit(); + } catch { + // Stats are observational and must never disrupt a healthy media path. + } + } + + private setError(error: unknown, fallback: string): void { + const message = error instanceof Error ? error.message : fallback; + this.snapshot = { ...this.snapshot, error: message || fallback }; + this.emit(); + } + + private emit(): void { + if (this.disposed) { + return; + } + this.snapshot = { + ...this.snapshot, + connectionState: this.connection.connectionState, + iceConnectionState: this.connection.iceConnectionState, + }; + this.events.onUpdate({ + ...this.snapshot, + metrics: { ...this.snapshot.metrics }, + }); + } +} diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts new file mode 100644 index 0000000..d8bd2bd --- /dev/null +++ b/src/client/webrtc/stats.ts @@ -0,0 +1,204 @@ +import { + EMPTY_METRICS, + type ConnectionMetrics, +} from "../types"; + +type StatsRecord = Record & { + id: string; + type: string; + timestamp: number; +}; + +export interface StatsAccumulator { + bytes: number | null; + frames: number | null; + timestamp: number | null; +} + +export function createStatsAccumulator(): StatsAccumulator { + return { bytes: null, frames: null, timestamp: null }; +} + +function numberValue(record: StatsRecord | null, key: string): number | null { + const value = record?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function stringValue(record: StatsRecord | null, key: string): string | null { + const value = record?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function booleanValue(record: StatsRecord | null, key: string): boolean | null { + const value = record?.[key]; + return typeof value === "boolean" ? value : null; +} + +function getRecord(report: RTCStatsReport, id: string | null): StatsRecord | null { + if (!id) { + return null; + } + return (report.get(id) as StatsRecord | undefined) ?? null; +} + +function selectedCandidatePair(report: RTCStatsReport): StatsRecord | null { + let transport: StatsRecord | null = null; + const candidatePairs: StatsRecord[] = []; + report.forEach((raw) => { + const record = raw as StatsRecord; + if (record.type === "transport") { + transport = record; + } else if (record.type === "candidate-pair") { + candidatePairs.push(record); + } + }); + + const pairId = stringValue(transport, "selectedCandidatePairId"); + if (pairId) { + return getRecord(report, pairId); + } + return ( + candidatePairs.find( + (pair) => + pair.state === "succeeded" && + (pair.nominated === true || pair.selected === true), + ) ?? null + ); +} + +function mediaRecord( + report: RTCStatsReport, + direction: "send" | "receive", +): StatsRecord | null { + let result: StatsRecord | null = null; + report.forEach((raw) => { + const record = raw as StatsRecord; + const expectedType = direction === "send" ? "outbound-rtp" : "inbound-rtp"; + if ( + record.type === expectedType && + record.kind === "video" && + record.isRemote !== true + ) { + result = record; + } + }); + return result; +} + +function remoteInboundVideo(report: RTCStatsReport): StatsRecord | null { + let result: StatsRecord | null = null; + report.forEach((raw) => { + const record = raw as StatsRecord; + if (record.type === "remote-inbound-rtp" && record.kind === "video") { + result = record; + } + }); + return result; +} + +export async function collectConnectionMetrics( + connection: RTCPeerConnection, + direction: "send" | "receive", + previous: StatsAccumulator, +): Promise { + const report = await connection.getStats(); + const pair = selectedCandidatePair(report); + const localCandidate = getRecord( + report, + stringValue(pair, "localCandidateId"), + ); + const remoteCandidate = getRecord( + report, + stringValue(pair, "remoteCandidateId"), + ); + const localType = stringValue(localCandidate, "candidateType"); + const remoteType = stringValue(remoteCandidate, "candidateType"); + const path = + localType === "relay" || remoteType === "relay" + ? "relay" + : localType !== null && remoteType !== null + ? "direct" + : "unknown"; + + const media = mediaRecord(report, direction); + const remoteInbound = direction === "send" ? remoteInboundVideo(report) : null; + const bytesKey = direction === "send" ? "bytesSent" : "bytesReceived"; + const bytes = numberValue(media, bytesKey); + const framesKey = direction === "send" ? "framesEncoded" : "framesDecoded"; + const frames = numberValue(media, framesKey); + const timestamp = media?.timestamp ?? null; + let bitrateKbps: number | null = null; + let derivedFps: number | null = null; + + if ( + bytes !== null && + timestamp !== null && + previous.bytes !== null && + previous.timestamp !== null && + timestamp > previous.timestamp + ) { + const elapsedSeconds = (timestamp - previous.timestamp) / 1_000; + bitrateKbps = ((bytes - previous.bytes) * 8) / elapsedSeconds / 1_000; + if (frames !== null && previous.frames !== null) { + derivedFps = (frames - previous.frames) / elapsedSeconds; + } + } + previous.bytes = bytes; + previous.frames = frames; + previous.timestamp = timestamp; + + const codec = getRecord(report, stringValue(media, "codecId")); + const width = numberValue(media, "frameWidth"); + const height = numberValue(media, "frameHeight"); + const framesEncoded = numberValue(media, "framesEncoded"); + const framesDecoded = numberValue(media, "framesDecoded"); + const totalEncodeTime = numberValue(media, "totalEncodeTime"); + const totalDecodeTime = numberValue(media, "totalDecodeTime"); + const iceProtocol = + stringValue(localCandidate, "protocol") ?? + stringValue(remoteCandidate, "protocol"); + const localRelayProtocol = + localType === "relay" ? stringValue(localCandidate, "relayProtocol") : null; + + return { + ...EMPTY_METRICS, + path, + iceProtocol, + localRelayProtocol, + localCandidateType: localType, + remoteCandidateType: remoteType, + rttMs: + numberValue(pair, "currentRoundTripTime") !== null + ? numberValue(pair, "currentRoundTripTime")! * 1_000 + : numberValue(remoteInbound, "roundTripTime") !== null + ? numberValue(remoteInbound, "roundTripTime")! * 1_000 + : null, + bitrateKbps, + availableOutgoingKbps: + numberValue(pair, "availableOutgoingBitrate") !== null + ? numberValue(pair, "availableOutgoingBitrate")! / 1_000 + : null, + framesPerSecond: numberValue(media, "framesPerSecond") ?? derivedFps, + resolution: width !== null && height !== null ? `${width}x${height}` : null, + packetsLost: + numberValue(direction === "send" ? remoteInbound : media, "packetsLost"), + jitterMs: + numberValue(direction === "send" ? remoteInbound : media, "jitter") !== null + ? numberValue(direction === "send" ? remoteInbound : media, "jitter")! * + 1_000 + : null, + framesDropped: numberValue(media, "framesDropped"), + codec: stringValue(codec, "mimeType"), + encoderImplementation: stringValue(media, "encoderImplementation"), + powerEfficientEncoder: booleanValue(media, "powerEfficientEncoder"), + averageEncodeMs: + framesEncoded && totalEncodeTime !== null + ? (totalEncodeTime / framesEncoded) * 1_000 + : null, + averageDecodeMs: + framesDecoded && totalDecodeTime !== null + ? (totalDecodeTime / framesDecoded) * 1_000 + : null, + qualityLimitationReason: stringValue(media, "qualityLimitationReason"), + }; +} diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts new file mode 100644 index 0000000..736fbff --- /dev/null +++ b/src/client/webrtc/viewer-peer.ts @@ -0,0 +1,331 @@ +import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import { + EMPTY_METRICS, + type PeerSnapshot, +} from "../types"; +import { + collectConnectionMetrics, + createStatsAccumulator, + type StatsAccumulator, +} from "./stats"; + +const MAX_PENDING_CANDIDATES = 64; +type SignalCandidate = Extract< + SignalPayload, + { kind: "candidate" } +>["candidate"]; + +interface ViewerPeerEvents { + sendSignal: (payload: SignalPayload) => boolean; + sendRestartRequest: (connectionId: string, rebuild: boolean) => boolean; + onStream: (stream: MediaStream) => void; + onUpdate: (snapshot: PeerSnapshot) => void; +} + +export class ViewerPeer { + private connection: RTCPeerConnection | null = null; + private connectionId: string | null = null; + private hostPeerId: string | null = null; + private remoteStream = new MediaStream(); + private pendingByConnection = new Map< + string, + SignalCandidate[] + >(); + private statsAccumulator: StatsAccumulator = createStatsAccumulator(); + private statsTimer: number | null = null; + private disconnectTimer: number | null = null; + private restartRequested = false; + private disposed = false; + private currentIceConfig: IceConfig; + private snapshot: PeerSnapshot | null = null; + + constructor( + iceConfig: IceConfig, + private readonly events: ViewerPeerEvents, + private readonly forceRelay = false, + ) { + this.currentIceConfig = iceConfig; + } + + async acceptSignal( + hostPeerId: string, + payload: SignalPayload, + ): Promise { + if (this.disposed) { + return; + } + this.hostPeerId = hostPeerId; + + try { + if (payload.kind === "description") { + if (payload.description.type !== "offer") { + return; + } + if (!this.connection || this.connectionId !== payload.connectionId) { + this.replaceConnection(hostPeerId, payload.connectionId); + } + const connection = this.connection; + if (!connection) { + return; + } + await connection.setRemoteDescription(payload.description); + await this.flushCandidates(payload.connectionId); + const answer = await connection.createAnswer(); + await connection.setLocalDescription(answer); + if (!connection.localDescription) { + throw new Error("Local description was not created"); + } + this.events.sendSignal({ + kind: "description", + connectionId: payload.connectionId, + description: { + type: "answer", + sdp: connection.localDescription.sdp, + }, + }); + this.restartRequested = false; + } else if ( + this.connection && + this.connectionId === payload.connectionId && + this.connection.remoteDescription + ) { + await this.connection.addIceCandidate(payload.candidate); + } else { + this.queueCandidate(payload.connectionId, payload.candidate); + } + } catch (error) { + this.setError(error, "处理分享端信令失败"); + } + } + + updateIceConfig(iceConfig: IceConfig): void { + this.currentIceConfig = iceConfig; + if (!this.connection) { + return; + } + try { + this.connection.setConfiguration({ + iceServers: iceConfig.iceServers, + iceTransportPolicy: this.forceRelay ? "relay" : "all", + }); + } catch (error) { + this.setError(error, "更新 TURN 配置失败"); + } + } + + requestRecovery(): boolean { + if (!this.connectionId) { + return false; + } + const sent = this.events.sendRestartRequest(this.connectionId, false); + if (sent) { + this.restartRequested = true; + } + return sent; + } + + hasConnection(): boolean { + return this.connection !== null; + } + + hasConnectionId(connectionId: string): boolean { + return this.connection !== null && this.connectionId === connectionId; + } + + isConnected(): boolean { + return this.connection?.connectionState === "connected"; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.disposeConnection(); + this.pendingByConnection.clear(); + } + + private replaceConnection(hostPeerId: string, connectionId: string): void { + this.disposeConnection(); + this.hostPeerId = hostPeerId; + this.connectionId = connectionId; + this.remoteStream = new MediaStream(); + this.statsAccumulator = createStatsAccumulator(); + this.restartRequested = false; + + const connection = new RTCPeerConnection({ + iceServers: this.currentIceConfig.iceServers, + iceTransportPolicy: this.forceRelay ? "relay" : "all", + }); + this.connection = connection; + this.snapshot = { + peerId: hostPeerId, + connectionId, + connectionState: connection.connectionState, + iceConnectionState: connection.iceConnectionState, + metrics: { ...EMPTY_METRICS }, + error: null, + }; + + connection.addEventListener("icecandidate", (event) => { + if (!this.hostPeerId || this.connection !== connection) { + return; + } + this.events.sendSignal({ + kind: "candidate", + connectionId, + candidate: event.candidate + ? { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + usernameFragment: event.candidate.usernameFragment, + } + : null, + }); + }); + connection.addEventListener("track", (event) => { + if (this.connection !== connection) { + return; + } + const sourceStream = event.streams[0]; + if (sourceStream) { + this.remoteStream = sourceStream; + } else if (!this.remoteStream.getTrackById(event.track.id)) { + this.remoteStream.addTrack(event.track); + } + this.events.onStream(this.remoteStream); + }); + connection.addEventListener("connectionstatechange", () => { + if (this.connection !== connection) { + return; + } + this.handleConnectionState(connection.connectionState); + this.emit(); + }); + connection.addEventListener("iceconnectionstatechange", () => this.emit()); + + this.statsTimer = window.setInterval(() => { + void this.updateStats(connection); + }, 2_000); + this.emit(); + } + + private handleConnectionState(state: RTCPeerConnectionState): void { + if (state === "connected") { + this.restartRequested = false; + this.clearDisconnectTimer(); + return; + } + if (state === "failed") { + this.clearDisconnectTimer(); + if (!this.restartRequested) { + this.requestRecovery(); + } + return; + } + if (state === "disconnected" && this.disconnectTimer === null) { + this.disconnectTimer = window.setTimeout(() => { + this.disconnectTimer = null; + if (this.connection?.connectionState === "disconnected") { + this.requestRecovery(); + } + }, 3_000); + } + } + + private queueCandidate( + connectionId: string, + candidate: SignalCandidate, + ): void { + let queue = this.pendingByConnection.get(connectionId); + if (!queue) { + if (this.pendingByConnection.size >= 2) { + const oldestKey = this.pendingByConnection.keys().next().value as + | string + | undefined; + if (oldestKey) { + this.pendingByConnection.delete(oldestKey); + } + } + queue = []; + this.pendingByConnection.set(connectionId, queue); + } + if (queue.length < MAX_PENDING_CANDIDATES) { + queue.push(candidate); + } + } + + private async flushCandidates(connectionId: string): Promise { + if (!this.connection || this.connectionId !== connectionId) { + return; + } + const candidates = this.pendingByConnection.get(connectionId) ?? []; + this.pendingByConnection.delete(connectionId); + for (const candidate of candidates) { + await this.connection.addIceCandidate(candidate); + } + } + + private async updateStats(connection: RTCPeerConnection): Promise { + if (this.disposed || this.connection !== connection) { + return; + } + try { + const metrics = await collectConnectionMetrics( + connection, + "receive", + this.statsAccumulator, + ); + if (this.snapshot) { + this.snapshot = { ...this.snapshot, metrics }; + this.emit(); + } + } catch { + // Stats are observational and must never disrupt a healthy media path. + } + } + + private setError(error: unknown, fallback: string): void { + if (!this.snapshot) { + return; + } + const message = error instanceof Error ? error.message : fallback; + this.snapshot = { ...this.snapshot, error: message || fallback }; + this.emit(); + } + + private emit(): void { + if (this.disposed || !this.connection || !this.snapshot) { + return; + } + this.snapshot = { + ...this.snapshot, + connectionState: this.connection.connectionState, + iceConnectionState: this.connection.iceConnectionState, + }; + this.events.onUpdate({ + ...this.snapshot, + metrics: { ...this.snapshot.metrics }, + }); + } + + private clearDisconnectTimer(): void { + if (this.disconnectTimer !== null) { + window.clearTimeout(this.disconnectTimer); + this.disconnectTimer = null; + } + } + + private disposeConnection(): void { + this.clearDisconnectTimer(); + if (this.statsTimer !== null) { + window.clearInterval(this.statsTimer); + this.statsTimer = null; + } + this.connection?.close(); + this.connection = null; + this.connectionId = null; + this.snapshot = null; + } +} diff --git a/src/server/app.ts b/src/server/app.ts new file mode 100644 index 0000000..fafd318 --- /dev/null +++ b/src/server/app.ts @@ -0,0 +1,283 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { fileURLToPath } from "node:url"; + +import sirv from "sirv"; +import type { ViteDevServer } from "vite"; + +import type { CreateRoomResponse } from "../shared/protocol.js"; +import { loadConfig, secretsEqual, type ServerConfig } from "./config.js"; +import { RoomStore, RoomStoreError } from "./room-store.js"; +import { SignalingServer } from "./signaling.js"; +import { createIceConfig } from "./turn.js"; + +export interface CreateServerOptions { + config?: ServerConfig; + roomStore?: RoomStore; + serveFrontend?: boolean; + staticDirectory?: string; + now?: () => number; + authenticationTimeoutMs?: number; + viewerDisconnectGraceMs?: number; + heartbeatIntervalMs?: number; + cleanupIntervalMs?: number; + maxSignalConnections?: number; + maxUnauthenticatedSignalConnections?: number; +} + +export interface ScreenerServer { + readonly httpServer: ReturnType; + readonly roomStore: RoomStore; + listen(port?: number, host?: string): Promise; + close(): Promise; +} + +type FrontendHandler = (request: IncomingMessage, response: ServerResponse) => void; + +export async function createScreenerServer( + options: CreateServerOptions = {}, +): Promise { + const config = options.config ?? loadConfig(); + const now = options.now ?? Date.now; + const roomStore = + options.roomStore ?? + new RoomStore({ + ttlMs: config.roomTtlMs, + maxRooms: config.maxRooms, + now, + }); + const iceOptions = { + stunUrls: config.stunUrls, + turnUrls: config.turnUrls, + turnSharedSecret: config.turnSharedSecret, + credentialTtlSeconds: config.turnCredentialTtlSeconds, + }; + + let frontendHandler: FrontendHandler | undefined; + let vite: ViteDevServer | undefined; + const httpServer = createServer((request, response) => { + void handleRequest( + request, + response, + config, + roomStore, + iceOptions, + now, + () => frontendHandler, + ).catch(() => { + if (!response.headersSent) { + sendJson(response, 500, { error: "Internal server error" }); + } else { + response.destroy(); + } + }); + }); + + const signaling = new SignalingServer({ + server: httpServer, + roomStore, + ice: iceOptions, + allowedOrigins: config.allowedOrigins, + now, + authenticationTimeoutMs: options.authenticationTimeoutMs, + viewerDisconnectGraceMs: options.viewerDisconnectGraceMs, + heartbeatIntervalMs: options.heartbeatIntervalMs, + cleanupIntervalMs: options.cleanupIntervalMs, + maxConnections: options.maxSignalConnections, + maxUnauthenticatedConnections: + options.maxUnauthenticatedSignalConnections, + passThroughUnknownUpgrades: + config.nodeEnv === "development" && options.serveFrontend !== false, + }); + + if (options.serveFrontend !== false) { + if (config.nodeEnv === "development") { + const { createServer: createViteServer } = await import("vite"); + vite = await createViteServer({ + appType: "spa", + server: { + middlewareMode: true, + hmr: { server: httpServer }, + }, + }); + frontendHandler = (request, response) => { + vite?.middlewares(request, response, () => { + sendJson(response, 404, { error: "Not found" }); + }); + }; + } else if (config.nodeEnv === "production") { + const staticDirectory = + options.staticDirectory ?? + fileURLToPath(new URL("../../client/", import.meta.url)); + const serve = sirv(staticDirectory, { + single: true, + setHeaders(response) { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Referrer-Policy", "no-referrer"); + }, + }); + frontendHandler = (request, response) => { + serve(request, response, () => { + sendJson(response, 404, { error: "Not found" }); + }); + }; + } + } + + httpServer.requestTimeout = 10_000; + httpServer.headersTimeout = 15_000; + httpServer.keepAliveTimeout = 5_000; + + return { + httpServer, + roomStore, + listen(port = config.port, host = "0.0.0.0") { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + httpServer.off("listening", onListening); + reject(error); + }; + const onListening = () => { + httpServer.off("error", onError); + const address = httpServer.address(); + if (!address || typeof address === "string") { + reject(new Error("Server did not bind to a TCP port")); + return; + } + resolve(address.port); + }; + httpServer.once("error", onError); + httpServer.once("listening", onListening); + httpServer.listen(port, host); + }); + }, + async close() { + await signaling.close(); + if (httpServer.listening) { + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + } + await vite?.close(); + }, + }; +} + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + config: ServerConfig, + roomStore: RoomStore, + iceOptions: Parameters[0], + now: () => number, + getFrontendHandler: () => FrontendHandler | undefined, +): Promise { + const url = new URL(request.url ?? "/", config.publicBaseUrl); + if (url.pathname === "/api/rooms") { + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + sendJson(response, 405, { error: "Method not allowed" }); + return; + } + if (!isAllowedRequestOrigin(request.headers.origin, config.allowedOrigins)) { + sendJson(response, 403, { error: "Forbidden" }); + return; + } + if (hasRequestBody(request)) { + sendJson(response, 400, { error: "Request body is not accepted" }); + return; + } + if (!isRoomCreationAuthorized(request, config.roomCreationToken)) { + response.setHeader("WWW-Authenticate", "Bearer"); + sendJson(response, 401, { error: "Unauthorized" }); + return; + } + + try { + const room = roomStore.createRoom(); + const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl); + inviteUrl.hash = new URLSearchParams({ token: room.viewerToken }).toString(); + const responseBody: CreateRoomResponse = { + roomId: room.roomId, + hostToken: room.hostToken, + inviteUrl: inviteUrl.toString(), + expiresAt: room.expiresAt, + iceConfig: createIceConfig( + iceOptions, + `${room.roomId}:host-bootstrap`, + now(), + Date.parse(room.expiresAt), + ), + }; + sendJson(response, 201, responseBody); + } catch (error) { + if (error instanceof RoomStoreError && error.code === "ROOM_LIMIT") { + sendJson(response, 503, { error: "Room capacity reached" }); + return; + } + throw error; + } + return; + } + + if (url.pathname.startsWith("/api/")) { + response.setHeader("Cache-Control", "no-store"); + sendJson(response, 404, { error: "Not found" }); + return; + } + + const frontendHandler = getFrontendHandler(); + if (frontendHandler) { + frontendHandler(request, response); + return; + } + sendJson(response, 404, { error: "Not found" }); +} + +function hasRequestBody(request: IncomingMessage): boolean { + const contentLength = request.headers["content-length"]; + const parsedLength = contentLength === undefined ? 0 : Number(contentLength); + return ( + request.headers["transfer-encoding"] !== undefined || + !Number.isFinite(parsedLength) || + parsedLength !== 0 + ); +} + +function isAllowedRequestOrigin( + origin: string | undefined, + allowedOrigins: ReadonlySet, +): boolean { + if (!origin) { + return true; + } + try { + return allowedOrigins.has(new URL(origin).origin); + } catch { + return false; + } +} + +function isRoomCreationAuthorized( + request: IncomingMessage, + expectedToken: string | undefined, +): boolean { + if (!expectedToken) { + return true; + } + const authorization = request.headers.authorization; + if (!authorization?.startsWith("Bearer ")) { + return false; + } + const provided = authorization.slice("Bearer ".length); + return provided.length > 0 && secretsEqual(provided, expectedToken); +} + +function sendJson(response: ServerResponse, status: number, body: unknown): void { + const encoded = JSON.stringify(body); + response.statusCode = status; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Content-Length", Buffer.byteLength(encoded)); + response.end(encoded); +} diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..ba1e669 --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,200 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +export type RuntimeEnvironment = "development" | "test" | "production"; + +const MAX_TURN_CREDENTIAL_TTL_SECONDS = 3_600; +const MIN_PRODUCTION_SECRET_BYTES = 32; + +export interface ServerConfig { + nodeEnv: RuntimeEnvironment; + port: number; + publicBaseUrl: URL; + allowedOrigins: ReadonlySet; + roomCreationToken?: string; + roomTtlMs: number; + maxRooms: number; + stunUrls: readonly string[]; + turnUrls: readonly string[]; + turnSharedSecret?: string; + turnCredentialTtlSeconds: number; +} + +function parsePositiveInteger( + value: string | undefined, + fallback: number, + name: string, +): number { + const parsed = value === undefined || value === "" ? fallback : Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function parseBoundedInteger( + value: string | undefined, + fallback: number, + name: string, + minimum: number, + maximum: number, +): number { + const parsed = parsePositiveInteger(value, fallback, name); + if (parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be between ${minimum} and ${maximum}`); + } + return parsed; +} + +function parseEnvironment(value: string | undefined): RuntimeEnvironment { + const environment = value ?? "development"; + if ( + environment !== "development" && + environment !== "test" && + environment !== "production" + ) { + throw new Error("NODE_ENV must be development, test, or production"); + } + return environment; +} + +function parseUrlList(value: string | undefined, name: string): string[] { + if (!value?.trim()) { + return []; + } + + return value.split(",").map((entry) => { + const url = entry.trim(); + if (!url) { + throw new Error(`${name} contains an empty URL`); + } + return url; + }); +} + +function parseIceUrlList( + value: string | undefined, + name: string, + allowedProtocols: ReadonlySet, +): string[] { + return parseUrlList(value, name).map((value) => { + let protocol: string; + try { + protocol = new URL(value).protocol; + } catch { + throw new Error(`${name} contains an invalid URL`); + } + if (!allowedProtocols.has(protocol)) { + throw new Error(`${name} contains an unsupported URL scheme`); + } + return value; + }); +} + +function parseOrigins(value: string | undefined, fallback: string): Set { + const origins = parseUrlList(value, "ALLOWED_ORIGINS"); + return new Set((origins.length > 0 ? origins : [fallback]).map(toOrigin)); +} + +function toOrigin(value: string): string { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Allowed origins must use http or https"); + } + return url.origin; +} + +export function loadConfig( + environment: NodeJS.ProcessEnv = process.env, +): ServerConfig { + const nodeEnv = parseEnvironment(environment.NODE_ENV); + const port = parsePositiveInteger(environment.PORT, 8787, "PORT"); + if (port > 65_535) { + throw new Error("PORT must be at most 65535"); + } + + const publicBaseUrl = new URL( + environment.PUBLIC_BASE_URL ?? `http://localhost:${port}`, + ); + if ( + publicBaseUrl.protocol !== "http:" && + publicBaseUrl.protocol !== "https:" + ) { + throw new Error("PUBLIC_BASE_URL must use http or https"); + } + if (nodeEnv === "production" && publicBaseUrl.protocol !== "https:") { + throw new Error("PUBLIC_BASE_URL must use https in production"); + } + + const roomCreationToken = environment.ROOM_CREATION_TOKEN?.trim() || undefined; + const turnSharedSecret = environment.TURN_SHARED_SECRET?.trim() || undefined; + const stunUrls = parseIceUrlList( + environment.STUN_URLS, + "STUN_URLS", + new Set(["stun:", "stuns:"]), + ); + const turnUrls = parseIceUrlList( + environment.TURN_URLS, + "TURN_URLS", + new Set(["turn:", "turns:"]), + ); + + if ((turnUrls.length > 0) !== Boolean(turnSharedSecret)) { + throw new Error( + "TURN_URLS and TURN_SHARED_SECRET must either both be configured or both be absent", + ); + } + if (nodeEnv === "production" && !roomCreationToken) { + throw new Error("ROOM_CREATION_TOKEN is required in production"); + } + if ( + nodeEnv === "production" && + roomCreationToken && + Buffer.byteLength(roomCreationToken) < MIN_PRODUCTION_SECRET_BYTES + ) { + throw new Error("ROOM_CREATION_TOKEN must contain at least 32 bytes in production"); + } + if (nodeEnv === "production" && turnUrls.length === 0) { + throw new Error("TURN is required in production"); + } + if (nodeEnv === "production" && stunUrls.length === 0) { + throw new Error("STUN is required in production"); + } + if ( + nodeEnv === "production" && + turnSharedSecret && + Buffer.byteLength(turnSharedSecret) < MIN_PRODUCTION_SECRET_BYTES + ) { + throw new Error("TURN_SHARED_SECRET must contain at least 32 bytes in production"); + } + + return { + nodeEnv, + port, + publicBaseUrl, + allowedOrigins: parseOrigins( + environment.ALLOWED_ORIGINS, + publicBaseUrl.origin, + ), + roomCreationToken, + roomTtlMs: + parsePositiveInteger(environment.ROOM_TTL_SECONDS, 14_400, "ROOM_TTL_SECONDS") * + 1_000, + maxRooms: parsePositiveInteger(environment.MAX_ROOMS, 1_000, "MAX_ROOMS"), + stunUrls, + turnUrls, + turnSharedSecret, + turnCredentialTtlSeconds: parseBoundedInteger( + environment.TURN_CREDENTIAL_TTL_SECONDS, + 3_600, + "TURN_CREDENTIAL_TTL_SECONDS", + 60, + MAX_TURN_CREDENTIAL_TTL_SECONDS, + ), + }; +} + +export function secretsEqual(actual: string, expected: string): boolean { + const actualDigest = createHash("sha256").update(actual).digest(); + const expectedDigest = createHash("sha256").update(expected).digest(); + return timingSafeEqual(actualDigest, expectedDigest); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..1d13e3d --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,42 @@ +import { loadEnvFile } from "node:process"; + +import { createScreenerServer } from "./app.js"; +import { loadConfig } from "./config.js"; + +try { + loadEnvFile(); +} catch (error) { + if (!isMissingFileError(error)) { + throw error; + } +} + +const config = loadConfig(); +const server = await createScreenerServer({ config }); +const port = await server.listen(); + +console.log(`Screener is listening on ${config.publicBaseUrl.origin} (port ${port})`); + +let shuttingDown = false; +async function shutdown(): Promise { + if (shuttingDown) { + return; + } + shuttingDown = true; + await server.close(); +} + +process.once("SIGINT", () => { + void shutdown().then(() => process.exit(0)); +}); +process.once("SIGTERM", () => { + void shutdown().then(() => process.exit(0)); +}); + +function isMissingFileError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} diff --git a/src/server/room-store.ts b/src/server/room-store.ts new file mode 100644 index 0000000..083756d --- /dev/null +++ b/src/server/room-store.ts @@ -0,0 +1,350 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +import { MAX_VIEWERS, type Role } from "../shared/protocol.js"; + +export type RoomStoreErrorCode = + | "INVALID_TOKEN" + | "ROOM_CLOSED" + | "ROOM_EXPIRED" + | "ROOM_FULL" + | "HOST_ALREADY_CONNECTED" + | "ROOM_LIMIT"; + +export class RoomStoreError extends Error { + constructor(public readonly code: RoomStoreErrorCode) { + super(code); + this.name = "RoomStoreError"; + } +} + +interface Participant { + clientId: string; + peerId: string; + sessionId?: string; +} + +interface Room { + roomId: string; + hostTokenDigest: Buffer; + viewerTokenDigest: Buffer; + expiresAtMs: number; + host?: Participant; + viewers: Map; +} + +export interface CreatedRoom { + roomId: string; + hostToken: string; + viewerToken: string; + expiresAt: string; +} + +export interface ConnectParticipantInput { + roomId: string; + role: Role; + token: string; + clientId: string; + sessionId: string; +} + +export interface ConnectedParticipant { + roomId: string; + role: Role; + peerId: string; + expiresAt: string; + hostOnline: boolean; + isNewParticipant: boolean; + replacedSessionId?: string; + viewerPeerIds: readonly string[]; +} + +export interface DisconnectedParticipant { + roomId: string; + role: Role; + peerId: string; +} + +export interface ConnectedPeer { + peerId: string; + sessionId: string; +} + +export interface ClosedRoom { + roomId: string; + sessionIds: readonly string[]; +} + +export interface RoomStoreOptions { + ttlMs: number; + maxRooms: number; + now?: () => number; + random?: (size: number) => Buffer; +} + +export class RoomStore { + private readonly rooms = new Map(); + private readonly now: () => number; + private readonly random: (size: number) => Buffer; + + constructor(private readonly options: RoomStoreOptions) { + if (!Number.isSafeInteger(options.ttlMs) || options.ttlMs <= 0) { + throw new Error("Room TTL must be a positive integer"); + } + if (!Number.isSafeInteger(options.maxRooms) || options.maxRooms <= 0) { + throw new Error("Room limit must be a positive integer"); + } + this.now = options.now ?? Date.now; + this.random = options.random ?? randomBytes; + } + + createRoom(): CreatedRoom { + if (this.rooms.size >= this.options.maxRooms) { + throw new RoomStoreError("ROOM_LIMIT"); + } + + const roomId = this.uniqueId(16, (candidate) => this.rooms.has(candidate)); + const hostToken = this.random(32).toString("base64url"); + const viewerToken = this.random(32).toString("base64url"); + const expiresAtMs = this.now() + this.options.ttlMs; + + this.rooms.set(roomId, { + roomId, + hostTokenDigest: digest(hostToken), + viewerTokenDigest: digest(viewerToken), + expiresAtMs, + viewers: new Map(), + }); + + return { + roomId, + hostToken, + viewerToken, + expiresAt: new Date(expiresAtMs).toISOString(), + }; + } + + connectParticipant(input: ConnectParticipantInput): ConnectedParticipant { + const room = this.getAvailableRoom(input.roomId); + const expectedDigest = + input.role === "host" ? room.hostTokenDigest : room.viewerTokenDigest; + if (!verifyDigest(input.token, expectedDigest)) { + throw new RoomStoreError("INVALID_TOKEN"); + } + + if (input.role === "host") { + return this.connectHost(room, input); + } + return this.connectViewer(room, input); + } + + disconnectParticipant( + roomId: string, + peerId: string, + sessionId: string, + ): DisconnectedParticipant | undefined { + const room = this.rooms.get(roomId); + if (!room) { + return undefined; + } + + if (room.host?.peerId === peerId) { + if (room.host.sessionId !== sessionId) { + return undefined; + } + room.host.sessionId = undefined; + return { roomId, role: "host", peerId }; + } + + const viewer = findViewerByPeerId(room, peerId); + if (!viewer || viewer.sessionId !== sessionId) { + return undefined; + } + viewer.sessionId = undefined; + return { roomId, role: "viewer", peerId }; + } + + removeDisconnectedViewer(roomId: string, peerId: string): boolean { + const room = this.rooms.get(roomId); + if (!room) { + return false; + } + const viewer = findViewerByPeerId(room, peerId); + if (!viewer || viewer.sessionId) { + return false; + } + return room.viewers.delete(viewer.clientId); + } + + getConnectedHost(roomId: string): ConnectedPeer | undefined { + const host = this.rooms.get(roomId)?.host; + return host?.sessionId + ? { peerId: host.peerId, sessionId: host.sessionId } + : undefined; + } + + getConnectedViewer(roomId: string, peerId: string): ConnectedPeer | undefined { + const room = this.rooms.get(roomId); + const viewer = room ? findViewerByPeerId(room, peerId) : undefined; + return viewer?.sessionId ? { peerId, sessionId: viewer.sessionId } : undefined; + } + + getConnectedViewers(roomId: string): ConnectedPeer[] { + const room = this.rooms.get(roomId); + if (!room) { + return []; + } + return [...room.viewers.values()].flatMap((viewer) => + viewer.sessionId + ? [{ peerId: viewer.peerId, sessionId: viewer.sessionId }] + : [], + ); + } + + closeRoom(roomId: string): ClosedRoom | undefined { + const room = this.rooms.get(roomId); + if (!room) { + return undefined; + } + const sessionIds = connectedSessionIds(room); + this.rooms.delete(roomId); + return { roomId, sessionIds }; + } + + expireRooms(nowMs = this.now()): ClosedRoom[] { + const expired: ClosedRoom[] = []; + for (const [roomId, room] of this.rooms) { + if (room.expiresAtMs > nowMs) { + continue; + } + expired.push({ roomId, sessionIds: connectedSessionIds(room) }); + this.rooms.delete(roomId); + } + return expired; + } + + get size(): number { + return this.rooms.size; + } + + private connectHost( + room: Room, + input: ConnectParticipantInput, + ): ConnectedParticipant { + const current = room.host; + if ( + current?.sessionId && + current.clientId !== input.clientId + ) { + throw new RoomStoreError("HOST_ALREADY_CONNECTED"); + } + + const isNewParticipant = !current || current.clientId !== input.clientId; + const participant = isNewParticipant + ? { + clientId: input.clientId, + peerId: this.newPeerId(room), + } + : current; + const replacedSessionId = participant.sessionId; + participant.sessionId = input.sessionId; + room.host = participant; + + return { + roomId: room.roomId, + role: "host", + peerId: participant.peerId, + expiresAt: new Date(room.expiresAtMs).toISOString(), + hostOnline: true, + isNewParticipant, + replacedSessionId: + replacedSessionId === input.sessionId ? undefined : replacedSessionId, + // Disconnected viewers remain room members until their grace period ends. + viewerPeerIds: [...room.viewers.values()].map((viewer) => viewer.peerId), + }; + } + + private connectViewer( + room: Room, + input: ConnectParticipantInput, + ): ConnectedParticipant { + const current = room.viewers.get(input.clientId); + if (!current && room.viewers.size >= MAX_VIEWERS) { + throw new RoomStoreError("ROOM_FULL"); + } + + const participant = + current ?? + ({ + clientId: input.clientId, + peerId: this.newPeerId(room), + } satisfies Participant); + const replacedSessionId = participant.sessionId; + participant.sessionId = input.sessionId; + room.viewers.set(input.clientId, participant); + + return { + roomId: room.roomId, + role: "viewer", + peerId: participant.peerId, + expiresAt: new Date(room.expiresAtMs).toISOString(), + hostOnline: Boolean(room.host?.sessionId), + isNewParticipant: current === undefined, + replacedSessionId: + replacedSessionId === input.sessionId ? undefined : replacedSessionId, + viewerPeerIds: [], + }; + } + + private getAvailableRoom(roomId: string): Room { + const room = this.rooms.get(roomId); + if (!room) { + // A single response avoids turning room IDs into an enumeration oracle. + throw new RoomStoreError("INVALID_TOKEN"); + } + if (room.expiresAtMs <= this.now()) { + throw new RoomStoreError("ROOM_EXPIRED"); + } + return room; + } + + private newPeerId(room: Room): string { + return this.uniqueId( + 16, + (candidate) => + room.host?.peerId === candidate || + [...room.viewers.values()].some((viewer) => viewer.peerId === candidate), + ); + } + + private uniqueId(size: number, exists: (candidate: string) => boolean): string { + for (let attempt = 0; attempt < 8; attempt += 1) { + const candidate = this.random(size).toString("base64url"); + if (!exists(candidate)) { + return candidate; + } + } + throw new Error("Unable to allocate a unique identifier"); + } +} + +function findViewerByPeerId(room: Room, peerId: string): Participant | undefined { + return [...room.viewers.values()].find((viewer) => viewer.peerId === peerId); +} + +function connectedSessionIds(room: Room): string[] { + const sessionIds = room.host?.sessionId ? [room.host.sessionId] : []; + for (const viewer of room.viewers.values()) { + if (viewer.sessionId) { + sessionIds.push(viewer.sessionId); + } + } + return sessionIds; +} + +function digest(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +function verifyDigest(value: string, expectedDigest: Buffer): boolean { + return timingSafeEqual(digest(value), expectedDigest); +} diff --git a/src/server/signaling.ts b/src/server/signaling.ts new file mode 100644 index 0000000..9981795 --- /dev/null +++ b/src/server/signaling.ts @@ -0,0 +1,651 @@ +import { randomBytes } from "node:crypto"; +import type { Server as HttpServer } from "node:http"; +import type { Duplex } from "node:stream"; + +import WebSocket, { WebSocketServer } from "ws"; + +import { + MAX_SIGNAL_BYTES, + MAX_VIEWERS, + decodeClientMessage, + type ClientMessage, + type Role, + type ServerMessage, +} from "../shared/protocol.js"; +import { RoomStore, RoomStoreError } from "./room-store.js"; +import { createIceConfig, type IceConfigOptions } from "./turn.js"; + +type ErrorCode = Extract["code"]; + +const MAX_BUFFERED_SIGNAL_BYTES = 256 * 1024; +const DEFAULT_MAX_SIGNAL_CONNECTIONS = 2_048; +const DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS = 256; + +interface AuthenticatedSession { + roomId: string; + role: Role; + peerId: string; + roomExpiresAtMs: number; +} + +interface SocketState { + sessionId: string; + alive: boolean; + authenticationTimer: NodeJS.Timeout; + authenticated?: AuthenticatedSession; +} + +export interface SignalingOptions { + server: HttpServer; + roomStore: RoomStore; + ice: IceConfigOptions; + allowedOrigins: ReadonlySet; + now?: () => number; + authenticationTimeoutMs?: number; + viewerDisconnectGraceMs?: number; + heartbeatIntervalMs?: number; + cleanupIntervalMs?: number; + passThroughUnknownUpgrades?: boolean; + maxConnections?: number; + maxUnauthenticatedConnections?: number; +} + +export class SignalingServer { + private readonly webSocketServer = new WebSocketServer({ + noServer: true, + maxPayload: MAX_SIGNAL_BYTES, + perMessageDeflate: false, + }); + private readonly socketStates = new Map(); + private readonly socketsBySessionId = new Map(); + private readonly viewerGraceTimers = new Map(); + private readonly connectionIdsByViewer = new Map(); + private readonly now: () => number; + private readonly authenticationTimeoutMs: number; + private readonly viewerDisconnectGraceMs: number; + private readonly heartbeatTimer: NodeJS.Timeout; + private readonly cleanupTimer: NodeJS.Timeout; + private readonly upgradeHandler: Parameters[1]; + private readonly maxConnections: number; + private readonly maxUnauthenticatedConnections: number; + private unauthenticatedConnections = 0; + private closing = false; + + constructor(private readonly options: SignalingOptions) { + this.now = options.now ?? Date.now; + this.authenticationTimeoutMs = options.authenticationTimeoutMs ?? 5_000; + this.viewerDisconnectGraceMs = options.viewerDisconnectGraceMs ?? 5_000; + this.maxConnections = options.maxConnections ?? DEFAULT_MAX_SIGNAL_CONNECTIONS; + this.maxUnauthenticatedConnections = + options.maxUnauthenticatedConnections ?? + DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS; + if ( + !Number.isSafeInteger(this.maxConnections) || + this.maxConnections <= 0 || + !Number.isSafeInteger(this.maxUnauthenticatedConnections) || + this.maxUnauthenticatedConnections <= 0 || + this.maxUnauthenticatedConnections > this.maxConnections + ) { + throw new Error("WebSocket connection limits are invalid"); + } + + this.upgradeHandler = (request, socket, head) => { + let requestUrl: URL; + try { + requestUrl = new URL(request.url ?? "/", "http://localhost"); + } catch { + rejectUpgrade(socket, 400, "Bad Request"); + return; + } + if (requestUrl.pathname !== "/signal" || requestUrl.search !== "") { + if (!options.passThroughUnknownUpgrades) { + rejectUpgrade(socket, 404, "Not Found"); + } + return; + } + if (!this.isAllowedOrigin(request.headers.origin)) { + rejectUpgrade(socket, 403, "Forbidden"); + return; + } + if (!this.hasConnectionCapacity()) { + rejectUpgrade(socket, 503, "Service Unavailable"); + return; + } + // No asynchronous verifyClient hook is installed: handleUpgrade emits + // connection synchronously, so accept() counts this slot before another + // upgrade event can observe the limit. + this.webSocketServer.handleUpgrade(request, socket, head, (webSocket) => { + this.webSocketServer.emit("connection", webSocket, request); + }); + }; + options.server.on("upgrade", this.upgradeHandler); + this.webSocketServer.on("connection", (socket) => this.accept(socket)); + + this.heartbeatTimer = setInterval( + () => this.heartbeat(), + options.heartbeatIntervalMs ?? 30_000, + ); + this.cleanupTimer = setInterval( + () => this.expireRooms(), + options.cleanupIntervalMs ?? 30_000, + ); + this.heartbeatTimer.unref(); + this.cleanupTimer.unref(); + } + + async close(): Promise { + this.closing = true; + clearInterval(this.heartbeatTimer); + clearInterval(this.cleanupTimer); + for (const timer of this.viewerGraceTimers.values()) { + clearTimeout(timer); + } + this.viewerGraceTimers.clear(); + this.options.server.off("upgrade", this.upgradeHandler); + + for (const socket of this.webSocketServer.clients) { + socket.terminate(); + } + await new Promise((resolve) => { + this.webSocketServer.close(() => resolve()); + }); + } + + private accept(socket: WebSocket): void { + if (!this.hasConnectionCapacity()) { + socket.terminate(); + return; + } + + const sessionId = randomBytes(16).toString("base64url"); + const authenticationTimer = setTimeout(() => { + this.sendError(socket, "AUTH_REQUIRED", "Authentication timed out"); + socket.close(4001, "Authentication required"); + }, this.authenticationTimeoutMs); + authenticationTimer.unref(); + + const state: SocketState = { + sessionId, + alive: true, + authenticationTimer, + }; + this.socketStates.set(socket, state); + this.socketsBySessionId.set(sessionId, socket); + this.unauthenticatedConnections += 1; + + socket.on("pong", () => { + state.alive = true; + }); + socket.on("message", (data, isBinary) => { + if (isBinary) { + this.rejectInvalidMessage(socket); + return; + } + this.handleMessage(socket, data.toString()); + }); + socket.on("close", () => this.handleDisconnect(socket)); + socket.on("error", () => { + // The close handler owns cleanup. Never log signaling payloads or tokens. + }); + } + + private handleMessage(socket: WebSocket, encoded: string): void { + let message: ClientMessage; + try { + message = decodeClientMessage(encoded); + } catch { + this.rejectInvalidMessage(socket); + return; + } + + const state = this.socketStates.get(socket); + if (!state) { + return; + } + if (!state.authenticated) { + if (message.type !== "authenticate") { + this.sendError(socket, "AUTH_REQUIRED", "Authenticate before sending messages"); + socket.close(4001, "Authentication required"); + return; + } + this.authenticate(socket, state, message); + return; + } + if (message.type === "authenticate") { + this.sendError(socket, "FORBIDDEN", "Session is already authenticated"); + return; + } + + if (this.now() >= state.authenticated.roomExpiresAtMs) { + this.expireRooms(); + return; + } + if (!this.isCurrentSession(state)) { + socket.close(4001, "Session replaced"); + return; + } + + this.handleAuthenticatedMessage(socket, state.authenticated, message); + } + + private authenticate( + socket: WebSocket, + state: SocketState, + message: Extract, + ): void { + let participant; + try { + participant = this.options.roomStore.connectParticipant({ + roomId: message.roomId, + role: message.role, + token: message.token, + clientId: message.clientId, + sessionId: state.sessionId, + }); + } catch (error) { + const code = + error instanceof RoomStoreError && error.code !== "ROOM_LIMIT" + ? error.code + : "SERVER_ERROR"; + this.sendError(socket, code, authenticationErrorMessage(code)); + socket.close(4003, "Authentication failed"); + return; + } + + clearTimeout(state.authenticationTimer); + this.unauthenticatedConnections -= 1; + state.authenticated = { + roomId: participant.roomId, + role: participant.role, + peerId: participant.peerId, + roomExpiresAtMs: Date.parse(participant.expiresAt), + }; + this.clearViewerGrace(participant.roomId, participant.peerId); + const connectionId = + participant.role === "viewer" + ? (this.connectionIdsByViewer.get( + viewerConnectionKey(participant.roomId, participant.peerId), + ) ?? null) + : null; + const connectedViewers = + participant.role === "host" + ? this.options.roomStore.getConnectedViewers(participant.roomId) + : []; + + this.send(socket, { + type: "authenticated", + role: participant.role, + peerId: participant.peerId, + roomExpiresAt: participant.expiresAt, + maxViewers: MAX_VIEWERS, + hostOnline: participant.hostOnline, + connectionId, + viewerPeerIds: [...participant.viewerPeerIds], + iceConfig: this.iceConfig( + participant.roomId, + participant.peerId, + Date.parse(participant.expiresAt), + ), + }); + + if (participant.replacedSessionId) { + const replaced = this.socketsBySessionId.get(participant.replacedSessionId); + if (replaced && replaced !== socket) { + replaced.close(4001, "Session replaced"); + } + } + + if (participant.role === "host") { + for (const viewer of connectedViewers) { + this.sendToSession(viewer.sessionId, { type: "host-status", online: true }); + this.send(socket, { type: "peer-joined", peerId: viewer.peerId }); + } + return; + } + + const host = this.options.roomStore.getConnectedHost(participant.roomId); + if (!host) { + return; + } + // Re-authentication is also a presence assertion. The host treats this + // event idempotently, which closes races where its local peer was rebuilt + // while the viewer retained the same server-side connection ID. + this.sendToSession(host.sessionId, { + type: "peer-joined", + peerId: participant.peerId, + }); + } + + private handleAuthenticatedMessage( + socket: WebSocket, + authenticated: AuthenticatedSession, + message: Exclude, + ): void { + switch (message.type) { + case "signal": + this.routeSignal(socket, authenticated, message); + return; + case "restart-request": + if (authenticated.role !== "viewer") { + this.sendError(socket, "FORBIDDEN", "Only viewers may request an ICE restart"); + return; + } + this.routeToHost(socket, authenticated, { + type: "restart-request", + fromPeerId: authenticated.peerId, + connectionId: message.connectionId, + rebuild: message.rebuild, + }); + return; + case "refresh-ice": + this.send(socket, { + type: "ice-config", + iceConfig: this.iceConfig( + authenticated.roomId, + authenticated.peerId, + authenticated.roomExpiresAtMs, + ), + }); + return; + case "close-room": + if (authenticated.role !== "host") { + this.sendError(socket, "FORBIDDEN", "Only the host may close the room"); + return; + } + this.closeRoom(authenticated.roomId); + return; + } + } + + private routeSignal( + sourceSocket: WebSocket, + source: AuthenticatedSession, + message: Extract, + ): void { + const description = + message.payload.kind === "description" ? message.payload.description : undefined; + + if (source.role === "host") { + if (!message.targetPeerId || description?.type === "answer") { + this.sendError(sourceSocket, "FORBIDDEN", "Host signals must target one viewer"); + return; + } + const viewer = this.options.roomStore.getConnectedViewer( + source.roomId, + message.targetPeerId, + ); + if (!viewer) { + this.sendError(sourceSocket, "PEER_NOT_FOUND", "Viewer is not connected"); + return; + } + if (description?.type === "offer") { + this.connectionIdsByViewer.set( + viewerConnectionKey(source.roomId, viewer.peerId), + message.payload.connectionId, + ); + } + this.sendToSession(viewer.sessionId, { + type: "signal", + fromPeerId: source.peerId, + payload: message.payload, + }); + return; + } + + if (message.targetPeerId || description?.type === "offer") { + this.sendError(sourceSocket, "FORBIDDEN", "Viewer signals may only reply to the host"); + return; + } + this.routeToHost(sourceSocket, source, { + type: "signal", + fromPeerId: source.peerId, + payload: message.payload, + }); + } + + private routeToHost( + sourceSocket: WebSocket, + source: AuthenticatedSession, + message: ServerMessage, + ): void { + const host = this.options.roomStore.getConnectedHost(source.roomId); + if (!host) { + this.sendError(sourceSocket, "PEER_NOT_FOUND", "Host is not connected"); + return; + } + this.sendToSession(host.sessionId, message); + } + + private handleDisconnect(socket: WebSocket): void { + const state = this.socketStates.get(socket); + if (!state) { + return; + } + clearTimeout(state.authenticationTimer); + this.socketStates.delete(socket); + if (this.socketsBySessionId.get(state.sessionId) === socket) { + this.socketsBySessionId.delete(state.sessionId); + } + if (!state.authenticated) { + this.unauthenticatedConnections -= 1; + return; + } + if (this.closing) { + return; + } + + const disconnected = this.options.roomStore.disconnectParticipant( + state.authenticated.roomId, + state.authenticated.peerId, + state.sessionId, + ); + if (!disconnected) { + return; + } + if (disconnected.role === "host") { + for (const viewer of this.options.roomStore.getConnectedViewers( + disconnected.roomId, + )) { + this.sendToSession(viewer.sessionId, { type: "host-status", online: false }); + } + return; + } + + const key = viewerGraceKey(disconnected.roomId, disconnected.peerId); + const timer = setTimeout(() => { + this.viewerGraceTimers.delete(key); + if ( + this.options.roomStore.removeDisconnectedViewer( + disconnected.roomId, + disconnected.peerId, + ) + ) { + this.connectionIdsByViewer.delete( + viewerConnectionKey(disconnected.roomId, disconnected.peerId), + ); + const host = this.options.roomStore.getConnectedHost(disconnected.roomId); + if (host) { + this.sendToSession(host.sessionId, { + type: "peer-left", + peerId: disconnected.peerId, + }); + } + } + }, this.viewerDisconnectGraceMs); + timer.unref(); + this.viewerGraceTimers.set(key, timer); + } + + private closeRoom(roomId: string): void { + const closed = this.options.roomStore.closeRoom(roomId); + if (!closed) { + return; + } + this.clearRoomGraceTimers(roomId); + this.clearRoomConnectionIds(roomId); + for (const sessionId of closed.sessionIds) { + const socket = this.socketsBySessionId.get(sessionId); + if (!socket) { + continue; + } + this.send(socket, { type: "room-closed", reason: "host-ended" }); + socket.close(1000, "Room closed"); + } + } + + private expireRooms(): void { + for (const expired of this.options.roomStore.expireRooms(this.now())) { + this.clearRoomGraceTimers(expired.roomId); + this.clearRoomConnectionIds(expired.roomId); + for (const sessionId of expired.sessionIds) { + const socket = this.socketsBySessionId.get(sessionId); + if (!socket) { + continue; + } + this.send(socket, { type: "room-closed", reason: "expired" }); + socket.close(1000, "Room expired"); + } + } + } + + private heartbeat(): void { + for (const [socket, state] of this.socketStates) { + if (!state.alive) { + socket.terminate(); + continue; + } + state.alive = false; + socket.ping(); + } + } + + private isCurrentSession(state: SocketState): boolean { + const authenticated = state.authenticated; + if (!authenticated) { + return false; + } + const current = + authenticated.role === "host" + ? this.options.roomStore.getConnectedHost(authenticated.roomId) + : this.options.roomStore.getConnectedViewer( + authenticated.roomId, + authenticated.peerId, + ); + return current?.sessionId === state.sessionId; + } + + private isAllowedOrigin(origin: string | undefined): boolean { + if (!origin) { + return false; + } + try { + return this.options.allowedOrigins.has(new URL(origin).origin); + } catch { + return false; + } + } + + private hasConnectionCapacity(): boolean { + return ( + !this.closing && + this.socketStates.size < this.maxConnections && + this.unauthenticatedConnections < this.maxUnauthenticatedConnections + ); + } + + private iceConfig(roomId: string, peerId: string, roomExpiresAtMs: number) { + return createIceConfig( + this.options.ice, + `${roomId}:${peerId}`, + this.now(), + roomExpiresAtMs, + ); + } + + private sendToSession(sessionId: string, message: ServerMessage): void { + const socket = this.socketsBySessionId.get(sessionId); + if (socket) { + this.send(socket, message); + } + } + + private send(socket: WebSocket, message: ServerMessage): void { + if (socket.readyState !== WebSocket.OPEN) { + return; + } + if (socket.bufferedAmount > MAX_BUFFERED_SIGNAL_BYTES) { + socket.terminate(); + return; + } + socket.send(JSON.stringify(message)); + } + + private sendError(socket: WebSocket, code: ErrorCode, message: string): void { + this.send(socket, { type: "error", code, message }); + } + + private rejectInvalidMessage(socket: WebSocket): void { + this.sendError(socket, "INVALID_MESSAGE", "Message is invalid"); + socket.close(1008, "Invalid message"); + } + + private clearViewerGrace(roomId: string, peerId: string): void { + const key = viewerGraceKey(roomId, peerId); + const timer = this.viewerGraceTimers.get(key); + if (timer) { + clearTimeout(timer); + this.viewerGraceTimers.delete(key); + } + } + + private clearRoomGraceTimers(roomId: string): void { + const prefix = `${roomId}:`; + for (const [key, timer] of this.viewerGraceTimers) { + if (key.startsWith(prefix)) { + clearTimeout(timer); + this.viewerGraceTimers.delete(key); + } + } + } + + private clearRoomConnectionIds(roomId: string): void { + const prefix = `${roomId}:`; + for (const key of this.connectionIdsByViewer.keys()) { + if (key.startsWith(prefix)) { + this.connectionIdsByViewer.delete(key); + } + } + } +} + +function viewerGraceKey(roomId: string, peerId: string): string { + return `${roomId}:${peerId}`; +} + +function viewerConnectionKey(roomId: string, peerId: string): string { + return `${roomId}:${peerId}`; +} + +function authenticationErrorMessage(code: ErrorCode): string { + switch (code) { + case "ROOM_CLOSED": + return "Room is closed"; + case "ROOM_EXPIRED": + return "Room has expired"; + case "ROOM_FULL": + return "Room is full"; + case "HOST_ALREADY_CONNECTED": + return "A host is already connected"; + case "INVALID_TOKEN": + return "Room or token is invalid"; + default: + return "Authentication failed"; + } +} + +function rejectUpgrade(socket: Duplex, status: number, message: string): void { + socket.write( + `HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ); + socket.destroy(); +} diff --git a/src/server/turn.ts b/src/server/turn.ts new file mode 100644 index 0000000..74fa854 --- /dev/null +++ b/src/server/turn.ts @@ -0,0 +1,108 @@ +import { createHmac } from "node:crypto"; + +import type { IceConfig } from "../shared/protocol.js"; + +export interface IceConfigOptions { + stunUrls: readonly string[]; + turnUrls: readonly string[]; + turnSharedSecret?: string; + credentialTtlSeconds: number; +} + +export interface TurnCredentials { + username: string; + credential: string; + expiresAt: string; +} + +export function createTurnCredentials( + sharedSecret: string, + subject: string, + ttlSeconds: number, + nowMs = Date.now(), + notAfterMs?: number, +): TurnCredentials { + if (!sharedSecret) { + throw new Error("TURN shared secret is required"); + } + if (!subject || subject.includes("\n") || subject.includes("\r")) { + throw new Error("TURN credential subject is invalid"); + } + if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) { + throw new Error("TURN credential TTL must be a positive integer"); + } + + const nowSeconds = Math.floor(nowMs / 1_000); + const requestedExpiry = nowSeconds + ttlSeconds; + const expiresAtSeconds = + notAfterMs === undefined + ? requestedExpiry + : Math.min(requestedExpiry, Math.floor(notAfterMs / 1_000)); + if (expiresAtSeconds <= nowSeconds) { + throw new Error("TURN credential lifetime has elapsed"); + } + const username = `${expiresAtSeconds}:${subject}`; + const credential = createHmac("sha1", sharedSecret) + .update(username) + .digest("base64"); + + return { + username, + credential, + expiresAt: new Date(expiresAtSeconds * 1_000).toISOString(), + }; +} + +export function createIceConfig( + options: IceConfigOptions, + subject: string, + nowMs = Date.now(), + notAfterMs?: number, +): IceConfig { + const iceServers: IceConfig["iceServers"] = []; + + if (options.stunUrls.length > 0) { + iceServers.push({ urls: [...options.stunUrls] }); + } + + if (options.turnUrls.length === 0) { + return { + iceServers, + expiresAt: null, + relayAvailable: false, + }; + } + if (!options.turnSharedSecret) { + throw new Error("TURN shared secret is required when TURN URLs are configured"); + } + + if ( + notAfterMs !== undefined && + Math.floor(notAfterMs / 1_000) <= Math.floor(nowMs / 1_000) + ) { + return { + iceServers, + expiresAt: null, + relayAvailable: false, + }; + } + + const credentials = createTurnCredentials( + options.turnSharedSecret, + subject, + options.credentialTtlSeconds, + nowMs, + notAfterMs, + ); + iceServers.push({ + urls: [...options.turnUrls], + username: credentials.username, + credential: credentials.credential, + }); + + return { + iceServers, + expiresAt: credentials.expiresAt, + relayAvailable: true, + }; +} diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts new file mode 100644 index 0000000..8893d3e --- /dev/null +++ b/src/shared/protocol.ts @@ -0,0 +1,203 @@ +import { z } from "zod"; + +export const MAX_VIEWERS = 3; +export const MAX_SIGNAL_BYTES = 64 * 1024; + +const opaqueIdSchema = z + .string() + .min(8) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/); + +const tokenSchema = z + .string() + .min(32) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/); + +export const roleSchema = z.enum(["host", "viewer"]); +export type Role = z.infer; + +const iceServerSchema = z + .object({ + urls: z.union([ + z.string().min(1).max(512), + z.array(z.string().min(1).max(512)).min(1).max(8), + ]), + username: z.string().max(512).optional(), + credential: z.string().max(512).optional(), + }) + .strict(); + +export const iceConfigSchema = z + .object({ + iceServers: z.array(iceServerSchema).max(8), + expiresAt: z.string().datetime().nullable(), + relayAvailable: z.boolean(), + }) + .strict(); +export type IceConfig = z.infer; + +const sessionDescriptionSchema = z + .object({ + type: z.enum(["offer", "answer"]), + sdp: z.string().min(1).max(48 * 1024), + }) + .strict(); + +const iceCandidateSchema = z + .object({ + candidate: z.string().max(4096), + sdpMid: z.string().max(128).nullable().optional(), + sdpMLineIndex: z.number().int().min(0).max(255).nullable().optional(), + usernameFragment: z.string().max(256).nullable().optional(), + }) + .strict(); + +export const signalPayloadSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("description"), + connectionId: opaqueIdSchema, + description: sessionDescriptionSchema, + }) + .strict(), + z + .object({ + kind: z.literal("candidate"), + connectionId: opaqueIdSchema, + candidate: iceCandidateSchema.nullable(), + }) + .strict(), +]); +export type SignalPayload = z.infer; + +export const clientMessageSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("authenticate"), + roomId: opaqueIdSchema, + role: roleSchema, + token: tokenSchema, + clientId: opaqueIdSchema, + }) + .strict(), + z + .object({ + type: z.literal("signal"), + targetPeerId: opaqueIdSchema.optional(), + payload: signalPayloadSchema, + }) + .strict(), + z + .object({ + type: z.literal("restart-request"), + connectionId: opaqueIdSchema, + rebuild: z.boolean(), + }) + .strict(), + z.object({ type: z.literal("refresh-ice") }).strict(), + z.object({ type: z.literal("close-room") }).strict(), +]); +export type ClientMessage = z.infer; + +const errorCodeSchema = z.enum([ + "AUTH_REQUIRED", + "INVALID_MESSAGE", + "INVALID_TOKEN", + "ROOM_CLOSED", + "ROOM_EXPIRED", + "ROOM_FULL", + "HOST_ALREADY_CONNECTED", + "PEER_NOT_FOUND", + "FORBIDDEN", + "SERVER_ERROR", +]); + +export const serverMessageSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("authenticated"), + role: roleSchema, + peerId: opaqueIdSchema, + roomExpiresAt: z.string().datetime(), + maxViewers: z.literal(MAX_VIEWERS), + hostOnline: z.boolean(), + connectionId: opaqueIdSchema.nullable(), + viewerPeerIds: z.array(opaqueIdSchema).max(MAX_VIEWERS), + iceConfig: iceConfigSchema, + }) + .strict(), + z + .object({ + type: z.literal("peer-joined"), + peerId: opaqueIdSchema, + }) + .strict(), + z + .object({ + type: z.literal("peer-left"), + peerId: opaqueIdSchema, + }) + .strict(), + z + .object({ + type: z.literal("signal"), + fromPeerId: opaqueIdSchema, + payload: signalPayloadSchema, + }) + .strict(), + z + .object({ + type: z.literal("restart-request"), + fromPeerId: opaqueIdSchema, + connectionId: opaqueIdSchema, + rebuild: z.boolean(), + }) + .strict(), + z + .object({ + type: z.literal("ice-config"), + iceConfig: iceConfigSchema, + }) + .strict(), + z + .object({ + type: z.literal("host-status"), + online: z.boolean(), + }) + .strict(), + z + .object({ + type: z.literal("room-closed"), + reason: z.enum(["host-ended", "expired"]), + }) + .strict(), + z + .object({ + type: z.literal("error"), + code: errorCodeSchema, + message: z.string().min(1).max(256), + }) + .strict(), +]); +export type ServerMessage = z.infer; + +export const createRoomResponseSchema = z + .object({ + roomId: opaqueIdSchema, + hostToken: tokenSchema, + inviteUrl: z.string().url().max(2048), + expiresAt: z.string().datetime(), + iceConfig: iceConfigSchema, + }) + .strict(); +export type CreateRoomResponse = z.infer; + +export function decodeClientMessage(value: string): ClientMessage { + return clientMessageSchema.parse(JSON.parse(value)); +} + +export function decodeServerMessage(value: string): ServerMessage { + return serverMessageSchema.parse(JSON.parse(value)); +} diff --git a/tests/client-state.test.ts b/tests/client-state.test.ts new file mode 100644 index 0000000..043784d --- /dev/null +++ b/tests/client-state.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { shouldReconnectSignaling } from "../src/client/lib/signaling.ts"; +import { createStatsAccumulator, collectConnectionMetrics } from "../src/client/webrtc/stats.ts"; + +describe("client signaling recovery policy", () => { + it("does not reconnect a session that another tab replaced", () => { + expect(shouldReconnectSignaling(4001)).toBe(false); + expect(shouldReconnectSignaling(1006)).toBe(true); + }); +}); + +describe("WebRTC stats parsing", () => { + it("separates the local TURN transport from the ICE protocol", async () => { + const report = new Map>([ + [ + "transport", + { + id: "transport", + type: "transport", + timestamp: 2_000, + selectedCandidatePairId: "pair", + }, + ], + [ + "pair", + { + id: "pair", + type: "candidate-pair", + timestamp: 2_000, + state: "succeeded", + nominated: true, + localCandidateId: "local", + remoteCandidateId: "remote", + }, + ], + [ + "local", + { + id: "local", + type: "local-candidate", + timestamp: 2_000, + candidateType: "relay", + protocol: "udp", + relayProtocol: "tls", + }, + ], + [ + "remote", + { + id: "remote", + type: "remote-candidate", + timestamp: 2_000, + candidateType: "host", + protocol: "udp", + }, + ], + [ + "inbound", + { + id: "inbound", + type: "inbound-rtp", + timestamp: 2_000, + kind: "video", + bytesReceived: 2_000, + framesDecoded: 100, + totalDecodeTime: 0.5, + }, + ], + ]); + const connection = { + getStats: async () => report as unknown as RTCStatsReport, + } as unknown as RTCPeerConnection; + + const metrics = await collectConnectionMetrics( + connection, + "receive", + createStatsAccumulator(), + ); + + expect(metrics).toMatchObject({ + path: "relay", + iceProtocol: "udp", + localRelayProtocol: "tls", + localCandidateType: "relay", + remoteCandidateType: "host", + averageDecodeMs: 5, + }); + }); + + it("does not infer a remote TURN transport", async () => { + const report = new Map([ + [ + "transport", + { + id: "transport", + type: "transport", + timestamp: 1_000, + selectedCandidatePairId: "pair", + }, + ], + [ + "pair", + { + id: "pair", + type: "candidate-pair", + timestamp: 1_000, + localCandidateId: "local", + remoteCandidateId: "remote", + state: "succeeded", + nominated: true, + }, + ], + [ + "local", + { + id: "local", + type: "local-candidate", + timestamp: 1_000, + candidateType: "host", + protocol: "udp", + }, + ], + [ + "remote", + { + id: "remote", + type: "remote-candidate", + timestamp: 1_000, + candidateType: "relay", + protocol: "udp", + }, + ], + ]) as unknown as RTCStatsReport; + const connection = { + getStats: async () => report, + } as unknown as RTCPeerConnection; + + const metrics = await collectConnectionMetrics( + connection, + "receive", + createStatsAccumulator(), + ); + + expect(metrics).toMatchObject({ + path: "relay", + iceProtocol: "udp", + localRelayProtocol: null, + localCandidateType: "host", + remoteCandidateType: "relay", + }); + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts new file mode 100644 index 0000000..6e2a74e --- /dev/null +++ b/tests/protocol.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_VIEWERS, + clientMessageSchema, + decodeClientMessage, + serverMessageSchema, +} from "../src/shared/protocol.js"; + +const token = "a".repeat(43); + +describe("client signaling protocol", () => { + it("accepts a bounded authentication message", () => { + expect( + decodeClientMessage( + JSON.stringify({ + type: "authenticate", + roomId: "room_12345678", + role: "viewer", + token, + clientId: "client_12345678", + }), + ), + ).toMatchObject({ type: "authenticate", role: "viewer" }); + }); + + it("rejects unknown fields and malformed tokens", () => { + const result = clientMessageSchema.safeParse({ + type: "authenticate", + roomId: "room_12345678", + role: "host", + token: "short", + clientId: "client_12345678", + admin: true, + }); + + expect(result.success).toBe(false); + }); + + it("bounds SDP before routing it", () => { + const result = clientMessageSchema.safeParse({ + type: "signal", + targetPeerId: "viewer_12345678", + payload: { + kind: "description", + connectionId: "connection_12345678", + description: { + type: "offer", + sdp: "v".repeat(48 * 1024 + 1), + }, + }, + }); + + expect(result.success).toBe(false); + }); + + it("requires viewers to choose ICE restart or peer rebuild explicitly", () => { + const restartRequest = { + type: "restart-request", + connectionId: "connection_12345678", + rebuild: true, + }; + + expect(clientMessageSchema.safeParse(restartRequest).success).toBe(true); + expect( + clientMessageSchema.safeParse({ + type: restartRequest.type, + connectionId: restartRequest.connectionId, + }).success, + ).toBe(false); + }); +}); + +describe("server signaling protocol", () => { + it("keeps the viewer limit in the wire contract", () => { + const result = serverMessageSchema.safeParse({ + type: "authenticated", + role: "host", + peerId: "host_12345678", + roomExpiresAt: "2026-08-18T18:00:00.000Z", + maxViewers: MAX_VIEWERS, + hostOnline: true, + connectionId: null, + viewerPeerIds: [], + iceConfig: { + iceServers: [], + expiresAt: null, + relayAvailable: false, + }, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/tests/room-store.test.ts b/tests/room-store.test.ts new file mode 100644 index 0000000..b1e920d --- /dev/null +++ b/tests/room-store.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; + +import { + RoomStore, + RoomStoreError, +} from "../src/server/room-store.ts"; + +function expectRoomError(action: () => unknown, code: RoomStoreError["code"]): void { + try { + action(); + throw new Error("Expected RoomStoreError"); + } catch (error) { + expect(error).toBeInstanceOf(RoomStoreError); + expect((error as RoomStoreError).code).toBe(code); + } +} + +describe("RoomStore", () => { + it("creates a four-hour room and authenticates both roles", () => { + const now = Date.UTC(2026, 7, 18, 12); + const store = new RoomStore({ ttlMs: 14_400_000, maxRooms: 10, now: () => now }); + const room = store.createRoom(); + + expect(room.hostToken).not.toBe(room.viewerToken); + expect(room.expiresAt).toBe(new Date(now + 14_400_000).toISOString()); + expect( + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: "viewer-client-1", + sessionId: "viewer-session-1", + }), + ).toMatchObject({ role: "viewer", hostOnline: false, isNewParticipant: true }); + expect( + store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "host-client-1", + sessionId: "host-session-1", + }), + ).toMatchObject({ + role: "host", + hostOnline: true, + viewerPeerIds: expect.any(Array), + }); + }); + + it("rejects the wrong role token without exposing which value failed", () => { + const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + const room = store.createRoom(); + + expectRoomError( + () => + store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.viewerToken, + clientId: "host-client-1", + sessionId: "host-session-1", + }), + "INVALID_TOKEN", + ); + }); + + it("keeps peer identity stable and ignores disconnect from a replaced socket", () => { + const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + const room = store.createRoom(); + const first = store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: "stable-viewer-client", + sessionId: "viewer-session-old", + }); + const replacement = store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: "stable-viewer-client", + sessionId: "viewer-session-new", + }); + + expect(replacement.peerId).toBe(first.peerId); + expect(replacement.isNewParticipant).toBe(false); + expect(replacement.replacedSessionId).toBe("viewer-session-old"); + expect( + store.disconnectParticipant(room.roomId, first.peerId, "viewer-session-old"), + ).toBeUndefined(); + expect(store.getConnectedViewer(room.roomId, first.peerId)?.sessionId).toBe( + "viewer-session-new", + ); + }); + + it("reserves at most three viewer slots until a disconnected viewer is released", () => { + const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + const room = store.createRoom(); + const viewers = [1, 2, 3].map((number) => + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: `viewer-client-${number}`, + sessionId: `viewer-session-${number}`, + }), + ); + + expectRoomError( + () => + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: "viewer-client-4", + sessionId: "viewer-session-4", + }), + "ROOM_FULL", + ); + + store.disconnectParticipant(room.roomId, viewers[0].peerId, "viewer-session-1"); + expectRoomError( + () => + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: "viewer-client-4", + sessionId: "viewer-session-4", + }), + "ROOM_FULL", + ); + expect(store.removeDisconnectedViewer(room.roomId, viewers[0].peerId)).toBe(true); + expect( + store.connectParticipant({ + roomId: room.roomId, + role: "viewer", + token: room.viewerToken, + clientId: "viewer-client-4", + sessionId: "viewer-session-4", + }).isNewParticipant, + ).toBe(true); + }); + + it("allows only the same host identity to replace an online host", () => { + const store = new RoomStore({ ttlMs: 10_000, maxRooms: 10 }); + const room = store.createRoom(); + const first = store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "stable-host-client", + sessionId: "host-session-old", + }); + const replacement = store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "stable-host-client", + sessionId: "host-session-new", + }); + + expect(replacement.peerId).toBe(first.peerId); + expect(replacement.replacedSessionId).toBe("host-session-old"); + expectRoomError( + () => + store.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "different-host-client", + sessionId: "different-host-session", + }), + "HOST_ALREADY_CONNECTED", + ); + }); + + it("expires, closes, and globally bounds rooms", () => { + let now = 1_000; + const expiringStore = new RoomStore({ ttlMs: 100, maxRooms: 1, now: () => now }); + const room = expiringStore.createRoom(); + expectRoomError(() => expiringStore.createRoom(), "ROOM_LIMIT"); + + now = 1_100; + expectRoomError( + () => + expiringStore.connectParticipant({ + roomId: room.roomId, + role: "host", + token: room.hostToken, + clientId: "host-client-1", + sessionId: "host-session-1", + }), + "ROOM_EXPIRED", + ); + expect(expiringStore.expireRooms()).toHaveLength(1); + expect(expiringStore.size).toBe(0); + + const openRoom = expiringStore.createRoom(); + expect(expiringStore.closeRoom(openRoom.roomId)).toBeDefined(); + expect(expiringStore.size).toBe(0); + expectRoomError( + () => + expiringStore.connectParticipant({ + roomId: openRoom.roomId, + role: "viewer", + token: openRoom.viewerToken, + clientId: "viewer-client-1", + sessionId: "viewer-session-1", + }), + "INVALID_TOKEN", + ); + expect(expiringStore.createRoom()).toBeDefined(); + }); +}); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts new file mode 100644 index 0000000..062deff --- /dev/null +++ b/tests/server-config.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { loadConfig } from "../src/server/config.ts"; + +describe("server configuration", () => { + it("allows development without TURN and defaults the origin", () => { + const config = loadConfig({ NODE_ENV: "development", PORT: "9123" }); + + expect(config.publicBaseUrl.href).toBe("http://localhost:9123/"); + expect(config.allowedOrigins).toEqual(new Set(["http://localhost:9123"])); + expect(config.turnUrls).toEqual([]); + }); + + it("requires room creation authorization and TURN in production", () => { + expect(() => + loadConfig({ NODE_ENV: "production", PUBLIC_BASE_URL: "https://share.test" }), + ).toThrow("ROOM_CREATION_TOKEN"); + + expect(() => + loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ROOM_CREATION_TOKEN: "c".repeat(32), + }), + ).toThrow("TURN is required"); + + expect(() => + loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ROOM_CREATION_TOKEN: "c".repeat(32), + TURN_URLS: "turn:turn.test:3478", + TURN_SHARED_SECRET: "t".repeat(32), + }), + ).toThrow("STUN is required"); + }); + + it("accepts complete production configuration", () => { + const config = loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ROOM_CREATION_TOKEN: "c".repeat(32), + STUN_URLS: "stun:turn.test:3478,stuns:turn.test:5349", + TURN_URLS: "turn:turn.test:3478,turns:turn.test:5349", + TURN_SHARED_SECRET: "t".repeat(32), + }); + + expect(config.turnUrls).toHaveLength(2); + expect(config.turnSharedSecret).toBe("t".repeat(32)); + }); + + it("rejects weak production secrets", () => { + expect(() => + loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ROOM_CREATION_TOKEN: "too-short", + STUN_URLS: "stun:turn.test:3478", + TURN_URLS: "turn:turn.test:3478", + TURN_SHARED_SECRET: "t".repeat(32), + }), + ).toThrow("ROOM_CREATION_TOKEN must contain at least 32 bytes"); + + expect(() => + loadConfig({ + NODE_ENV: "production", + PUBLIC_BASE_URL: "https://share.test", + ROOM_CREATION_TOKEN: "c".repeat(32), + STUN_URLS: "stun:turn.test:3478", + TURN_URLS: "turn:turn.test:3478", + TURN_SHARED_SECRET: "too-short", + }), + ).toThrow("TURN_SHARED_SECRET must contain at least 32 bytes"); + }); + + it("rejects partial TURN settings and mismatched ICE schemes", () => { + expect(() => loadConfig({ TURN_URLS: "turn:turn.test:3478" })).toThrow( + "TURN_URLS and TURN_SHARED_SECRET", + ); + expect(() => loadConfig({ STUN_URLS: "https://stun.test" })).toThrow( + "STUN_URLS contains an unsupported URL scheme", + ); + expect(() => + loadConfig({ + TURN_URLS: "stun:turn.test:3478", + TURN_SHARED_SECRET: "turn-secret", + }), + ).toThrow("TURN_URLS contains an unsupported URL scheme"); + expect(() => loadConfig({ TURN_CREDENTIAL_TTL_SECONDS: "3601" })).toThrow( + "TURN_CREDENTIAL_TTL_SECONDS must be between 60 and 3600", + ); + }); +}); diff --git a/tests/server-http.test.ts b/tests/server-http.test.ts new file mode 100644 index 0000000..a337caa --- /dev/null +++ b/tests/server-http.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { createRoomResponseSchema } from "../src/shared/protocol.ts"; +import { + createScreenerServer, + type ScreenerServer, +} from "../src/server/app.ts"; +import type { ServerConfig } from "../src/server/config.ts"; + +const allowedOrigin = "http://allowed.test"; +let runningServer: ScreenerServer | undefined; + +afterEach(async () => { + await runningServer?.close(); + runningServer = undefined; +}); + +function testConfig(overrides: Partial = {}): ServerConfig { + return { + nodeEnv: "test", + port: 0, + publicBaseUrl: new URL("https://share.example.test"), + allowedOrigins: new Set([allowedOrigin]), + roomCreationToken: "create-secret", + roomTtlMs: 14_400_000, + maxRooms: 10, + stunUrls: [], + turnUrls: [], + turnCredentialTtlSeconds: 3_600, + ...overrides, + }; +} + +async function start(config = testConfig()): Promise { + runningServer = await createScreenerServer({ config, serveFrontend: false }); + const port = await runningServer.listen(0, "127.0.0.1"); + return `http://127.0.0.1:${port}`; +} + +describe("room HTTP API", () => { + it("creates a no-store room response with an invite fragment", async () => { + const baseUrl = await start(); + const response = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { + Authorization: "Bearer create-secret", + Origin: allowedOrigin, + }, + }); + + expect(response.status).toBe(201); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = createRoomResponseSchema.parse(await response.json()); + const invite = new URL(body.inviteUrl); + expect(invite.origin).toBe("https://share.example.test"); + expect(invite.pathname).toBe(`/r/${body.roomId}`); + expect(new URLSearchParams(invite.hash.slice(1)).get("token")).toHaveLength(43); + expect(invite.search).toBe(""); + expect(body.iceConfig.relayAvailable).toBe(false); + }); + + it("requires the configured creation token", async () => { + const baseUrl = await start(); + const response = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { Origin: allowedOrigin }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe("Bearer"); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + }); + + it("rejects a request body and a foreign browser origin", async () => { + const baseUrl = await start(); + const bodyResponse = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { + Authorization: "Bearer create-secret", + "Content-Type": "application/json", + Origin: allowedOrigin, + }, + body: "{}", + }); + expect(bodyResponse.status).toBe(400); + + const originResponse = await fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { + Authorization: "Bearer create-secret", + Origin: "https://foreign.example.test", + }, + }); + expect(originResponse.status).toBe(403); + }); + + it("returns service unavailable at the global room bound", async () => { + const baseUrl = await start(testConfig({ maxRooms: 1 })); + const create = () => + fetch(`${baseUrl}/api/rooms`, { + method: "POST", + headers: { + Authorization: "Bearer create-secret", + Origin: allowedOrigin, + }, + }); + + expect((await create()).status).toBe(201); + const full = await create(); + expect(full.status).toBe(503); + expect(await full.json()).toEqual({ error: "Room capacity reached" }); + }); +}); diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts new file mode 100644 index 0000000..c5fb9eb --- /dev/null +++ b/tests/server-signal.test.ts @@ -0,0 +1,680 @@ +import { connect } from "node:net"; + +import { afterEach, describe, expect, it } from "vitest"; +import WebSocket from "ws"; + +import { + MAX_SIGNAL_BYTES, + decodeServerMessage, + type Role, + type ServerMessage, +} from "../src/shared/protocol.ts"; +import { + createScreenerServer, + type ScreenerServer, +} from "../src/server/app.ts"; +import type { ServerConfig } from "../src/server/config.ts"; +import { RoomStore, type CreatedRoom } from "../src/server/room-store.ts"; + +const allowedOrigin = "http://allowed.test"; +let runningServer: ScreenerServer | undefined; + +afterEach(async () => { + await runningServer?.close(); + runningServer = undefined; +}); + +class MessageInbox { + private readonly messages: ServerMessage[] = []; + private readonly waiters: Array<{ + type: ServerMessage["type"]; + resolve: (message: ServerMessage) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + }> = []; + + constructor(socket: WebSocket) { + socket.on("message", (data) => { + const message = decodeServerMessage(data.toString()); + const waiterIndex = this.waiters.findIndex((waiter) => waiter.type === message.type); + if (waiterIndex === -1) { + this.messages.push(message); + return; + } + const [waiter] = this.waiters.splice(waiterIndex, 1); + clearTimeout(waiter.timer); + waiter.resolve(message); + }); + } + + next( + type: Type, + timeoutMs = 1_000, + ): Promise> { + const existingIndex = this.messages.findIndex((message) => message.type === type); + if (existingIndex !== -1) { + const [message] = this.messages.splice(existingIndex, 1); + return Promise.resolve(message as Extract); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const waiterIndex = this.waiters.findIndex((waiter) => waiter.timer === timer); + if (waiterIndex !== -1) { + this.waiters.splice(waiterIndex, 1); + } + reject(new Error(`Timed out waiting for ${type}`)); + }, timeoutMs); + this.waiters.push({ + type, + resolve: (message) => + resolve(message as Extract), + reject, + timer, + }); + }); + } + + async expectNone(timeoutMs: number): Promise { + if (this.messages.length > 0) { + throw new Error(`Unexpected message: ${this.messages[0].type}`); + } + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + if (this.messages.length > 0) { + throw new Error(`Unexpected message: ${this.messages[0].type}`); + } + } +} + +interface TestClient { + socket: WebSocket; + inbox: MessageInbox; +} + +interface SignalHarness { + webSocketUrl: string; + roomStore: RoomStore; + room: CreatedRoom; +} + +function testConfig(): ServerConfig { + return { + nodeEnv: "test", + port: 0, + publicBaseUrl: new URL("https://share.example.test"), + allowedOrigins: new Set([allowedOrigin]), + roomTtlMs: 14_400_000, + maxRooms: 10, + stunUrls: [], + turnUrls: [], + turnCredentialTtlSeconds: 3_600, + }; +} + +async function startHarness( + overrides: { + authenticationTimeoutMs?: number; + viewerDisconnectGraceMs?: number; + maxSignalConnections?: number; + maxUnauthenticatedSignalConnections?: number; + } = {}, +): Promise { + const config = testConfig(); + const roomStore = new RoomStore({ + ttlMs: config.roomTtlMs, + maxRooms: config.maxRooms, + }); + const room = roomStore.createRoom(); + runningServer = await createScreenerServer({ + config, + roomStore, + serveFrontend: false, + authenticationTimeoutMs: overrides.authenticationTimeoutMs ?? 500, + viewerDisconnectGraceMs: overrides.viewerDisconnectGraceMs ?? 50, + heartbeatIntervalMs: 60_000, + cleanupIntervalMs: 60_000, + maxSignalConnections: overrides.maxSignalConnections, + maxUnauthenticatedSignalConnections: + overrides.maxUnauthenticatedSignalConnections, + }); + const port = await runningServer.listen(0, "127.0.0.1"); + return { webSocketUrl: `ws://127.0.0.1:${port}/signal`, roomStore, room }; +} + +async function openClient(webSocketUrl: string): Promise { + const socket = new WebSocket(webSocketUrl, { origin: allowedOrigin }); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + return { socket, inbox: new MessageInbox(socket) }; +} + +async function rejectedUpgradeStatus(webSocketUrl: string): Promise { + const socket = new WebSocket(webSocketUrl, { origin: allowedOrigin }); + socket.on("error", () => { + // The HTTP status is asserted through unexpected-response below. + }); + return new Promise((resolve, reject) => { + socket.once("open", () => reject(new Error("WebSocket upgrade unexpectedly succeeded"))); + socket.once("unexpected-response", (_request, response) => { + const status = response.statusCode; + response.resume(); + resolve(status); + }); + }); +} + +async function rawUpgradeResponse( + webSocketUrl: string, + requestTarget: string, +): Promise { + const url = new URL(webSocketUrl); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const socket = connect(Number(url.port), url.hostname); + socket.setTimeout(2_000, () => socket.destroy(new Error("Raw upgrade timed out"))); + socket.on("error", reject); + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("close", () => resolve(Buffer.concat(chunks).toString("utf8"))); + socket.on("connect", () => { + socket.write( + [ + `GET ${requestTarget} HTTP/1.1`, + `Host: ${url.host}`, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version: 13", + `Origin: ${allowedOrigin}`, + "", + "", + ].join("\r\n"), + ); + }); + }); +} + +async function authenticate( + client: TestClient, + room: CreatedRoom, + role: Role, + clientId: string, +) { + client.socket.send( + JSON.stringify({ + type: "authenticate", + roomId: room.roomId, + role, + token: role === "host" ? room.hostToken : room.viewerToken, + clientId, + }), + ); + return client.inbox.next("authenticated"); +} + +async function closeClient(client: TestClient): Promise { + if (client.socket.readyState === WebSocket.CLOSED) { + return; + } + const closed = new Promise((resolve) => client.socket.once("close", () => resolve())); + client.socket.close(); + await closed; +} + +describe("WebSocket signaling", () => { + it("lets viewers arrive first and replays their snapshot when the host joins", async () => { + const harness = await startHarness(); + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = await authenticate( + viewer, + harness.room, + "viewer", + "viewer-client-early", + ); + expect(viewerAuth.hostOnline).toBe(false); + + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "host-client-stable"); + expect((await host.inbox.next("peer-joined")).peerId).toBe(viewerAuth.peerId); + expect(await viewer.inbox.next("host-status")).toEqual({ + type: "host-status", + online: true, + }); + }); + + it("routes offer and answer only between the host and the targeted viewer", async () => { + const harness = await startHarness(); + const secondRoom = harness.roomStore.createRoom(); + const host = await openClient(harness.webSocketUrl); + const hostAuth = await authenticate(host, harness.room, "host", "host-client-a"); + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = await authenticate(viewer, harness.room, "viewer", "viewer-client-a"); + await host.inbox.next("peer-joined"); + const foreignViewer = await openClient(harness.webSocketUrl); + const foreignAuth = await authenticate( + foreignViewer, + secondRoom, + "viewer", + "viewer-client-b", + ); + + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: viewerAuth.peerId, + payload: { + kind: "description", + connectionId: "connection-a", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + expect(await viewer.inbox.next("signal")).toMatchObject({ + fromPeerId: hostAuth.peerId, + payload: { description: { type: "offer" } }, + }); + + viewer.socket.send( + JSON.stringify({ + type: "signal", + payload: { + kind: "description", + connectionId: "connection-a", + description: { type: "answer", sdp: "v=0\r\n" }, + }, + }), + ); + expect(await host.inbox.next("signal")).toMatchObject({ + fromPeerId: viewerAuth.peerId, + payload: { description: { type: "answer" } }, + }); + + viewer.socket.send( + JSON.stringify({ + type: "signal", + payload: { + kind: "description", + connectionId: "connection-b", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN"); + + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: foreignAuth.peerId, + payload: { + kind: "candidate", + connectionId: "connection-a", + candidate: null, + }, + }), + ); + expect((await host.inbox.next("error")).code).toBe("PEER_NOT_FOUND"); + await foreignViewer.inbox.expectNone(30); + }); + + it("keeps a viewer peer stable when it reconnects inside the grace period", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 80 }); + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "host-client-stable"); + const firstViewer = await openClient(harness.webSocketUrl); + const firstAuth = await authenticate( + firstViewer, + harness.room, + "viewer", + "viewer-client-stable", + ); + await host.inbox.next("peer-joined"); + host.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstAuth.peerId, + payload: { + kind: "description", + connectionId: "stable-connection", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await firstViewer.inbox.next("signal"); + + await closeClient(firstViewer); + const reconnectedViewer = await openClient(harness.webSocketUrl); + const reconnectedAuth = await authenticate( + reconnectedViewer, + harness.room, + "viewer", + "viewer-client-stable", + ); + expect(reconnectedAuth.peerId).toBe(firstAuth.peerId); + expect(reconnectedAuth.connectionId).toBe("stable-connection"); + expect((await host.inbox.next("peer-joined")).peerId).toBe(firstAuth.peerId); + + reconnectedViewer.socket.send( + JSON.stringify({ + type: "restart-request", + connectionId: reconnectedAuth.connectionId, + rebuild: false, + }), + ); + expect(await host.inbox.next("restart-request")).toMatchObject({ + fromPeerId: firstAuth.peerId, + connectionId: "stable-connection", + rebuild: false, + }); + await host.inbox.expectNone(110); + + await closeClient(reconnectedViewer); + expect((await host.inbox.next("peer-left", 500)).peerId).toBe(firstAuth.peerId); + }); + + it("atomically replaces the same client socket without leave churn", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 40 }); + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "host-client-stable"); + const original = await openClient(harness.webSocketUrl); + const originalAuth = await authenticate( + original, + harness.room, + "viewer", + "viewer-client-stable", + ); + await host.inbox.next("peer-joined"); + + const originalClosed = new Promise((resolve) => + original.socket.once("close", (code) => resolve(code)), + ); + const replacement = await openClient(harness.webSocketUrl); + const replacementAuth = await authenticate( + replacement, + harness.room, + "viewer", + "viewer-client-stable", + ); + + expect(replacementAuth.peerId).toBe(originalAuth.peerId); + expect(await originalClosed).toBe(4001); + expect((await host.inbox.next("peer-joined")).peerId).toBe(originalAuth.peerId); + await host.inbox.expectNone(70); + }); + + it("notifies the host when a pre-offer viewer reconnects inside grace", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 300 }); + const originalViewer = await openClient(harness.webSocketUrl); + const originalAuth = await authenticate( + originalViewer, + harness.room, + "viewer", + "viewer-client-before-offer", + ); + await closeClient(originalViewer); + + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "host-client-stable"); + await host.inbox.expectNone(30); + + const reconnectedViewer = await openClient(harness.webSocketUrl); + const reconnectedAuth = await authenticate( + reconnectedViewer, + harness.room, + "viewer", + "viewer-client-before-offer", + ); + expect(reconnectedAuth.peerId).toBe(originalAuth.peerId); + expect(await host.inbox.next("peer-joined")).toMatchObject({ + peerId: originalAuth.peerId, + }); + }); + + it("replays viewers when a host reconnects", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 500 }); + const viewer = await openClient(harness.webSocketUrl); + const viewerAuth = await authenticate( + viewer, + harness.room, + "viewer", + "viewer-client-stable", + ); + const firstHost = await openClient(harness.webSocketUrl); + const firstHostAuth = await authenticate( + firstHost, + harness.room, + "host", + "host-client-stable", + ); + await firstHost.inbox.next("peer-joined"); + await viewer.inbox.next("host-status"); + firstHost.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: viewerAuth.peerId, + payload: { + kind: "description", + connectionId: "connection-before-control-outage", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await viewer.inbox.next("signal"); + + await closeClient(firstHost); + expect(await viewer.inbox.next("host-status")).toMatchObject({ online: false }); + await closeClient(viewer); + const refreshedViewer = await openClient(harness.webSocketUrl); + const refreshedViewerAuth = await authenticate( + refreshedViewer, + harness.room, + "viewer", + "viewer-client-stable", + ); + expect(refreshedViewerAuth.peerId).toBe(viewerAuth.peerId); + expect(refreshedViewerAuth.hostOnline).toBe(false); + + const secondHost = await openClient(harness.webSocketUrl); + const secondHostAuth = await authenticate( + secondHost, + harness.room, + "host", + "host-client-stable", + ); + expect(secondHostAuth.peerId).toBe(firstHostAuth.peerId); + expect(secondHostAuth.viewerPeerIds).toEqual([viewerAuth.peerId]); + expect((await secondHost.inbox.next("peer-joined")).peerId).toBe( + viewerAuth.peerId, + ); + await secondHost.inbox.expectNone(30); + expect(await refreshedViewer.inbox.next("host-status")).toMatchObject({ + online: true, + }); + }); + + it("reconciles an empty roster after a viewer grace period ends offline", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 30 }); + const firstHost = await openClient(harness.webSocketUrl); + const firstHostAuth = await authenticate( + firstHost, + harness.room, + "host", + "host-client-roster", + ); + const viewer = await openClient(harness.webSocketUrl); + await authenticate(viewer, harness.room, "viewer", "viewer-client-roster"); + await firstHost.inbox.next("peer-joined"); + + await closeClient(firstHost); + await closeClient(viewer); + await new Promise((resolve) => setTimeout(resolve, 70)); + + const secondHost = await openClient(harness.webSocketUrl); + const secondHostAuth = await authenticate( + secondHost, + harness.room, + "host", + "host-client-roster", + ); + expect(secondHostAuth.peerId).toBe(firstHostAuth.peerId); + expect(secondHostAuth.viewerPeerIds).toEqual([]); + await secondHost.inbox.expectNone(30); + }); + + it("keeps a grace-period viewer in the host roster and announces its return", async () => { + const harness = await startHarness({ viewerDisconnectGraceMs: 500 }); + const firstHost = await openClient(harness.webSocketUrl); + await authenticate(firstHost, harness.room, "host", "host-client-grace-roster"); + const firstViewer = await openClient(harness.webSocketUrl); + const firstViewerAuth = await authenticate( + firstViewer, + harness.room, + "viewer", + "viewer-client-grace-roster", + ); + await firstHost.inbox.next("peer-joined"); + firstHost.socket.send( + JSON.stringify({ + type: "signal", + targetPeerId: firstViewerAuth.peerId, + payload: { + kind: "description", + connectionId: "connection-during-grace", + description: { type: "offer", sdp: "v=0\r\n" }, + }, + }), + ); + await firstViewer.inbox.next("signal"); + + await closeClient(firstViewer); + await closeClient(firstHost); + + const secondHost = await openClient(harness.webSocketUrl); + const secondHostAuth = await authenticate( + secondHost, + harness.room, + "host", + "host-client-grace-roster", + ); + expect(secondHostAuth.viewerPeerIds).toEqual([firstViewerAuth.peerId]); + await secondHost.inbox.expectNone(30); + + const secondViewer = await openClient(harness.webSocketUrl); + const secondViewerAuth = await authenticate( + secondViewer, + harness.room, + "viewer", + "viewer-client-grace-roster", + ); + expect(secondViewerAuth.peerId).toBe(firstViewerAuth.peerId); + expect(secondViewerAuth.connectionId).toBe("connection-during-grace"); + expect((await secondHost.inbox.next("peer-joined")).peerId).toBe( + firstViewerAuth.peerId, + ); + + secondViewer.socket.send( + JSON.stringify({ + type: "restart-request", + connectionId: secondViewerAuth.connectionId, + rebuild: false, + }), + ); + expect(await secondHost.inbox.next("restart-request")).toMatchObject({ + fromPeerId: firstViewerAuth.peerId, + connectionId: "connection-during-grace", + rebuild: false, + }); + }); + + it("rejects a fourth viewer and lets the host close the room", async () => { + const harness = await startHarness(); + const host = await openClient(harness.webSocketUrl); + await authenticate(host, harness.room, "host", "host-client-stable"); + const viewers: TestClient[] = []; + for (let index = 1; index <= 3; index += 1) { + const viewer = await openClient(harness.webSocketUrl); + await authenticate(viewer, harness.room, "viewer", `viewer-client-${index}`); + await host.inbox.next("peer-joined"); + viewers.push(viewer); + } + + const fourth = await openClient(harness.webSocketUrl); + fourth.socket.send( + JSON.stringify({ + type: "authenticate", + roomId: harness.room.roomId, + role: "viewer", + token: harness.room.viewerToken, + clientId: "viewer-client-4", + }), + ); + expect((await fourth.inbox.next("error")).code).toBe("ROOM_FULL"); + + host.socket.send(JSON.stringify({ type: "close-room" })); + expect(await host.inbox.next("room-closed")).toMatchObject({ reason: "host-ended" }); + for (const viewer of viewers) { + expect(await viewer.inbox.next("room-closed")).toMatchObject({ + reason: "host-ended", + }); + } + }); + + it("requires an allowed Origin, timely authentication, and bounded payloads", async () => { + const harness = await startHarness({ authenticationTimeoutMs: 30 }); + const foreign = new WebSocket(harness.webSocketUrl, { + origin: "https://foreign.example.test", + }); + foreign.on("error", () => { + // The status assertion below is the expected failure path. + }); + const status = await new Promise((resolve) => { + foreign.once("unexpected-response", (_request, response) => + resolve(response.statusCode), + ); + }); + expect(status).toBe(403); + + const unauthenticated = await openClient(harness.webSocketUrl); + expect((await unauthenticated.inbox.next("error", 300)).code).toBe( + "AUTH_REQUIRED", + ); + + const oversized = await openClient(harness.webSocketUrl); + const closeCode = new Promise((resolve) => + oversized.socket.once("close", (code) => resolve(code)), + ); + oversized.socket.send("x".repeat(MAX_SIGNAL_BYTES + 1)); + expect(await closeCode).toBe(1009); + }); + + it("rejects an invalid upgrade request target without stopping the server", async () => { + const harness = await startHarness(); + const response = await rawUpgradeResponse(harness.webSocketUrl, "//["); + expect(response).toMatch(/^HTTP\/1\.1 400 Bad Request/); + + const host = await openClient(harness.webSocketUrl); + expect( + (await authenticate(host, harness.room, "host", "host-after-invalid-url")) + .role, + ).toBe("host"); + }); + + it("rejects unauthenticated and total connection overflow before upgrade", async () => { + const harness = await startHarness({ + maxSignalConnections: 2, + maxUnauthenticatedSignalConnections: 1, + }); + const host = await openClient(harness.webSocketUrl); + expect(await rejectedUpgradeStatus(harness.webSocketUrl)).toBe(503); + + await authenticate(host, harness.room, "host", "capacity-host"); + const viewer = await openClient(harness.webSocketUrl); + await authenticate( + viewer, + harness.room, + "viewer", + "capacity-viewer", + ); + expect(await rejectedUpgradeStatus(harness.webSocketUrl)).toBe(503); + }); +}); diff --git a/tests/turn.test.ts b/tests/turn.test.ts new file mode 100644 index 0000000..acf9ecb --- /dev/null +++ b/tests/turn.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { createIceConfig, createTurnCredentials } from "../src/server/turn.ts"; + +describe("TURN credentials", () => { + it("matches a fixed coturn REST HMAC-SHA1 vector", () => { + expect( + createTurnCredentials("test-secret", "room_peer", 3_600, 1_700_000_000_000), + ).toEqual({ + username: "1700003600:room_peer", + credential: "h3+0FAYKf7T7n49pOZborViEEVQ=", + expiresAt: "2023-11-14T23:13:20.000Z", + }); + }); + + it("does not issue credentials beyond room expiry", () => { + const now = 1_700_000_000_000; + const credentials = createTurnCredentials( + "test-secret", + "room_peer", + 3_600, + now, + now + 600_000, + ); + + expect(credentials.username).toBe("1700000600:room_peer"); + expect(Date.parse(credentials.expiresAt)).toBe(now + 600_000); + }); + + it("returns an explicit STUN-only configuration when TURN is absent", () => { + expect( + createIceConfig( + { + stunUrls: ["stun:stun.example.test:3478"], + turnUrls: [], + credentialTtlSeconds: 3_600, + }, + "subject", + ), + ).toEqual({ + iceServers: [{ urls: ["stun:stun.example.test:3478"] }], + expiresAt: null, + relayAvailable: false, + }); + }); + + it("does not advertise unusable relay credentials in the final room second", () => { + const now = 1_700_000_000_900; + expect( + createIceConfig( + { + stunUrls: [], + turnUrls: ["turn:turn.example.test:3478"], + turnSharedSecret: "test-secret", + credentialTtlSeconds: 3_600, + }, + "subject", + now, + now + 50, + ), + ).toEqual({ iceServers: [], expiresAt: null, relayAvailable: false }); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..1dea226 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.cache/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/client", "src/shared"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c03f2b9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.server.json" }, + { "path": "./tsconfig.tools.json" } + ] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..36b918c --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.cache/tsconfig.server.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "rootDir": "src", + "outDir": "dist/server", + "sourceMap": true, + "declaration": false, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/server", "src/shared"] +} diff --git a/tsconfig.tools.json b/tsconfig.tools.json new file mode 100644 index 0000000..78bef7a --- /dev/null +++ b/tsconfig.tools.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.cache/tsconfig.tools.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node", "vitest/globals"], + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["vite.config.ts", "vitest.config.ts", "tests"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b41611d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist/client", + emptyOutDir: true, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..dd392af --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + coverage: { + reporter: ["text", "html"], + }, + }, +});