Serverless group video calls: rooms, WebRTC mesh, shared settings
Full-mesh group calls over nostr signaling + WebRTC, combining commonview's
auto-connecting mesh with commoncall's media handling. Rooms are arbitrary
strings hashed into nostr topics; everyone joins muted; anyone can change the
room-wide video quality (low/medium/high/auto, default medium); screen share
swaps the camera track. Soft cap of 8 participants.
18 changed files+4134−0
.github/workflows/deploy.ymladded+41−0View file
@@ -0,0 +1,41 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+# Allow one concurrent deployment, cancel in-progress runs.
14+concurrency:
15+ group: pages
16+ cancel-in-progress: true
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - uses: actions/checkout@v4
23+ - uses: actions/setup-node@v4
24+ with:
25+ node-version: 20
26+ cache: npm
27+ - run: npm ci
28+ - run: npm run build
29+ - uses: actions/upload-pages-artifact@v3
30+ with:
31+ path: dist
32+
33+ deploy:
34+ needs: build
35+ runs-on: ubuntu-latest
36+ environment:
37+ name: github-pages
38+ url: ${{ steps.deployment.outputs.page_url }}
39+ steps:
40+ - id: deployment
41+ uses: actions/deploy-pages@v4
.gitignoreadded+3−0View file
@@ -0,0 +1,3 @@
1+node_modules
2+dist
3+*.log
CLAUDE.mdadded+68−0View file
@@ -0,0 +1,68 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo. It combines the p2p techniques of
4+the sibling projects `commonview` (auto-connecting mesh) and `commoncall`
5+(WebRTC media, settings, screen share) — read those first; this file only
6+covers what is different here.
7+
8+## Architecture
9+
10+```
11+src/p2p/
12+ identity.ts schnorr keypair; pubkey hex = peer ID — ported from commoncall
13+ nostr.ts minimal relay client + topic scheme — ported (roomTopic takes a room ID)
14+ peer.ts WebRTC wrapper: media + control channel — ported (replaceTrack generalized to audio|video)
15+ settings.ts shared ROOM settings, quality presets — default quality is 'medium', not 'auto'
16+ network.ts the heart: rooms, presence, mesh, media, settings sync
17+src/App.tsx landing form (light) + in-room view (dark), video grid, control bar
18+```
19+
20+## Key design decisions
21+
22+- **Rooms, no registry.** The room ID is any string (whitespace stripped,
23+ exact otherwise, case-sensitive); `roomTopic` hashes it into the nostr
24+ presence topic. The URL hash holds the room (`#<encoded-room>`) so the
25+ address bar is the invite link.
26+- **Auto-mesh, no consent handshake.** Unlike commoncall, entering the room IS
27+ the consent: on every presence announcement, `maybeConnect` brings up a
28+ `Peer` (initiator = smaller peer ID, commonview's stalled-connection retry
29+ at 15 s). All of commoncall's call-request/accept machinery is gone.
30+- **Muted by default; placeholder tracks.** getUserMedia runs at entry but
31+ tracks start `enabled = false`. Every participant ALWAYS carries exactly one
32+ audio + one video track (denied/missing devices get a silent
33+ AudioContext-destination track / black canvas-capture track), so
34+ offer/answer stays symmetric and the one-offer, no-renegotiation design
35+ holds. Unmuting without a real device retries getUserMedia and upgrades the
36+ placeholder via `replaceTrack` on every connection.
37+- **Soft cap of 8** (`MAX_PARTICIPANTS`). A peer already holding 7 connections
38+ answers an unknown peer's announcement/offer with `{t:'room-full'}` on the
39+ newcomer's topic instead of connecting; a newcomer with zero connections
40+ that receives room-full tears down and shows a notice. Two simultaneous
41+ joiners racing for the last slot can briefly exceed the cap — accepted.
42+- **Settings are room-wide, multi-party LWW.** One entry per key in
43+ `settingsMeta` (`{rev, by}`); changes broadcast `{t:'set', key, value, rev,
44+ by}` to all peers (complete graph — no relaying), late joiners get every
45+ entry inside each peer's `hello`, and a same-rev tie is won by the SMALLER
46+ setter ID. Default quality is `medium` — so quality caps are applied to each
47+ sender on connect (`applyVideoParamsTo`, with one delayed retry because
48+ encodings may not exist right at 'connected'), not only on change.
49+- **Mute is per-participant, NOT a shared setting** — same as commoncall: own
50+ flags, `{t:'mute'}` notices, `track.enabled` toggling, and the notice
51+ carries the EFFECTIVE outgoing video state (screen share overrides camera
52+ mute). Remote participants are assumed muted until told otherwise.
53+- **Screen share = track swap on every connection.** `getDisplayMedia` +
54+ `replaceTrack` per peer; a peer that joins mid-share gets the screen track
55+ from `outgoingStream()`. Same-kind replacement avoids renegotiation — never
56+ addTrack mid-connection.
57+- **Cleanup is join-generation-guarded.** `joinSeq` is bumped on every
58+ join/leave; async work (getUserMedia, topic hashing, display capture)
59+ re-checks it after each await. `leave()` unsubscribes topics, stops all
60+ tracks, closes the AudioContext, and resets settings to defaults.
61+
62+## Testing
63+
64+`npm run dev`, then open the room in two browsers (identity is
65+per-browser-profile via localStorage, so two tabs in one profile are the SAME
66+peer — use a private window or second browser). `npm run build` type-checks
67+(`tsc -b`) and bundles. Let the user test multi-party media in real browsers;
68+don't try to automate camera/mic flows.
README.mdadded+59−0View file
@@ -0,0 +1,59 @@
1+# commonroom
2+
3+Serverless group video calls in the browser.
4+
5+**Live page:** https://concept-collection.github.io/commonroom/
6+
7+Enter your name and a room name — any string you like (no spaces) — and you're
8+in. Share the room URL with anyone; everyone who joins the same room is
9+connected to everyone else over a full WebRTC mesh, up to 8 people. You enter
10+with your microphone and camera **off** and turn them on when you're ready.
11+
12+The room has shared settings that anyone can change and that apply to
13+everyone — currently the video quality (low / medium / high / auto, medium by
14+default). You can also share your screen in place of your camera.
15+
16+## How it works
17+
18+There is no backend and no room registry. The techniques come from the sibling
19+projects [commonview](https://github.com/concept-collection/commonview) (the
20+auto-connecting mesh) and
21+[commoncall](https://github.com/concept-collection/commoncall) (WebRTC media,
22+quality presets, screen share):
23+
24+- **Identity** — each browser generates a secp256k1 (BIP340 schnorr) keypair,
25+ persisted in localStorage. The x-only public key is the peer ID, and every
26+ nostr event is signed with it, so peers can't be impersonated.
27+- **Rooms** — the room name is hashed into a nostr topic; knowing the name IS
28+ the key. Everyone in the room announces `{peerId, name}` on that topic every
29+ few seconds via ephemeral events on public relays; entries expire when
30+ announcements stop.
31+- **Mesh** — being in the room is the consent: every participant automatically
32+ brings up a WebRTC connection with every other participant (deterministic
33+ initiator = smaller peer ID; offer/answer/ICE ride per-peer nostr topics).
34+ Audio/video flows directly between browsers, with public STUN servers and a
35+ free TURN relay as fallback. Rooms are softly capped at 8 — peers already at
36+ capacity turn newcomers away.
37+- **Muted by default** — camera/mic are requested on entry so unmuting is
38+ instant, but tracks start disabled. If you deny access you still join,
39+ sending silent/black placeholder tracks; unmuting retries the device and
40+ upgrades the track in place (`replaceTrack`, no renegotiation).
41+- **Shared settings** — one settings object for the whole room, synced over
42+ the per-peer control data channels with per-key last-writer-wins (revision
43+ counters; ties resolved by the setter's peer ID). The video-quality presets
44+ map to `RTCRtpSender.setParameters` caps that each participant applies to
45+ its own outgoing senders.
46+
47+## Development
48+
49+```sh
50+npm install
51+npm run dev
52+```
53+
54+Identity is per-browser-profile (localStorage), so two tabs in the same
55+profile are the *same* peer — to try a room with multiple participants, use a
56+second browser or a private window.
57+
58+`npm run build` type-checks and bundles to `dist/`. Pushes to `main` deploy to
59+GitHub Pages via `.github/workflows/deploy.yml`.
index.htmladded+12−0View file
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>CommonRoom</title>
7+ </head>
8+ <body>
9+ <div id="root"></div>
10+ <script type="module" src="/src/main.tsx"></script>
11+ </body>
12+</html>
package-lock.jsonadded+1781−0View file
@@ -0,0 +1,1781 @@
1+{
2+ "name": "commonroom",
3+ "version": "0.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "commonroom",
9+ "version": "0.0.0",
10+ "dependencies": {
11+ "@noble/secp256k1": "^3.1.0",
12+ "react": "^18.3.1",
13+ "react-dom": "^18.3.1"
14+ },
15+ "devDependencies": {
16+ "@types/react": "^18.3.12",
17+ "@types/react-dom": "^18.3.1",
18+ "@vitejs/plugin-react": "^4.3.4",
19+ "typescript": "^5.6.3",
20+ "vite": "^5.4.11"
21+ }
22+ },
23+ "node_modules/@babel/code-frame": {
24+ "version": "7.29.7",
25+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
26+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
27+ "dev": true,
28+ "license": "MIT",
29+ "dependencies": {
30+ "@babel/helper-validator-identifier": "^7.29.7",
31+ "js-tokens": "^4.0.0",
32+ "picocolors": "^1.1.1"
33+ },
34+ "engines": {
35+ "node": ">=6.9.0"
36+ }
37+ },
38+ "node_modules/@babel/compat-data": {
39+ "version": "7.29.7",
40+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
41+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
42+ "dev": true,
43+ "license": "MIT",
44+ "engines": {
45+ "node": ">=6.9.0"
46+ }
47+ },
48+ "node_modules/@babel/core": {
49+ "version": "7.29.7",
50+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
51+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
52+ "dev": true,
53+ "license": "MIT",
54+ "dependencies": {
55+ "@babel/code-frame": "^7.29.7",
56+ "@babel/generator": "^7.29.7",
57+ "@babel/helper-compilation-targets": "^7.29.7",
58+ "@babel/helper-module-transforms": "^7.29.7",
59+ "@babel/helpers": "^7.29.7",
60+ "@babel/parser": "^7.29.7",
61+ "@babel/template": "^7.29.7",
62+ "@babel/traverse": "^7.29.7",
63+ "@babel/types": "^7.29.7",
64+ "@jridgewell/remapping": "^2.3.5",
65+ "convert-source-map": "^2.0.0",
66+ "debug": "^4.1.0",
67+ "gensync": "^1.0.0-beta.2",
68+ "json5": "^2.2.3",
69+ "semver": "^6.3.1"
70+ },
71+ "engines": {
72+ "node": ">=6.9.0"
73+ },
74+ "funding": {
75+ "type": "opencollective",
76+ "url": "https://opencollective.com/babel"
77+ }
78+ },
79+ "node_modules/@babel/generator": {
80+ "version": "7.29.7",
81+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
82+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
83+ "dev": true,
84+ "license": "MIT",
85+ "dependencies": {
86+ "@babel/parser": "^7.29.7",
87+ "@babel/types": "^7.29.7",
88+ "@jridgewell/gen-mapping": "^0.3.12",
89+ "@jridgewell/trace-mapping": "^0.3.28",
90+ "jsesc": "^3.0.2"
91+ },
92+ "engines": {
93+ "node": ">=6.9.0"
94+ }
95+ },
96+ "node_modules/@babel/helper-compilation-targets": {
97+ "version": "7.29.7",
98+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
99+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
100+ "dev": true,
101+ "license": "MIT",
102+ "dependencies": {
103+ "@babel/compat-data": "^7.29.7",
104+ "@babel/helper-validator-option": "^7.29.7",
105+ "browserslist": "^4.24.0",
106+ "lru-cache": "^5.1.1",
107+ "semver": "^6.3.1"
108+ },
109+ "engines": {
110+ "node": ">=6.9.0"
111+ }
112+ },
113+ "node_modules/@babel/helper-globals": {
114+ "version": "7.29.7",
115+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
116+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
117+ "dev": true,
118+ "license": "MIT",
119+ "engines": {
120+ "node": ">=6.9.0"
121+ }
122+ },
123+ "node_modules/@babel/helper-module-imports": {
124+ "version": "7.29.7",
125+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
126+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
127+ "dev": true,
128+ "license": "MIT",
129+ "dependencies": {
130+ "@babel/traverse": "^7.29.7",
131+ "@babel/types": "^7.29.7"
132+ },
133+ "engines": {
134+ "node": ">=6.9.0"
135+ }
136+ },
137+ "node_modules/@babel/helper-module-transforms": {
138+ "version": "7.29.7",
139+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
140+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
141+ "dev": true,
142+ "license": "MIT",
143+ "dependencies": {
144+ "@babel/helper-module-imports": "^7.29.7",
145+ "@babel/helper-validator-identifier": "^7.29.7",
146+ "@babel/traverse": "^7.29.7"
147+ },
148+ "engines": {
149+ "node": ">=6.9.0"
150+ },
151+ "peerDependencies": {
152+ "@babel/core": "^7.0.0"
153+ }
154+ },
155+ "node_modules/@babel/helper-plugin-utils": {
156+ "version": "7.29.7",
157+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
158+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
159+ "dev": true,
160+ "license": "MIT",
161+ "engines": {
162+ "node": ">=6.9.0"
163+ }
164+ },
165+ "node_modules/@babel/helper-string-parser": {
166+ "version": "7.29.7",
167+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
168+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
169+ "dev": true,
170+ "license": "MIT",
171+ "engines": {
172+ "node": ">=6.9.0"
173+ }
174+ },
175+ "node_modules/@babel/helper-validator-identifier": {
176+ "version": "7.29.7",
177+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
178+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
179+ "dev": true,
180+ "license": "MIT",
181+ "engines": {
182+ "node": ">=6.9.0"
183+ }
184+ },
185+ "node_modules/@babel/helper-validator-option": {
186+ "version": "7.29.7",
187+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
188+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
189+ "dev": true,
190+ "license": "MIT",
191+ "engines": {
192+ "node": ">=6.9.0"
193+ }
194+ },
195+ "node_modules/@babel/helpers": {
196+ "version": "7.29.7",
197+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
198+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
199+ "dev": true,
200+ "license": "MIT",
201+ "dependencies": {
202+ "@babel/template": "^7.29.7",
203+ "@babel/types": "^7.29.7"
204+ },
205+ "engines": {
206+ "node": ">=6.9.0"
207+ }
208+ },
209+ "node_modules/@babel/parser": {
210+ "version": "7.29.7",
211+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
212+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
213+ "dev": true,
214+ "license": "MIT",
215+ "dependencies": {
216+ "@babel/types": "^7.29.7"
217+ },
218+ "bin": {
219+ "parser": "bin/babel-parser.js"
220+ },
221+ "engines": {
222+ "node": ">=6.0.0"
223+ }
224+ },
225+ "node_modules/@babel/plugin-transform-react-jsx-self": {
226+ "version": "7.29.7",
227+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
228+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
229+ "dev": true,
230+ "license": "MIT",
231+ "dependencies": {
232+ "@babel/helper-plugin-utils": "^7.29.7"
233+ },
234+ "engines": {
235+ "node": ">=6.9.0"
236+ },
237+ "peerDependencies": {
238+ "@babel/core": "^7.0.0-0"
239+ }
240+ },
241+ "node_modules/@babel/plugin-transform-react-jsx-source": {
242+ "version": "7.29.7",
243+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
244+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
245+ "dev": true,
246+ "license": "MIT",
247+ "dependencies": {
248+ "@babel/helper-plugin-utils": "^7.29.7"
249+ },
250+ "engines": {
251+ "node": ">=6.9.0"
252+ },
253+ "peerDependencies": {
254+ "@babel/core": "^7.0.0-0"
255+ }
256+ },
257+ "node_modules/@babel/template": {
258+ "version": "7.29.7",
259+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
260+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
261+ "dev": true,
262+ "license": "MIT",
263+ "dependencies": {
264+ "@babel/code-frame": "^7.29.7",
265+ "@babel/parser": "^7.29.7",
266+ "@babel/types": "^7.29.7"
267+ },
268+ "engines": {
269+ "node": ">=6.9.0"
270+ }
271+ },
272+ "node_modules/@babel/traverse": {
273+ "version": "7.29.7",
274+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
275+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
276+ "dev": true,
277+ "license": "MIT",
278+ "dependencies": {
279+ "@babel/code-frame": "^7.29.7",
280+ "@babel/generator": "^7.29.7",
281+ "@babel/helper-globals": "^7.29.7",
282+ "@babel/parser": "^7.29.7",
283+ "@babel/template": "^7.29.7",
284+ "@babel/types": "^7.29.7",
285+ "debug": "^4.3.1"
286+ },
287+ "engines": {
288+ "node": ">=6.9.0"
289+ }
290+ },
291+ "node_modules/@babel/types": {
292+ "version": "7.29.7",
293+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
294+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
295+ "dev": true,
296+ "license": "MIT",
297+ "dependencies": {
298+ "@babel/helper-string-parser": "^7.29.7",
299+ "@babel/helper-validator-identifier": "^7.29.7"
300+ },
301+ "engines": {
302+ "node": ">=6.9.0"
303+ }
304+ },
305+ "node_modules/@esbuild/aix-ppc64": {
306+ "version": "0.21.5",
307+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
308+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
309+ "cpu": [
310+ "ppc64"
311+ ],
312+ "dev": true,
313+ "license": "MIT",
314+ "optional": true,
315+ "os": [
316+ "aix"
317+ ],
318+ "engines": {
319+ "node": ">=12"
320+ }
321+ },
322+ "node_modules/@esbuild/android-arm": {
323+ "version": "0.21.5",
324+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
325+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
326+ "cpu": [
327+ "arm"
328+ ],
329+ "dev": true,
330+ "license": "MIT",
331+ "optional": true,
332+ "os": [
333+ "android"
334+ ],
335+ "engines": {
336+ "node": ">=12"
337+ }
338+ },
339+ "node_modules/@esbuild/android-arm64": {
340+ "version": "0.21.5",
341+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
342+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
343+ "cpu": [
344+ "arm64"
345+ ],
346+ "dev": true,
347+ "license": "MIT",
348+ "optional": true,
349+ "os": [
350+ "android"
351+ ],
352+ "engines": {
353+ "node": ">=12"
354+ }
355+ },
356+ "node_modules/@esbuild/android-x64": {
357+ "version": "0.21.5",
358+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
359+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
360+ "cpu": [
361+ "x64"
362+ ],
363+ "dev": true,
364+ "license": "MIT",
365+ "optional": true,
366+ "os": [
367+ "android"
368+ ],
369+ "engines": {
370+ "node": ">=12"
371+ }
372+ },
373+ "node_modules/@esbuild/darwin-arm64": {
374+ "version": "0.21.5",
375+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
376+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
377+ "cpu": [
378+ "arm64"
379+ ],
380+ "dev": true,
381+ "license": "MIT",
382+ "optional": true,
383+ "os": [
384+ "darwin"
385+ ],
386+ "engines": {
387+ "node": ">=12"
388+ }
389+ },
390+ "node_modules/@esbuild/darwin-x64": {
391+ "version": "0.21.5",
392+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
393+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
394+ "cpu": [
395+ "x64"
396+ ],
397+ "dev": true,
398+ "license": "MIT",
399+ "optional": true,
400+ "os": [
401+ "darwin"
402+ ],
403+ "engines": {
404+ "node": ">=12"
405+ }
406+ },
407+ "node_modules/@esbuild/freebsd-arm64": {
408+ "version": "0.21.5",
409+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
410+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
411+ "cpu": [
412+ "arm64"
413+ ],
414+ "dev": true,
415+ "license": "MIT",
416+ "optional": true,
417+ "os": [
418+ "freebsd"
419+ ],
420+ "engines": {
421+ "node": ">=12"
422+ }
423+ },
424+ "node_modules/@esbuild/freebsd-x64": {
425+ "version": "0.21.5",
426+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
427+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
428+ "cpu": [
429+ "x64"
430+ ],
431+ "dev": true,
432+ "license": "MIT",
433+ "optional": true,
434+ "os": [
435+ "freebsd"
436+ ],
437+ "engines": {
438+ "node": ">=12"
439+ }
440+ },
441+ "node_modules/@esbuild/linux-arm": {
442+ "version": "0.21.5",
443+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
444+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
445+ "cpu": [
446+ "arm"
447+ ],
448+ "dev": true,
449+ "license": "MIT",
450+ "optional": true,
451+ "os": [
452+ "linux"
453+ ],
454+ "engines": {
455+ "node": ">=12"
456+ }
457+ },
458+ "node_modules/@esbuild/linux-arm64": {
459+ "version": "0.21.5",
460+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
461+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
462+ "cpu": [
463+ "arm64"
464+ ],
465+ "dev": true,
466+ "license": "MIT",
467+ "optional": true,
468+ "os": [
469+ "linux"
470+ ],
471+ "engines": {
472+ "node": ">=12"
473+ }
474+ },
475+ "node_modules/@esbuild/linux-ia32": {
476+ "version": "0.21.5",
477+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
478+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
479+ "cpu": [
480+ "ia32"
481+ ],
482+ "dev": true,
483+ "license": "MIT",
484+ "optional": true,
485+ "os": [
486+ "linux"
487+ ],
488+ "engines": {
489+ "node": ">=12"
490+ }
491+ },
492+ "node_modules/@esbuild/linux-loong64": {
493+ "version": "0.21.5",
494+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
495+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
496+ "cpu": [
497+ "loong64"
498+ ],
499+ "dev": true,
500+ "license": "MIT",
501+ "optional": true,
502+ "os": [
503+ "linux"
504+ ],
505+ "engines": {
506+ "node": ">=12"
507+ }
508+ },
509+ "node_modules/@esbuild/linux-mips64el": {
510+ "version": "0.21.5",
511+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
512+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
513+ "cpu": [
514+ "mips64el"
515+ ],
516+ "dev": true,
517+ "license": "MIT",
518+ "optional": true,
519+ "os": [
520+ "linux"
521+ ],
522+ "engines": {
523+ "node": ">=12"
524+ }
525+ },
526+ "node_modules/@esbuild/linux-ppc64": {
527+ "version": "0.21.5",
528+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
529+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
530+ "cpu": [
531+ "ppc64"
532+ ],
533+ "dev": true,
534+ "license": "MIT",
535+ "optional": true,
536+ "os": [
537+ "linux"
538+ ],
539+ "engines": {
540+ "node": ">=12"
541+ }
542+ },
543+ "node_modules/@esbuild/linux-riscv64": {
544+ "version": "0.21.5",
545+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
546+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
547+ "cpu": [
548+ "riscv64"
549+ ],
550+ "dev": true,
551+ "license": "MIT",
552+ "optional": true,
553+ "os": [
554+ "linux"
555+ ],
556+ "engines": {
557+ "node": ">=12"
558+ }
559+ },
560+ "node_modules/@esbuild/linux-s390x": {
561+ "version": "0.21.5",
562+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
563+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
564+ "cpu": [
565+ "s390x"
566+ ],
567+ "dev": true,
568+ "license": "MIT",
569+ "optional": true,
570+ "os": [
571+ "linux"
572+ ],
573+ "engines": {
574+ "node": ">=12"
575+ }
576+ },
577+ "node_modules/@esbuild/linux-x64": {
578+ "version": "0.21.5",
579+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
580+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
581+ "cpu": [
582+ "x64"
583+ ],
584+ "dev": true,
585+ "license": "MIT",
586+ "optional": true,
587+ "os": [
588+ "linux"
589+ ],
590+ "engines": {
591+ "node": ">=12"
592+ }
593+ },
594+ "node_modules/@esbuild/netbsd-x64": {
595+ "version": "0.21.5",
596+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
597+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
598+ "cpu": [
599+ "x64"
600+ ],
601+ "dev": true,
602+ "license": "MIT",
603+ "optional": true,
604+ "os": [
605+ "netbsd"
606+ ],
607+ "engines": {
608+ "node": ">=12"
609+ }
610+ },
611+ "node_modules/@esbuild/openbsd-x64": {
612+ "version": "0.21.5",
613+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
614+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
615+ "cpu": [
616+ "x64"
617+ ],
618+ "dev": true,
619+ "license": "MIT",
620+ "optional": true,
621+ "os": [
622+ "openbsd"
623+ ],
624+ "engines": {
625+ "node": ">=12"
626+ }
627+ },
628+ "node_modules/@esbuild/sunos-x64": {
629+ "version": "0.21.5",
630+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
631+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
632+ "cpu": [
633+ "x64"
634+ ],
635+ "dev": true,
636+ "license": "MIT",
637+ "optional": true,
638+ "os": [
639+ "sunos"
640+ ],
641+ "engines": {
642+ "node": ">=12"
643+ }
644+ },
645+ "node_modules/@esbuild/win32-arm64": {
646+ "version": "0.21.5",
647+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
648+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
649+ "cpu": [
650+ "arm64"
651+ ],
652+ "dev": true,
653+ "license": "MIT",
654+ "optional": true,
655+ "os": [
656+ "win32"
657+ ],
658+ "engines": {
659+ "node": ">=12"
660+ }
661+ },
662+ "node_modules/@esbuild/win32-ia32": {
663+ "version": "0.21.5",
664+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
665+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
666+ "cpu": [
667+ "ia32"
668+ ],
669+ "dev": true,
670+ "license": "MIT",
671+ "optional": true,
672+ "os": [
673+ "win32"
674+ ],
675+ "engines": {
676+ "node": ">=12"
677+ }
678+ },
679+ "node_modules/@esbuild/win32-x64": {
680+ "version": "0.21.5",
681+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
682+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
683+ "cpu": [
684+ "x64"
685+ ],
686+ "dev": true,
687+ "license": "MIT",
688+ "optional": true,
689+ "os": [
690+ "win32"
691+ ],
692+ "engines": {
693+ "node": ">=12"
694+ }
695+ },
696+ "node_modules/@jridgewell/gen-mapping": {
697+ "version": "0.3.13",
698+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
699+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
700+ "dev": true,
701+ "license": "MIT",
702+ "dependencies": {
703+ "@jridgewell/sourcemap-codec": "^1.5.0",
704+ "@jridgewell/trace-mapping": "^0.3.24"
705+ }
706+ },
707+ "node_modules/@jridgewell/remapping": {
708+ "version": "2.3.5",
709+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
710+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
711+ "dev": true,
712+ "license": "MIT",
713+ "dependencies": {
714+ "@jridgewell/gen-mapping": "^0.3.5",
715+ "@jridgewell/trace-mapping": "^0.3.24"
716+ }
717+ },
718+ "node_modules/@jridgewell/resolve-uri": {
719+ "version": "3.1.2",
720+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
721+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
722+ "dev": true,
723+ "license": "MIT",
724+ "engines": {
725+ "node": ">=6.0.0"
726+ }
727+ },
728+ "node_modules/@jridgewell/sourcemap-codec": {
729+ "version": "1.5.5",
730+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
731+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
732+ "dev": true,
733+ "license": "MIT"
734+ },
735+ "node_modules/@jridgewell/trace-mapping": {
736+ "version": "0.3.31",
737+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
738+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
739+ "dev": true,
740+ "license": "MIT",
741+ "dependencies": {
742+ "@jridgewell/resolve-uri": "^3.1.0",
743+ "@jridgewell/sourcemap-codec": "^1.4.14"
744+ }
745+ },
746+ "node_modules/@noble/secp256k1": {
747+ "version": "3.1.0",
748+ "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
749+ "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
750+ "license": "MIT",
751+ "funding": {
752+ "url": "https://paulmillr.com/funding/"
753+ }
754+ },
755+ "node_modules/@rolldown/pluginutils": {
756+ "version": "1.0.0-beta.27",
757+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
758+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
759+ "dev": true,
760+ "license": "MIT"
761+ },
762+ "node_modules/@rollup/rollup-android-arm-eabi": {
763+ "version": "4.62.2",
764+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
765+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
766+ "cpu": [
767+ "arm"
768+ ],
769+ "dev": true,
770+ "license": "MIT",
771+ "optional": true,
772+ "os": [
773+ "android"
774+ ]
775+ },
776+ "node_modules/@rollup/rollup-android-arm64": {
777+ "version": "4.62.2",
778+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
779+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
780+ "cpu": [
781+ "arm64"
782+ ],
783+ "dev": true,
784+ "license": "MIT",
785+ "optional": true,
786+ "os": [
787+ "android"
788+ ]
789+ },
790+ "node_modules/@rollup/rollup-darwin-arm64": {
791+ "version": "4.62.2",
792+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
793+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
794+ "cpu": [
795+ "arm64"
796+ ],
797+ "dev": true,
798+ "license": "MIT",
799+ "optional": true,
800+ "os": [
801+ "darwin"
802+ ]
803+ },
804+ "node_modules/@rollup/rollup-darwin-x64": {
805+ "version": "4.62.2",
806+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
807+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
808+ "cpu": [
809+ "x64"
810+ ],
811+ "dev": true,
812+ "license": "MIT",
813+ "optional": true,
814+ "os": [
815+ "darwin"
816+ ]
817+ },
818+ "node_modules/@rollup/rollup-freebsd-arm64": {
819+ "version": "4.62.2",
820+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
821+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
822+ "cpu": [
823+ "arm64"
824+ ],
825+ "dev": true,
826+ "license": "MIT",
827+ "optional": true,
828+ "os": [
829+ "freebsd"
830+ ]
831+ },
832+ "node_modules/@rollup/rollup-freebsd-x64": {
833+ "version": "4.62.2",
834+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
835+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
836+ "cpu": [
837+ "x64"
838+ ],
839+ "dev": true,
840+ "license": "MIT",
841+ "optional": true,
842+ "os": [
843+ "freebsd"
844+ ]
845+ },
846+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
847+ "version": "4.62.2",
848+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
849+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
850+ "cpu": [
851+ "arm"
852+ ],
853+ "dev": true,
854+ "libc": [
855+ "glibc"
856+ ],
857+ "license": "MIT",
858+ "optional": true,
859+ "os": [
860+ "linux"
861+ ]
862+ },
863+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
864+ "version": "4.62.2",
865+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
866+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
867+ "cpu": [
868+ "arm"
869+ ],
870+ "dev": true,
871+ "libc": [
872+ "musl"
873+ ],
874+ "license": "MIT",
875+ "optional": true,
876+ "os": [
877+ "linux"
878+ ]
879+ },
880+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
881+ "version": "4.62.2",
882+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
883+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
884+ "cpu": [
885+ "arm64"
886+ ],
887+ "dev": true,
888+ "libc": [
889+ "glibc"
890+ ],
891+ "license": "MIT",
892+ "optional": true,
893+ "os": [
894+ "linux"
895+ ]
896+ },
897+ "node_modules/@rollup/rollup-linux-arm64-musl": {
898+ "version": "4.62.2",
899+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
900+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
901+ "cpu": [
902+ "arm64"
903+ ],
904+ "dev": true,
905+ "libc": [
906+ "musl"
907+ ],
908+ "license": "MIT",
909+ "optional": true,
910+ "os": [
911+ "linux"
912+ ]
913+ },
914+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
915+ "version": "4.62.2",
916+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
917+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
918+ "cpu": [
919+ "loong64"
920+ ],
921+ "dev": true,
922+ "libc": [
923+ "glibc"
924+ ],
925+ "license": "MIT",
926+ "optional": true,
927+ "os": [
928+ "linux"
929+ ]
930+ },
931+ "node_modules/@rollup/rollup-linux-loong64-musl": {
932+ "version": "4.62.2",
933+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
934+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
935+ "cpu": [
936+ "loong64"
937+ ],
938+ "dev": true,
939+ "libc": [
940+ "musl"
941+ ],
942+ "license": "MIT",
943+ "optional": true,
944+ "os": [
945+ "linux"
946+ ]
947+ },
948+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
949+ "version": "4.62.2",
950+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
951+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
952+ "cpu": [
953+ "ppc64"
954+ ],
955+ "dev": true,
956+ "libc": [
957+ "glibc"
958+ ],
959+ "license": "MIT",
960+ "optional": true,
961+ "os": [
962+ "linux"
963+ ]
964+ },
965+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
966+ "version": "4.62.2",
967+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
968+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
969+ "cpu": [
970+ "ppc64"
971+ ],
972+ "dev": true,
973+ "libc": [
974+ "musl"
975+ ],
976+ "license": "MIT",
977+ "optional": true,
978+ "os": [
979+ "linux"
980+ ]
981+ },
982+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
983+ "version": "4.62.2",
984+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
985+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
986+ "cpu": [
987+ "riscv64"
988+ ],
989+ "dev": true,
990+ "libc": [
991+ "glibc"
992+ ],
993+ "license": "MIT",
994+ "optional": true,
995+ "os": [
996+ "linux"
997+ ]
998+ },
999+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1000+ "version": "4.62.2",
1001+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
1002+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
1003+ "cpu": [
1004+ "riscv64"
1005+ ],
1006+ "dev": true,
1007+ "libc": [
1008+ "musl"
1009+ ],
1010+ "license": "MIT",
1011+ "optional": true,
1012+ "os": [
1013+ "linux"
1014+ ]
1015+ },
1016+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1017+ "version": "4.62.2",
1018+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
1019+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
1020+ "cpu": [
1021+ "s390x"
1022+ ],
1023+ "dev": true,
1024+ "libc": [
1025+ "glibc"
1026+ ],
1027+ "license": "MIT",
1028+ "optional": true,
1029+ "os": [
1030+ "linux"
1031+ ]
1032+ },
1033+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1034+ "version": "4.62.2",
1035+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
1036+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
1037+ "cpu": [
1038+ "x64"
1039+ ],
1040+ "dev": true,
1041+ "libc": [
1042+ "glibc"
1043+ ],
1044+ "license": "MIT",
1045+ "optional": true,
1046+ "os": [
1047+ "linux"
1048+ ]
1049+ },
1050+ "node_modules/@rollup/rollup-linux-x64-musl": {
1051+ "version": "4.62.2",
1052+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
1053+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
1054+ "cpu": [
1055+ "x64"
1056+ ],
1057+ "dev": true,
1058+ "libc": [
1059+ "musl"
1060+ ],
1061+ "license": "MIT",
1062+ "optional": true,
1063+ "os": [
1064+ "linux"
1065+ ]
1066+ },
1067+ "node_modules/@rollup/rollup-openbsd-x64": {
1068+ "version": "4.62.2",
1069+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
1070+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
1071+ "cpu": [
1072+ "x64"
1073+ ],
1074+ "dev": true,
1075+ "license": "MIT",
1076+ "optional": true,
1077+ "os": [
1078+ "openbsd"
1079+ ]
1080+ },
1081+ "node_modules/@rollup/rollup-openharmony-arm64": {
1082+ "version": "4.62.2",
1083+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
1084+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
1085+ "cpu": [
1086+ "arm64"
1087+ ],
1088+ "dev": true,
1089+ "license": "MIT",
1090+ "optional": true,
1091+ "os": [
1092+ "openharmony"
1093+ ]
1094+ },
1095+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1096+ "version": "4.62.2",
1097+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
1098+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
1099+ "cpu": [
1100+ "arm64"
1101+ ],
1102+ "dev": true,
1103+ "license": "MIT",
1104+ "optional": true,
1105+ "os": [
1106+ "win32"
1107+ ]
1108+ },
1109+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1110+ "version": "4.62.2",
1111+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
1112+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
1113+ "cpu": [
1114+ "ia32"
1115+ ],
1116+ "dev": true,
1117+ "license": "MIT",
1118+ "optional": true,
1119+ "os": [
1120+ "win32"
1121+ ]
1122+ },
1123+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1124+ "version": "4.62.2",
1125+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
1126+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
1127+ "cpu": [
1128+ "x64"
1129+ ],
1130+ "dev": true,
1131+ "license": "MIT",
1132+ "optional": true,
1133+ "os": [
1134+ "win32"
1135+ ]
1136+ },
1137+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1138+ "version": "4.62.2",
1139+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
1140+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
1141+ "cpu": [
1142+ "x64"
1143+ ],
1144+ "dev": true,
1145+ "license": "MIT",
1146+ "optional": true,
1147+ "os": [
1148+ "win32"
1149+ ]
1150+ },
1151+ "node_modules/@types/babel__core": {
1152+ "version": "7.20.5",
1153+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1154+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1155+ "dev": true,
1156+ "license": "MIT",
1157+ "dependencies": {
1158+ "@babel/parser": "^7.20.7",
1159+ "@babel/types": "^7.20.7",
1160+ "@types/babel__generator": "*",
1161+ "@types/babel__template": "*",
1162+ "@types/babel__traverse": "*"
1163+ }
1164+ },
1165+ "node_modules/@types/babel__generator": {
1166+ "version": "7.27.0",
1167+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1168+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1169+ "dev": true,
1170+ "license": "MIT",
1171+ "dependencies": {
1172+ "@babel/types": "^7.0.0"
1173+ }
1174+ },
1175+ "node_modules/@types/babel__template": {
1176+ "version": "7.4.4",
1177+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1178+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1179+ "dev": true,
1180+ "license": "MIT",
1181+ "dependencies": {
1182+ "@babel/parser": "^7.1.0",
1183+ "@babel/types": "^7.0.0"
1184+ }
1185+ },
1186+ "node_modules/@types/babel__traverse": {
1187+ "version": "7.28.0",
1188+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1189+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1190+ "dev": true,
1191+ "license": "MIT",
1192+ "dependencies": {
1193+ "@babel/types": "^7.28.2"
1194+ }
1195+ },
1196+ "node_modules/@types/estree": {
1197+ "version": "1.0.9",
1198+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1199+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1200+ "dev": true,
1201+ "license": "MIT"
1202+ },
1203+ "node_modules/@types/prop-types": {
1204+ "version": "15.7.15",
1205+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1206+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1207+ "dev": true,
1208+ "license": "MIT"
1209+ },
1210+ "node_modules/@types/react": {
1211+ "version": "18.3.31",
1212+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1213+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1214+ "dev": true,
1215+ "license": "MIT",
1216+ "dependencies": {
1217+ "@types/prop-types": "*",
1218+ "csstype": "^3.2.2"
1219+ }
1220+ },
1221+ "node_modules/@types/react-dom": {
1222+ "version": "18.3.7",
1223+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1224+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1225+ "dev": true,
1226+ "license": "MIT",
1227+ "peerDependencies": {
1228+ "@types/react": "^18.0.0"
1229+ }
1230+ },
1231+ "node_modules/@vitejs/plugin-react": {
1232+ "version": "4.7.0",
1233+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1234+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1235+ "dev": true,
1236+ "license": "MIT",
1237+ "dependencies": {
1238+ "@babel/core": "^7.28.0",
1239+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1240+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1241+ "@rolldown/pluginutils": "1.0.0-beta.27",
1242+ "@types/babel__core": "^7.20.5",
1243+ "react-refresh": "^0.17.0"
1244+ },
1245+ "engines": {
1246+ "node": "^14.18.0 || >=16.0.0"
1247+ },
1248+ "peerDependencies": {
1249+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1250+ }
1251+ },
1252+ "node_modules/baseline-browser-mapping": {
1253+ "version": "2.11.1",
1254+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
1255+ "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
1256+ "dev": true,
1257+ "license": "Apache-2.0",
1258+ "bin": {
1259+ "baseline-browser-mapping": "dist/cli.cjs"
1260+ },
1261+ "engines": {
1262+ "node": ">=6.0.0"
1263+ }
1264+ },
1265+ "node_modules/browserslist": {
1266+ "version": "4.28.7",
1267+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
1268+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
1269+ "dev": true,
1270+ "funding": [
1271+ {
1272+ "type": "opencollective",
1273+ "url": "https://opencollective.com/browserslist"
1274+ },
1275+ {
1276+ "type": "tidelift",
1277+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1278+ },
1279+ {
1280+ "type": "github",
1281+ "url": "https://github.com/sponsors/ai"
1282+ }
1283+ ],
1284+ "license": "MIT",
1285+ "dependencies": {
1286+ "baseline-browser-mapping": "^2.10.44",
1287+ "caniuse-lite": "^1.0.30001806",
1288+ "electron-to-chromium": "^1.5.393",
1289+ "node-releases": "^2.0.51",
1290+ "update-browserslist-db": "^1.2.3"
1291+ },
1292+ "bin": {
1293+ "browserslist": "cli.js"
1294+ },
1295+ "engines": {
1296+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1297+ }
1298+ },
1299+ "node_modules/caniuse-lite": {
1300+ "version": "1.0.30001806",
1301+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
1302+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
1303+ "dev": true,
1304+ "funding": [
1305+ {
1306+ "type": "opencollective",
1307+ "url": "https://opencollective.com/browserslist"
1308+ },
1309+ {
1310+ "type": "tidelift",
1311+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1312+ },
1313+ {
1314+ "type": "github",
1315+ "url": "https://github.com/sponsors/ai"
1316+ }
1317+ ],
1318+ "license": "CC-BY-4.0"
1319+ },
1320+ "node_modules/convert-source-map": {
1321+ "version": "2.0.0",
1322+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1323+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1324+ "dev": true,
1325+ "license": "MIT"
1326+ },
1327+ "node_modules/csstype": {
1328+ "version": "3.2.3",
1329+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1330+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1331+ "dev": true,
1332+ "license": "MIT"
1333+ },
1334+ "node_modules/debug": {
1335+ "version": "4.4.3",
1336+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1337+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1338+ "dev": true,
1339+ "license": "MIT",
1340+ "dependencies": {
1341+ "ms": "^2.1.3"
1342+ },
1343+ "engines": {
1344+ "node": ">=6.0"
1345+ },
1346+ "peerDependenciesMeta": {
1347+ "supports-color": {
1348+ "optional": true
1349+ }
1350+ }
1351+ },
1352+ "node_modules/electron-to-chromium": {
1353+ "version": "1.5.395",
1354+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
1355+ "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
1356+ "dev": true,
1357+ "license": "ISC"
1358+ },
1359+ "node_modules/esbuild": {
1360+ "version": "0.21.5",
1361+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1362+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1363+ "dev": true,
1364+ "hasInstallScript": true,
1365+ "license": "MIT",
1366+ "bin": {
1367+ "esbuild": "bin/esbuild"
1368+ },
1369+ "engines": {
1370+ "node": ">=12"
1371+ },
1372+ "optionalDependencies": {
1373+ "@esbuild/aix-ppc64": "0.21.5",
1374+ "@esbuild/android-arm": "0.21.5",
1375+ "@esbuild/android-arm64": "0.21.5",
1376+ "@esbuild/android-x64": "0.21.5",
1377+ "@esbuild/darwin-arm64": "0.21.5",
1378+ "@esbuild/darwin-x64": "0.21.5",
1379+ "@esbuild/freebsd-arm64": "0.21.5",
1380+ "@esbuild/freebsd-x64": "0.21.5",
1381+ "@esbuild/linux-arm": "0.21.5",
1382+ "@esbuild/linux-arm64": "0.21.5",
1383+ "@esbuild/linux-ia32": "0.21.5",
1384+ "@esbuild/linux-loong64": "0.21.5",
1385+ "@esbuild/linux-mips64el": "0.21.5",
1386+ "@esbuild/linux-ppc64": "0.21.5",
1387+ "@esbuild/linux-riscv64": "0.21.5",
1388+ "@esbuild/linux-s390x": "0.21.5",
1389+ "@esbuild/linux-x64": "0.21.5",
1390+ "@esbuild/netbsd-x64": "0.21.5",
1391+ "@esbuild/openbsd-x64": "0.21.5",
1392+ "@esbuild/sunos-x64": "0.21.5",
1393+ "@esbuild/win32-arm64": "0.21.5",
1394+ "@esbuild/win32-ia32": "0.21.5",
1395+ "@esbuild/win32-x64": "0.21.5"
1396+ }
1397+ },
1398+ "node_modules/escalade": {
1399+ "version": "3.2.0",
1400+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1401+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1402+ "dev": true,
1403+ "license": "MIT",
1404+ "engines": {
1405+ "node": ">=6"
1406+ }
1407+ },
1408+ "node_modules/fsevents": {
1409+ "version": "2.3.3",
1410+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1411+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1412+ "dev": true,
1413+ "hasInstallScript": true,
1414+ "license": "MIT",
1415+ "optional": true,
1416+ "os": [
1417+ "darwin"
1418+ ],
1419+ "engines": {
1420+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1421+ }
1422+ },
1423+ "node_modules/gensync": {
1424+ "version": "1.0.0-beta.2",
1425+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1426+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1427+ "dev": true,
1428+ "license": "MIT",
1429+ "engines": {
1430+ "node": ">=6.9.0"
1431+ }
1432+ },
1433+ "node_modules/js-tokens": {
1434+ "version": "4.0.0",
1435+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1436+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1437+ "license": "MIT"
1438+ },
1439+ "node_modules/jsesc": {
1440+ "version": "3.1.0",
1441+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1442+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1443+ "dev": true,
1444+ "license": "MIT",
1445+ "bin": {
1446+ "jsesc": "bin/jsesc"
1447+ },
1448+ "engines": {
1449+ "node": ">=6"
1450+ }
1451+ },
1452+ "node_modules/json5": {
1453+ "version": "2.2.3",
1454+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1455+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1456+ "dev": true,
1457+ "license": "MIT",
1458+ "bin": {
1459+ "json5": "lib/cli.js"
1460+ },
1461+ "engines": {
1462+ "node": ">=6"
1463+ }
1464+ },
1465+ "node_modules/loose-envify": {
1466+ "version": "1.4.0",
1467+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1468+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1469+ "license": "MIT",
1470+ "dependencies": {
1471+ "js-tokens": "^3.0.0 || ^4.0.0"
1472+ },
1473+ "bin": {
1474+ "loose-envify": "cli.js"
1475+ }
1476+ },
1477+ "node_modules/lru-cache": {
1478+ "version": "5.1.1",
1479+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1480+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1481+ "dev": true,
1482+ "license": "ISC",
1483+ "dependencies": {
1484+ "yallist": "^3.0.2"
1485+ }
1486+ },
1487+ "node_modules/ms": {
1488+ "version": "2.1.3",
1489+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1490+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1491+ "dev": true,
1492+ "license": "MIT"
1493+ },
1494+ "node_modules/nanoid": {
1495+ "version": "3.3.16",
1496+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
1497+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
1498+ "dev": true,
1499+ "funding": [
1500+ {
1501+ "type": "github",
1502+ "url": "https://github.com/sponsors/ai"
1503+ }
1504+ ],
1505+ "license": "MIT",
1506+ "bin": {
1507+ "nanoid": "bin/nanoid.cjs"
1508+ },
1509+ "engines": {
1510+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1511+ }
1512+ },
1513+ "node_modules/node-releases": {
1514+ "version": "2.0.51",
1515+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
1516+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
1517+ "dev": true,
1518+ "license": "MIT",
1519+ "engines": {
1520+ "node": ">=18"
1521+ }
1522+ },
1523+ "node_modules/picocolors": {
1524+ "version": "1.1.1",
1525+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1526+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1527+ "dev": true,
1528+ "license": "ISC"
1529+ },
1530+ "node_modules/postcss": {
1531+ "version": "8.5.22",
1532+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
1533+ "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
1534+ "dev": true,
1535+ "funding": [
1536+ {
1537+ "type": "opencollective",
1538+ "url": "https://opencollective.com/postcss/"
1539+ },
1540+ {
1541+ "type": "tidelift",
1542+ "url": "https://tidelift.com/funding/github/npm/postcss"
1543+ },
1544+ {
1545+ "type": "github",
1546+ "url": "https://github.com/sponsors/ai"
1547+ }
1548+ ],
1549+ "license": "MIT",
1550+ "dependencies": {
1551+ "nanoid": "^3.3.16",
1552+ "picocolors": "^1.1.1",
1553+ "source-map-js": "^1.2.1"
1554+ },
1555+ "engines": {
1556+ "node": "^10 || ^12 || >=14"
1557+ }
1558+ },
1559+ "node_modules/react": {
1560+ "version": "18.3.1",
1561+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1562+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1563+ "license": "MIT",
1564+ "dependencies": {
1565+ "loose-envify": "^1.1.0"
1566+ },
1567+ "engines": {
1568+ "node": ">=0.10.0"
1569+ }
1570+ },
1571+ "node_modules/react-dom": {
1572+ "version": "18.3.1",
1573+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1574+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1575+ "license": "MIT",
1576+ "dependencies": {
1577+ "loose-envify": "^1.1.0",
1578+ "scheduler": "^0.23.2"
1579+ },
1580+ "peerDependencies": {
1581+ "react": "^18.3.1"
1582+ }
1583+ },
1584+ "node_modules/react-refresh": {
1585+ "version": "0.17.0",
1586+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1587+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1588+ "dev": true,
1589+ "license": "MIT",
1590+ "engines": {
1591+ "node": ">=0.10.0"
1592+ }
1593+ },
1594+ "node_modules/rollup": {
1595+ "version": "4.62.2",
1596+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
1597+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
1598+ "dev": true,
1599+ "license": "MIT",
1600+ "dependencies": {
1601+ "@types/estree": "1.0.9"
1602+ },
1603+ "bin": {
1604+ "rollup": "dist/bin/rollup"
1605+ },
1606+ "engines": {
1607+ "node": ">=18.0.0",
1608+ "npm": ">=8.0.0"
1609+ },
1610+ "optionalDependencies": {
1611+ "@rollup/rollup-android-arm-eabi": "4.62.2",
1612+ "@rollup/rollup-android-arm64": "4.62.2",
1613+ "@rollup/rollup-darwin-arm64": "4.62.2",
1614+ "@rollup/rollup-darwin-x64": "4.62.2",
1615+ "@rollup/rollup-freebsd-arm64": "4.62.2",
1616+ "@rollup/rollup-freebsd-x64": "4.62.2",
1617+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
1618+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
1619+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
1620+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
1621+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
1622+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
1623+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
1624+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
1625+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
1626+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
1627+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
1628+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
1629+ "@rollup/rollup-linux-x64-musl": "4.62.2",
1630+ "@rollup/rollup-openbsd-x64": "4.62.2",
1631+ "@rollup/rollup-openharmony-arm64": "4.62.2",
1632+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
1633+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
1634+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
1635+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
1636+ "fsevents": "~2.3.2"
1637+ }
1638+ },
1639+ "node_modules/scheduler": {
1640+ "version": "0.23.2",
1641+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1642+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1643+ "license": "MIT",
1644+ "dependencies": {
1645+ "loose-envify": "^1.1.0"
1646+ }
1647+ },
1648+ "node_modules/semver": {
1649+ "version": "6.3.1",
1650+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1651+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1652+ "dev": true,
1653+ "license": "ISC",
1654+ "bin": {
1655+ "semver": "bin/semver.js"
1656+ }
1657+ },
1658+ "node_modules/source-map-js": {
1659+ "version": "1.2.1",
1660+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1661+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1662+ "dev": true,
1663+ "license": "BSD-3-Clause",
1664+ "engines": {
1665+ "node": ">=0.10.0"
1666+ }
1667+ },
1668+ "node_modules/typescript": {
1669+ "version": "5.9.3",
1670+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1671+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1672+ "dev": true,
1673+ "license": "Apache-2.0",
1674+ "bin": {
1675+ "tsc": "bin/tsc",
1676+ "tsserver": "bin/tsserver"
1677+ },
1678+ "engines": {
1679+ "node": ">=14.17"
1680+ }
1681+ },
1682+ "node_modules/update-browserslist-db": {
1683+ "version": "1.2.3",
1684+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
1685+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
1686+ "dev": true,
1687+ "funding": [
1688+ {
1689+ "type": "opencollective",
1690+ "url": "https://opencollective.com/browserslist"
1691+ },
1692+ {
1693+ "type": "tidelift",
1694+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1695+ },
1696+ {
1697+ "type": "github",
1698+ "url": "https://github.com/sponsors/ai"
1699+ }
1700+ ],
1701+ "license": "MIT",
1702+ "dependencies": {
1703+ "escalade": "^3.2.0",
1704+ "picocolors": "^1.1.1"
1705+ },
1706+ "bin": {
1707+ "update-browserslist-db": "cli.js"
1708+ },
1709+ "peerDependencies": {
1710+ "browserslist": ">= 4.21.0"
1711+ }
1712+ },
1713+ "node_modules/vite": {
1714+ "version": "5.4.21",
1715+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1716+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1717+ "dev": true,
1718+ "license": "MIT",
1719+ "dependencies": {
1720+ "esbuild": "^0.21.3",
1721+ "postcss": "^8.4.43",
1722+ "rollup": "^4.20.0"
1723+ },
1724+ "bin": {
1725+ "vite": "bin/vite.js"
1726+ },
1727+ "engines": {
1728+ "node": "^18.0.0 || >=20.0.0"
1729+ },
1730+ "funding": {
1731+ "url": "https://github.com/vitejs/vite?sponsor=1"
1732+ },
1733+ "optionalDependencies": {
1734+ "fsevents": "~2.3.3"
1735+ },
1736+ "peerDependencies": {
1737+ "@types/node": "^18.0.0 || >=20.0.0",
1738+ "less": "*",
1739+ "lightningcss": "^1.21.0",
1740+ "sass": "*",
1741+ "sass-embedded": "*",
1742+ "stylus": "*",
1743+ "sugarss": "*",
1744+ "terser": "^5.4.0"
1745+ },
1746+ "peerDependenciesMeta": {
1747+ "@types/node": {
1748+ "optional": true
1749+ },
1750+ "less": {
1751+ "optional": true
1752+ },
1753+ "lightningcss": {
1754+ "optional": true
1755+ },
1756+ "sass": {
1757+ "optional": true
1758+ },
1759+ "sass-embedded": {
1760+ "optional": true
1761+ },
1762+ "stylus": {
1763+ "optional": true
1764+ },
1765+ "sugarss": {
1766+ "optional": true
1767+ },
1768+ "terser": {
1769+ "optional": true
1770+ }
1771+ }
1772+ },
1773+ "node_modules/yallist": {
1774+ "version": "3.1.1",
1775+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1776+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1777+ "dev": true,
1778+ "license": "ISC"
1779+ }
1780+ }
1781+}
package.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "name": "commonroom",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "preview": "vite preview"
10+ },
11+ "dependencies": {
12+ "@noble/secp256k1": "^3.1.0",
13+ "react": "^18.3.1",
14+ "react-dom": "^18.3.1"
15+ },
16+ "devDependencies": {
17+ "@types/react": "^18.3.12",
18+ "@types/react-dom": "^18.3.1",
19+ "@vitejs/plugin-react": "^4.3.4",
20+ "typescript": "^5.6.3",
21+ "vite": "^5.4.11"
22+ }
23+}
src/App.tsxadded+724−0View file
@@ -0,0 +1,724 @@
1+import {useEffect, useRef, useState} from 'react'
2+import {MAX_PARTICIPANTS, type ParticipantInfo} from './p2p/network'
3+import {VIDEO_QUALITIES, type VideoQuality} from './p2p/settings'
4+import {useNetwork} from './useNetwork'
5+
6+// Screen capture is desktop-only in practice; hide the button where the API
7+// doesn't exist (most mobile browsers).
8+const canShareScreen =
9+ typeof navigator.mediaDevices?.getDisplayMedia === 'function'
10+
11+const initialRoomFromHash = (): string => {
12+ try {
13+ return decodeURIComponent(location.hash.slice(1)).replace(/\s/g, '')
14+ } catch {
15+ return ''
16+ }
17+}
18+
19+// ---- styles ---------------------------------------------------------------
20+
21+const lightBtn: React.CSSProperties = {
22+ padding: '0.45rem 1.1rem',
23+ borderRadius: 6,
24+ border: '1px solid #888',
25+ background: '#fff',
26+ cursor: 'pointer',
27+ fontSize: '1rem'
28+}
29+
30+const primaryBtn: React.CSSProperties = {
31+ ...lightBtn,
32+ background: '#1a7f37',
33+ borderColor: '#1a7f37',
34+ color: '#fff'
35+}
36+
37+const disabledStyle: React.CSSProperties = {
38+ opacity: 0.4,
39+ cursor: 'not-allowed'
40+}
41+
42+const inputStyle: React.CSSProperties = {
43+ padding: '0.5rem',
44+ fontSize: '1rem',
45+ borderRadius: 6,
46+ border: '1px solid #bbb',
47+ width: '100%',
48+ boxSizing: 'border-box'
49+}
50+
51+// Dark in-room controls.
52+const darkBtn: React.CSSProperties = {
53+ padding: '0.5rem 0.9rem',
54+ borderRadius: 8,
55+ border: '1px solid #555',
56+ background: '#2a2a2a',
57+ color: '#eee',
58+ cursor: 'pointer',
59+ fontSize: '0.95rem',
60+ display: 'inline-flex',
61+ alignItems: 'center',
62+ gap: '0.4rem'
63+}
64+
65+// Square icon-only buttons for the control bar. Their meaning is carried by
66+// the icon plus a title tooltip and aria-label.
67+const iconBtn: React.CSSProperties = {
68+ ...darkBtn,
69+ padding: '0.65rem',
70+ justifyContent: 'center'
71+}
72+
73+// A mute button while muted — red, the universal "you are muted" signal.
74+const mutedIconBtn: React.CSSProperties = {
75+ ...iconBtn,
76+ background: '#c62828',
77+ borderColor: '#c62828',
78+ color: '#fff'
79+}
80+
81+// The screen-share button while sharing.
82+const sharingIconBtn: React.CSSProperties = {
83+ ...iconBtn,
84+ background: '#1a7f37',
85+ borderColor: '#1a7f37',
86+ color: '#fff'
87+}
88+
89+const leaveBtn: React.CSSProperties = {
90+ ...darkBtn,
91+ background: '#c62828',
92+ borderColor: '#c62828',
93+ color: '#fff'
94+}
95+
96+// Chip overlaid on a tile (name label, mute badges).
97+const tileChip: React.CSSProperties = {
98+ background: 'rgba(0, 0, 0, 0.65)',
99+ color: '#fff',
100+ borderRadius: 6,
101+ padding: '0.2rem 0.5rem',
102+ fontSize: '0.85rem',
103+ display: 'inline-flex',
104+ alignItems: 'center',
105+ gap: '0.3rem',
106+ maxWidth: '90%'
107+}
108+
109+// ---- icons (stroke-style paths from Feather icons, MIT) --------------------
110+
111+const ICONS = {
112+ mic: (
113+ <>
114+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
115+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
116+ <path d="M12 19v4" />
117+ <path d="M8 23h8" />
118+ </>
119+ ),
120+ micOff: (
121+ <>
122+ <path d="M1 1l22 22" />
123+ <path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
124+ <path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
125+ <path d="M12 19v4" />
126+ <path d="M8 23h8" />
127+ </>
128+ ),
129+ video: (
130+ <>
131+ <path d="M23 7l-7 5 7 5V7z" />
132+ <rect x="1" y="5" width="15" height="14" rx="2" />
133+ </>
134+ ),
135+ videoOff: (
136+ <>
137+ <path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" />
138+ <path d="M1 1l22 22" />
139+ </>
140+ ),
141+ monitor: (
142+ <>
143+ <rect x="2" y="3" width="20" height="14" rx="2" />
144+ <path d="M8 21h8" />
145+ <path d="M12 17v4" />
146+ </>
147+ ),
148+ link: (
149+ <>
150+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
151+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
152+ </>
153+ ),
154+ logout: (
155+ <>
156+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
157+ <path d="M16 17l5-5-5-5" />
158+ <path d="M21 12H9" />
159+ </>
160+ )
161+} as const
162+
163+function Icon({
164+ name,
165+ size = 18,
166+ style
167+}: {
168+ name: keyof typeof ICONS
169+ size?: number
170+ style?: React.CSSProperties
171+}) {
172+ return (
173+ <svg
174+ width={size}
175+ height={size}
176+ viewBox="0 0 24 24"
177+ fill="none"
178+ stroke="currentColor"
179+ strokeWidth={2}
180+ strokeLinecap="round"
181+ strokeLinejoin="round"
182+ style={{display: 'block', ...style}}
183+ aria-hidden
184+ >
185+ {ICONS[name]}
186+ </svg>
187+ )
188+}
189+
190+// ---- video tile -------------------------------------------------------------
191+
192+function VideoView({
193+ stream,
194+ muted,
195+ mirror
196+}: {
197+ stream: MediaStream | null
198+ muted: boolean
199+ mirror: boolean
200+}) {
201+ const ref = useRef<HTMLVideoElement>(null)
202+ useEffect(() => {
203+ if (ref.current && ref.current.srcObject !== stream) {
204+ ref.current.srcObject = stream
205+ }
206+ }, [stream])
207+ return (
208+ <video
209+ ref={ref}
210+ autoPlay
211+ playsInline
212+ muted={muted}
213+ style={{
214+ position: 'absolute',
215+ inset: 0,
216+ width: '100%',
217+ height: '100%',
218+ objectFit: 'cover',
219+ transform: mirror ? 'scaleX(-1)' : undefined
220+ }}
221+ />
222+ )
223+}
224+
225+function Tile({
226+ stream,
227+ label,
228+ isSelf,
229+ mirror,
230+ audioMuted,
231+ videoMuted,
232+ connecting
233+}: {
234+ stream: MediaStream | null
235+ label: string
236+ isSelf: boolean
237+ mirror: boolean
238+ audioMuted: boolean
239+ videoMuted: boolean
240+ connecting: boolean
241+}) {
242+ return (
243+ <div
244+ style={{
245+ position: 'relative',
246+ background: '#000',
247+ borderRadius: 10,
248+ overflow: 'hidden',
249+ aspectRatio: '4 / 3',
250+ border: isSelf ? '1px solid #444' : '1px solid #222'
251+ }}
252+ >
253+ {/* The video element stays mounted even when their camera is off so the
254+ audio keeps playing; the placeholder just covers it. */}
255+ <VideoView stream={stream} muted={isSelf} mirror={mirror} />
256+ {(videoMuted || connecting) && (
257+ <div
258+ style={{
259+ position: 'absolute',
260+ inset: 0,
261+ background: '#222',
262+ display: 'flex',
263+ flexDirection: 'column',
264+ alignItems: 'center',
265+ justifyContent: 'center',
266+ gap: '0.5rem'
267+ }}
268+ >
269+ <div
270+ style={{
271+ width: 64,
272+ height: 64,
273+ borderRadius: '50%',
274+ background: '#3a3a3a',
275+ color: '#ddd',
276+ display: 'flex',
277+ alignItems: 'center',
278+ justifyContent: 'center',
279+ fontSize: '1.6rem',
280+ fontFamily: 'sans-serif'
281+ }}
282+ >
283+ {(label[0] ?? '?').toUpperCase()}
284+ </div>
285+ {connecting && (
286+ <span style={{color: '#999', fontSize: '0.9rem'}}>Connecting…</span>
287+ )}
288+ </div>
289+ )}
290+ <div
291+ style={{
292+ position: 'absolute',
293+ left: 8,
294+ bottom: 8,
295+ display: 'flex',
296+ gap: '0.35rem',
297+ alignItems: 'center'
298+ }}
299+ >
300+ <span style={tileChip}>
301+ <span
302+ style={{
303+ overflow: 'hidden',
304+ textOverflow: 'ellipsis',
305+ whiteSpace: 'nowrap'
306+ }}
307+ >
308+ {label}
309+ </span>
310+ {audioMuted && <Icon name="micOff" size={13} />}
311+ </span>
312+ </div>
313+ </div>
314+ )
315+}
316+
317+// ---- landing ------------------------------------------------------------------
318+
319+function Landing({
320+ notice,
321+ initialName,
322+ onDismissNotice,
323+ onEnter
324+}: {
325+ notice: string | null
326+ initialName: string
327+ onDismissNotice: () => void
328+ onEnter: (name: string, room: string) => void
329+}) {
330+ const [name, setName] = useState(initialName)
331+ const [room, setRoom] = useState(initialRoomFromHash)
332+ const canEnter = name.trim().length > 0 && room.length > 0
333+ const submit = (e: React.FormEvent) => {
334+ e.preventDefault()
335+ if (canEnter) onEnter(name, room)
336+ }
337+ return (
338+ <div
339+ style={{
340+ fontFamily: 'sans-serif',
341+ maxWidth: 460,
342+ margin: '3rem auto',
343+ padding: '0 1rem'
344+ }}
345+ >
346+ <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
347+ <p style={{color: '#666', marginTop: 0}}>
348+ Group video calls with no server. Pick a room, share the link, and
349+ talk — everything flows peer-to-peer.
350+ </p>
351+
352+ {notice && (
353+ <div
354+ style={{
355+ background: '#fff3cd',
356+ border: '1px solid #e0c968',
357+ borderRadius: 6,
358+ padding: '0.5rem 0.75rem',
359+ margin: '0.75rem 0',
360+ display: 'flex',
361+ justifyContent: 'space-between',
362+ alignItems: 'center',
363+ gap: '0.5rem'
364+ }}
365+ >
366+ <span>{notice}</span>
367+ <button style={lightBtn} onClick={onDismissNotice}>
368+ OK
369+ </button>
370+ </div>
371+ )}
372+
373+ <form onSubmit={submit} style={{marginTop: '1.5rem'}}>
374+ <label style={{display: 'block', marginBottom: '1rem'}}>
375+ <div style={{marginBottom: '0.3rem'}}>Your name</div>
376+ <input
377+ autoFocus={!initialName}
378+ value={name}
379+ onChange={e => setName(e.target.value)}
380+ placeholder="e.g. Jeremy"
381+ maxLength={40}
382+ style={inputStyle}
383+ />
384+ </label>
385+ <label style={{display: 'block', marginBottom: '1.25rem'}}>
386+ <div style={{marginBottom: '0.3rem'}}>Room name</div>
387+ <input
388+ autoFocus={!!initialName}
389+ value={room}
390+ onChange={e => setRoom(e.target.value.replace(/\s/g, ''))}
391+ placeholder="any-name-you-like (no spaces)"
392+ maxLength={100}
393+ style={inputStyle}
394+ />
395+ </label>
396+ <button
397+ type="submit"
398+ style={canEnter ? primaryBtn : {...primaryBtn, ...disabledStyle}}
399+ disabled={!canEnter}
400+ >
401+ Enter room
402+ </button>
403+ </form>
404+
405+ <p style={{color: '#888', fontSize: '0.85rem', marginTop: '1.5rem'}}>
406+ Anyone who knows the room name can join — up to {MAX_PARTICIPANTS}{' '}
407+ people per room. You enter with your microphone and camera off.
408+ </p>
409+ </div>
410+ )
411+}
412+
413+// ---- room ----------------------------------------------------------------------
414+
415+function CopyLinkButton({compact}: {compact: boolean}) {
416+ const [copied, setCopied] = useState(false)
417+ const copy = async () => {
418+ try {
419+ await navigator.clipboard.writeText(location.href)
420+ setCopied(true)
421+ setTimeout(() => setCopied(false), 1500)
422+ } catch {
423+ /* clipboard unavailable; the address bar still has the link */
424+ }
425+ }
426+ return (
427+ <button
428+ style={darkBtn}
429+ onClick={() => void copy()}
430+ title="Copy the room link to share"
431+ >
432+ <Icon name="link" size={15} />
433+ {copied ? 'Copied!' : compact ? 'Copy link' : 'Copy room link'}
434+ </button>
435+ )
436+}
437+
438+function ParticipantTile({p}: {p: ParticipantInfo}) {
439+ return (
440+ <Tile
441+ stream={p.stream}
442+ label={p.name}
443+ isSelf={false}
444+ mirror={false}
445+ audioMuted={p.audioMuted}
446+ videoMuted={p.videoMuted}
447+ connecting={!p.connected}
448+ />
449+ )
450+}
451+
452+export default function App() {
453+ const {snapshot, network} = useNetwork()
454+ const {
455+ phase,
456+ roomId,
457+ name,
458+ participants,
459+ audioMuted,
460+ videoMuted,
461+ micAvailable,
462+ camAvailable,
463+ localStream,
464+ screenStream,
465+ settings,
466+ notice
467+ } = snapshot
468+
469+ if (phase === 'landing') {
470+ return (
471+ <Landing
472+ notice={notice}
473+ initialName={network.savedName}
474+ onDismissNotice={() => network.dismissNotice()}
475+ onEnter={(n, r) => void network.enterRoom(n, r)}
476+ />
477+ )
478+ }
479+
480+ if (phase === 'joining') {
481+ return (
482+ <div
483+ style={{
484+ fontFamily: 'sans-serif',
485+ maxWidth: 460,
486+ margin: '3rem auto',
487+ padding: '0 1rem'
488+ }}
489+ >
490+ <h1 style={{marginBottom: '0.25rem'}}>CommonRoom</h1>
491+ <p>
492+ Joining <strong>#{roomId}</strong>… your browser may ask for camera
493+ and microphone access (you'll still be muted until you turn them on).
494+ </p>
495+ <button style={lightBtn} onClick={() => network.leave()}>
496+ Cancel
497+ </button>
498+ </div>
499+ )
500+ }
501+
502+ // ---- in-room (dark) ----
503+ const sharing = screenStream !== null
504+ const count = participants.length + 1
505+ const alone = participants.length === 0
506+
507+ return (
508+ <div
509+ style={{
510+ position: 'fixed',
511+ inset: 0,
512+ background: '#111',
513+ color: '#eee',
514+ fontFamily: 'sans-serif',
515+ display: 'flex',
516+ flexDirection: 'column'
517+ }}
518+ >
519+ <header
520+ style={{
521+ display: 'flex',
522+ alignItems: 'center',
523+ gap: '0.75rem',
524+ padding: '0.55rem 1rem',
525+ background: '#1c1c1c',
526+ borderBottom: '1px solid #333',
527+ flexWrap: 'wrap'
528+ }}
529+ >
530+ <strong>CommonRoom</strong>
531+ <span
532+ style={{
533+ color: '#bbb',
534+ overflow: 'hidden',
535+ textOverflow: 'ellipsis',
536+ whiteSpace: 'nowrap',
537+ maxWidth: '40%'
538+ }}
539+ >
540+ #{roomId}
541+ </span>
542+ <span style={{color: '#888', fontSize: '0.9rem'}}>
543+ {count} of {MAX_PARTICIPANTS}
544+ </span>
545+ <span style={{flex: 1}} />
546+ <CopyLinkButton compact />
547+ <button
548+ style={leaveBtn}
549+ onClick={() => network.leave()}
550+ title="Leave the room"
551+ >
552+ <Icon name="logout" size={15} />
553+ Leave
554+ </button>
555+ </header>
556+
557+ <main style={{flex: 1, overflowY: 'auto', padding: '1rem'}}>
558+ {notice && (
559+ <div
560+ style={{
561+ background: '#3a2f10',
562+ border: '1px solid #8a6d1a',
563+ color: '#f0dfa2',
564+ borderRadius: 8,
565+ padding: '0.5rem 0.75rem',
566+ margin: '0 auto 1rem',
567+ maxWidth: 1100,
568+ display: 'flex',
569+ justifyContent: 'space-between',
570+ alignItems: 'center',
571+ gap: '0.5rem'
572+ }}
573+ >
574+ <span>{notice}</span>
575+ <button style={darkBtn} onClick={() => network.dismissNotice()}>
576+ OK
577+ </button>
578+ </div>
579+ )}
580+
581+ <div
582+ style={{
583+ display: 'grid',
584+ gap: '0.75rem',
585+ gridTemplateColumns: alone
586+ ? 'minmax(0, 480px)'
587+ : 'repeat(auto-fit, minmax(min(280px, 100%), 1fr))',
588+ justifyContent: 'center',
589+ maxWidth: 1100,
590+ margin: '0 auto'
591+ }}
592+ >
593+ <Tile
594+ stream={screenStream ?? localStream}
595+ label={`${name} (you)`}
596+ isSelf
597+ mirror={!sharing}
598+ audioMuted={audioMuted}
599+ videoMuted={videoMuted && !sharing}
600+ connecting={false}
601+ />
602+ {participants.map(p => (
603+ <ParticipantTile key={p.peerId} p={p} />
604+ ))}
605+ </div>
606+
607+ {alone && (
608+ <div
609+ style={{
610+ maxWidth: 480,
611+ margin: '1.25rem auto 0',
612+ background: '#1c1c1c',
613+ border: '1px solid #333',
614+ borderRadius: 10,
615+ padding: '1rem',
616+ textAlign: 'center'
617+ }}
618+ >
619+ <p style={{marginTop: 0}}>
620+ You're the only one here. Share this link so others can join:
621+ </p>
622+ <p
623+ style={{
624+ color: '#9cc4ff',
625+ wordBreak: 'break-all',
626+ fontSize: '0.9rem',
627+ userSelect: 'all'
628+ }}
629+ >
630+ {location.href}
631+ </p>
632+ <CopyLinkButton compact={false} />
633+ </div>
634+ )}
635+ </main>
636+
637+ <footer
638+ style={{
639+ display: 'flex',
640+ alignItems: 'center',
641+ justifyContent: 'center',
642+ flexWrap: 'wrap',
643+ gap: '0.6rem',
644+ padding: '0.7rem 1rem',
645+ background: '#1c1c1c',
646+ borderTop: '1px solid #333'
647+ }}
648+ >
649+ <button
650+ style={audioMuted ? mutedIconBtn : iconBtn}
651+ title={
652+ audioMuted
653+ ? micAvailable
654+ ? 'Unmute your microphone'
655+ : 'Microphone unavailable — click to try again'
656+ : 'Mute your microphone'
657+ }
658+ aria-label={audioMuted ? 'Unmute your microphone' : 'Mute your microphone'}
659+ onClick={() => network.setAudioMuted(!audioMuted)}
660+ >
661+ <Icon name={audioMuted ? 'micOff' : 'mic'} />
662+ </button>
663+ <button
664+ style={videoMuted ? mutedIconBtn : iconBtn}
665+ title={
666+ videoMuted
667+ ? camAvailable
668+ ? 'Turn your camera on'
669+ : 'Camera unavailable — click to try again'
670+ : 'Turn your camera off'
671+ }
672+ aria-label={videoMuted ? 'Turn your camera on' : 'Turn your camera off'}
673+ onClick={() => network.setVideoMuted(!videoMuted)}
674+ >
675+ <Icon name={videoMuted ? 'videoOff' : 'video'} />
676+ </button>
677+ {canShareScreen && (
678+ <button
679+ style={sharing ? sharingIconBtn : iconBtn}
680+ title={sharing ? 'Stop sharing your screen' : 'Share your screen'}
681+ aria-label={sharing ? 'Stop sharing your screen' : 'Share your screen'}
682+ onClick={() =>
683+ sharing
684+ ? void network.stopScreenShare()
685+ : void network.startScreenShare()
686+ }
687+ >
688+ <Icon name="monitor" />
689+ </button>
690+ )}
691+ <label
692+ title="Video quality for the whole room — anyone can change it, and it applies to everyone"
693+ style={{
694+ display: 'flex',
695+ alignItems: 'center',
696+ gap: '0.4rem',
697+ fontSize: '0.9rem',
698+ color: '#ccc'
699+ }}
700+ >
701+ Quality
702+ <select
703+ value={settings.videoQuality}
704+ onChange={e => network.setVideoQuality(e.target.value as VideoQuality)}
705+ style={{
706+ padding: '0.4rem',
707+ borderRadius: 8,
708+ border: '1px solid #555',
709+ background: '#2a2a2a',
710+ color: '#eee',
711+ fontSize: '0.9rem'
712+ }}
713+ >
714+ {VIDEO_QUALITIES.map(q => (
715+ <option key={q} value={q}>
716+ {q[0].toUpperCase() + q.slice(1)}
717+ </option>
718+ ))}
719+ </select>
720+ </label>
721+ </footer>
722+ </div>
723+ )
724+}
src/main.tsxadded+9−0View file
@@ -0,0 +1,9 @@
1+import {StrictMode} from 'react'
2+import {createRoot} from 'react-dom/client'
3+import App from './App'
4+
5+createRoot(document.getElementById('root')!).render(
6+ <StrictMode>
7+ <App />
8+ </StrictMode>
9+)
src/p2p/identity.tsadded+75−0View file
@@ -0,0 +1,75 @@
1+import * as secp from '@noble/secp256k1'
2+
3+// The peer's identity is a secp256k1 / BIP340 (schnorr) keypair.
4+// - The x-only public key (hex) IS the peer ID.
5+// - The private key is persisted in localStorage so the identity survives reloads.
6+// - The key signs the nostr events used for presence and WebRTC signaling, so
7+// nobody can speak on behalf of another peer ID.
8+
9+const STORAGE_KEY = 'commonroom:privkey'
10+
11+const toHex = (bytes: Uint8Array): string =>
12+ bytes.reduce((s, b) => s + b.toString(16).padStart(2, '0'), '')
13+
14+const fromHex = (hex: string): Uint8Array => {
15+ const out = new Uint8Array(hex.length / 2)
16+ for (let i = 0; i < out.length; i++) {
17+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
18+ }
19+ return out
20+}
21+
22+const loadOrCreateSecretKey = (): Uint8Array => {
23+ const existing = localStorage.getItem(STORAGE_KEY)
24+ if (existing && existing.length === 64) {
25+ return fromHex(existing)
26+ }
27+ const {secretKey} = secp.schnorr.keygen()
28+ localStorage.setItem(STORAGE_KEY, toHex(secretKey))
29+ return secretKey
30+}
31+
32+const secretKey = loadOrCreateSecretKey()
33+const publicKey = secp.schnorr.getPublicKey(secretKey)
34+
35+/** This peer's ID = its x-only public key, as hex. */
36+export const selfId: string = toHex(publicKey)
37+
38+const sha256 = async (str: string): Promise<Uint8Array> =>
39+ new Uint8Array(
40+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
41+ )
42+
43+// ---- nostr event signing (schnorr over the nostr event id) ----
44+
45+export interface NostrEvent {
46+ id: string
47+ pubkey: string
48+ created_at: number
49+ kind: number
50+ tags: string[][]
51+ content: string
52+ sig: string
53+}
54+
55+/** Build and sign a nostr event with this peer's key. */
56+export const makeNostrEvent = async (
57+ kind: number,
58+ tags: string[][],
59+ content: string
60+): Promise<NostrEvent> => {
61+ const created_at = Math.floor(Date.now() / 1000)
62+ const serialized = JSON.stringify([
63+ 0,
64+ selfId,
65+ created_at,
66+ kind,
67+ tags,
68+ content
69+ ])
70+ const id = toHex(await sha256(serialized))
71+ const sig = toHex(await secp.schnorr.signAsync(fromHex(id), secretKey))
72+ return {id, pubkey: selfId, created_at, kind, tags, content, sig}
73+}
74+
75+export {toHex, fromHex}
src/p2p/network.tsadded+845−0View file
@@ -0,0 +1,845 @@
1+import {selfId} from './identity'
2+import {Nostr, peerTopic, roomTopic} from './nostr'
3+import {Peer, type Signal, type VideoSendParams} from './peer'
4+import {
5+ DEFAULT_SETTINGS,
6+ QUALITY_PARAMS,
7+ SETTING_VALIDATORS,
8+ type RoomSettings,
9+ type VideoQuality
10+} from './settings'
11+
12+// ---------------------------------------------------------------------------
13+// CommonRoom network layer: a full-mesh group video call.
14+//
15+// Rooms: the room ID is any string (no spaces); it is hashed into a nostr
16+// topic, so there is no room registry anywhere — knowing the name IS the key.
17+//
18+// Presence: everyone in the room announces {peerId, name} on the room topic
19+// every few seconds; entries expire when announcements stop.
20+//
21+// Mesh: unlike commoncall (mutual consent, one call at a time), being in the
22+// room IS the consent — every participant automatically brings up a WebRTC
23+// connection with every other participant (commonview's approach, but carrying
24+// media). Camera/mic are requested on entry, but both start MUTED; if access
25+// is denied you still join, sending synthetic silent/black placeholder tracks,
26+// and unmuting retries the device and upgrades the tracks in place.
27+//
28+// Everything else (deterministic initiator = smaller peer ID, per-peer nostr
29+// signaling topics, control data channel, track-swap screen share, quality
30+// caps via setParameters) is the commoncall design, applied per-peer.
31+// ---------------------------------------------------------------------------
32+
33+/** Soft cap: peers at capacity turn newcomers away with {t:'room-full'}. */
34+export const MAX_PARTICIPANTS = 8
35+
36+interface Announcement {
37+ peerId: string
38+ name: string
39+}
40+
41+// Messages on per-peer nostr topics (pre-connection).
42+type PeerMsg = {t: 'signal'; signal: Signal} | {t: 'room-full'}
43+
44+// Messages on the per-peer control data channels (WebRTC, not nostr).
45+interface SettingEntry {
46+ key: string
47+ value: unknown
48+ rev: number
49+ by: string
50+}
51+type ControlMsg =
52+ | {
53+ t: 'hello'
54+ name: string
55+ audioMuted: boolean
56+ videoMuted: boolean
57+ settings: SettingEntry[]
58+ }
59+ | ({t: 'set'} & SettingEntry)
60+ | {t: 'mute'; audio: boolean; video: boolean}
61+ | {t: 'bye'}
62+
63+const ANNOUNCE_INTERVAL_MS = 5000
64+const PRESENCE_TTL_MS = 15000
65+// A connection attempt that hasn't opened after this long is torn down and
66+// retried on the peer's next announcement. Signaling events are ephemeral, so
67+// an offer published before the other side was listening is simply lost —
68+// without a retry the pair would deadlock forever.
69+const CONNECT_RETRY_MS = 15000
70+
71+const NAME_KEY = 'commonroom:name'
72+
73+export type Phase = 'landing' | 'joining' | 'room'
74+
75+interface Conn {
76+ peer: Peer
77+ /** When this connection attempt started (local clock), for retry pacing. */
78+ createdAt: number
79+ /** Name from the hello message (presence announcements may lag behind). */
80+ name: string | null
81+ connected: boolean
82+ stream: MediaStream | null
83+ /** Their reported effective outgoing mute state (muted until told otherwise
84+ * — everyone starts muted). */
85+ audioMuted: boolean
86+ videoMuted: boolean
87+}
88+
89+export interface ParticipantInfo {
90+ peerId: string
91+ name: string
92+ connected: boolean
93+ stream: MediaStream | null
94+ audioMuted: boolean
95+ videoMuted: boolean
96+}
97+
98+export interface Snapshot {
99+ selfId: string
100+ phase: Phase
101+ roomId: string | null
102+ name: string | null
103+ /** Everyone else in the room (connected or still connecting). */
104+ participants: ParticipantInfo[]
105+ audioMuted: boolean
106+ videoMuted: boolean
107+ micAvailable: boolean
108+ camAvailable: boolean
109+ localStream: MediaStream | null
110+ screenStream: MediaStream | null
111+ settings: RoomSettings
112+ notice: string | null
113+}
114+
115+export class Network {
116+ private nostr = new Nostr()
117+ private phase: Phase = 'landing'
118+ private roomId: string | null = null
119+ private root = ''
120+ private name: string | null = null
121+ private presence = new Map<string, {name: string; lastSeen: number}>()
122+ private conns = new Map<string, Conn>()
123+ private unsubs: (() => void)[] = []
124+ private announceTimer: number | null = null
125+ private sweepTimer: number | null = null
126+ /** Bumped on every join/leave so stale async work can detect it's obsolete. */
127+ private joinSeq = 0
128+
129+ private localStream: MediaStream | null = null
130+ private screenStream: MediaStream | null = null
131+ private micAvailable = false
132+ private camAvailable = false
133+ private audioMuted = true
134+ private videoMuted = true
135+ private audioCtx: AudioContext | null = null
136+
137+ private settings: RoomSettings = {...DEFAULT_SETTINGS}
138+ /** Per-key revision + setter for the last-writer-wins settings sync. */
139+ private settingsMeta: Partial<
140+ Record<keyof RoomSettings, {rev: number; by: string}>
141+ > = {}
142+
143+ private notice: string | null = null
144+
145+ private snapshot!: Snapshot
146+ private listeners = new Set<() => void>()
147+
148+ /** Last name used on this browser, for prefilling the join form. */
149+ readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
150+
151+ constructor() {
152+ this.rebuildSnapshot()
153+ window.addEventListener('online', () => void this.announce())
154+ // Best-effort goodbye so tiles vanish immediately instead of after the
155+ // presence TTL when a tab closes.
156+ window.addEventListener('pagehide', () => {
157+ if (this.phase === 'room') this.broadcastControl({t: 'bye'})
158+ })
159+ }
160+
161+ // ---- joining and leaving ----------------------------------------------
162+
163+ async enterRoom(name: string, room: string) {
164+ if (this.phase !== 'landing') return
165+ const nm = name.trim().slice(0, 40)
166+ const rm = room.replace(/\s+/g, '').slice(0, 100)
167+ if (!nm || !rm) return
168+ this.name = nm
169+ this.roomId = rm
170+ localStorage.setItem(NAME_KEY, nm)
171+ // Put the room in the URL so the address bar is the invite link.
172+ try {
173+ location.hash = encodeURIComponent(rm)
174+ } catch {
175+ /* ignore */
176+ }
177+ this.notice = null
178+ this.phase = 'joining'
179+ this.rebuildSnapshot()
180+ const seq = ++this.joinSeq
181+
182+ const media = await this.acquireMedia()
183+ if (this.joinSeq !== seq) {
184+ for (const t of media.stream.getTracks()) t.stop()
185+ return
186+ }
187+ this.localStream = media.stream
188+ this.micAvailable = media.mic
189+ this.camAvailable = media.cam
190+ // Everyone enters muted.
191+ this.audioMuted = true
192+ this.videoMuted = true
193+ for (const t of media.stream.getTracks()) t.enabled = false
194+
195+ this.root = await roomTopic(rm)
196+ if (this.joinSeq !== seq) return
197+ const selfTopic = await peerTopic(this.root, selfId)
198+ if (this.joinSeq !== seq) return
199+
200+ // WebRTC signaling (and room-full notices) addressed to us.
201+ this.unsubs.push(
202+ this.nostr.subscribe(selfTopic, (content, from) => {
203+ if (from === selfId) return
204+ let msg: PeerMsg
205+ try {
206+ msg = JSON.parse(content)
207+ } catch {
208+ return
209+ }
210+ this.handlePeerMsg(from, msg)
211+ })
212+ )
213+
214+ // Presence announcements on the room topic.
215+ this.unsubs.push(
216+ this.nostr.subscribe(this.root, (content, from) => {
217+ if (from === selfId) return
218+ let ann: Partial<Announcement>
219+ try {
220+ ann = JSON.parse(content)
221+ } catch {
222+ return
223+ }
224+ if (ann.peerId !== from || typeof ann.name !== 'string') return
225+ const prev = this.presence.get(from)
226+ const annName = ann.name.slice(0, 40)
227+ this.presence.set(from, {name: annName, lastSeen: Date.now()})
228+ if (!prev || prev.name !== annName) this.rebuildSnapshot()
229+ this.maybeConnect(from)
230+ })
231+ )
232+
233+ this.phase = 'room'
234+ void this.announce()
235+ this.announceTimer = window.setInterval(
236+ () => void this.announce(),
237+ ANNOUNCE_INTERVAL_MS
238+ )
239+ this.sweepTimer = window.setInterval(
240+ () => this.sweepPresence(),
241+ ANNOUNCE_INTERVAL_MS
242+ )
243+ this.rebuildSnapshot()
244+ }
245+
246+ leave() {
247+ if (this.phase === 'landing') return
248+ this.teardown()
249+ this.notice = null
250+ this.rebuildSnapshot()
251+ }
252+
253+ private teardown() {
254+ this.joinSeq++
255+ this.broadcastControl({t: 'bye'})
256+ const conns = [...this.conns.values()]
257+ this.conns.clear() // cleared first so close handlers no-op
258+ for (const c of conns) c.peer.destroy()
259+ this.presence.clear()
260+ for (const u of this.unsubs.splice(0)) u()
261+ if (this.announceTimer !== null) clearInterval(this.announceTimer)
262+ if (this.sweepTimer !== null) clearInterval(this.sweepTimer)
263+ this.announceTimer = null
264+ this.sweepTimer = null
265+ if (this.screenStream) {
266+ for (const t of this.screenStream.getTracks()) t.stop()
267+ this.screenStream = null
268+ }
269+ if (this.localStream) {
270+ for (const t of this.localStream.getTracks()) t.stop()
271+ this.localStream = null
272+ }
273+ if (this.audioCtx) {
274+ void this.audioCtx.close().catch(() => undefined)
275+ this.audioCtx = null
276+ }
277+ this.micAvailable = false
278+ this.camAvailable = false
279+ this.audioMuted = true
280+ this.videoMuted = true
281+ this.settings = {...DEFAULT_SETTINGS}
282+ this.settingsMeta = {}
283+ this.root = ''
284+ this.roomId = null
285+ this.phase = 'landing'
286+ }
287+
288+ // ---- local media -------------------------------------------------------
289+ //
290+ // Every participant always carries exactly one audio and one video track so
291+ // the WebRTC offer/answer is symmetric for everyone. If a device is missing
292+ // or permission is denied, a synthetic placeholder (silent audio / black
293+ // video) stands in; unmuting later retries getUserMedia and upgrades the
294+ // placeholder via replaceTrack on every connection — no renegotiation.
295+
296+ private async acquireMedia(): Promise<{
297+ stream: MediaStream
298+ mic: boolean
299+ cam: boolean
300+ }> {
301+ try {
302+ const s = await navigator.mediaDevices.getUserMedia({
303+ audio: true,
304+ video: true
305+ })
306+ return {stream: s, mic: true, cam: true}
307+ } catch {
308+ /* fall through to per-kind attempts */
309+ }
310+ let audio: MediaStreamTrack | null = null
311+ let video: MediaStreamTrack | null = null
312+ try {
313+ const s = await navigator.mediaDevices.getUserMedia({audio: true})
314+ audio = s.getAudioTracks()[0] ?? null
315+ } catch {
316+ /* no mic */
317+ }
318+ try {
319+ const s = await navigator.mediaDevices.getUserMedia({video: true})
320+ video = s.getVideoTracks()[0] ?? null
321+ } catch {
322+ /* no camera */
323+ }
324+ const stream = new MediaStream()
325+ stream.addTrack(audio ?? this.silentAudioTrack())
326+ stream.addTrack(video ?? blackVideoTrack())
327+ return {stream, mic: audio !== null, cam: video !== null}
328+ }
329+
330+ private silentAudioTrack(): MediaStreamTrack {
331+ if (!this.audioCtx) this.audioCtx = new AudioContext()
332+ const dst = this.audioCtx.createMediaStreamDestination()
333+ return dst.stream.getAudioTracks()[0]
334+ }
335+
336+ /** The tracks we send to a (new) peer: mic audio plus screen or camera. */
337+ private outgoingStream(): MediaStream {
338+ const s = new MediaStream()
339+ const audio = this.localStream?.getAudioTracks()[0]
340+ if (audio) s.addTrack(audio)
341+ const video =
342+ this.screenStream?.getVideoTracks()[0] ??
343+ this.localStream?.getVideoTracks()[0]
344+ if (video) s.addTrack(video)
345+ return s
346+ }
347+
348+ // ---- presence and the mesh ----------------------------------------------
349+
350+ private async announce() {
351+ if (this.phase !== 'room' || !this.name || !this.root) return
352+ const ann: Announcement = {peerId: selfId, name: this.name}
353+ void this.nostr.publish(this.root, JSON.stringify(ann))
354+ }
355+
356+ private sweepPresence() {
357+ const cutoff = Date.now() - PRESENCE_TTL_MS
358+ let changed = false
359+ for (const [peerId, p] of this.presence) {
360+ if (p.lastSeen < cutoff) {
361+ this.presence.delete(peerId)
362+ changed = true
363+ }
364+ }
365+ if (changed) this.rebuildSnapshot()
366+ }
367+
368+ private async sendToPeer(peerId: string, msg: PeerMsg) {
369+ if (!this.root) return
370+ const topic = await peerTopic(this.root, peerId)
371+ void this.nostr.publish(topic, JSON.stringify(msg))
372+ }
373+
374+ private atCapacity(): boolean {
375+ return this.conns.size >= MAX_PARTICIPANTS - 1
376+ }
377+
378+ private maybeConnect(peerId: string) {
379+ if (this.phase !== 'room' || !this.localStream || peerId === selfId) return
380+ const existing = this.conns.get(peerId)
381+ if (existing) {
382+ const stalled =
383+ !existing.connected &&
384+ Date.now() - existing.createdAt > CONNECT_RETRY_MS
385+ if (!stalled) return
386+ this.conns.delete(peerId) // deleted first so the close handler no-ops
387+ existing.peer.destroy()
388+ }
389+ if (this.atCapacity()) {
390+ // The room is full from our point of view: turn the newcomer away.
391+ void this.sendToPeer(peerId, {t: 'room-full'})
392+ return
393+ }
394+ // Deterministic initiator: the peer with the smaller ID makes the offer.
395+ this.createPeer(peerId, selfId < peerId)
396+ }
397+
398+ private createPeer(peerId: string, initiator: boolean): Conn {
399+ const peer = new Peer(initiator, this.outgoingStream())
400+ const conn: Conn = {
401+ peer,
402+ createdAt: Date.now(),
403+ name: null,
404+ connected: false,
405+ stream: null,
406+ audioMuted: true,
407+ videoMuted: true
408+ }
409+ this.conns.set(peerId, conn)
410+
411+ peer.setHandlers({
412+ signal: signal => {
413+ void this.sendToPeer(peerId, {t: 'signal', signal})
414+ },
415+ track: stream => {
416+ conn.stream = stream
417+ this.rebuildSnapshot()
418+ },
419+ connect: () => {
420+ conn.connected = true
421+ this.sendHello(conn)
422+ this.applyVideoParamsTo(conn)
423+ this.rebuildSnapshot()
424+ },
425+ data: raw => this.handleControl(peerId, conn, raw),
426+ close: () => {
427+ if (this.conns.get(peerId) === conn) {
428+ this.conns.delete(peerId)
429+ this.rebuildSnapshot()
430+ }
431+ }
432+ })
433+
434+ this.rebuildSnapshot()
435+ return conn
436+ }
437+
438+ private handlePeerMsg(from: string, msg: PeerMsg) {
439+ if (this.phase !== 'room') return
440+ switch (msg.t) {
441+ case 'signal': {
442+ let conn = this.conns.get(from)
443+ if (!conn) {
444+ // An offer can arrive before we've seen the peer's announcement.
445+ if (msg.signal?.type !== 'offer') return
446+ if (this.atCapacity()) {
447+ void this.sendToPeer(from, {t: 'room-full'})
448+ return
449+ }
450+ conn = this.createPeer(from, false)
451+ }
452+ void conn.peer.signal(msg.signal)
453+ return
454+ }
455+ case 'room-full': {
456+ // Only honor this while we haven't gotten a foothold in the room —
457+ // once we have any connection, we're in.
458+ if (this.conns.size === 0) {
459+ this.teardown()
460+ this.notice = `That room is full — up to ${MAX_PARTICIPANTS} people can be in a room.`
461+ this.rebuildSnapshot()
462+ }
463+ return
464+ }
465+ }
466+ }
467+
468+ // ---- control channel ----------------------------------------------------
469+
470+ private broadcastControl(msg: ControlMsg) {
471+ const payload = JSON.stringify(msg)
472+ for (const conn of this.conns.values()) conn.peer.send(payload)
473+ }
474+
475+ private sendHello(conn: Conn) {
476+ const settings: SettingEntry[] = []
477+ for (const [key, meta] of Object.entries(this.settingsMeta)) {
478+ settings.push({
479+ key,
480+ value: this.settings[key as keyof RoomSettings],
481+ rev: meta.rev,
482+ by: meta.by
483+ })
484+ }
485+ conn.peer.send(
486+ JSON.stringify({
487+ t: 'hello',
488+ name: this.name ?? '',
489+ audioMuted: this.audioMuted,
490+ videoMuted: this.effectiveVideoMuted(),
491+ settings
492+ } satisfies ControlMsg)
493+ )
494+ }
495+
496+ private handleControl(peerId: string, conn: Conn, raw: string) {
497+ if (this.conns.get(peerId) !== conn) return
498+ let msg: ControlMsg
499+ try {
500+ msg = JSON.parse(raw)
501+ } catch {
502+ return
503+ }
504+ switch (msg.t) {
505+ case 'hello': {
506+ if (typeof msg.name === 'string') conn.name = msg.name.slice(0, 40)
507+ conn.audioMuted = msg.audioMuted !== false
508+ conn.videoMuted = msg.videoMuted !== false
509+ if (Array.isArray(msg.settings)) {
510+ for (const entry of msg.settings) this.applyRemoteSetting(entry)
511+ }
512+ this.rebuildSnapshot()
513+ return
514+ }
515+ case 'set': {
516+ this.applyRemoteSetting(msg)
517+ return
518+ }
519+ case 'mute': {
520+ if (typeof msg.audio !== 'boolean' || typeof msg.video !== 'boolean') {
521+ return
522+ }
523+ conn.audioMuted = msg.audio
524+ conn.videoMuted = msg.video
525+ this.rebuildSnapshot()
526+ return
527+ }
528+ case 'bye': {
529+ this.presence.delete(peerId)
530+ conn.peer.destroy() // its close handler removes it and rebuilds
531+ return
532+ }
533+ }
534+ }
535+
536+ // ---- shared room settings ------------------------------------------------
537+ //
538+ // ONE settings object for the whole room, editable by anyone. Sync is
539+ // per-key last-writer-wins: every change bumps that key's revision and is
540+ // broadcast as {t:'set'} to every peer (the mesh is a complete graph, so no
541+ // relaying is needed). Late joiners receive the current entries in each
542+ // hello. Concurrent changes at the same revision must resolve identically
543+ // everywhere, so the SETTER with the smaller peer ID wins the tie.
544+
545+ private setSetting<K extends keyof RoomSettings>(
546+ key: K,
547+ value: RoomSettings[K]
548+ ) {
549+ if (this.phase !== 'room' || this.settings[key] === value) return
550+ const rev = (this.settingsMeta[key]?.rev ?? 0) + 1
551+ this.settingsMeta[key] = {rev, by: selfId}
552+ this.settings = {...this.settings}
553+ this.settings[key] = value
554+ this.broadcastControl({t: 'set', key, value, rev, by: selfId})
555+ this.settingChanged(key)
556+ this.rebuildSnapshot()
557+ }
558+
559+ private applyRemoteSetting(entry: SettingEntry) {
560+ if (typeof entry !== 'object' || entry === null) return
561+ if (typeof entry.key !== 'string' || !(entry.key in SETTING_VALIDATORS)) {
562+ return
563+ }
564+ const key = entry.key as keyof RoomSettings
565+ if (!SETTING_VALIDATORS[key](entry.value)) return
566+ if (!Number.isInteger(entry.rev) || entry.rev < 1) return
567+ if (typeof entry.by !== 'string' || entry.by.length !== 64) return
568+ const cur = this.settingsMeta[key]
569+ const curRev = cur?.rev ?? 0
570+ if (entry.rev < curRev) return // stale
571+ if (entry.rev === curRev && cur && cur.by <= entry.by) return // tie: they lose
572+ this.settingsMeta[key] = {rev: entry.rev, by: entry.by}
573+ if (this.settings[key] !== entry.value) {
574+ this.settings = {...this.settings}
575+ this.settings[key] = entry.value
576+ this.settingChanged(key)
577+ }
578+ this.rebuildSnapshot()
579+ }
580+
581+ /** Side effects of a setting taking a new value (local or remote). */
582+ private settingChanged(key: keyof RoomSettings) {
583+ if (key === 'videoQuality') this.applyVideoParamsAll()
584+ }
585+
586+ private videoParams(): VideoSendParams {
587+ const p = QUALITY_PARAMS[this.settings.videoQuality]
588+ const sharing = this.screenStream !== null
589+ return {
590+ maxBitrate: p.maxBitrate,
591+ // Downscaled screen text is unreadable: while sharing, send full
592+ // resolution and let the bitrate/framerate caps do the limiting.
593+ scaleResolutionDownBy: sharing ? undefined : p.scaleResolutionDownBy,
594+ maxFramerate: p.maxFramerate,
595+ degradationPreference: sharing ? 'maintain-resolution' : undefined
596+ }
597+ }
598+
599+ private applyVideoParamsAll() {
600+ for (const conn of this.conns.values()) this.applyVideoParamsTo(conn)
601+ }
602+
603+ private applyVideoParamsTo(conn: Conn) {
604+ void conn.peer.setVideoParameters(this.videoParams()).then(ok => {
605+ if (!ok) {
606+ // Right at 'connected' the encoding may not be negotiated yet.
607+ window.setTimeout(
608+ () => void conn.peer.setVideoParameters(this.videoParams()),
609+ 1500
610+ )
611+ }
612+ })
613+ }
614+
615+ // ---- mute -----------------------------------------------------------------
616+ //
617+ // Mute is per-participant state, not a shared setting: each participant owns
618+ // its own flags and just notifies the others (the ordered channel makes
619+ // last-sent win). Toggling track.enabled sends silence/black without
620+ // renegotiation. Unmuting without a usable device retries getUserMedia and,
621+ // on success, upgrades the placeholder track in place on every connection.
622+
623+ setAudioMuted(muted: boolean) {
624+ if (this.phase !== 'room' || !this.localStream) return
625+ if (this.audioMuted === muted) return
626+ if (!muted && !this.micAvailable) {
627+ void this.enableAudioWithRetry()
628+ return
629+ }
630+ this.audioMuted = muted
631+ for (const t of this.localStream.getAudioTracks()) t.enabled = !muted
632+ this.broadcastMuteNotice()
633+ this.rebuildSnapshot()
634+ }
635+
636+ setVideoMuted(muted: boolean) {
637+ if (this.phase !== 'room' || !this.localStream) return
638+ if (this.videoMuted === muted) return
639+ if (!muted && !this.camAvailable) {
640+ void this.enableVideoWithRetry()
641+ return
642+ }
643+ this.videoMuted = muted
644+ for (const t of this.localStream.getVideoTracks()) t.enabled = !muted
645+ this.broadcastMuteNotice()
646+ this.rebuildSnapshot()
647+ }
648+
649+ private async enableAudioWithRetry() {
650+ const seq = this.joinSeq
651+ let stream: MediaStream
652+ try {
653+ stream = await navigator.mediaDevices.getUserMedia({audio: true})
654+ } catch {
655+ this.notice =
656+ 'Could not access your microphone — check browser permissions.'
657+ this.rebuildSnapshot()
658+ return
659+ }
660+ const track = stream.getAudioTracks()[0]
661+ if (!track || this.joinSeq !== seq || !this.localStream) {
662+ for (const t of stream.getTracks()) t.stop()
663+ return
664+ }
665+ const old = this.localStream.getAudioTracks()[0] ?? null
666+ for (const conn of this.conns.values()) {
667+ void conn.peer.replaceTrack('audio', track)
668+ }
669+ if (old) {
670+ this.localStream.removeTrack(old)
671+ old.stop()
672+ }
673+ this.localStream.addTrack(track)
674+ this.micAvailable = true
675+ this.audioMuted = false
676+ track.enabled = true
677+ this.broadcastMuteNotice()
678+ this.rebuildSnapshot()
679+ }
680+
681+ private async enableVideoWithRetry() {
682+ const seq = this.joinSeq
683+ let stream: MediaStream
684+ try {
685+ stream = await navigator.mediaDevices.getUserMedia({video: true})
686+ } catch {
687+ this.notice = 'Could not access your camera — check browser permissions.'
688+ this.rebuildSnapshot()
689+ return
690+ }
691+ const track = stream.getVideoTracks()[0]
692+ if (!track || this.joinSeq !== seq || !this.localStream) {
693+ for (const t of stream.getTracks()) t.stop()
694+ return
695+ }
696+ const old = this.localStream.getVideoTracks()[0] ?? null
697+ // While screen sharing, the connections carry the screen track; the new
698+ // camera track takes over when the share stops.
699+ if (!this.screenStream) {
700+ for (const conn of this.conns.values()) {
701+ void conn.peer.replaceTrack('video', track)
702+ }
703+ }
704+ if (old) {
705+ this.localStream.removeTrack(old)
706+ old.stop()
707+ }
708+ this.localStream.addTrack(track)
709+ this.camAvailable = true
710+ this.videoMuted = false
711+ track.enabled = true
712+ this.broadcastMuteNotice()
713+ this.rebuildSnapshot()
714+ }
715+
716+ /** While screen sharing the outgoing video is the (always live) screen, so
717+ * a muted camera is latent until the share ends. */
718+ private effectiveVideoMuted(): boolean {
719+ return this.videoMuted && !this.screenStream
720+ }
721+
722+ private broadcastMuteNotice() {
723+ this.broadcastControl({
724+ t: 'mute',
725+ audio: this.audioMuted,
726+ video: this.effectiveVideoMuted()
727+ })
728+ }
729+
730+ // ---- screen share ---------------------------------------------------------
731+
732+ /** Swap the outgoing camera track for a screen capture on EVERY connection.
733+ * Everyone sees the screen in place of the camera; no renegotiation. */
734+ async startScreenShare() {
735+ if (this.phase !== 'room' || this.screenStream) return
736+ const seq = this.joinSeq
737+ let stream: MediaStream
738+ try {
739+ stream = await navigator.mediaDevices.getDisplayMedia({video: true})
740+ } catch {
741+ return // user canceled the picker (or capture is unsupported)
742+ }
743+ const track = stream.getVideoTracks()[0]
744+ if (!track || this.joinSeq !== seq) {
745+ for (const t of stream.getTracks()) t.stop()
746+ return
747+ }
748+ this.screenStream = stream
749+ for (const conn of this.conns.values()) {
750+ void conn.peer.replaceTrack('video', track)
751+ }
752+ this.applyVideoParamsAll() // re-derive caps for screen-share mode
753+ this.broadcastMuteNotice() // outgoing video is now the live screen
754+ // The browser's own "Stop sharing" bar ends the track; swap back then.
755+ track.onended = () => void this.stopScreenShare()
756+ this.rebuildSnapshot()
757+ }
758+
759+ async stopScreenShare() {
760+ if (!this.screenStream) return
761+ const screen = this.screenStream
762+ this.screenStream = null
763+ const camTrack = this.localStream?.getVideoTracks()[0]
764+ if (camTrack) {
765+ for (const conn of this.conns.values()) {
766+ void conn.peer.replaceTrack('video', camTrack)
767+ }
768+ }
769+ for (const t of screen.getTracks()) t.stop()
770+ if (this.phase === 'room') {
771+ this.applyVideoParamsAll() // restore camera-mode caps
772+ this.broadcastMuteNotice() // the camera, with its mute state, is back
773+ this.rebuildSnapshot()
774+ }
775+ }
776+
777+ // ---- public API -------------------------------------------------------
778+
779+ /** Change the room-wide video-quality preset. Anyone can change it; every
780+ * participant caps its own outgoing video, and the change syncs across. */
781+ setVideoQuality(quality: VideoQuality) {
782+ this.setSetting('videoQuality', quality)
783+ }
784+
785+ dismissNotice() {
786+ this.notice = null
787+ this.rebuildSnapshot()
788+ }
789+
790+ getSnapshot = (): Snapshot => this.snapshot
791+
792+ subscribe = (listener: () => void): (() => void) => {
793+ this.listeners.add(listener)
794+ return () => this.listeners.delete(listener)
795+ }
796+
797+ private rebuildSnapshot() {
798+ const ids = new Set<string>([...this.conns.keys(), ...this.presence.keys()])
799+ const participants: ParticipantInfo[] = [...ids]
800+ .map(peerId => {
801+ const conn = this.conns.get(peerId)
802+ return {
803+ peerId,
804+ name:
805+ this.presence.get(peerId)?.name ??
806+ conn?.name ??
807+ peerId.slice(0, 8),
808+ connected: conn?.connected ?? false,
809+ stream: conn?.stream ?? null,
810+ audioMuted: conn?.audioMuted ?? true,
811+ videoMuted: conn?.videoMuted ?? true
812+ }
813+ })
814+ .sort(
815+ (a, b) =>
816+ a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
817+ )
818+
819+ this.snapshot = {
820+ selfId,
821+ phase: this.phase,
822+ roomId: this.roomId,
823+ name: this.name,
824+ participants,
825+ audioMuted: this.audioMuted,
826+ videoMuted: this.videoMuted,
827+ micAvailable: this.micAvailable,
828+ camAvailable: this.camAvailable,
829+ localStream: this.localStream,
830+ screenStream: this.screenStream,
831+ settings: this.settings,
832+ notice: this.notice
833+ }
834+ for (const l of this.listeners) l()
835+ }
836+}
837+
838+/** A tiny black video track, used as a placeholder when there is no camera. */
839+const blackVideoTrack = (): MediaStreamTrack => {
840+ const canvas = document.createElement('canvas')
841+ canvas.width = 320
842+ canvas.height = 240
843+ canvas.getContext('2d')?.fillRect(0, 0, canvas.width, canvas.height)
844+ return canvas.captureStream(2).getVideoTracks()[0]
845+}
src/p2p/nostr.tsadded+153−0View file
@@ -0,0 +1,153 @@
1+import {makeNostrEvent, type NostrEvent} from './identity'
2+
3+// Minimal nostr client, modeled on trystero's nostr strategy but trimmed to
4+// only what we need: publish to a topic, and subscribe to a topic. Topics are
5+// carried in an 'x' tag; each topic maps to an ephemeral event kind (20000+)
6+// so relays don't store the messages.
7+
8+const RELAYS = [
9+ 'wss://relay.damus.io',
10+ 'wss://nos.lol',
11+ 'wss://relay.mostr.pub',
12+ 'wss://purplerelay.com'
13+]
14+
15+const TAG = 'x'
16+
17+const strToNum = (str: string, limit: number): number => {
18+ let sum = 0
19+ for (let i = 0; i < str.length; i++) sum += str.charCodeAt(i)
20+ return sum % limit
21+}
22+
23+const kindForTopic = (topic: string): number => strToNum(topic, 10000) + 20000
24+
25+const nowSec = (): number => Math.floor(Date.now() / 1000)
26+
27+const genSubId = (): string =>
28+ Array.from({length: 16}, () =>
29+ Math.floor(Math.random() * 16).toString(16)
30+ ).join('')
31+
32+type TopicHandler = (content: string, fromPubkey: string) => void
33+
34+// We publish every event to all relays and subscribe on all relays, so each
35+// event can arrive several times. Remember recently seen event ids and drop
36+// repeats so handlers fire exactly once per event.
37+const SEEN_CAP = 1000
38+
39+export class Nostr {
40+ private sockets: WebSocket[] = []
41+ private subs = new Map<string, {topic: string; handler: TopicHandler}>()
42+ private seen = new Set<string>()
43+
44+ constructor() {
45+ for (const url of RELAYS) this.connect(url)
46+ }
47+
48+ private connect(url: string) {
49+ let ws: WebSocket
50+ try {
51+ ws = new WebSocket(url)
52+ } catch {
53+ return
54+ }
55+ this.sockets.push(ws)
56+
57+ ws.onopen = () => {
58+ // (re)send all active subscriptions on this socket
59+ for (const [subId, {topic}] of this.subs) this.sendReq(ws, subId, topic)
60+ }
61+
62+ ws.onmessage = ev => {
63+ let msg: unknown
64+ try {
65+ msg = JSON.parse(ev.data as string)
66+ } catch {
67+ return
68+ }
69+ if (!Array.isArray(msg) || msg[0] !== 'EVENT') return
70+ const subId = msg[1] as string
71+ const event = msg[2] as NostrEvent
72+ const sub = this.subs.get(subId)
73+ if (!sub || !event || typeof event.content !== 'string') return
74+ if (event.id) {
75+ if (this.seen.has(event.id)) return
76+ this.seen.add(event.id)
77+ if (this.seen.size > SEEN_CAP) {
78+ for (const id of this.seen) {
79+ this.seen.delete(id)
80+ if (this.seen.size <= SEEN_CAP / 2) break
81+ }
82+ }
83+ }
84+ sub.handler(event.content, event.pubkey)
85+ }
86+
87+ ws.onclose = () => {
88+ this.sockets = this.sockets.filter(s => s !== ws)
89+ // reconnect after a short delay
90+ setTimeout(() => this.connect(url), 3000)
91+ }
92+
93+ ws.onerror = () => ws.close()
94+ }
95+
96+ private sendReq(ws: WebSocket, subId: string, topic: string) {
97+ if (ws.readyState !== WebSocket.OPEN) return
98+ ws.send(
99+ JSON.stringify([
100+ 'REQ',
101+ subId,
102+ {kinds: [kindForTopic(topic)], since: nowSec(), ['#' + TAG]: [topic]}
103+ ])
104+ )
105+ }
106+
107+ /** Subscribe to a topic. Handler fires once per incoming event. */
108+ subscribe(topic: string, handler: TopicHandler): () => void {
109+ const subId = genSubId()
110+ this.subs.set(subId, {topic, handler})
111+ for (const ws of this.sockets) this.sendReq(ws, subId, topic)
112+ return () => {
113+ this.subs.delete(subId)
114+ for (const ws of this.sockets) {
115+ if (ws.readyState === WebSocket.OPEN) {
116+ ws.send(JSON.stringify(['CLOSE', subId]))
117+ }
118+ }
119+ }
120+ }
121+
122+ /** Publish a signed event to a topic. */
123+ async publish(topic: string, content: string): Promise<void> {
124+ const event = await makeNostrEvent(
125+ kindForTopic(topic),
126+ [[TAG, topic]],
127+ content
128+ )
129+ const payload = JSON.stringify(['EVENT', event])
130+ for (const ws of this.sockets) {
131+ if (ws.readyState === WebSocket.OPEN) ws.send(payload)
132+ }
133+ }
134+}
135+
136+const sha256Hex = async (str: string): Promise<string> => {
137+ const buf = await crypto.subtle.digest(
138+ 'SHA-256',
139+ new TextEncoder().encode(str)
140+ )
141+ return Array.from(new Uint8Array(buf))
142+ .map(b => b.toString(16).padStart(2, '0'))
143+ .join('')
144+}
145+
146+/** Topic everyone in a room announces on / listens to for presence. The room
147+ * ID is any string (exact match — no normalization). */
148+export const roomTopic = (roomId: string): Promise<string> =>
149+ sha256Hex(`commonroom:${roomId}`)
150+
151+/** Per-peer topic used to deliver WebRTC signaling to a specific peer. */
152+export const peerTopic = (root: string, peerId: string): Promise<string> =>
153+ sha256Hex(`${root}:${peerId}`)
src/p2p/peer.tsadded+262−0View file
@@ -0,0 +1,262 @@
1+// A thin WebRTC wrapper, ported from commoncall's peer.ts. One instance per
2+// remote participant: it carries the local audio/video tracks plus one small
3+// control data channel (hello, mute notices, settings sync). As in the sibling
4+// projects we avoid "perfect negotiation" glare handling by ensuring only ONE
5+// side (a deterministically chosen initiator) ever creates the offer.
6+
7+export type Signal =
8+ | {type: 'offer'; sdp: string}
9+ | {type: 'answer'; sdp: string}
10+ | {type: 'candidate'; candidate: RTCIceCandidateInit}
11+
12+/** Caps for the outgoing video encoding; an undefined field CLEARS that cap. */
13+export interface VideoSendParams {
14+ maxBitrate?: number
15+ scaleResolutionDownBy?: number
16+ maxFramerate?: number
17+ degradationPreference?: 'balanced' | 'maintain-framerate' | 'maintain-resolution'
18+}
19+
20+export interface PeerHandlers {
21+ signal: (signal: Signal) => void
22+ /** Connection reached the 'connected' state. */
23+ connect: () => void
24+ /** Remote media stream became available. */
25+ track: (stream: MediaStream) => void
26+ /** A string message arrived on the control channel. */
27+ data: (data: string) => void
28+ close: () => void
29+}
30+
31+export const ICE_SERVERS: RTCIceServer[] = [
32+ {urls: 'stun:stun.l.google.com:19302'},
33+ {urls: 'stun:stun1.l.google.com:19302'},
34+ {urls: 'stun:stun.cloudflare.com:3478'},
35+ // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
36+ // fails (symmetric NAT, hairpinning, host-candidate blocking).
37+ {
38+ urls: [
39+ 'turn:openrelay.metered.ca:80',
40+ 'turn:openrelay.metered.ca:443',
41+ 'turns:openrelay.metered.ca:443'
42+ ],
43+ username: 'openrelayproject',
44+ credential: 'openrelayproject'
45+ }
46+]
47+
48+// A media connection can survive a brief network blip: 'disconnected' often
49+// recovers on its own, so only tear down if it persists this long.
50+const DISCONNECT_GRACE_MS = 5000
51+
52+export class Peer {
53+ private pc: RTCPeerConnection
54+ private channel: RTCDataChannel | null = null
55+ /** Control messages sent before the channel opens; flushed on open. */
56+ private outbox: string[] = []
57+ private handlers: Partial<PeerHandlers> = {}
58+ private pendingCandidates: RTCIceCandidateInit[] = []
59+ private disconnectTimer: number | null = null
60+ private closed = false
61+
62+ constructor(private initiator: boolean, localStream: MediaStream) {
63+ this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
64+
65+ // Both sides add their tracks up front: the initiator's single offer then
66+ // covers all media, and the answerer's tracks ride back in the answer.
67+ // (Every participant always has one audio + one video track — real or a
68+ // synthetic placeholder — so the m-lines are always symmetric.)
69+ for (const track of localStream.getTracks()) {
70+ this.pc.addTrack(track, localStream)
71+ }
72+
73+ this.pc.ontrack = ({streams}) => {
74+ if (streams[0]) this.handlers.track?.(streams[0])
75+ }
76+
77+ this.pc.onicecandidate = ({candidate}) => {
78+ if (candidate) {
79+ this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
80+ }
81+ }
82+
83+ this.pc.onconnectionstatechange = () => {
84+ const s = this.pc.connectionState
85+ if (s === 'connected') {
86+ this.clearDisconnectTimer()
87+ this.handlers.connect?.()
88+ } else if (s === 'failed' || s === 'closed') {
89+ this.destroy()
90+ } else if (s === 'disconnected') {
91+ this.clearDisconnectTimer()
92+ this.disconnectTimer = window.setTimeout(() => {
93+ if (this.pc.connectionState !== 'connected') this.destroy()
94+ }, DISCONNECT_GRACE_MS)
95+ }
96+ }
97+
98+ if (initiator) {
99+ this.setupChannel(this.pc.createDataChannel('control'))
100+ this.pc.onnegotiationneeded = () => void this.makeOffer()
101+ } else {
102+ this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
103+ }
104+ }
105+
106+ setHandlers(handlers: Partial<PeerHandlers>) {
107+ Object.assign(this.handlers, handlers)
108+ }
109+
110+ private clearDisconnectTimer() {
111+ if (this.disconnectTimer !== null) {
112+ clearTimeout(this.disconnectTimer)
113+ this.disconnectTimer = null
114+ }
115+ }
116+
117+ private setupChannel(channel: RTCDataChannel) {
118+ this.channel = channel
119+ const flush = () => {
120+ for (const data of this.outbox.splice(0)) channel.send(data)
121+ }
122+ if (channel.readyState === 'open') flush()
123+ else channel.onopen = flush
124+ channel.onclose = () => this.destroy()
125+ channel.onmessage = e => {
126+ if (typeof e.data === 'string') this.handlers.data?.(e.data)
127+ }
128+ }
129+
130+ private async makeOffer() {
131+ if (this.closed) return
132+ try {
133+ await this.pc.setLocalDescription(await this.pc.createOffer())
134+ this.handlers.signal?.({
135+ type: 'offer',
136+ sdp: this.pc.localDescription!.sdp
137+ })
138+ } catch {
139+ /* ignore */
140+ }
141+ }
142+
143+ async signal(signal: Signal) {
144+ if (this.closed) return
145+ try {
146+ if (signal.type === 'candidate') {
147+ if (this.pc.remoteDescription) {
148+ await this.pc.addIceCandidate(signal.candidate)
149+ } else {
150+ this.pendingCandidates.push(signal.candidate)
151+ }
152+ return
153+ }
154+
155+ if (signal.type === 'offer') {
156+ if (this.initiator) return // initiators never accept remote offers
157+ await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
158+ await this.flushCandidates()
159+ await this.pc.setLocalDescription(await this.pc.createAnswer())
160+ this.handlers.signal?.({
161+ type: 'answer',
162+ sdp: this.pc.localDescription!.sdp
163+ })
164+ return
165+ }
166+
167+ if (signal.type === 'answer') {
168+ await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
169+ await this.flushCandidates()
170+ }
171+ } catch {
172+ /* ignore transient signaling errors */
173+ }
174+ }
175+
176+ private async flushCandidates() {
177+ const queued = this.pendingCandidates.splice(0)
178+ for (const c of queued) {
179+ try {
180+ await this.pc.addIceCandidate(c)
181+ } catch {
182+ /* ignore */
183+ }
184+ }
185+ }
186+
187+ send(data: string) {
188+ if (this.channel?.readyState === 'open') this.channel.send(data)
189+ else if (!this.closed) this.outbox.push(data)
190+ }
191+
192+ /** Swap an outgoing track in place (camera ↔ screen, placeholder → real
193+ * device). A same-kind replaceTrack does not trigger renegotiation, so no
194+ * signaling is needed and the one-offer design is preserved. */
195+ async replaceTrack(
196+ kind: 'audio' | 'video',
197+ track: MediaStreamTrack
198+ ): Promise<boolean> {
199+ if (this.closed) return false
200+ const sender = this.pc.getSenders().find(s => s.track?.kind === kind)
201+ if (!sender) return false
202+ try {
203+ await sender.replaceTrack(track)
204+ return true
205+ } catch {
206+ return false
207+ }
208+ }
209+
210+ /** Cap (or uncap) the outgoing video encoding. Like replaceTrack,
211+ * setParameters applies live with no renegotiation, so it fits the
212+ * one-offer design. */
213+ async setVideoParameters(opts: VideoSendParams): Promise<boolean> {
214+ if (this.closed) return false
215+ const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
216+ if (!sender) return false
217+ const params = sender.getParameters()
218+ const enc = params.encodings[0]
219+ if (!enc) return false // no negotiated encoding yet
220+ if (opts.maxBitrate === undefined) delete enc.maxBitrate
221+ else enc.maxBitrate = opts.maxBitrate
222+ if (opts.scaleResolutionDownBy === undefined) {
223+ delete enc.scaleResolutionDownBy
224+ } else {
225+ enc.scaleResolutionDownBy = opts.scaleResolutionDownBy
226+ }
227+ if (opts.maxFramerate === undefined) delete enc.maxFramerate
228+ else enc.maxFramerate = opts.maxFramerate
229+ // Not in all TS dom typings, but supported by Chrome/Safari; harmless
230+ // where ignored.
231+ const p = params as {degradationPreference?: string}
232+ if (opts.degradationPreference === undefined) delete p.degradationPreference
233+ else p.degradationPreference = opts.degradationPreference
234+ try {
235+ await sender.setParameters(params)
236+ return true
237+ } catch {
238+ return false
239+ }
240+ }
241+
242+ get isConnected(): boolean {
243+ return this.pc.connectionState === 'connected'
244+ }
245+
246+ destroy() {
247+ if (this.closed) return
248+ this.closed = true
249+ this.clearDisconnectTimer()
250+ try {
251+ this.channel?.close()
252+ } catch {
253+ /* ignore */
254+ }
255+ try {
256+ this.pc.close()
257+ } catch {
258+ /* ignore */
259+ }
260+ this.handlers.close?.()
261+ }
262+}
src/p2p/settings.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+// Shared room settings. Everyone in the room sees and controls ONE settings
2+// object; anyone can change any setting and it applies to the whole room.
3+// Changes sync over the per-peer control data channels with per-key
4+// last-writer-wins (see network.ts).
5+//
6+// To add a future setting: extend RoomSettings, DEFAULT_SETTINGS, and
7+// SETTING_VALIDATORS, then handle its side effect in Network.settingChanged.
8+
9+export const VIDEO_QUALITIES = ['low', 'medium', 'high', 'auto'] as const
10+export type VideoQuality = (typeof VIDEO_QUALITIES)[number]
11+
12+export interface RoomSettings {
13+ videoQuality: VideoQuality
14+}
15+
16+export const DEFAULT_SETTINGS: RoomSettings = {videoQuality: 'medium'}
17+
18+/** Encoder caps for each preset, applied by EACH participant to every one of
19+ * its outgoing video senders (the setting is room-wide and symmetric).
20+ * 'auto' clears all caps and leaves adaptation entirely to the browser's
21+ * congestion control; the others are proactive ceilings — important in a
22+ * mesh, where upload cost multiplies by the number of other participants. */
23+export const QUALITY_PARAMS: Record<
24+ VideoQuality,
25+ {maxBitrate?: number; scaleResolutionDownBy?: number; maxFramerate?: number}
26+> = {
27+ auto: {},
28+ high: {maxBitrate: 2_500_000, maxFramerate: 30},
29+ medium: {maxBitrate: 800_000, scaleResolutionDownBy: 2, maxFramerate: 24},
30+ low: {maxBitrate: 200_000, scaleResolutionDownBy: 4, maxFramerate: 15}
31+}
32+
33+/** Settings arrive over the network, so every value is validated before use. */
34+export const SETTING_VALIDATORS: {
35+ [K in keyof RoomSettings]: (v: unknown) => v is RoomSettings[K]
36+} = {
37+ videoQuality: (v): v is VideoQuality =>
38+ (VIDEO_QUALITIES as readonly unknown[]).includes(v)
39+}
src/useNetwork.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+import {useSyncExternalStore} from 'react'
2+import {Network, type Snapshot} from './p2p/network'
3+
4+// A single Network instance for the whole app (module-level so React StrictMode
5+// double-mounting doesn't create two peer networks).
6+const network = new Network()
7+
8+export const useNetwork = (): {snapshot: Snapshot; network: Network} => {
9+ const snapshot = useSyncExternalStore(network.subscribe, network.getSnapshot)
10+ return {snapshot, network}
11+}
tsconfig.jsonadded+20−0View file
@@ -0,0 +1,20 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2020",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+ "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
10+ "resolveJsonModule": true,
11+ "isolatedModules": true,
12+ "noEmit": true,
13+ "jsx": "react-jsx",
14+ "strict": true,
15+ "noUnusedLocals": true,
16+ "noUnusedParameters": true,
17+ "noFallthroughCasesInSwitch": true
18+ },
19+ "include": ["src"]
20+}
tsconfig.tsbuildinfoadded+1−0View file
@@ -0,0 +1 @@
1+{"root":["./src/App.tsx","./src/main.tsx","./src/useNetwork.ts","./src/p2p/identity.ts","./src/p2p/network.ts","./src/p2p/nostr.ts","./src/p2p/peer.ts","./src/p2p/settings.ts"],"version":"5.9.3"}
vite.config.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+import {defineConfig} from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+export default defineConfig({
5+ // Project pages are served from https://<org>.github.io/commonroom/
6+ base: '/commonroom/',
7+ plugins: [react()]
8+})