concept-collection / commoncall
Serverless p2p video calls: presence + mutual-consent WebRTC over nostr signaling
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 1cec29314ff6 Browse files
17 changed files+3163−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+39−0View file
@@ -0,0 +1,39 @@
1+# CLAUDE.md
2+
3+Tips for future agents working in this repo. It borrows the p2p techniques of
4+the sibling project `commonview` — read that first; this file only covers what
5+is different here.
6+
7+## Architecture
8+
9+```
10+src/p2p/
11+ identity.ts schnorr keypair; pubkey hex = peer ID — ported from commonview
12+ nostr.ts minimal relay client + topic scheme — ported (adds event-id dedup)
13+ peer.ts WebRTC wrapper: media tracks + a control data channel
14+ network.ts the heart: presence roster + the call state machine
15+src/App.tsx join form, roster, ring/accept UI, video views
16+```
17+
18+## Key design decisions
19+
20+- **No auto-connect.** Unlike commonview (which meshes every peer), WebRTC is
21+ only brought up after an explicit `call-request` → `call-accept` handshake;
22+ `getUserMedia` is also deferred until then. All pre-call messaging rides on
23+ nostr per-peer topics.
24+- **One call at a time.** A second incoming ring is auto-declined with
25+ `busy: true`. Glare (both users call each other) is treated as mutual
26+ acceptance.
27+- **Ephemeral events need retries.** The caller re-publishes its ring every 4 s
28+ (the `Nostr` class dedupes by event id on the receiving side); a callee in
29+ `connecting` answers a re-ring by re-sending `call-accept`. Ring and connect
30+ phases both time out at 45 s.
31+- **Deterministic initiator.** The smaller peer ID creates the offer, same as
32+ commonview — no perfect-negotiation glare handling. Both sides add their
33+ tracks before signaling starts so one offer/answer round covers all media.
34+
35+## Testing
36+
37+`npm run dev`, then open two browsers (identity is per-browser-profile via
38+localStorage, so two tabs in one profile are the SAME peer — use a private
39+window or second browser). `npm run build` type-checks (`tsc -b`) and bundles.
README.mdadded+45−0View file
@@ -0,0 +1,45 @@
1+# commoncall
2+
3+Serverless peer-to-peer video calls in the browser.
4+
5+**Live page:** https://concept-collection.github.io/commoncall/
6+
7+Visit the page, enter an ID, and you'll see the IDs of everyone else currently
8+on the page. Click a visitor to request a call; once they accept — both users
9+must agree — a direct WebRTC connection is established and audio/video flows
10+peer-to-peer.
11+
12+## How it works
13+
14+There is no backend. The techniques are the same as the sibling project
15+[commonview](https://github.com/concept-collection/commonview):
16+
17+- **Identity** — each browser generates a secp256k1 (BIP340 schnorr) keypair,
18+ persisted in localStorage. The x-only public key is the peer ID, and every
19+ message is signed with it, so peers can't be impersonated.
20+- **Presence & signaling over nostr** — a minimal nostr client (modeled on
21+ trystero's nostr strategy) publishes ephemeral events to a handful of public
22+ relays. Everyone announces `{peerId, name, busy}` on a shared root topic
23+ every few seconds; entries expire when announcements stop. Call requests,
24+ accept/decline, and WebRTC offer/answer/ICE messages are delivered on a
25+ per-peer topic derived from the recipient's ID.
26+- **Mutual consent** — clicking "Call" only publishes a `call-request`. Neither
27+ side touches the camera or opens a WebRTC connection until the callee
28+ explicitly accepts.
29+- **WebRTC media** — after acceptance, the peer with the smaller ID creates the
30+ offer (deterministic initiator, no glare handling needed). Audio and video
31+ tracks flow directly between the browsers, with public STUN servers and a
32+ free TURN relay as fallback for hard NATs.
33+
34+## Development
35+
36+```sh
37+npm install
38+npm run dev
39+```
40+
41+Open the page in two browsers (or one normal + one private window — identity is
42+per-browser-profile) and call yourself.
43+
44+`npm run build` type-checks and bundles to `dist/`. Pushes to `main` deploy to
45+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>CommonCall</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": "commoncall",
3+ "version": "0.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "commoncall",
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.0",
1254+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz",
1255+ "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==",
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": "commoncall",
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+268−0View file
@@ -0,0 +1,268 @@
1+import {useEffect, useRef, useState} from 'react'
2+import {useNetwork} from './useNetwork'
3+
4+const short = (id: string) => id.slice(0, 8) + '…'
5+
6+const btn: React.CSSProperties = {
7+ padding: '0.4rem 1rem',
8+ borderRadius: 6,
9+ border: '1px solid #888',
10+ background: '#fff',
11+ cursor: 'pointer',
12+ fontSize: '1rem'
13+}
14+
15+const primaryBtn: React.CSSProperties = {
16+ ...btn,
17+ background: '#1a7f37',
18+ borderColor: '#1a7f37',
19+ color: '#fff'
20+}
21+
22+const dangerBtn: React.CSSProperties = {
23+ ...btn,
24+ background: '#c62828',
25+ borderColor: '#c62828',
26+ color: '#fff'
27+}
28+
29+function VideoView({
30+ stream,
31+ muted,
32+ style
33+}: {
34+ stream: MediaStream | null
35+ muted: boolean
36+ style: React.CSSProperties
37+}) {
38+ const ref = useRef<HTMLVideoElement>(null)
39+ useEffect(() => {
40+ if (ref.current && ref.current.srcObject !== stream) {
41+ ref.current.srcObject = stream
42+ }
43+ }, [stream])
44+ return <video ref={ref} autoPlay playsInline muted={muted} style={style} />
45+}
46+
47+function JoinForm({onJoin, initial}: {onJoin: (name: string) => void; initial: string}) {
48+ const [name, setName] = useState(initial)
49+ const submit = (e: React.FormEvent) => {
50+ e.preventDefault()
51+ onJoin(name)
52+ }
53+ return (
54+ <form onSubmit={submit} style={{marginTop: '1rem'}}>
55+ <p>Enter an ID so other visitors can see you and call you:</p>
56+ <input
57+ autoFocus
58+ value={name}
59+ onChange={e => setName(e.target.value)}
60+ placeholder="your id"
61+ maxLength={40}
62+ style={{padding: '0.4rem', fontSize: '1rem', marginRight: '0.5rem'}}
63+ />
64+ <button type="submit" style={primaryBtn} disabled={!name.trim()}>
65+ Join
66+ </button>
67+ </form>
68+ )
69+}
70+
71+export default function App() {
72+ const {snapshot, network} = useNetwork()
73+ const {selfId, name, roster, call, notice} = snapshot
74+
75+ const inCall = call?.phase === 'connecting' || call?.phase === 'connected'
76+
77+ return (
78+ <div
79+ style={{
80+ fontFamily: 'sans-serif',
81+ maxWidth: 720,
82+ margin: '2rem auto',
83+ padding: '0 1rem'
84+ }}
85+ >
86+ <h1 style={{marginBottom: '0.25rem'}}>CommonCall</h1>
87+ <p style={{color: '#666', marginTop: 0}}>
88+ Peer-to-peer video calls. No server: presence and call setup ride over
89+ public nostr relays; audio/video flows directly over WebRTC.
90+ </p>
91+
92+ {notice && (
93+ <div
94+ style={{
95+ background: '#fff3cd',
96+ border: '1px solid #e0c968',
97+ borderRadius: 6,
98+ padding: '0.5rem 0.75rem',
99+ margin: '0.75rem 0',
100+ display: 'flex',
101+ justifyContent: 'space-between',
102+ alignItems: 'center'
103+ }}
104+ >
105+ <span>{notice}</span>
106+ <button style={btn} onClick={() => network.dismissNotice()}>
107+ OK
108+ </button>
109+ </div>
110+ )}
111+
112+ {!name ? (
113+ <JoinForm onJoin={n => network.join(n)} initial={network.savedName} />
114+ ) : (
115+ <div style={{margin: '0.75rem 0', color: '#444'}}>
116+ You are <strong>{name}</strong>{' '}
117+ <code style={{color: '#999'}}>{short(selfId)}</code>{' '}
118+ <button
119+ style={{...btn, fontSize: '0.85rem', padding: '0.2rem 0.6rem'}}
120+ onClick={() => network.leave()}
121+ disabled={inCall}
122+ >
123+ Leave
124+ </button>
125+ </div>
126+ )}
127+
128+ {call?.phase === 'incoming' && (
129+ <section
130+ style={{
131+ border: '2px solid #1a7f37',
132+ borderRadius: 8,
133+ padding: '1rem',
134+ margin: '1rem 0'
135+ }}
136+ >
137+ <p style={{marginTop: 0}}>
138+ <strong>{call.peerName}</strong>{' '}
139+ <code style={{color: '#999'}}>{short(call.peerId)}</code> wants to
140+ start a video call with you.
141+ </p>
142+ <button style={primaryBtn} onClick={() => network.accept()}>
143+ Accept
144+ </button>{' '}
145+ <button style={dangerBtn} onClick={() => network.decline()}>
146+ Decline
147+ </button>
148+ </section>
149+ )}
150+
151+ {call?.phase === 'outgoing' && (
152+ <section
153+ style={{
154+ border: '1px solid #ccc',
155+ borderRadius: 8,
156+ padding: '1rem',
157+ margin: '1rem 0'
158+ }}
159+ >
160+ <p style={{marginTop: 0}}>
161+ Calling <strong>{call.peerName}</strong>… waiting for them to
162+ accept.
163+ </p>
164+ <button style={dangerBtn} onClick={() => network.endCall()}>
165+ Cancel
166+ </button>
167+ </section>
168+ )}
169+
170+ {inCall && call && (
171+ <section
172+ style={{
173+ background: '#111',
174+ borderRadius: 8,
175+ padding: '0.75rem',
176+ margin: '1rem 0',
177+ color: '#eee'
178+ }}
179+ >
180+ <div style={{position: 'relative'}}>
181+ <VideoView
182+ stream={call.remoteStream}
183+ muted={false}
184+ style={{
185+ width: '100%',
186+ aspectRatio: '4 / 3',
187+ background: '#000',
188+ borderRadius: 6,
189+ objectFit: 'cover'
190+ }}
191+ />
192+ <VideoView
193+ stream={call.localStream}
194+ muted
195+ style={{
196+ position: 'absolute',
197+ right: 10,
198+ bottom: 10,
199+ width: '25%',
200+ background: '#000',
201+ border: '1px solid #444',
202+ borderRadius: 6,
203+ transform: 'scaleX(-1)'
204+ }}
205+ />
206+ </div>
207+ <div
208+ style={{
209+ display: 'flex',
210+ justifyContent: 'space-between',
211+ alignItems: 'center',
212+ marginTop: '0.5rem'
213+ }}
214+ >
215+ <span>
216+ {call.phase === 'connected'
217+ ? `In a call with ${call.peerName}`
218+ : `Connecting to ${call.peerName}…`}
219+ </span>
220+ <button style={dangerBtn} onClick={() => network.endCall()}>
221+ Hang up
222+ </button>
223+ </div>
224+ </section>
225+ )}
226+
227+ <section>
228+ <h2>Visitors ({roster.length})</h2>
229+ {roster.length === 0 ? (
230+ <p style={{color: '#666'}}>
231+ Nobody else is here right now. Open this page in another browser or
232+ send the link to a friend.
233+ </p>
234+ ) : (
235+ <table style={{borderCollapse: 'collapse', width: '100%'}}>
236+ <tbody>
237+ {roster.map(p => (
238+ <tr key={p.peerId} style={{borderBottom: '1px solid #eee'}}>
239+ <td style={{padding: '0.4rem'}}>
240+ <strong>{p.name}</strong>{' '}
241+ <code style={{color: '#999'}}>{short(p.peerId)}</code>
242+ </td>
243+ <td style={{padding: '0.4rem', color: '#666'}}>
244+ {p.busy ? 'in a call' : 'available'}
245+ </td>
246+ <td style={{padding: '0.4rem', textAlign: 'right'}}>
247+ <button
248+ style={primaryBtn}
249+ disabled={!name || call !== null || p.busy}
250+ onClick={() => network.callPeer(p.peerId)}
251+ >
252+ Call
253+ </button>
254+ </td>
255+ </tr>
256+ ))}
257+ </tbody>
258+ </table>
259+ )}
260+ {!name && roster.length > 0 && (
261+ <p style={{color: '#666', fontSize: '0.9rem'}}>
262+ Enter an ID above to call someone.
263+ </p>
264+ )}
265+ </section>
266+ </div>
267+ )
268+}
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, call requests, and
7+// WebRTC signaling, so nobody can speak on behalf of another peer ID.
8+
9+const STORAGE_KEY = 'commoncall: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+481−0View file
@@ -0,0 +1,481 @@
1+import {selfId} from './identity'
2+import {Nostr, peerTopic, rootTopic} from './nostr'
3+import {Peer, type Signal} from './peer'
4+
5+// ---------------------------------------------------------------------------
6+// CommonCall network layer.
7+//
8+// Presence: everyone who has entered an ID announces {peerId, name, busy} on
9+// the root topic every few seconds; entries expire when announcements stop.
10+//
11+// Calls: clicking a peer publishes a call-request on that peer's personal
12+// topic. The callee must explicitly accept (call-accept) before either side
13+// touches getUserMedia or WebRTC — both users must agree. After acceptance the
14+// two sides exchange offer/answer/ICE via {t:'signal'} messages on the same
15+// per-peer topics, exactly the technique used by commonview, and the media
16+// flows peer-to-peer.
17+//
18+// Messages are authenticated by the nostr layer: every event is schnorr-signed
19+// and the sender's pubkey IS the peer ID, so `from` cannot be spoofed.
20+// ---------------------------------------------------------------------------
21+
22+interface Announcement {
23+ peerId: string
24+ name: string
25+ busy: boolean
26+}
27+
28+type PeerMsg =
29+ | {t: 'call-request'; name: string}
30+ | {t: 'call-accept'; name: string}
31+ | {t: 'call-decline'; busy?: boolean}
32+ | {t: 'call-cancel'}
33+ | {t: 'hang-up'}
34+ | {t: 'signal'; signal: Signal}
35+
36+const ROOM_ID = 'default'
37+const ANNOUNCE_INTERVAL_MS = 5000
38+const PRESENCE_TTL_MS = 15000
39+// Nostr events are ephemeral and relays are flaky, so re-publish the ring
40+// while it's pending; the event-id dedup on the far side absorbs repeats.
41+const RING_RESEND_MS = 4000
42+const RING_TIMEOUT_MS = 45000
43+const CONNECT_TIMEOUT_MS = 45000
44+
45+const NAME_KEY = 'commoncall:name'
46+
47+export type CallPhase = 'outgoing' | 'incoming' | 'connecting' | 'connected'
48+
49+interface Call {
50+ phase: CallPhase
51+ peerId: string
52+ peerName: string
53+ peer: Peer | null
54+ localStream: MediaStream | null
55+ remoteStream: MediaStream | null
56+ /** Signals that arrived before our getUserMedia resolved. */
57+ pendingSignals: Signal[]
58+ ringInterval: number | null
59+ ringTimeout: number | null
60+ connectTimeout: number | null
61+}
62+
63+export interface RosterEntry {
64+ peerId: string
65+ name: string
66+ busy: boolean
67+}
68+
69+export interface CallInfo {
70+ phase: CallPhase
71+ peerId: string
72+ peerName: string
73+ localStream: MediaStream | null
74+ remoteStream: MediaStream | null
75+}
76+
77+export interface Snapshot {
78+ selfId: string
79+ name: string | null
80+ roster: RosterEntry[]
81+ call: CallInfo | null
82+ notice: string | null
83+}
84+
85+export class Network {
86+ private nostr = new Nostr()
87+ private rootReady: Promise<string>
88+ private presence = new Map<
89+ string,
90+ {name: string; busy: boolean; lastSeen: number}
91+ >()
92+ private name: string | null = null
93+ private call: Call | null = null
94+ private notice: string | null = null
95+
96+ private snapshot!: Snapshot
97+ private listeners = new Set<() => void>()
98+
99+ /** Last name used on this browser, for prefilling the join form. */
100+ readonly savedName: string = localStorage.getItem(NAME_KEY) ?? ''
101+
102+ constructor() {
103+ this.rebuildSnapshot()
104+ this.rootReady = rootTopic(ROOM_ID)
105+ void this.start()
106+ if (this.savedName) this.join(this.savedName)
107+ }
108+
109+ private async start() {
110+ const root = await this.rootReady
111+
112+ // Call requests + WebRTC signaling addressed to us.
113+ const selfTopic = await peerTopic(root, selfId)
114+ this.nostr.subscribe(selfTopic, (content, from) => {
115+ if (from === selfId) return
116+ let msg: PeerMsg
117+ try {
118+ msg = JSON.parse(content)
119+ } catch {
120+ return
121+ }
122+ this.handlePeerMsg(from, msg)
123+ })
124+
125+ // Presence announcements.
126+ this.nostr.subscribe(root, (content, from) => {
127+ if (from === selfId) return
128+ let ann: Partial<Announcement>
129+ try {
130+ ann = JSON.parse(content)
131+ } catch {
132+ return
133+ }
134+ if (ann.peerId !== from || typeof ann.name !== 'string') return
135+ const prev = this.presence.get(from)
136+ const busy = ann.busy === true
137+ this.presence.set(from, {name: ann.name, busy, lastSeen: Date.now()})
138+ if (!prev || prev.name !== ann.name || prev.busy !== busy) {
139+ this.rebuildSnapshot()
140+ }
141+ })
142+
143+ setInterval(() => void this.announce(), ANNOUNCE_INTERVAL_MS)
144+ setInterval(() => this.sweepPresence(), ANNOUNCE_INTERVAL_MS)
145+
146+ window.addEventListener('online', () => void this.announce())
147+ }
148+
149+ private async announce() {
150+ if (!this.name) return
151+ const root = await this.rootReady
152+ const ann: Announcement = {
153+ peerId: selfId,
154+ name: this.name,
155+ busy: this.call !== null
156+ }
157+ void this.nostr.publish(root, JSON.stringify(ann))
158+ }
159+
160+ private sweepPresence() {
161+ const cutoff = Date.now() - PRESENCE_TTL_MS
162+ let changed = false
163+ for (const [peerId, p] of this.presence) {
164+ if (p.lastSeen < cutoff) {
165+ this.presence.delete(peerId)
166+ changed = true
167+ }
168+ }
169+ if (changed) this.rebuildSnapshot()
170+ }
171+
172+ private async sendToPeer(peerId: string, msg: PeerMsg) {
173+ const root = await this.rootReady
174+ const topic = await peerTopic(root, peerId)
175+ void this.nostr.publish(topic, JSON.stringify(msg))
176+ }
177+
178+ // ---- incoming messages ------------------------------------------------
179+
180+ private handlePeerMsg(from: string, msg: PeerMsg) {
181+ switch (msg.t) {
182+ case 'call-request': {
183+ if (this.call) {
184+ if (this.call.peerId !== from) {
185+ // Busy with someone else.
186+ void this.sendToPeer(from, {t: 'call-decline', busy: true})
187+ } else if (this.call.phase === 'outgoing') {
188+ // Glare: we each called the other — that's mutual agreement.
189+ this.beginConnecting()
190+ } else if (
191+ this.call.phase === 'connecting' ||
192+ this.call.phase === 'connected'
193+ ) {
194+ // Their resent ring means our accept was lost; send it again.
195+ void this.sendToPeer(from, {
196+ t: 'call-accept',
197+ name: this.name ?? ''
198+ })
199+ }
200+ // phase 'incoming': duplicate ring, ignore.
201+ return
202+ }
203+ if (!this.name) {
204+ // Not joined; we shouldn't be getting calls — turn them away.
205+ void this.sendToPeer(from, {t: 'call-decline', busy: true})
206+ return
207+ }
208+ this.notice = null
209+ this.call = this.newCall('incoming', from, msg.name)
210+ this.rebuildSnapshot()
211+ void this.announce()
212+ return
213+ }
214+
215+ case 'call-accept': {
216+ if (this.call?.phase === 'outgoing' && this.call.peerId === from) {
217+ if (msg.name) this.call.peerName = msg.name
218+ this.beginConnecting()
219+ }
220+ return
221+ }
222+
223+ case 'call-decline': {
224+ if (this.call?.peerId === from && this.call.phase === 'outgoing') {
225+ const who = this.call.peerName
226+ this.teardown(msg.busy ? `${who} is busy.` : `${who} declined.`)
227+ }
228+ return
229+ }
230+
231+ case 'call-cancel': {
232+ if (this.call?.peerId === from) {
233+ this.teardown(`${this.call.peerName} canceled the call.`)
234+ }
235+ return
236+ }
237+
238+ case 'hang-up': {
239+ if (this.call?.peerId === from) {
240+ this.teardown(`${this.call.peerName} hung up.`)
241+ }
242+ return
243+ }
244+
245+ case 'signal': {
246+ const call = this.call
247+ if (!call || call.peerId !== from) return
248+ if (call.phase !== 'connecting' && call.phase !== 'connected') return
249+ if (call.peer) void call.peer.signal(msg.signal)
250+ else call.pendingSignals.push(msg.signal)
251+ return
252+ }
253+ }
254+ }
255+
256+ // ---- call lifecycle ---------------------------------------------------
257+
258+ private newCall(phase: CallPhase, peerId: string, peerName: string): Call {
259+ return {
260+ phase,
261+ peerId,
262+ peerName,
263+ peer: null,
264+ localStream: null,
265+ remoteStream: null,
266+ pendingSignals: [],
267+ ringInterval: null,
268+ ringTimeout: null,
269+ connectTimeout: null
270+ }
271+ }
272+
273+ private clearCallTimers(call: Call) {
274+ if (call.ringInterval !== null) clearInterval(call.ringInterval)
275+ if (call.ringTimeout !== null) clearTimeout(call.ringTimeout)
276+ if (call.connectTimeout !== null) clearTimeout(call.connectTimeout)
277+ call.ringInterval = null
278+ call.ringTimeout = null
279+ call.connectTimeout = null
280+ }
281+
282+ /** Both sides agreed: get the camera/mic and bring up the WebRTC call. */
283+ private beginConnecting() {
284+ const call = this.call
285+ if (!call || call.phase === 'connecting' || call.phase === 'connected') {
286+ return
287+ }
288+ this.clearCallTimers(call)
289+ call.phase = 'connecting'
290+ call.connectTimeout = window.setTimeout(() => {
291+ if (this.call === call && call.phase === 'connecting') {
292+ void this.sendToPeer(call.peerId, {t: 'hang-up'})
293+ this.teardown('Could not establish a connection.')
294+ }
295+ }, CONNECT_TIMEOUT_MS)
296+ this.rebuildSnapshot()
297+ void this.startMedia(call)
298+ }
299+
300+ private async startMedia(call: Call) {
301+ let stream: MediaStream
302+ try {
303+ stream = await navigator.mediaDevices.getUserMedia({
304+ video: true,
305+ audio: true
306+ })
307+ } catch {
308+ if (this.call === call) {
309+ void this.sendToPeer(call.peerId, {t: 'hang-up'})
310+ this.teardown('Could not access your camera/microphone.')
311+ }
312+ return
313+ }
314+ if (this.call !== call || call.phase !== 'connecting') {
315+ // The call went away while we were waiting for permission.
316+ for (const track of stream.getTracks()) track.stop()
317+ return
318+ }
319+
320+ call.localStream = stream
321+ // Deterministic initiator (no glare): the smaller peer ID makes the offer.
322+ const peer = new Peer(selfId < call.peerId, stream)
323+ call.peer = peer
324+ peer.setHandlers({
325+ signal: signal => {
326+ void this.sendToPeer(call.peerId, {t: 'signal', signal})
327+ },
328+ track: remote => {
329+ if (this.call !== call) return
330+ call.remoteStream = remote
331+ this.rebuildSnapshot()
332+ },
333+ connect: () => {
334+ if (this.call !== call) return
335+ call.phase = 'connected'
336+ this.clearCallTimers(call)
337+ this.rebuildSnapshot()
338+ },
339+ data: raw => {
340+ let msg: {t?: string}
341+ try {
342+ msg = JSON.parse(raw)
343+ } catch {
344+ return
345+ }
346+ if (msg.t === 'hang-up' && this.call === call) {
347+ this.teardown(`${call.peerName} hung up.`)
348+ }
349+ },
350+ close: () => {
351+ if (this.call === call) this.teardown('Call ended.')
352+ }
353+ })
354+ for (const signal of call.pendingSignals.splice(0)) {
355+ void peer.signal(signal)
356+ }
357+ this.rebuildSnapshot()
358+ }
359+
360+ private teardown(notice: string | null) {
361+ const call = this.call
362+ if (!call) return
363+ this.call = null // cleared first so the peer's close handler no-ops
364+ this.clearCallTimers(call)
365+ call.peer?.destroy()
366+ if (call.localStream) {
367+ for (const track of call.localStream.getTracks()) track.stop()
368+ }
369+ this.notice = notice
370+ this.rebuildSnapshot()
371+ void this.announce()
372+ }
373+
374+ // ---- public API -------------------------------------------------------
375+
376+ join(name: string) {
377+ const trimmed = name.trim().slice(0, 40)
378+ if (!trimmed) return
379+ this.name = trimmed
380+ localStorage.setItem(NAME_KEY, trimmed)
381+ this.notice = null
382+ this.rebuildSnapshot()
383+ void this.announce()
384+ }
385+
386+ leave() {
387+ if (this.call) this.endCall()
388+ this.name = null
389+ this.rebuildSnapshot()
390+ // Others will drop us from their rosters when announcements stop.
391+ }
392+
393+ callPeer(peerId: string) {
394+ if (!this.name || this.call || peerId === selfId) return
395+ const peerName = this.presence.get(peerId)?.name ?? peerId.slice(0, 8)
396+ this.notice = null
397+ const call = this.newCall('outgoing', peerId, peerName)
398+ this.call = call
399+ const ring = () => void this.sendToPeer(peerId, {
400+ t: 'call-request',
401+ name: this.name ?? ''
402+ })
403+ ring()
404+ call.ringInterval = window.setInterval(ring, RING_RESEND_MS)
405+ call.ringTimeout = window.setTimeout(() => {
406+ if (this.call === call && call.phase === 'outgoing') {
407+ void this.sendToPeer(peerId, {t: 'call-cancel'})
408+ this.teardown(`${call.peerName} did not answer.`)
409+ }
410+ }, RING_TIMEOUT_MS)
411+ this.rebuildSnapshot()
412+ void this.announce()
413+ }
414+
415+ accept() {
416+ const call = this.call
417+ if (!call || call.phase !== 'incoming') return
418+ void this.sendToPeer(call.peerId, {t: 'call-accept', name: this.name ?? ''})
419+ this.beginConnecting()
420+ }
421+
422+ decline() {
423+ const call = this.call
424+ if (!call || call.phase !== 'incoming') return
425+ void this.sendToPeer(call.peerId, {t: 'call-decline'})
426+ this.teardown(null)
427+ }
428+
429+ endCall() {
430+ const call = this.call
431+ if (!call) return
432+ if (call.phase === 'outgoing') {
433+ void this.sendToPeer(call.peerId, {t: 'call-cancel'})
434+ } else if (call.phase === 'incoming') {
435+ void this.sendToPeer(call.peerId, {t: 'call-decline'})
436+ } else {
437+ // Belt and braces: the control channel may not be open yet.
438+ call.peer?.send(JSON.stringify({t: 'hang-up'}))
439+ void this.sendToPeer(call.peerId, {t: 'hang-up'})
440+ }
441+ this.teardown(null)
442+ }
443+
444+ dismissNotice() {
445+ this.notice = null
446+ this.rebuildSnapshot()
447+ }
448+
449+ getSnapshot = (): Snapshot => this.snapshot
450+
451+ subscribe = (listener: () => void): (() => void) => {
452+ this.listeners.add(listener)
453+ return () => this.listeners.delete(listener)
454+ }
455+
456+ private rebuildSnapshot() {
457+ const roster: RosterEntry[] = [...this.presence.entries()]
458+ .map(([peerId, p]) => ({peerId, name: p.name, busy: p.busy}))
459+ .sort(
460+ (a, b) =>
461+ a.name.localeCompare(b.name) || a.peerId.localeCompare(b.peerId)
462+ )
463+ const call: CallInfo | null = this.call
464+ ? {
465+ phase: this.call.phase,
466+ peerId: this.call.peerId,
467+ peerName: this.call.peerName,
468+ localStream: this.call.localStream,
469+ remoteStream: this.call.remoteStream
470+ }
471+ : null
472+ this.snapshot = {
473+ selfId,
474+ name: this.name,
475+ roster,
476+ call,
477+ notice: this.notice
478+ }
479+ for (const l of this.listeners) l()
480+ }
481+}
src/p2p/nostr.tsadded+152−0View file
@@ -0,0 +1,152 @@
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. Call semantics (request/accept) must fire
36+// exactly once, so remember recently seen event ids and drop repeats.
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 on the page announces on / listens to for presence. */
147+export const rootTopic = (roomId: string): Promise<string> =>
148+ sha256Hex(`commoncall:${roomId}`)
149+
150+/** Per-peer topic used to deliver call requests + WebRTC signaling to a specific peer. */
151+export const peerTopic = (root: string, peerId: string): Promise<string> =>
152+ sha256Hex(`${root}:${peerId}`)
src/p2p/peer.tsadded+194−0View file
@@ -0,0 +1,194 @@
1+// A thin WebRTC wrapper, distilled from commonview's peer.ts. Instead of a
2+// data-only connection it carries the local audio/video tracks plus one small
3+// control data channel (hang-up, mute notices). As in commonview we avoid
4+// "perfect negotiation" glare handling by ensuring only ONE side (a
5+// 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+export interface PeerHandlers {
13+ signal: (signal: Signal) => void
14+ /** Connection reached the 'connected' state. */
15+ connect: () => void
16+ /** Remote media stream became available. */
17+ track: (stream: MediaStream) => void
18+ /** A string message arrived on the control channel. */
19+ data: (data: string) => void
20+ close: () => void
21+}
22+
23+export const ICE_SERVERS: RTCIceServer[] = [
24+ {urls: 'stun:stun.l.google.com:19302'},
25+ {urls: 'stun:stun1.l.google.com:19302'},
26+ {urls: 'stun:stun.cloudflare.com:3478'},
27+ // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
28+ // fails (symmetric NAT, hairpinning, host-candidate blocking).
29+ {
30+ urls: [
31+ 'turn:openrelay.metered.ca:80',
32+ 'turn:openrelay.metered.ca:443',
33+ 'turns:openrelay.metered.ca:443'
34+ ],
35+ username: 'openrelayproject',
36+ credential: 'openrelayproject'
37+ }
38+]
39+
40+// A media call can survive a brief network blip: 'disconnected' often recovers
41+// on its own, so only tear down if it persists this long.
42+const DISCONNECT_GRACE_MS = 5000
43+
44+export class Peer {
45+ private pc: RTCPeerConnection
46+ private channel: RTCDataChannel | null = null
47+ private handlers: Partial<PeerHandlers> = {}
48+ private pendingCandidates: RTCIceCandidateInit[] = []
49+ private disconnectTimer: number | null = null
50+ private closed = false
51+
52+ constructor(private initiator: boolean, localStream: MediaStream) {
53+ this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
54+
55+ // Both sides add their tracks up front: the initiator's single offer then
56+ // covers all media, and the answerer's tracks ride back in the answer.
57+ for (const track of localStream.getTracks()) {
58+ this.pc.addTrack(track, localStream)
59+ }
60+
61+ this.pc.ontrack = ({streams}) => {
62+ if (streams[0]) this.handlers.track?.(streams[0])
63+ }
64+
65+ this.pc.onicecandidate = ({candidate}) => {
66+ if (candidate) {
67+ this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
68+ }
69+ }
70+
71+ this.pc.onconnectionstatechange = () => {
72+ const s = this.pc.connectionState
73+ if (s === 'connected') {
74+ this.clearDisconnectTimer()
75+ this.handlers.connect?.()
76+ } else if (s === 'failed' || s === 'closed') {
77+ this.destroy()
78+ } else if (s === 'disconnected') {
79+ this.clearDisconnectTimer()
80+ this.disconnectTimer = window.setTimeout(() => {
81+ if (this.pc.connectionState !== 'connected') this.destroy()
82+ }, DISCONNECT_GRACE_MS)
83+ }
84+ }
85+
86+ if (initiator) {
87+ this.setupChannel(this.pc.createDataChannel('control'))
88+ this.pc.onnegotiationneeded = () => void this.makeOffer()
89+ } else {
90+ this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
91+ }
92+ }
93+
94+ setHandlers(handlers: Partial<PeerHandlers>) {
95+ Object.assign(this.handlers, handlers)
96+ }
97+
98+ private clearDisconnectTimer() {
99+ if (this.disconnectTimer !== null) {
100+ clearTimeout(this.disconnectTimer)
101+ this.disconnectTimer = null
102+ }
103+ }
104+
105+ private setupChannel(channel: RTCDataChannel) {
106+ this.channel = channel
107+ channel.onclose = () => this.destroy()
108+ channel.onmessage = e => {
109+ if (typeof e.data === 'string') this.handlers.data?.(e.data)
110+ }
111+ }
112+
113+ private async makeOffer() {
114+ if (this.closed) return
115+ try {
116+ await this.pc.setLocalDescription(await this.pc.createOffer())
117+ this.handlers.signal?.({
118+ type: 'offer',
119+ sdp: this.pc.localDescription!.sdp
120+ })
121+ } catch {
122+ /* ignore */
123+ }
124+ }
125+
126+ async signal(signal: Signal) {
127+ if (this.closed) return
128+ try {
129+ if (signal.type === 'candidate') {
130+ if (this.pc.remoteDescription) {
131+ await this.pc.addIceCandidate(signal.candidate)
132+ } else {
133+ this.pendingCandidates.push(signal.candidate)
134+ }
135+ return
136+ }
137+
138+ if (signal.type === 'offer') {
139+ if (this.initiator) return // initiators never accept remote offers
140+ await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
141+ await this.flushCandidates()
142+ await this.pc.setLocalDescription(await this.pc.createAnswer())
143+ this.handlers.signal?.({
144+ type: 'answer',
145+ sdp: this.pc.localDescription!.sdp
146+ })
147+ return
148+ }
149+
150+ if (signal.type === 'answer') {
151+ await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
152+ await this.flushCandidates()
153+ }
154+ } catch {
155+ /* ignore transient signaling errors */
156+ }
157+ }
158+
159+ private async flushCandidates() {
160+ const queued = this.pendingCandidates.splice(0)
161+ for (const c of queued) {
162+ try {
163+ await this.pc.addIceCandidate(c)
164+ } catch {
165+ /* ignore */
166+ }
167+ }
168+ }
169+
170+ send(data: string) {
171+ if (this.channel?.readyState === 'open') this.channel.send(data)
172+ }
173+
174+ get isConnected(): boolean {
175+ return this.pc.connectionState === 'connected'
176+ }
177+
178+ destroy() {
179+ if (this.closed) return
180+ this.closed = true
181+ this.clearDisconnectTimer()
182+ try {
183+ this.channel?.close()
184+ } catch {
185+ /* ignore */
186+ }
187+ try {
188+ this.pc.close()
189+ } catch {
190+ /* ignore */
191+ }
192+ this.handlers.close?.()
193+ }
194+}
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"],"version":"5.9.3"}
\ No newline at end of file
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/commoncall/
6+ base: '/commoncall/',
7+ plugins: [react()]
8+})