concept-collection / trystero-messaging-demo
Trystero P2P messaging demo (text + large binary over Nostr)
Jeremy Magland <jmagland@flatironinstitute.org> committed commit efc2f43840e1 Browse files
23 changed files+2716−0
.github/workflows/deploy.ymladded+51−0View file
@@ -0,0 +1,51 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches:
6+ - main
7+ workflow_dispatch:
8+
9+permissions:
10+ contents: read
11+ pages: write
12+ id-token: write
13+
14+concurrency:
15+ group: "pages"
16+ cancel-in-progress: false
17+
18+jobs:
19+ build:
20+ runs-on: ubuntu-latest
21+ steps:
22+ - name: Checkout
23+ uses: actions/checkout@v4
24+
25+ - name: Setup Node
26+ uses: actions/setup-node@v4
27+ with:
28+ node-version: '22'
29+ cache: 'npm'
30+
31+ - name: Install dependencies
32+ run: npm ci
33+
34+ - name: Build
35+ run: npm run build
36+
37+ - name: Upload artifact
38+ uses: actions/upload-pages-artifact@v3
39+ with:
40+ path: ./dist
41+
42+ deploy:
43+ environment:
44+ name: github-pages
45+ url: ${{ steps.deployment.outputs.page_url }}
46+ runs-on: ubuntu-latest
47+ needs: build
48+ steps:
49+ - name: Deploy to GitHub Pages
50+ id: deployment
51+ uses: actions/deploy-pages@v4
.gitignoreadded+6−0View file
@@ -0,0 +1,6 @@
1+node_modules
2+dist
3+dist-ssr
4+*.local
5+*.tsbuildinfo
6+.DS_Store
README.mdadded+120−0View file
@@ -0,0 +1,120 @@
1+# Trystero P2P Messaging Demo
2+
3+A tiny **serverless, peer-to-peer messaging** web app built on
4+[Trystero](https://github.com/dmotz/trystero). Everyone who opens the app lands
5+in the same room, sees everyone else who is connected, and can send **text
6+messages** and **large binary payloads** directly to any other peer (or
7+broadcast to all) — with live progress bars and SHA-256 integrity checks.
8+
9+No backend, no accounts, no infrastructure. Peer discovery happens over the
10+public **Nostr** relay network (Trystero's default strategy); after that, all
11+data travels **directly browser-to-browser over WebRTC, end-to-end encrypted**.
12+
13+Built with **Vite + React + TypeScript**.
14+
15+**▶ Live demo: https://concept-collection.github.io/trystero-messaging-demo/**
16+(open it in two tabs to see peers connect)
17+
18+## Quick start
19+
20+```sh
21+npm install
22+npm run dev
23+```
24+
25+Open the printed URL (e.g. http://localhost:5173). Then open the **same URL in
26+another browser tab, another browser, or another device**. Within a few seconds
27+the two tabs discover each other and appear in each other's peer list.
28+
29+> The first connection can take a few seconds while peers find each other on the
30+> Nostr relays. A couple of relays in the default list may be offline at any
31+> time — Trystero connects to several for redundancy, so this is expected and
32+> harmless.
33+
34+## What it demonstrates
35+
36+- **Presence** — each browser tab is its own peer with a unique id
37+ (Trystero's `selfId`). Joining/leaving updates everyone's roster live.
38+- **Display names** — your name is broadcast to peers and persisted in
39+ `localStorage`.
40+- **Targeted text messages** — pick a peer (or "Everyone") and send a chat
41+ message. This is the "messages between pairs of people" case.
42+- **Large binary transfers** — send a file, or generate a random payload up to
43+ 32 MB. Trystero automatically chunks/throttles it and reassembles it on the
44+ other side. You get:
45+ - a **send progress** bar (`onProgress`) and a **receive progress** bar
46+ (`onReceiveProgress`),
47+ - a **SHA-256** computed on both ends so you can confirm the bytes arrived
48+ intact,
49+ - a **download link** for the received payload.
50+
51+## How it maps to the Trystero API
52+
53+Everything P2P lives in [`src/useRoom.ts`](src/useRoom.ts):
54+
55+```ts
56+import {joinRoom, selfId} from 'trystero' // default export = Nostr strategy
57+
58+const room = joinRoom({appId: APP_ID}, roomId)
59+
60+const chat = room.makeAction<string>('chat')
61+const binary = room.makeAction('binary')
62+
63+room.onPeerJoin = peerId => name.send(myName, {target: peerId})
64+room.onPeerLeave = peerId => { /* drop from roster */ }
65+
66+chat.onMessage = (text, {peerId}) => { /* show it */ }
67+
68+binary.send(bytes, {
69+ target: peerId, // or omit to broadcast
70+ metadata: {fileName, mime, size, transferId},
71+ onProgress: pct => updateBar(pct)
72+})
73+binary.onReceiveProgress = (pct, {peerId, metadata}) => updateBar(pct)
74+binary.onMessage = (data, {peerId, metadata}) => { /* data is an ArrayBuffer */ }
75+```
76+
77+- `APP_ID` and the default room are set in [`src/config.ts`](src/config.ts).
78+- No relay/strategy setup is needed — importing from `trystero` uses Nostr out
79+ of the box.
80+
81+## Rooms
82+
83+Everyone shares one room (`lobby`) by default. To use an isolated room, add a
84+hash to the URL, e.g. `http://localhost:5173/#my-room`. Anyone using the same
85+`#hash` is in the same room; the change is shareable as a link.
86+
87+## Project layout
88+
89+```
90+src/
91+ config.ts APP_ID + room handling
92+ useRoom.ts Trystero wrapper hook (presence, names, text + binary)
93+ util.ts byte formatting, SHA-256, random payload generator
94+ types.ts shared types
95+ App.tsx layout + target selection
96+ components/
97+ Header.tsx identity, room, relay/peer status
98+ PeerList.tsx pick who to message (a peer or "Everyone")
99+ Composer.tsx send text / file / random binary
100+ TransferList.tsx live progress bars
101+ MessageLog.tsx message + transfer history
102+```
103+
104+## Scripts
105+
106+| Command | Description |
107+| ----------------- | ------------------------------------ |
108+| `npm run dev` | Start the Vite dev server |
109+| `npm run build` | Type-check and build for production |
110+| `npm run preview` | Preview the production build locally |
111+
112+## Notes
113+
114+- WebRTC needs a secure context. `localhost` is fine; to test across devices on
115+ your network, serve over HTTPS (or use a tunnel) since plain `http://<ip>`
116+ origins are not treated as secure for WebRTC in most browsers.
117+- This demo intentionally does **not** wrap the app in `<StrictMode>` — its
118+ double-mounting of effects in development would make the room leave and
119+ rejoin, causing spurious peer churn. See the comment in
120+ [`src/main.tsx`](src/main.tsx).
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>Trystero P2P Messaging Demo</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+1039−0View file
@@ -0,0 +1,1039 @@
1+{
2+ "name": "trystero-messaging-demo",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "trystero-messaging-demo",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "react": "^19.2.7",
12+ "react-dom": "^19.2.7",
13+ "trystero": "^0.25.2"
14+ },
15+ "devDependencies": {
16+ "@types/react": "^19.2.17",
17+ "@types/react-dom": "^19.2.3",
18+ "@vitejs/plugin-react": "^6.0.3",
19+ "typescript": "^6.0.3",
20+ "vite": "^8.1.0"
21+ }
22+ },
23+ "node_modules/@emnapi/core": {
24+ "version": "1.11.1",
25+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
26+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
27+ "dev": true,
28+ "license": "MIT",
29+ "optional": true,
30+ "dependencies": {
31+ "@emnapi/wasi-threads": "1.2.2",
32+ "tslib": "^2.4.0"
33+ }
34+ },
35+ "node_modules/@emnapi/runtime": {
36+ "version": "1.11.1",
37+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
38+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
39+ "dev": true,
40+ "license": "MIT",
41+ "optional": true,
42+ "dependencies": {
43+ "tslib": "^2.4.0"
44+ }
45+ },
46+ "node_modules/@emnapi/wasi-threads": {
47+ "version": "1.2.2",
48+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
49+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
50+ "dev": true,
51+ "license": "MIT",
52+ "optional": true,
53+ "dependencies": {
54+ "tslib": "^2.4.0"
55+ }
56+ },
57+ "node_modules/@napi-rs/wasm-runtime": {
58+ "version": "1.1.6",
59+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
60+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
61+ "dev": true,
62+ "license": "MIT",
63+ "optional": true,
64+ "dependencies": {
65+ "@tybys/wasm-util": "^0.10.3"
66+ },
67+ "funding": {
68+ "type": "github",
69+ "url": "https://github.com/sponsors/Brooooooklyn"
70+ },
71+ "peerDependencies": {
72+ "@emnapi/core": "^1.7.1",
73+ "@emnapi/runtime": "^1.7.1"
74+ }
75+ },
76+ "node_modules/@noble/secp256k1": {
77+ "version": "3.1.0",
78+ "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
79+ "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
80+ "license": "MIT",
81+ "funding": {
82+ "url": "https://paulmillr.com/funding/"
83+ }
84+ },
85+ "node_modules/@oxc-project/types": {
86+ "version": "0.137.0",
87+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
88+ "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
89+ "dev": true,
90+ "license": "MIT",
91+ "funding": {
92+ "url": "https://github.com/sponsors/Boshen"
93+ }
94+ },
95+ "node_modules/@rolldown/binding-android-arm64": {
96+ "version": "1.1.3",
97+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
98+ "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
99+ "cpu": [
100+ "arm64"
101+ ],
102+ "dev": true,
103+ "license": "MIT",
104+ "optional": true,
105+ "os": [
106+ "android"
107+ ],
108+ "engines": {
109+ "node": "^20.19.0 || >=22.12.0"
110+ }
111+ },
112+ "node_modules/@rolldown/binding-darwin-arm64": {
113+ "version": "1.1.3",
114+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
115+ "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
116+ "cpu": [
117+ "arm64"
118+ ],
119+ "dev": true,
120+ "license": "MIT",
121+ "optional": true,
122+ "os": [
123+ "darwin"
124+ ],
125+ "engines": {
126+ "node": "^20.19.0 || >=22.12.0"
127+ }
128+ },
129+ "node_modules/@rolldown/binding-darwin-x64": {
130+ "version": "1.1.3",
131+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
132+ "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
133+ "cpu": [
134+ "x64"
135+ ],
136+ "dev": true,
137+ "license": "MIT",
138+ "optional": true,
139+ "os": [
140+ "darwin"
141+ ],
142+ "engines": {
143+ "node": "^20.19.0 || >=22.12.0"
144+ }
145+ },
146+ "node_modules/@rolldown/binding-freebsd-x64": {
147+ "version": "1.1.3",
148+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
149+ "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
150+ "cpu": [
151+ "x64"
152+ ],
153+ "dev": true,
154+ "license": "MIT",
155+ "optional": true,
156+ "os": [
157+ "freebsd"
158+ ],
159+ "engines": {
160+ "node": "^20.19.0 || >=22.12.0"
161+ }
162+ },
163+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
164+ "version": "1.1.3",
165+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
166+ "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
167+ "cpu": [
168+ "arm"
169+ ],
170+ "dev": true,
171+ "license": "MIT",
172+ "optional": true,
173+ "os": [
174+ "linux"
175+ ],
176+ "engines": {
177+ "node": "^20.19.0 || >=22.12.0"
178+ }
179+ },
180+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
181+ "version": "1.1.3",
182+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
183+ "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
184+ "cpu": [
185+ "arm64"
186+ ],
187+ "dev": true,
188+ "libc": [
189+ "glibc"
190+ ],
191+ "license": "MIT",
192+ "optional": true,
193+ "os": [
194+ "linux"
195+ ],
196+ "engines": {
197+ "node": "^20.19.0 || >=22.12.0"
198+ }
199+ },
200+ "node_modules/@rolldown/binding-linux-arm64-musl": {
201+ "version": "1.1.3",
202+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
203+ "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
204+ "cpu": [
205+ "arm64"
206+ ],
207+ "dev": true,
208+ "libc": [
209+ "musl"
210+ ],
211+ "license": "MIT",
212+ "optional": true,
213+ "os": [
214+ "linux"
215+ ],
216+ "engines": {
217+ "node": "^20.19.0 || >=22.12.0"
218+ }
219+ },
220+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
221+ "version": "1.1.3",
222+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
223+ "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
224+ "cpu": [
225+ "ppc64"
226+ ],
227+ "dev": true,
228+ "libc": [
229+ "glibc"
230+ ],
231+ "license": "MIT",
232+ "optional": true,
233+ "os": [
234+ "linux"
235+ ],
236+ "engines": {
237+ "node": "^20.19.0 || >=22.12.0"
238+ }
239+ },
240+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
241+ "version": "1.1.3",
242+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
243+ "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
244+ "cpu": [
245+ "s390x"
246+ ],
247+ "dev": true,
248+ "libc": [
249+ "glibc"
250+ ],
251+ "license": "MIT",
252+ "optional": true,
253+ "os": [
254+ "linux"
255+ ],
256+ "engines": {
257+ "node": "^20.19.0 || >=22.12.0"
258+ }
259+ },
260+ "node_modules/@rolldown/binding-linux-x64-gnu": {
261+ "version": "1.1.3",
262+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
263+ "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
264+ "cpu": [
265+ "x64"
266+ ],
267+ "dev": true,
268+ "libc": [
269+ "glibc"
270+ ],
271+ "license": "MIT",
272+ "optional": true,
273+ "os": [
274+ "linux"
275+ ],
276+ "engines": {
277+ "node": "^20.19.0 || >=22.12.0"
278+ }
279+ },
280+ "node_modules/@rolldown/binding-linux-x64-musl": {
281+ "version": "1.1.3",
282+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
283+ "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
284+ "cpu": [
285+ "x64"
286+ ],
287+ "dev": true,
288+ "libc": [
289+ "musl"
290+ ],
291+ "license": "MIT",
292+ "optional": true,
293+ "os": [
294+ "linux"
295+ ],
296+ "engines": {
297+ "node": "^20.19.0 || >=22.12.0"
298+ }
299+ },
300+ "node_modules/@rolldown/binding-openharmony-arm64": {
301+ "version": "1.1.3",
302+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
303+ "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
304+ "cpu": [
305+ "arm64"
306+ ],
307+ "dev": true,
308+ "license": "MIT",
309+ "optional": true,
310+ "os": [
311+ "openharmony"
312+ ],
313+ "engines": {
314+ "node": "^20.19.0 || >=22.12.0"
315+ }
316+ },
317+ "node_modules/@rolldown/binding-wasm32-wasi": {
318+ "version": "1.1.3",
319+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
320+ "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
321+ "cpu": [
322+ "wasm32"
323+ ],
324+ "dev": true,
325+ "license": "MIT",
326+ "optional": true,
327+ "dependencies": {
328+ "@emnapi/core": "1.11.1",
329+ "@emnapi/runtime": "1.11.1",
330+ "@napi-rs/wasm-runtime": "^1.1.6"
331+ },
332+ "engines": {
333+ "node": "^20.19.0 || >=22.12.0"
334+ }
335+ },
336+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
337+ "version": "1.1.3",
338+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
339+ "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
340+ "cpu": [
341+ "arm64"
342+ ],
343+ "dev": true,
344+ "license": "MIT",
345+ "optional": true,
346+ "os": [
347+ "win32"
348+ ],
349+ "engines": {
350+ "node": "^20.19.0 || >=22.12.0"
351+ }
352+ },
353+ "node_modules/@rolldown/binding-win32-x64-msvc": {
354+ "version": "1.1.3",
355+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
356+ "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
357+ "cpu": [
358+ "x64"
359+ ],
360+ "dev": true,
361+ "license": "MIT",
362+ "optional": true,
363+ "os": [
364+ "win32"
365+ ],
366+ "engines": {
367+ "node": "^20.19.0 || >=22.12.0"
368+ }
369+ },
370+ "node_modules/@rolldown/pluginutils": {
371+ "version": "1.0.1",
372+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
373+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
374+ "dev": true,
375+ "license": "MIT"
376+ },
377+ "node_modules/@trystero-p2p/core": {
378+ "version": "0.25.2",
379+ "resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.2.tgz",
380+ "integrity": "sha512-NOlNyKiVjsr0u4rHIHV1O0GLPFbHSNIKmS49XshWPcVHM38tuoTffQeARC9S3ytrY/wAX3DJQDCE/iTGYw//Aw==",
381+ "license": "MIT"
382+ },
383+ "node_modules/@trystero-p2p/nostr": {
384+ "version": "0.25.2",
385+ "resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.2.tgz",
386+ "integrity": "sha512-JTRktpt8VMtKeQuA2h4OmOzMEXq28KuBgL4JyfVatkxiFkFITJo9Q/D5Fx6xqeBT3cCFsfA4oDh4vUpp8SVZhg==",
387+ "license": "MIT",
388+ "dependencies": {
389+ "@noble/secp256k1": "^3.1.0",
390+ "@trystero-p2p/core": "0.25.2"
391+ }
392+ },
393+ "node_modules/@tybys/wasm-util": {
394+ "version": "0.10.3",
395+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
396+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
397+ "dev": true,
398+ "license": "MIT",
399+ "optional": true,
400+ "dependencies": {
401+ "tslib": "^2.4.0"
402+ }
403+ },
404+ "node_modules/@types/react": {
405+ "version": "19.2.17",
406+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
407+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
408+ "dev": true,
409+ "license": "MIT",
410+ "dependencies": {
411+ "csstype": "^3.2.2"
412+ }
413+ },
414+ "node_modules/@types/react-dom": {
415+ "version": "19.2.3",
416+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
417+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
418+ "dev": true,
419+ "license": "MIT",
420+ "peerDependencies": {
421+ "@types/react": "^19.2.0"
422+ }
423+ },
424+ "node_modules/@vitejs/plugin-react": {
425+ "version": "6.0.3",
426+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
427+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
428+ "dev": true,
429+ "license": "MIT",
430+ "dependencies": {
431+ "@rolldown/pluginutils": "^1.0.1"
432+ },
433+ "engines": {
434+ "node": "^20.19.0 || >=22.12.0"
435+ },
436+ "peerDependencies": {
437+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
438+ "babel-plugin-react-compiler": "^1.0.0",
439+ "vite": "^8.0.0"
440+ },
441+ "peerDependenciesMeta": {
442+ "@rolldown/plugin-babel": {
443+ "optional": true
444+ },
445+ "babel-plugin-react-compiler": {
446+ "optional": true
447+ }
448+ }
449+ },
450+ "node_modules/csstype": {
451+ "version": "3.2.3",
452+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
453+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
454+ "dev": true,
455+ "license": "MIT"
456+ },
457+ "node_modules/detect-libc": {
458+ "version": "2.1.2",
459+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
460+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
461+ "dev": true,
462+ "license": "Apache-2.0",
463+ "engines": {
464+ "node": ">=8"
465+ }
466+ },
467+ "node_modules/fdir": {
468+ "version": "6.5.0",
469+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
470+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
471+ "dev": true,
472+ "license": "MIT",
473+ "engines": {
474+ "node": ">=12.0.0"
475+ },
476+ "peerDependencies": {
477+ "picomatch": "^3 || ^4"
478+ },
479+ "peerDependenciesMeta": {
480+ "picomatch": {
481+ "optional": true
482+ }
483+ }
484+ },
485+ "node_modules/fsevents": {
486+ "version": "2.3.3",
487+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
488+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
489+ "dev": true,
490+ "hasInstallScript": true,
491+ "license": "MIT",
492+ "optional": true,
493+ "os": [
494+ "darwin"
495+ ],
496+ "engines": {
497+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
498+ }
499+ },
500+ "node_modules/lightningcss": {
501+ "version": "1.32.0",
502+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
503+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
504+ "dev": true,
505+ "license": "MPL-2.0",
506+ "dependencies": {
507+ "detect-libc": "^2.0.3"
508+ },
509+ "engines": {
510+ "node": ">= 12.0.0"
511+ },
512+ "funding": {
513+ "type": "opencollective",
514+ "url": "https://opencollective.com/parcel"
515+ },
516+ "optionalDependencies": {
517+ "lightningcss-android-arm64": "1.32.0",
518+ "lightningcss-darwin-arm64": "1.32.0",
519+ "lightningcss-darwin-x64": "1.32.0",
520+ "lightningcss-freebsd-x64": "1.32.0",
521+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
522+ "lightningcss-linux-arm64-gnu": "1.32.0",
523+ "lightningcss-linux-arm64-musl": "1.32.0",
524+ "lightningcss-linux-x64-gnu": "1.32.0",
525+ "lightningcss-linux-x64-musl": "1.32.0",
526+ "lightningcss-win32-arm64-msvc": "1.32.0",
527+ "lightningcss-win32-x64-msvc": "1.32.0"
528+ }
529+ },
530+ "node_modules/lightningcss-android-arm64": {
531+ "version": "1.32.0",
532+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
533+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
534+ "cpu": [
535+ "arm64"
536+ ],
537+ "dev": true,
538+ "license": "MPL-2.0",
539+ "optional": true,
540+ "os": [
541+ "android"
542+ ],
543+ "engines": {
544+ "node": ">= 12.0.0"
545+ },
546+ "funding": {
547+ "type": "opencollective",
548+ "url": "https://opencollective.com/parcel"
549+ }
550+ },
551+ "node_modules/lightningcss-darwin-arm64": {
552+ "version": "1.32.0",
553+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
554+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
555+ "cpu": [
556+ "arm64"
557+ ],
558+ "dev": true,
559+ "license": "MPL-2.0",
560+ "optional": true,
561+ "os": [
562+ "darwin"
563+ ],
564+ "engines": {
565+ "node": ">= 12.0.0"
566+ },
567+ "funding": {
568+ "type": "opencollective",
569+ "url": "https://opencollective.com/parcel"
570+ }
571+ },
572+ "node_modules/lightningcss-darwin-x64": {
573+ "version": "1.32.0",
574+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
575+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
576+ "cpu": [
577+ "x64"
578+ ],
579+ "dev": true,
580+ "license": "MPL-2.0",
581+ "optional": true,
582+ "os": [
583+ "darwin"
584+ ],
585+ "engines": {
586+ "node": ">= 12.0.0"
587+ },
588+ "funding": {
589+ "type": "opencollective",
590+ "url": "https://opencollective.com/parcel"
591+ }
592+ },
593+ "node_modules/lightningcss-freebsd-x64": {
594+ "version": "1.32.0",
595+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
596+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
597+ "cpu": [
598+ "x64"
599+ ],
600+ "dev": true,
601+ "license": "MPL-2.0",
602+ "optional": true,
603+ "os": [
604+ "freebsd"
605+ ],
606+ "engines": {
607+ "node": ">= 12.0.0"
608+ },
609+ "funding": {
610+ "type": "opencollective",
611+ "url": "https://opencollective.com/parcel"
612+ }
613+ },
614+ "node_modules/lightningcss-linux-arm-gnueabihf": {
615+ "version": "1.32.0",
616+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
617+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
618+ "cpu": [
619+ "arm"
620+ ],
621+ "dev": true,
622+ "license": "MPL-2.0",
623+ "optional": true,
624+ "os": [
625+ "linux"
626+ ],
627+ "engines": {
628+ "node": ">= 12.0.0"
629+ },
630+ "funding": {
631+ "type": "opencollective",
632+ "url": "https://opencollective.com/parcel"
633+ }
634+ },
635+ "node_modules/lightningcss-linux-arm64-gnu": {
636+ "version": "1.32.0",
637+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
638+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
639+ "cpu": [
640+ "arm64"
641+ ],
642+ "dev": true,
643+ "libc": [
644+ "glibc"
645+ ],
646+ "license": "MPL-2.0",
647+ "optional": true,
648+ "os": [
649+ "linux"
650+ ],
651+ "engines": {
652+ "node": ">= 12.0.0"
653+ },
654+ "funding": {
655+ "type": "opencollective",
656+ "url": "https://opencollective.com/parcel"
657+ }
658+ },
659+ "node_modules/lightningcss-linux-arm64-musl": {
660+ "version": "1.32.0",
661+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
662+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
663+ "cpu": [
664+ "arm64"
665+ ],
666+ "dev": true,
667+ "libc": [
668+ "musl"
669+ ],
670+ "license": "MPL-2.0",
671+ "optional": true,
672+ "os": [
673+ "linux"
674+ ],
675+ "engines": {
676+ "node": ">= 12.0.0"
677+ },
678+ "funding": {
679+ "type": "opencollective",
680+ "url": "https://opencollective.com/parcel"
681+ }
682+ },
683+ "node_modules/lightningcss-linux-x64-gnu": {
684+ "version": "1.32.0",
685+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
686+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
687+ "cpu": [
688+ "x64"
689+ ],
690+ "dev": true,
691+ "libc": [
692+ "glibc"
693+ ],
694+ "license": "MPL-2.0",
695+ "optional": true,
696+ "os": [
697+ "linux"
698+ ],
699+ "engines": {
700+ "node": ">= 12.0.0"
701+ },
702+ "funding": {
703+ "type": "opencollective",
704+ "url": "https://opencollective.com/parcel"
705+ }
706+ },
707+ "node_modules/lightningcss-linux-x64-musl": {
708+ "version": "1.32.0",
709+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
710+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
711+ "cpu": [
712+ "x64"
713+ ],
714+ "dev": true,
715+ "libc": [
716+ "musl"
717+ ],
718+ "license": "MPL-2.0",
719+ "optional": true,
720+ "os": [
721+ "linux"
722+ ],
723+ "engines": {
724+ "node": ">= 12.0.0"
725+ },
726+ "funding": {
727+ "type": "opencollective",
728+ "url": "https://opencollective.com/parcel"
729+ }
730+ },
731+ "node_modules/lightningcss-win32-arm64-msvc": {
732+ "version": "1.32.0",
733+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
734+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
735+ "cpu": [
736+ "arm64"
737+ ],
738+ "dev": true,
739+ "license": "MPL-2.0",
740+ "optional": true,
741+ "os": [
742+ "win32"
743+ ],
744+ "engines": {
745+ "node": ">= 12.0.0"
746+ },
747+ "funding": {
748+ "type": "opencollective",
749+ "url": "https://opencollective.com/parcel"
750+ }
751+ },
752+ "node_modules/lightningcss-win32-x64-msvc": {
753+ "version": "1.32.0",
754+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
755+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
756+ "cpu": [
757+ "x64"
758+ ],
759+ "dev": true,
760+ "license": "MPL-2.0",
761+ "optional": true,
762+ "os": [
763+ "win32"
764+ ],
765+ "engines": {
766+ "node": ">= 12.0.0"
767+ },
768+ "funding": {
769+ "type": "opencollective",
770+ "url": "https://opencollective.com/parcel"
771+ }
772+ },
773+ "node_modules/nanoid": {
774+ "version": "3.3.15",
775+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
776+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
777+ "dev": true,
778+ "funding": [
779+ {
780+ "type": "github",
781+ "url": "https://github.com/sponsors/ai"
782+ }
783+ ],
784+ "license": "MIT",
785+ "bin": {
786+ "nanoid": "bin/nanoid.cjs"
787+ },
788+ "engines": {
789+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
790+ }
791+ },
792+ "node_modules/picocolors": {
793+ "version": "1.1.1",
794+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
795+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
796+ "dev": true,
797+ "license": "ISC"
798+ },
799+ "node_modules/picomatch": {
800+ "version": "4.0.4",
801+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
802+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
803+ "dev": true,
804+ "license": "MIT",
805+ "engines": {
806+ "node": ">=12"
807+ },
808+ "funding": {
809+ "url": "https://github.com/sponsors/jonschlinkert"
810+ }
811+ },
812+ "node_modules/postcss": {
813+ "version": "8.5.15",
814+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
815+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
816+ "dev": true,
817+ "funding": [
818+ {
819+ "type": "opencollective",
820+ "url": "https://opencollective.com/postcss/"
821+ },
822+ {
823+ "type": "tidelift",
824+ "url": "https://tidelift.com/funding/github/npm/postcss"
825+ },
826+ {
827+ "type": "github",
828+ "url": "https://github.com/sponsors/ai"
829+ }
830+ ],
831+ "license": "MIT",
832+ "dependencies": {
833+ "nanoid": "^3.3.12",
834+ "picocolors": "^1.1.1",
835+ "source-map-js": "^1.2.1"
836+ },
837+ "engines": {
838+ "node": "^10 || ^12 || >=14"
839+ }
840+ },
841+ "node_modules/react": {
842+ "version": "19.2.7",
843+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
844+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
845+ "license": "MIT",
846+ "engines": {
847+ "node": ">=0.10.0"
848+ }
849+ },
850+ "node_modules/react-dom": {
851+ "version": "19.2.7",
852+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
853+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
854+ "license": "MIT",
855+ "dependencies": {
856+ "scheduler": "^0.27.0"
857+ },
858+ "peerDependencies": {
859+ "react": "^19.2.7"
860+ }
861+ },
862+ "node_modules/rolldown": {
863+ "version": "1.1.3",
864+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
865+ "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
866+ "dev": true,
867+ "license": "MIT",
868+ "dependencies": {
869+ "@oxc-project/types": "=0.137.0",
870+ "@rolldown/pluginutils": "^1.0.0"
871+ },
872+ "bin": {
873+ "rolldown": "bin/cli.mjs"
874+ },
875+ "engines": {
876+ "node": "^20.19.0 || >=22.12.0"
877+ },
878+ "optionalDependencies": {
879+ "@rolldown/binding-android-arm64": "1.1.3",
880+ "@rolldown/binding-darwin-arm64": "1.1.3",
881+ "@rolldown/binding-darwin-x64": "1.1.3",
882+ "@rolldown/binding-freebsd-x64": "1.1.3",
883+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
884+ "@rolldown/binding-linux-arm64-gnu": "1.1.3",
885+ "@rolldown/binding-linux-arm64-musl": "1.1.3",
886+ "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
887+ "@rolldown/binding-linux-s390x-gnu": "1.1.3",
888+ "@rolldown/binding-linux-x64-gnu": "1.1.3",
889+ "@rolldown/binding-linux-x64-musl": "1.1.3",
890+ "@rolldown/binding-openharmony-arm64": "1.1.3",
891+ "@rolldown/binding-wasm32-wasi": "1.1.3",
892+ "@rolldown/binding-win32-arm64-msvc": "1.1.3",
893+ "@rolldown/binding-win32-x64-msvc": "1.1.3"
894+ }
895+ },
896+ "node_modules/scheduler": {
897+ "version": "0.27.0",
898+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
899+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
900+ "license": "MIT"
901+ },
902+ "node_modules/source-map-js": {
903+ "version": "1.2.1",
904+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
905+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
906+ "dev": true,
907+ "license": "BSD-3-Clause",
908+ "engines": {
909+ "node": ">=0.10.0"
910+ }
911+ },
912+ "node_modules/tinyglobby": {
913+ "version": "0.2.17",
914+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
915+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
916+ "dev": true,
917+ "license": "MIT",
918+ "dependencies": {
919+ "fdir": "^6.5.0",
920+ "picomatch": "^4.0.4"
921+ },
922+ "engines": {
923+ "node": ">=12.0.0"
924+ },
925+ "funding": {
926+ "url": "https://github.com/sponsors/SuperchupuDev"
927+ }
928+ },
929+ "node_modules/trystero": {
930+ "version": "0.25.2",
931+ "resolved": "https://registry.npmjs.org/trystero/-/trystero-0.25.2.tgz",
932+ "integrity": "sha512-A8IF1hT1dHdSODpRubXcaq0ZHBg58ve34qlZ9bQrylgTTw2tN+YYGTs63DJadeTEXOXUuZfSXnBu4LsLfbyjRg==",
933+ "license": "MIT",
934+ "dependencies": {
935+ "@trystero-p2p/nostr": "0.25.2"
936+ }
937+ },
938+ "node_modules/tslib": {
939+ "version": "2.8.1",
940+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
941+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
942+ "dev": true,
943+ "license": "0BSD",
944+ "optional": true
945+ },
946+ "node_modules/typescript": {
947+ "version": "6.0.3",
948+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
949+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
950+ "dev": true,
951+ "license": "Apache-2.0",
952+ "bin": {
953+ "tsc": "bin/tsc",
954+ "tsserver": "bin/tsserver"
955+ },
956+ "engines": {
957+ "node": ">=14.17"
958+ }
959+ },
960+ "node_modules/vite": {
961+ "version": "8.1.0",
962+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
963+ "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
964+ "dev": true,
965+ "license": "MIT",
966+ "dependencies": {
967+ "lightningcss": "^1.32.0",
968+ "picomatch": "^4.0.4",
969+ "postcss": "^8.5.15",
970+ "rolldown": "~1.1.2",
971+ "tinyglobby": "^0.2.17"
972+ },
973+ "bin": {
974+ "vite": "bin/vite.js"
975+ },
976+ "engines": {
977+ "node": "^20.19.0 || >=22.12.0"
978+ },
979+ "funding": {
980+ "url": "https://github.com/vitejs/vite?sponsor=1"
981+ },
982+ "optionalDependencies": {
983+ "fsevents": "~2.3.3"
984+ },
985+ "peerDependencies": {
986+ "@types/node": "^20.19.0 || >=22.12.0",
987+ "@vitejs/devtools": "^0.3.0",
988+ "esbuild": "^0.27.0 || ^0.28.0",
989+ "jiti": ">=1.21.0",
990+ "less": "^4.0.0",
991+ "sass": "^1.70.0",
992+ "sass-embedded": "^1.70.0",
993+ "stylus": ">=0.54.8",
994+ "sugarss": "^5.0.0",
995+ "terser": "^5.16.0",
996+ "tsx": "^4.8.1",
997+ "yaml": "^2.4.2"
998+ },
999+ "peerDependenciesMeta": {
1000+ "@types/node": {
1001+ "optional": true
1002+ },
1003+ "@vitejs/devtools": {
1004+ "optional": true
1005+ },
1006+ "esbuild": {
1007+ "optional": true
1008+ },
1009+ "jiti": {
1010+ "optional": true
1011+ },
1012+ "less": {
1013+ "optional": true
1014+ },
1015+ "sass": {
1016+ "optional": true
1017+ },
1018+ "sass-embedded": {
1019+ "optional": true
1020+ },
1021+ "stylus": {
1022+ "optional": true
1023+ },
1024+ "sugarss": {
1025+ "optional": true
1026+ },
1027+ "terser": {
1028+ "optional": true
1029+ },
1030+ "tsx": {
1031+ "optional": true
1032+ },
1033+ "yaml": {
1034+ "optional": true
1035+ }
1036+ }
1037+ }
1038+ }
1039+}
package.jsonadded+24−0View file
@@ -0,0 +1,24 @@
1+{
2+ "name": "trystero-messaging-demo",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "description": "Serverless P2P messaging demo (text + large binary) built on Trystero over Nostr",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc -b && vite build",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "react": "^19.2.7",
14+ "react-dom": "^19.2.7",
15+ "trystero": "^0.25.2"
16+ },
17+ "devDependencies": {
18+ "@types/react": "^19.2.17",
19+ "@types/react-dom": "^19.2.3",
20+ "@vitejs/plugin-react": "^6.0.3",
21+ "typescript": "^6.0.3",
22+ "vite": "^8.1.0"
23+ }
24+}
src/App.tsxadded+69−0View file
@@ -0,0 +1,69 @@
1+import {useEffect, useState} from 'react'
2+import {useRoom} from './useRoom'
3+import {getRoomFromUrl} from './config'
4+import {Header} from './components/Header'
5+import {PeerList} from './components/PeerList'
6+import {Composer} from './components/Composer'
7+import {TransferList} from './components/TransferList'
8+import {MessageLog} from './components/MessageLog'
9+
10+export default function App() {
11+ const [roomId, setRoomId] = useState<string>(getRoomFromUrl)
12+
13+ // keep room in sync with the URL hash so #room links work and are shareable
14+ useEffect(() => {
15+ const onHashChange = () => setRoomId(getRoomFromUrl())
16+ window.addEventListener('hashchange', onHashChange)
17+ return () => window.removeEventListener('hashchange', onHashChange)
18+ }, [])
19+
20+ const room = useRoom(roomId)
21+
22+ // The currently selected message target: a peer id, or null for "everyone".
23+ const [target, setTarget] = useState<string | null>(null)
24+
25+ // If the selected target leaves, fall back to broadcast.
26+ useEffect(() => {
27+ if (target && !room.others.some(p => p.id === target)) {
28+ setTarget(null)
29+ }
30+ }, [room.others, target])
31+
32+ return (
33+ <div className="app">
34+ <Header
35+ roomId={roomId}
36+ selfId={room.selfId}
37+ selfName={room.selfName}
38+ onChangeName={room.setSelfName}
39+ peerCount={room.others.length}
40+ relayCount={room.relayCount}
41+ />
42+
43+ <main className="layout">
44+ <aside className="col-peers">
45+ <PeerList
46+ others={room.others}
47+ target={target}
48+ onSelectTarget={setTarget}
49+ />
50+ </aside>
51+
52+ <section className="col-main">
53+ <Composer
54+ target={target}
55+ targetName={
56+ target
57+ ? room.others.find(p => p.id === target)?.name ?? target
58+ : 'Everyone'
59+ }
60+ onSendText={room.sendText}
61+ onSendBinary={room.sendBinary}
62+ />
63+ <TransferList transfers={room.transfers} />
64+ <MessageLog log={room.log} onClear={room.clearLog} />
65+ </section>
66+ </main>
67+ </div>
68+ )
69+}
src/components/Composer.tsxadded+138−0View file
@@ -0,0 +1,138 @@
1+import {useRef, useState} from 'react'
2+import {formatBytes, randomBytes} from '../util'
3+
4+type Props = {
5+ target: string | null
6+ targetName: string
7+ onSendText: (text: string, target: string | null) => void
8+ onSendBinary: (
9+ data: Uint8Array<ArrayBuffer>,
10+ fileName: string,
11+ mime: string,
12+ target: string | null
13+ ) => Promise<void>
14+}
15+
16+const RANDOM_SIZES = [
17+ {label: '256 KB', bytes: 256 * 1024},
18+ {label: '1 MB', bytes: 1024 * 1024},
19+ {label: '8 MB', bytes: 8 * 1024 * 1024},
20+ {label: '32 MB', bytes: 32 * 1024 * 1024}
21+]
22+
23+export function Composer({target, targetName, onSendText, onSendBinary}: Props) {
24+ const [text, setText] = useState('')
25+ const [file, setFile] = useState<File | null>(null)
26+ const [randomSize, setRandomSize] = useState(RANDOM_SIZES[1].bytes)
27+ const [sending, setSending] = useState(false)
28+ const fileInputRef = useRef<HTMLInputElement>(null)
29+
30+ const sendText = () => {
31+ const trimmed = text.trim()
32+ if (!trimmed) return
33+ onSendText(trimmed, target)
34+ setText('')
35+ }
36+
37+ const sendFile = async () => {
38+ if (!file || sending) return
39+ setSending(true)
40+ try {
41+ const buf = new Uint8Array(await file.arrayBuffer())
42+ await onSendBinary(
43+ buf,
44+ file.name,
45+ file.type || 'application/octet-stream',
46+ target
47+ )
48+ } finally {
49+ setSending(false)
50+ }
51+ }
52+
53+ const sendRandom = async () => {
54+ if (sending) return
55+ setSending(true)
56+ try {
57+ const bytes = randomBytes(randomSize)
58+ const name = `random-${formatBytes(randomSize).replace(' ', '')}.bin`
59+ await onSendBinary(bytes, name, 'application/octet-stream', target)
60+ } finally {
61+ setSending(false)
62+ }
63+ }
64+
65+ return (
66+ <div className="panel">
67+ <h2 className="panel-title">
68+ Compose <span className="to-badge">→ {targetName}</span>
69+ </h2>
70+
71+ <div className="composer-row">
72+ <input
73+ className="text-input"
74+ placeholder={`Message ${targetName}…`}
75+ value={text}
76+ onChange={e => setText(e.target.value)}
77+ onKeyDown={e => {
78+ if (e.key === 'Enter') sendText()
79+ }}
80+ />
81+ <button className="btn primary" onClick={sendText} disabled={!text.trim()}>
82+ Send text
83+ </button>
84+ </div>
85+
86+ <div className="divider">
87+ <span>binary payload</span>
88+ </div>
89+
90+ <div className="composer-row">
91+ <input
92+ ref={fileInputRef}
93+ type="file"
94+ className="file-input"
95+ onChange={e => setFile(e.target.files?.[0] ?? null)}
96+ />
97+ <button
98+ className="btn"
99+ onClick={sendFile}
100+ disabled={!file || sending}
101+ >
102+ {sending ? 'Sending…' : 'Send file'}
103+ </button>
104+ </div>
105+ {file && (
106+ <p className="hint">
107+ Selected: <b>{file.name}</b> ({formatBytes(file.size)})
108+ </p>
109+ )}
110+
111+ <div className="composer-row">
112+ <label className="hint" style={{margin: 0}}>
113+ …or generate a random payload:
114+ </label>
115+ <select
116+ className="select"
117+ value={randomSize}
118+ onChange={e => setRandomSize(Number(e.target.value))}
119+ >
120+ {RANDOM_SIZES.map(s => (
121+ <option key={s.bytes} value={s.bytes}>
122+ {s.label}
123+ </option>
124+ ))}
125+ </select>
126+ <button className="btn" onClick={sendRandom} disabled={sending}>
127+ {sending ? 'Sending…' : 'Send random'}
128+ </button>
129+ </div>
130+
131+ <p className="hint subtle">
132+ Large payloads are automatically chunked & throttled by Trystero and sent
133+ directly peer-to-peer (end-to-end encrypted). A SHA-256 is computed on
134+ both ends so you can confirm the bytes arrived intact.
135+ </p>
136+ </div>
137+ )
138+}
src/components/Header.tsxadded+75−0View file
@@ -0,0 +1,75 @@
1+import {useEffect, useState} from 'react'
2+import {shortId} from '../util'
3+
4+type Props = {
5+ roomId: string
6+ selfId: string
7+ selfName: string
8+ onChangeName: (name: string) => void
9+ peerCount: number
10+ relayCount: number
11+}
12+
13+export function Header({
14+ roomId,
15+ selfId,
16+ selfName,
17+ onChangeName,
18+ peerCount,
19+ relayCount
20+}: Props) {
21+ const [draft, setDraft] = useState(selfName)
22+
23+ // keep the input in sync if the committed name changes elsewhere
24+ useEffect(() => setDraft(selfName), [selfName])
25+
26+ const commit = () => {
27+ if (draft !== selfName) onChangeName(draft)
28+ }
29+
30+ return (
31+ <header className="header">
32+ <div className="header-title">
33+ <h1>
34+ 🤝 Trystero P2P Messaging
35+ </h1>
36+ <p className="subtitle">
37+ Serverless WebRTC over Nostr — no backend, no accounts. Open this page
38+ in another tab or on another device to see peers appear.
39+ </p>
40+ </div>
41+
42+ <div className="identity">
43+ <label className="field">
44+ <span className="field-label">Your name</span>
45+ <input
46+ className="name-input"
47+ value={draft}
48+ onChange={e => setDraft(e.target.value)}
49+ onBlur={commit}
50+ onKeyDown={e => {
51+ if (e.key === 'Enter') {
52+ e.currentTarget.blur()
53+ }
54+ }}
55+ maxLength={32}
56+ />
57+ </label>
58+ <div className="identity-meta">
59+ <span title={selfId}>
60+ id <code>{shortId(selfId)}</code>
61+ </span>
62+ <span>
63+ room <code>{roomId}</code>
64+ </span>
65+ <span>
66+ <b>{peerCount}</b> peer{peerCount === 1 ? '' : 's'} connected
67+ </span>
68+ <span className={relayCount > 0 ? 'dot ok' : 'dot'}>
69+ {relayCount} nostr relay{relayCount === 1 ? '' : 's'}
70+ </span>
71+ </div>
72+ </div>
73+ </header>
74+ )
75+}
src/components/MessageLog.tsxadded+84−0View file
@@ -0,0 +1,84 @@
1+import {useEffect, useRef} from 'react'
2+import type {LogEntry} from '../types'
3+import {formatBytes} from '../util'
4+
5+type Props = {
6+ log: LogEntry[]
7+ onClear: () => void
8+}
9+
10+const time = (t: number) =>
11+ new Date(t).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'})
12+
13+export function MessageLog({log, onClear}: Props) {
14+ const bottomRef = useRef<HTMLDivElement>(null)
15+
16+ // autoscroll to the newest entry
17+ useEffect(() => {
18+ bottomRef.current?.scrollIntoView({behavior: 'smooth', block: 'end'})
19+ }, [log.length])
20+
21+ return (
22+ <div className="panel log-panel">
23+ <h2 className="panel-title">
24+ Message log
25+ <button className="btn tiny" onClick={onClear} disabled={log.length === 0}>
26+ clear
27+ </button>
28+ </h2>
29+
30+ <div className="log">
31+ {log.length === 0 && (
32+ <p className="empty">Messages and transfers will appear here.</p>
33+ )}
34+ {log.map(e => (
35+ <LogRow key={e.key} e={e} />
36+ ))}
37+ <div ref={bottomRef} />
38+ </div>
39+ </div>
40+ )
41+}
42+
43+function LogRow({e}: {e: LogEntry}) {
44+ if (e.kind === 'system') {
45+ return (
46+ <div className="log-row system">
47+ <span className="log-time">{time(e.time)}</span>
48+ <span className="log-body">{e.text}</span>
49+ </div>
50+ )
51+ }
52+
53+ const arrow = e.dir === 'out' ? '→' : '←'
54+ const who =
55+ e.dir === 'out' ? `to ${e.peerName}` : `from ${e.peerName}`
56+
57+ return (
58+ <div className={`log-row ${e.dir}`}>
59+ <span className="log-time">{time(e.time)}</span>
60+ <span className="log-body">
61+ <span className="log-who">
62+ {arrow} {who}
63+ </span>
64+ {e.kind === 'text' ? (
65+ <span className="log-text">{e.text}</span>
66+ ) : (
67+ <span className="log-binary">
68+ 📦 <b>{e.fileName}</b> · {formatBytes(e.size ?? 0)}
69+ {e.blobUrl && (
70+ <a className="dl" href={e.blobUrl} download={e.fileName}>
71+ download
72+ </a>
73+ )}
74+ {e.sha256 && (
75+ <code className="sha" title={`SHA-256: ${e.sha256}`}>
76+ sha256 {e.sha256.slice(0, 12)}…
77+ </code>
78+ )}
79+ </span>
80+ )}
81+ </span>
82+ </div>
83+ )
84+}
src/components/PeerList.tsxadded+57−0View file
@@ -0,0 +1,57 @@
1+import type {Peer} from '../types'
2+import {shortId} from '../util'
3+
4+type Props = {
5+ others: Peer[]
6+ target: string | null
7+ onSelectTarget: (target: string | null) => void
8+}
9+
10+export function PeerList({others, target, onSelectTarget}: Props) {
11+ return (
12+ <div className="panel">
13+ <h2 className="panel-title">Send messages to</h2>
14+
15+ <ul className="peer-list">
16+ <li>
17+ <button
18+ className={`peer broadcast ${target === null ? 'selected' : ''}`}
19+ onClick={() => onSelectTarget(null)}
20+ >
21+ <span className="peer-avatar">📣</span>
22+ <span className="peer-name">Everyone</span>
23+ <span className="peer-sub">broadcast to all peers</span>
24+ </button>
25+ </li>
26+
27+ {others.map(p => (
28+ <li key={p.id}>
29+ <button
30+ className={`peer ${target === p.id ? 'selected' : ''}`}
31+ onClick={() => onSelectTarget(p.id)}
32+ title={p.id}
33+ >
34+ <span className="peer-avatar">{initials(p.name)}</span>
35+ <span className="peer-name">{p.name}</span>
36+ <span className="peer-sub">
37+ <code>{shortId(p.id)}</code>
38+ </span>
39+ </button>
40+ </li>
41+ ))}
42+ </ul>
43+
44+ {others.length === 0 && (
45+ <p className="empty">
46+ No other peers yet. Open this page in a second browser tab (or send the
47+ URL to a friend) and they'll show up here.
48+ </p>
49+ )}
50+ </div>
51+ )
52+}
53+
54+function initials(name: string): string {
55+ const cleaned = name.replace(/[()]/g, '').trim()
56+ return cleaned.slice(0, 2).toUpperCase() || '??'
57+}
src/components/TransferList.tsxadded+44−0View file
@@ -0,0 +1,44 @@
1+import type {Transfer} from '../types'
2+import {formatBytes} from '../util'
3+
4+type Props = {
5+ transfers: Transfer[]
6+}
7+
8+export function TransferList({transfers}: Props) {
9+ if (transfers.length === 0) return null
10+
11+ // newest first
12+ const ordered = [...transfers].reverse()
13+
14+ return (
15+ <div className="panel">
16+ <h2 className="panel-title">Transfers</h2>
17+ <ul className="transfer-list">
18+ {ordered.map(t => (
19+ <li key={t.id} className="transfer">
20+ <div className="transfer-head">
21+ <span className="transfer-dir">{t.dir === 'out' ? '⬆' : '⬇'}</span>
22+ <span className="transfer-name" title={t.fileName}>
23+ {t.fileName}
24+ </span>
25+ <span className="transfer-meta">
26+ {formatBytes(t.size)} · {t.dir === 'out' ? 'to' : 'from'}{' '}
27+ {t.peerName}
28+ </span>
29+ <span className="transfer-pct">
30+ {t.done ? '✓' : `${Math.round(t.percent * 100)}%`}
31+ </span>
32+ </div>
33+ <div className="progress">
34+ <div
35+ className={`progress-bar ${t.done ? 'done' : ''}`}
36+ style={{width: `${Math.round(t.percent * 100)}%`}}
37+ />
38+ </div>
39+ </li>
40+ ))}
41+ </ul>
42+ </div>
43+ )
44+}
src/config.tsadded+17−0View file
@@ -0,0 +1,17 @@
1+// A globally-unique identifier for THIS app. Trystero uses it (together with
2+// the room id) to derive the Nostr topics peers use to find each other, so it
3+// should be unique enough not to collide with other Trystero apps on the public
4+// Nostr relays. Change this if you fork the demo.
5+export const APP_ID = 'trystero-messaging-demo-7c1f9a'
6+
7+// The default room everyone lands in. Because the requirement is "whoever opens
8+// the app sees everyone else", we put all visitors in one shared room by
9+// default. You can still create a private room by adding `#myroom` to the URL.
10+export const DEFAULT_ROOM = 'lobby'
11+
12+// Read the desired room id from the URL hash (e.g. https://.../#dev-room) so you
13+// can demo multiple isolated rooms without any UI, falling back to the default.
14+export const getRoomFromUrl = (): string => {
15+ const hash = window.location.hash.replace(/^#/, '').trim()
16+ return hash || DEFAULT_ROOM
17+}
src/index.cssadded+509−0View file
@@ -0,0 +1,509 @@
1+:root {
2+ --bg: #0f1216;
3+ --panel: #181d24;
4+ --panel-2: #1f2630;
5+ --border: #2a323d;
6+ --text: #e6e9ee;
7+ --muted: #8a94a3;
8+ --accent: #5b9dff;
9+ --accent-2: #7c5bff;
10+ --ok: #3ddc97;
11+ --in: #3ddc97;
12+ --out: #5b9dff;
13+ --danger: #ff6b6b;
14+ font-synthesis: none;
15+ text-rendering: optimizeLegibility;
16+}
17+
18+* {
19+ box-sizing: border-box;
20+}
21+
22+body {
23+ margin: 0;
24+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
25+ background: var(--bg);
26+ color: var(--text);
27+ line-height: 1.5;
28+}
29+
30+code {
31+ font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
32+ font-size: 0.85em;
33+ background: rgba(255, 255, 255, 0.06);
34+ padding: 1px 5px;
35+ border-radius: 4px;
36+}
37+
38+.app {
39+ max-width: 1100px;
40+ margin: 0 auto;
41+ padding: 20px 16px 60px;
42+}
43+
44+/* ---------- header ---------- */
45+.header {
46+ display: flex;
47+ flex-wrap: wrap;
48+ gap: 16px;
49+ justify-content: space-between;
50+ align-items: flex-start;
51+ border-bottom: 1px solid var(--border);
52+ padding-bottom: 18px;
53+ margin-bottom: 18px;
54+}
55+
56+.header-title h1 {
57+ margin: 0 0 4px;
58+ font-size: 1.5rem;
59+}
60+
61+.subtitle {
62+ margin: 0;
63+ color: var(--muted);
64+ max-width: 52ch;
65+ font-size: 0.9rem;
66+}
67+
68+.identity {
69+ display: flex;
70+ flex-direction: column;
71+ gap: 8px;
72+ align-items: flex-end;
73+}
74+
75+.field {
76+ display: flex;
77+ flex-direction: column;
78+ gap: 4px;
79+}
80+
81+.field-label {
82+ font-size: 0.72rem;
83+ text-transform: uppercase;
84+ letter-spacing: 0.05em;
85+ color: var(--muted);
86+ text-align: right;
87+}
88+
89+.name-input {
90+ background: var(--panel-2);
91+ border: 1px solid var(--border);
92+ color: var(--text);
93+ border-radius: 8px;
94+ padding: 8px 10px;
95+ font-size: 1rem;
96+ min-width: 180px;
97+ text-align: right;
98+}
99+
100+.name-input:focus {
101+ outline: none;
102+ border-color: var(--accent);
103+}
104+
105+.identity-meta {
106+ display: flex;
107+ flex-wrap: wrap;
108+ gap: 10px 14px;
109+ justify-content: flex-end;
110+ font-size: 0.8rem;
111+ color: var(--muted);
112+}
113+
114+.dot::before {
115+ content: '';
116+ display: inline-block;
117+ width: 8px;
118+ height: 8px;
119+ border-radius: 50%;
120+ background: var(--muted);
121+ margin-right: 6px;
122+}
123+
124+.dot.ok::before {
125+ background: var(--ok);
126+}
127+
128+/* ---------- layout ---------- */
129+.layout {
130+ display: grid;
131+ grid-template-columns: 280px 1fr;
132+ gap: 16px;
133+ align-items: start;
134+}
135+
136+@media (max-width: 760px) {
137+ .layout {
138+ grid-template-columns: 1fr;
139+ }
140+ .identity {
141+ align-items: flex-start;
142+ }
143+ .identity-meta,
144+ .field-label,
145+ .name-input {
146+ text-align: left;
147+ justify-content: flex-start;
148+ }
149+}
150+
151+.col-main {
152+ display: flex;
153+ flex-direction: column;
154+ gap: 16px;
155+ min-width: 0;
156+}
157+
158+/* ---------- panel ---------- */
159+.panel {
160+ background: var(--panel);
161+ border: 1px solid var(--border);
162+ border-radius: 12px;
163+ padding: 14px 16px;
164+}
165+
166+.panel-title {
167+ margin: 0 0 12px;
168+ font-size: 0.82rem;
169+ text-transform: uppercase;
170+ letter-spacing: 0.06em;
171+ color: var(--muted);
172+ display: flex;
173+ align-items: center;
174+ gap: 10px;
175+}
176+
177+/* ---------- peer list ---------- */
178+.peer-list {
179+ list-style: none;
180+ margin: 0;
181+ padding: 0;
182+ display: flex;
183+ flex-direction: column;
184+ gap: 6px;
185+}
186+
187+.peer {
188+ width: 100%;
189+ display: grid;
190+ grid-template-columns: 34px 1fr auto;
191+ align-items: center;
192+ gap: 4px 10px;
193+ background: var(--panel-2);
194+ border: 1px solid transparent;
195+ border-radius: 10px;
196+ padding: 8px 10px;
197+ color: var(--text);
198+ cursor: pointer;
199+ text-align: left;
200+ transition: border-color 0.12s, background 0.12s;
201+}
202+
203+.peer:hover {
204+ border-color: var(--border);
205+}
206+
207+.peer.selected {
208+ border-color: var(--accent);
209+ background: rgba(91, 157, 255, 0.12);
210+}
211+
212+.peer-avatar {
213+ grid-row: span 2;
214+ width: 34px;
215+ height: 34px;
216+ border-radius: 50%;
217+ display: grid;
218+ place-items: center;
219+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
220+ color: white;
221+ font-weight: 700;
222+ font-size: 0.8rem;
223+}
224+
225+.peer.broadcast .peer-avatar {
226+ background: linear-gradient(135deg, #ffae57, #ff6b6b);
227+}
228+
229+.peer-name {
230+ font-weight: 600;
231+ overflow: hidden;
232+ text-overflow: ellipsis;
233+ white-space: nowrap;
234+}
235+
236+.peer-sub {
237+ grid-column: 2 / 4;
238+ font-size: 0.75rem;
239+ color: var(--muted);
240+}
241+
242+.empty {
243+ color: var(--muted);
244+ font-size: 0.85rem;
245+ margin: 10px 2px 2px;
246+}
247+
248+/* ---------- composer ---------- */
249+.composer-row {
250+ display: flex;
251+ gap: 8px;
252+ align-items: center;
253+ margin-bottom: 10px;
254+ flex-wrap: wrap;
255+}
256+
257+.text-input {
258+ flex: 1;
259+ min-width: 160px;
260+ background: var(--panel-2);
261+ border: 1px solid var(--border);
262+ color: var(--text);
263+ border-radius: 8px;
264+ padding: 10px 12px;
265+ font-size: 0.95rem;
266+}
267+
268+.text-input:focus {
269+ outline: none;
270+ border-color: var(--accent);
271+}
272+
273+.file-input {
274+ flex: 1;
275+ min-width: 160px;
276+ color: var(--muted);
277+ font-size: 0.85rem;
278+}
279+
280+.file-input::file-selector-button {
281+ background: var(--panel-2);
282+ border: 1px solid var(--border);
283+ color: var(--text);
284+ border-radius: 8px;
285+ padding: 7px 12px;
286+ margin-right: 10px;
287+ cursor: pointer;
288+}
289+
290+.select {
291+ background: var(--panel-2);
292+ border: 1px solid var(--border);
293+ color: var(--text);
294+ border-radius: 8px;
295+ padding: 8px 10px;
296+}
297+
298+.btn {
299+ background: var(--panel-2);
300+ border: 1px solid var(--border);
301+ color: var(--text);
302+ border-radius: 8px;
303+ padding: 9px 16px;
304+ font-size: 0.9rem;
305+ cursor: pointer;
306+ white-space: nowrap;
307+ transition: border-color 0.12s, background 0.12s, opacity 0.12s;
308+}
309+
310+.btn:hover:not(:disabled) {
311+ border-color: var(--accent);
312+}
313+
314+.btn.primary {
315+ background: var(--accent);
316+ border-color: var(--accent);
317+ color: #08121f;
318+ font-weight: 600;
319+}
320+
321+.btn.primary:hover:not(:disabled) {
322+ background: #6fabff;
323+}
324+
325+.btn:disabled {
326+ opacity: 0.45;
327+ cursor: not-allowed;
328+}
329+
330+.btn.tiny {
331+ padding: 3px 10px;
332+ font-size: 0.75rem;
333+ margin-left: auto;
334+}
335+
336+.to-badge {
337+ color: var(--accent);
338+ text-transform: none;
339+ letter-spacing: 0;
340+ font-weight: 600;
341+}
342+
343+.divider {
344+ display: flex;
345+ align-items: center;
346+ gap: 10px;
347+ color: var(--muted);
348+ font-size: 0.72rem;
349+ text-transform: uppercase;
350+ letter-spacing: 0.06em;
351+ margin: 14px 0 12px;
352+}
353+
354+.divider::before,
355+.divider::after {
356+ content: '';
357+ flex: 1;
358+ height: 1px;
359+ background: var(--border);
360+}
361+
362+.hint {
363+ font-size: 0.82rem;
364+ color: var(--muted);
365+ margin: 4px 2px 12px;
366+}
367+
368+.hint.subtle {
369+ font-size: 0.78rem;
370+ opacity: 0.8;
371+ margin-bottom: 0;
372+}
373+
374+/* ---------- transfers ---------- */
375+.transfer-list {
376+ list-style: none;
377+ margin: 0;
378+ padding: 0;
379+ display: flex;
380+ flex-direction: column;
381+ gap: 12px;
382+}
383+
384+.transfer-head {
385+ display: flex;
386+ align-items: center;
387+ gap: 8px;
388+ font-size: 0.85rem;
389+ margin-bottom: 5px;
390+}
391+
392+.transfer-name {
393+ font-weight: 600;
394+ overflow: hidden;
395+ text-overflow: ellipsis;
396+ white-space: nowrap;
397+ max-width: 40%;
398+}
399+
400+.transfer-meta {
401+ color: var(--muted);
402+ font-size: 0.8rem;
403+}
404+
405+.transfer-pct {
406+ margin-left: auto;
407+ font-variant-numeric: tabular-nums;
408+ color: var(--accent);
409+ font-weight: 600;
410+}
411+
412+.progress {
413+ height: 8px;
414+ background: var(--panel-2);
415+ border-radius: 999px;
416+ overflow: hidden;
417+}
418+
419+.progress-bar {
420+ height: 100%;
421+ background: linear-gradient(90deg, var(--accent), var(--accent-2));
422+ transition: width 0.15s ease;
423+}
424+
425+.progress-bar.done {
426+ background: var(--ok);
427+}
428+
429+/* ---------- log ---------- */
430+.log-panel {
431+ display: flex;
432+ flex-direction: column;
433+}
434+
435+.log {
436+ display: flex;
437+ flex-direction: column;
438+ gap: 7px;
439+ max-height: 360px;
440+ overflow-y: auto;
441+ padding-right: 4px;
442+}
443+
444+.log-row {
445+ display: flex;
446+ gap: 10px;
447+ font-size: 0.88rem;
448+ align-items: baseline;
449+}
450+
451+.log-time {
452+ color: var(--muted);
453+ font-size: 0.72rem;
454+ font-variant-numeric: tabular-nums;
455+ flex-shrink: 0;
456+ padding-top: 1px;
457+}
458+
459+.log-body {
460+ min-width: 0;
461+}
462+
463+.log-row.system .log-body {
464+ color: var(--muted);
465+ font-style: italic;
466+}
467+
468+.log-who {
469+ font-size: 0.72rem;
470+ text-transform: uppercase;
471+ letter-spacing: 0.04em;
472+ margin-right: 8px;
473+}
474+
475+.log-row.in .log-who {
476+ color: var(--in);
477+}
478+
479+.log-row.out .log-who {
480+ color: var(--out);
481+}
482+
483+.log-text {
484+ word-break: break-word;
485+}
486+
487+.log-binary {
488+ display: inline-flex;
489+ flex-wrap: wrap;
490+ gap: 8px;
491+ align-items: center;
492+}
493+
494+.dl {
495+ color: var(--accent);
496+ text-decoration: none;
497+ border: 1px solid var(--border);
498+ border-radius: 6px;
499+ padding: 1px 8px;
500+ font-size: 0.78rem;
501+}
502+
503+.dl:hover {
504+ border-color: var(--accent);
505+}
506+
507+.sha {
508+ color: var(--muted);
509+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import {createRoot} from 'react-dom/client'
2+import App from './App.tsx'
3+import './index.css'
4+
5+// NOTE: We intentionally do NOT wrap <App /> in <StrictMode>. In development
6+// StrictMode mounts effects twice (mount → unmount → mount), which would make
7+// us join the Trystero room, leave it, then rejoin. Other peers would see a
8+// spurious leave/join and the WebRTC connections would churn. Skipping it keeps
9+// the live P2P behavior easy to reason about while developing.
10+createRoot(document.getElementById('root')!).render(<App />)
src/types.tsadded+44−0View file
@@ -0,0 +1,44 @@
1+// A peer in the room, keyed by Trystero peer id. `self` marks our own entry.
2+export type Peer = {
3+ id: string
4+ name: string
5+ self: boolean
6+}
7+
8+// One line in the message log.
9+export type LogEntry = {
10+ key: string
11+ time: number
12+ kind: 'system' | 'text' | 'binary'
13+ dir: 'in' | 'out' | 'sys'
14+ peerId?: string
15+ peerName?: string
16+ text?: string
17+ // binary-only fields
18+ fileName?: string
19+ size?: number
20+ sha256?: string
21+ // a received binary payload, kept so the user can download it
22+ blobUrl?: string
23+}
24+
25+// Metadata that rides along with a binary transfer so the receiver knows what
26+// it's getting (Trystero delivers this via the `metadata` send option).
27+export type BinaryMeta = {
28+ transferId: string
29+ fileName: string
30+ mime: string
31+ size: number
32+}
33+
34+// An in-flight (or completed) binary transfer, shown as a progress bar.
35+export type Transfer = {
36+ id: string
37+ dir: 'in' | 'out'
38+ peerId: string
39+ peerName: string
40+ fileName: string
41+ size: number
42+ percent: number
43+ done: boolean
44+}
src/useRoom.tsadded+310−0View file
@@ -0,0 +1,310 @@
1+import {useCallback, useEffect, useRef, useState} from 'react'
2+import {joinRoom, selfId, getRelaySockets} from 'trystero'
3+import type {MessageAction} from 'trystero'
4+import {APP_ID} from './config'
5+import type {BinaryMeta, LogEntry, Peer, Transfer} from './types'
6+import {sha256Hex, shortId, uid} from './util'
7+
8+const NAME_STORAGE_KEY = 'trystero-demo:name'
9+const MAX_LOG = 200
10+
11+const loadName = (): string =>
12+ localStorage.getItem(NAME_STORAGE_KEY) || `User-${selfId.slice(0, 4)}`
13+
14+export type RoomApi = {
15+ selfId: string
16+ selfName: string
17+ setSelfName: (name: string) => void
18+ peers: Peer[] // includes self (self is always first)
19+ others: Peer[] // everyone except self
20+ log: LogEntry[]
21+ transfers: Transfer[]
22+ relayCount: number
23+ sendText: (text: string, target: string | null) => void
24+ sendBinary: (
25+ data: Uint8Array<ArrayBuffer>,
26+ fileName: string,
27+ mime: string,
28+ target: string | null
29+ ) => Promise<void>
30+ clearLog: () => void
31+}
32+
33+type Actions = {
34+ name: MessageAction<string>
35+ chat: MessageAction<string>
36+ binary: MessageAction
37+}
38+
39+// Normalize whatever a Trystero binary action hands us into an ArrayBuffer-backed
40+// Uint8Array. (Binary payloads arrive as raw ArrayBuffers, but we guard for
41+// views too.)
42+const toBytes = (data: unknown): Uint8Array<ArrayBuffer> => {
43+ if (data instanceof ArrayBuffer) return new Uint8Array(data)
44+ if (ArrayBuffer.isView(data)) {
45+ const v = data as ArrayBufferView
46+ const out = new Uint8Array(v.byteLength)
47+ out.set(new Uint8Array(v.buffer, v.byteOffset, v.byteLength))
48+ return out
49+ }
50+ return new Uint8Array(0)
51+}
52+
53+export function useRoom(roomId: string): RoomApi {
54+ const [selfName, setSelfNameState] = useState<string>(loadName)
55+ const [peerNames, setPeerNames] = useState<Record<string, string>>({})
56+ const [log, setLog] = useState<LogEntry[]>([])
57+ const [transfers, setTransfers] = useState<Transfer[]>([])
58+ const [relayCount, setRelayCount] = useState(0)
59+
60+ // Action callbacks registered with Trystero are long-lived, so they must read
61+ // the *latest* name map / self name through refs to avoid stale closures.
62+ const selfNameRef = useRef(selfName)
63+ selfNameRef.current = selfName
64+ const peerNamesRef = useRef(peerNames)
65+ peerNamesRef.current = peerNames
66+
67+ const actionsRef = useRef<Actions | null>(null)
68+
69+ const nameOf = useCallback(
70+ (id: string): string => peerNamesRef.current[id] || `(${shortId(id)})`,
71+ []
72+ )
73+
74+ const pushLog = useCallback((entry: Omit<LogEntry, 'key' | 'time'>) => {
75+ setLog(prev =>
76+ [...prev, {...entry, key: uid(), time: Date.now()}].slice(-MAX_LOG)
77+ )
78+ }, [])
79+
80+ // ---- join / leave the room whenever roomId changes -------------------------
81+ useEffect(() => {
82+ setPeerNames({})
83+ setTransfers([])
84+
85+ const room = joinRoom({appId: APP_ID}, roomId)
86+
87+ const name = room.makeAction<string>('name')
88+ const chat = room.makeAction<string>('chat')
89+ const binary = room.makeAction('binary')
90+ actionsRef.current = {name, chat, binary}
91+
92+ pushLog({kind: 'system', dir: 'sys', text: `Joined room "${roomId}"`})
93+
94+ room.onPeerJoin = peerId => {
95+ // show the peer immediately (name fills in once their `name` msg arrives)
96+ setPeerNames(prev =>
97+ peerId in prev ? prev : {...prev, [peerId]: ''}
98+ )
99+ pushLog({
100+ kind: 'system',
101+ dir: 'sys',
102+ text: `${shortId(peerId)} connected`
103+ })
104+ // greet the newcomer with our current display name
105+ name.send(selfNameRef.current, {target: peerId})
106+ }
107+
108+ room.onPeerLeave = peerId => {
109+ pushLog({
110+ kind: 'system',
111+ dir: 'sys',
112+ text: `${nameOf(peerId)} disconnected`
113+ })
114+ setPeerNames(prev => {
115+ const next = {...prev}
116+ delete next[peerId]
117+ return next
118+ })
119+ }
120+
121+ name.onMessage = (value, {peerId}) => {
122+ setPeerNames(prev => ({...prev, [peerId]: String(value)}))
123+ }
124+
125+ chat.onMessage = (text, {peerId}) => {
126+ pushLog({
127+ kind: 'text',
128+ dir: 'in',
129+ peerId,
130+ peerName: nameOf(peerId),
131+ text: String(text)
132+ })
133+ }
134+
135+ // receiver-side progress for incoming binary
136+ binary.onReceiveProgress = (percent, {peerId, metadata}) => {
137+ const meta = metadata as BinaryMeta | undefined
138+ if (!meta) return
139+ setTransfers(prev => upsertTransfer(prev, {
140+ id: meta.transferId,
141+ dir: 'in',
142+ peerId,
143+ peerName: nameOf(peerId),
144+ fileName: meta.fileName,
145+ size: meta.size,
146+ percent,
147+ done: false
148+ }))
149+ }
150+
151+ // full binary payload received
152+ binary.onMessage = async (data, {peerId, metadata}) => {
153+ const meta = metadata as BinaryMeta | undefined
154+ const bytes = toBytes(data)
155+ const hash = await sha256Hex(bytes)
156+ const blob = new Blob([bytes], {
157+ type: meta?.mime || 'application/octet-stream'
158+ })
159+ const blobUrl = URL.createObjectURL(blob)
160+ const transferId = meta?.transferId ?? uid()
161+ setTransfers(prev =>
162+ prev.map(t =>
163+ t.id === transferId ? {...t, percent: 1, done: true} : t
164+ )
165+ )
166+ pushLog({
167+ kind: 'binary',
168+ dir: 'in',
169+ peerId,
170+ peerName: nameOf(peerId),
171+ fileName: meta?.fileName ?? 'payload.bin',
172+ size: bytes.byteLength,
173+ sha256: hash,
174+ blobUrl
175+ })
176+ }
177+
178+ return () => {
179+ actionsRef.current = null
180+ room.leave()
181+ }
182+ // nameOf / pushLog are stable (useCallback []), roomId drives re-join
183+ // eslint-disable-next-line react-hooks/exhaustive-deps
184+ }, [roomId])
185+
186+ // ---- poll relay connection count (nostr signalling backend) ----------------
187+ useEffect(() => {
188+ const tick = () => {
189+ try {
190+ setRelayCount(Object.keys(getRelaySockets()).length)
191+ } catch {
192+ setRelayCount(0)
193+ }
194+ }
195+ tick()
196+ const interval = setInterval(tick, 2000)
197+ return () => clearInterval(interval)
198+ }, [])
199+
200+ const setSelfName = useCallback((name: string) => {
201+ const trimmed = name.trim() || `User-${selfId.slice(0, 4)}`
202+ setSelfNameState(trimmed)
203+ localStorage.setItem(NAME_STORAGE_KEY, trimmed)
204+ actionsRef.current?.name.send(trimmed) // broadcast to everyone
205+ }, [])
206+
207+ const sendText = useCallback(
208+ (text: string, target: string | null) => {
209+ const actions = actionsRef.current
210+ if (!actions || !text) return
211+ actions.chat.send(text, target ? {target} : undefined)
212+ pushLog({
213+ kind: 'text',
214+ dir: 'out',
215+ peerId: target ?? undefined,
216+ peerName: target ? nameOf(target) : 'everyone',
217+ text
218+ })
219+ },
220+ [nameOf, pushLog]
221+ )
222+
223+ const sendBinary = useCallback(
224+ async (
225+ data: Uint8Array<ArrayBuffer>,
226+ fileName: string,
227+ mime: string,
228+ target: string | null
229+ ) => {
230+ const actions = actionsRef.current
231+ if (!actions) return
232+ const transferId = uid()
233+ const meta: BinaryMeta = {
234+ transferId,
235+ fileName,
236+ mime,
237+ size: data.byteLength
238+ }
239+ const hash = await sha256Hex(data)
240+
241+ setTransfers(prev => [
242+ ...prev,
243+ {
244+ id: transferId,
245+ dir: 'out',
246+ peerId: target ?? 'everyone',
247+ peerName: target ? nameOf(target) : 'everyone',
248+ fileName,
249+ size: data.byteLength,
250+ percent: 0,
251+ done: false
252+ }
253+ ])
254+
255+ await actions.binary.send(data, {
256+ target: target ?? undefined,
257+ metadata: meta,
258+ onProgress: percent =>
259+ setTransfers(prev =>
260+ prev.map(t => (t.id === transferId ? {...t, percent} : t))
261+ )
262+ })
263+
264+ setTransfers(prev =>
265+ prev.map(t => (t.id === transferId ? {...t, percent: 1, done: true} : t))
266+ )
267+ pushLog({
268+ kind: 'binary',
269+ dir: 'out',
270+ peerId: target ?? undefined,
271+ peerName: target ? nameOf(target) : 'everyone',
272+ fileName,
273+ size: data.byteLength,
274+ sha256: hash
275+ })
276+ },
277+ [nameOf, pushLog]
278+ )
279+
280+ const clearLog = useCallback(() => setLog([]), [])
281+
282+ const others: Peer[] = Object.keys(peerNames)
283+ .sort()
284+ .map(id => ({id, name: peerNames[id] || `(${shortId(id)})`, self: false}))
285+ const peers: Peer[] = [{id: selfId, name: selfName, self: true}, ...others]
286+
287+ return {
288+ selfId,
289+ selfName,
290+ setSelfName,
291+ peers,
292+ others,
293+ log,
294+ transfers,
295+ relayCount,
296+ sendText,
297+ sendBinary,
298+ clearLog
299+ }
300+}
301+
302+// Insert or update an incoming transfer entry by id.
303+function upsertTransfer(prev: Transfer[], next: Transfer): Transfer[] {
304+ const idx = prev.findIndex(t => t.id === next.id)
305+ if (idx === -1) return [...prev, next]
306+ const copy = prev.slice()
307+ // keep `done` sticky once set
308+ copy[idx] = {...copy[idx], ...next, done: copy[idx].done || next.done}
309+ return copy
310+}
src/util.tsadded+41−0View file
@@ -0,0 +1,41 @@
1+// Shorten a long Trystero/Nostr peer id for display.
2+export const shortId = (id: string): string => id.slice(0, 8)
3+
4+// Human-readable byte sizes.
5+export const formatBytes = (n: number): string => {
6+ if (n < 1024) return `${n} B`
7+ const units = ['KB', 'MB', 'GB']
8+ let v = n / 1024
9+ let i = 0
10+ while (v >= 1024 && i < units.length - 1) {
11+ v /= 1024
12+ i++
13+ }
14+ return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`
15+}
16+
17+// A short random id for transfers / log keys.
18+export const uid = (): string => Math.random().toString(36).slice(2, 10)
19+
20+// SHA-256 of a buffer, hex-encoded. Used to prove that what arrived on the
21+// other side is byte-for-byte identical to what was sent.
22+export const sha256Hex = async (
23+ data: Uint8Array<ArrayBuffer> | ArrayBuffer
24+): Promise<string> => {
25+ const digest = await crypto.subtle.digest('SHA-256', data)
26+ return [...new Uint8Array(digest)]
27+ .map(b => b.toString(16).padStart(2, '0'))
28+ .join('')
29+}
30+
31+// Generate `size` bytes of random data, to demo sending large binary payloads
32+// without needing a file. crypto.getRandomValues caps at 65536 bytes per call,
33+// so we fill in chunks.
34+export const randomBytes = (size: number): Uint8Array<ArrayBuffer> => {
35+ const out = new Uint8Array(size)
36+ const chunk = 65536
37+ for (let offset = 0; offset < size; offset += chunk) {
38+ crypto.getRandomValues(out.subarray(offset, Math.min(offset + chunk, size)))
39+ }
40+ return out
41+}
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.app.jsonadded+25−0View file
@@ -0,0 +1,25 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "moduleResolution": "bundler",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+ "jsx": "react-jsx",
16+
17+ /* Linting */
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "noFallthroughCasesInSwitch": true,
22+ "noUncheckedSideEffectImports": true
23+ },
24+ "include": ["src"]
25+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ {"path": "./tsconfig.app.json"},
5+ {"path": "./tsconfig.node.json"}
6+ ]
7+}
tsconfig.node.jsonadded+21−0View file
@@ -0,0 +1,21 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2023",
4+ "lib": ["ES2023"],
5+ "module": "ESNext",
6+ "skipLibCheck": true,
7+
8+ "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
10+ "verbatimModuleSyntax": true,
11+ "moduleDetection": "force",
12+ "noEmit": true,
13+
14+ "strict": true,
15+ "noUnusedLocals": true,
16+ "noUnusedParameters": true,
17+ "noFallthroughCasesInSwitch": true,
18+ "noUncheckedSideEffectImports": true
19+ },
20+ "include": ["vite.config.ts"]
21+}
vite.config.tsadded+12−0View file
@@ -0,0 +1,12 @@
1+import {defineConfig} from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+ // Served from https://concept-collection.github.io/trystero-messaging-demo/
8+ base: '/trystero-messaging-demo/',
9+ server: {
10+ host: true
11+ }
12+})