uniwrtc 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,126 +1,32 @@
1
1
  # UniWRTC Cloudflare Deployment Guide
2
2
 
3
- ## Prerequisites
3
+ ## Deploy Without Durable Objects (Cloudflare Pages)
4
4
 
5
- 1. **Cloudflare Account** - Free tier is sufficient
6
- 2. **Wrangler CLI** - Install: `npm install -g wrangler`
7
- 3. **Node.js** - v16 or higher
8
- 4. **Your Domain** - Domain must be on Cloudflare (e.g., `peer.ooo`)
9
-
10
- ## Setup Steps
11
-
12
- ### 1. Install Wrangler
13
-
14
- ```bash
15
- npm install -g wrangler
16
- ```
17
-
18
- ### 2. Authenticate with Cloudflare
19
-
20
- ```bash
21
- wrangler login
22
- ```
23
-
24
- This will open your browser to authorize the CLI.
25
-
26
- ### 3. Update wrangler.toml
27
-
28
- Replace the zone configuration with your domain:
29
-
30
- ```toml
31
- [env.production]
32
- routes = [
33
- { pattern = "signal.peer.ooo/*", zone_name = "peer.ooo" }
34
- ]
35
- ```
36
-
37
- ### 4. Deploy to Cloudflare
38
-
39
- ```bash
40
- # Deploy to production
41
- wrangler publish --env production
42
-
43
- # Or deploy to staging first
44
- wrangler publish
45
- ```
46
-
47
- ### 5. Access Your Signaling Server
48
-
49
- Your server will be available at:
50
- - **Production**: `https://signal.peer.ooo/`
51
- - **Development**: `https://uniwrtc.<subdomain>.workers.dev/`
5
+ If you only want to host the demo UI (static assets) and do NOT want Durable Objects, deploy the Vite build to Cloudflare Pages.
52
6
 
53
- ## Using with UniWRTC Demo
7
+ ### Prerequisites
54
8
 
55
- Update the demo to use your Cloudflare endpoint:
56
-
57
- ```javascript
58
- // In demo.html or client
59
- const serverUrl = 'https://signal.peer.ooo/';
60
- const roomId = 'my-room';
61
-
62
- const client = new UniWRTCClient(serverUrl, {
63
- roomId: roomId,
64
- customPeerId: 'optional-id'
65
- });
66
-
67
- await client.connect();
68
- ```
69
-
70
- ## How It Works
71
-
72
- 1. **Durable Objects** - One per room, manages peer discovery
73
- 2. **HTTP polling** - Browsers connect for signaling
74
- 3. **Signaling Only** - Offers/answers/ICE via Worker
75
- 4. **P2P Data** - WebRTC data channels bypass Cloudflare
76
- 5. **Free Tier** - Plenty of capacity for small deployments
77
-
78
- ## Cost
79
-
80
- - **Requests**: 100,000 free per day (signaling only)
81
- - **Compute**: Included in free tier
82
- - **Durable Objects**: ~$0.15/million operations (minimal for signaling)
83
- - **Total**: Free to very low cost
84
-
85
- ## Monitoring
86
-
87
- Check deployment status:
88
-
89
- ```bash
90
- wrangler tail --env production
91
- ```
92
-
93
- View real-time logs from your Worker.
94
-
95
- ## Local Development
9
+ 1. **Cloudflare Account** - Free tier is sufficient
10
+ 2. **Wrangler CLI** - Install: `npm install -g wrangler` (or use `npx`)
11
+ 3. **Node.js** - v16 or higher
96
12
 
97
- Test locally before deploying:
13
+ ### Deploy
98
14
 
99
15
  ```bash
100
- wrangler dev
16
+ npm install
17
+ npm run deploy:cf:no-do
101
18
  ```
102
19
 
103
- Your local server will run at `http://localhost:8787`
104
-
105
- Update demo to test:
106
- ```javascript
107
- const serverUrl = 'http://localhost:8787/';
108
- ```
109
-
110
- ## Troubleshooting
111
-
112
- **Connection errors**: Ensure your domain is on Cloudflare with SSL enabled
113
-
114
- **Connection refused**: Check the Worker route pattern in `wrangler.toml`
20
+ Notes:
21
+ - This deploys the `dist/` folder (static hosting).
22
+ - No server routes are deployed; Durable Objects are not used.
115
23
 
116
- **Durable Objects not found**: Run `wrangler publish` with migrations enabled
24
+ ## Custom Domain (signal.peer.ooo)
117
25
 
118
- ## Next Steps
26
+ To serve the Pages project at `https://signal.peer.ooo`:
119
27
 
120
- 1. Deploy the Worker
121
- 2. Update demo.html to use your Cloudflare endpoint
122
- 3. Test with multiple browsers
123
- 4. Scale up!
28
+ 1. Cloudflare Dashboard → Pages → your project → **Custom domains** → add `signal.peer.ooo`
29
+ 2. Cloudflare DNS set `signal` as a CNAME to `<your-pages-project>.pages.dev`
124
30
 
125
31
  ---
126
32
 
@@ -1,8 +1,24 @@
1
- # Quick Start - Deploy to Cloudflare in 30 seconds
1
+ # Quick Start - Deploy to Cloudflare
2
+
3
+ ## Option A (No Durable Objects): Cloudflare Pages (static)
4
+
5
+ This repo’s current demo works client-side (Nostr), so you can deploy just the static site to Cloudflare Pages.
6
+
7
+ ### Prerequisites
8
+ - Cloudflare account (free tier works)
9
+ - Node.js installed
10
+ - Wrangler CLI authenticated (`npx wrangler login`)
11
+
12
+ ### Deploy
13
+ ```bash
14
+ npm install
15
+ npm run deploy:cf:no-do
16
+ ```
17
+
18
+ Wrangler will prompt you to pick/create a Pages project the first time.
2
19
 
3
20
  ## Prerequisites
4
21
  - Cloudflare account (free tier works)
5
- - Your domain on Cloudflare
6
22
  - Node.js installed
7
23
 
8
24
  ## Deploy
@@ -18,38 +34,12 @@ chmod +x deploy-cloudflare.sh
18
34
  deploy-cloudflare.bat
19
35
  ```
20
36
 
21
- ## What the script does:
22
- 1. ✅ Checks Node.js and installs Wrangler
23
- 2. ✅ Authenticates with Cloudflare
24
- 3. ✅ Asks for your domain (e.g., `signal.peer.ooo`)
25
- 4. ✅ Updates `wrangler.toml`
26
- 5. ✅ Deploys to Cloudflare Workers
27
- 6. ✅ Gives you the live URL
37
+ ## What this does
38
+ 1. ✅ Builds the Vite site into `dist/`
39
+ 2. ✅ Deploys `dist/` to Cloudflare Pages
28
40
 
29
41
  ## After deployment:
30
42
 
31
- Update demo.html:
32
- ```javascript
33
- const serverUrl = 'https://signal.peer.ooo/'; // Your domain
34
- ```
35
-
36
- Then reload the demo and it will connect to your Cloudflare Workers signaling server! 🚀
37
-
38
- ## Testing
39
-
40
- Test the server:
41
- ```bash
42
- curl https://signal.peer.ooo/health
43
- ```
44
-
45
- View logs:
46
- ```bash
47
- wrangler tail --env production
48
- ```
49
-
50
- Local development:
51
- ```bash
52
- wrangler dev
53
- ```
43
+ Then set your custom domain in Cloudflare Pages (and point `signal` to `<project>.pages.dev`).
54
44
 
55
- That's it! Your WebRTC signaling is now on Cloudflare! 🎉
45
+ That's it! Your demo is now on Cloudflare Pages (no Durable Objects).
@@ -1,9 +1,10 @@
1
1
  @echo off
2
- REM UniWRTC Cloudflare Automated Setup Script (Windows)
2
+ REM UniWRTC Cloudflare Automated Setup Script (Windows, NO Durable Objects)
3
+ REM Deploys the static demo to Cloudflare Pages.
3
4
 
4
5
  setlocal enabledelayedexpansion
5
6
 
6
- echo 🚀 UniWRTC Cloudflare Setup
7
+ echo 🚀 UniWRTC Cloudflare Setup (Pages / no Durable Objects)
7
8
  echo ============================
8
9
  echo.
9
10
 
@@ -40,74 +41,29 @@ if errorlevel 1 (
40
41
  echo ✅ Authenticated with Cloudflare
41
42
  echo.
42
43
 
43
- REM Ask for domain
44
- echo 🌐 Domain Configuration
45
- echo =====================
46
- set /p DOMAIN="Enter your Cloudflare domain (e.g., peer.ooo): "
47
- set /p SUBDOMAIN="Enter subdomain for signaling (e.g., signal): "
44
+ REM Project name
45
+ set PROJECT_NAME=signal-peer-ooo
46
+ if not "%~1"=="" set PROJECT_NAME=%~1
48
47
 
49
- if "!DOMAIN!"=="" (
50
- echo Domain required
51
- exit /b 1
52
- )
53
-
54
- if "!SUBDOMAIN!"=="" (
55
- echo ❌ Subdomain required
56
- exit /b 1
57
- )
58
-
59
- set FULL_DOMAIN=!SUBDOMAIN!.!DOMAIN!
60
-
61
- REM Update wrangler.toml
62
- echo 📝 Updating wrangler.toml...
63
- (
64
- echo name = "uniwrtc"
65
- echo main = "src/index.js"
66
- echo compatibility_date = "2024-12-20"
67
- echo.
68
- echo [env.production]
69
- echo routes = [
70
- echo { pattern = "!FULL_DOMAIN!/*", zone_name = "!DOMAIN!" }
71
- echo ]
72
- echo.
73
- echo [[durable_objects.bindings]]
74
- echo name = "ROOMS"
75
- echo class_name = "Room"
76
- echo.
77
- echo [durable_objects]
78
- echo migrations = [
79
- echo { tag = "v1", new_classes = ["Room"] }
80
- echo ]
81
- echo.
82
- echo [build]
83
- echo command = "npm install"
84
- ) > wrangler.toml
48
+ echo 📦 Building static site...
49
+ call npm run build
85
50
 
86
- echo wrangler.toml updated
51
+ echo 🚀 Deploying to Cloudflare Pages project: %PROJECT_NAME%
87
52
  echo.
88
53
 
54
+ REM Create project if needed (ignore errors)
55
+ call npx wrangler pages project create %PROJECT_NAME% --production-branch main >nul 2>nul
56
+
89
57
  REM Deploy
90
- echo 🚀 Deploying to Cloudflare...
91
- echo.
92
- call wrangler deploy --env production
58
+ call npx wrangler pages deploy dist --project-name %PROJECT_NAME%
93
59
 
94
60
  echo.
95
61
  echo ✅ Deployment Complete!
96
62
  echo.
97
- echo 🎉 Your UniWRTC signaling server is live at:
98
- echo https://!FULL_DOMAIN!/
99
- echo.
100
- echo 📊 Test it:
101
- echo curl https://!FULL_DOMAIN!/health
102
- echo.
103
- echo 🧪 Local testing:
104
- echo wrangler dev
105
- echo.
106
- echo 📊 View logs:
107
- echo wrangler tail --env production
108
- echo.
109
- echo 🛠️ Next: Update demo.html to use:
110
- echo const serverUrl = 'https://!FULL_DOMAIN!/';
63
+ echo 🎉 Your Pages site is deployed.
64
+ echo Next step for custom domain (manual in Cloudflare UI):
65
+ echo - Pages ^> %PROJECT_NAME% ^> Custom domains ^> add signal.peer.ooo
66
+ echo - DNS: CNAME signal ^> %PROJECT_NAME%.pages.dev
111
67
  echo.
112
68
 
113
69
  endlocal
@@ -1,11 +1,11 @@
1
1
  #!/bin/bash
2
2
 
3
- # UniWRTC Cloudflare Automated Setup Script
4
- # Run this to setup and deploy to Cloudflare
3
+ # UniWRTC Cloudflare Automated Setup Script (NO Durable Objects)
4
+ # Deploys the static demo to Cloudflare Pages.
5
5
 
6
6
  set -e
7
7
 
8
- echo "🚀 UniWRTC Cloudflare Setup"
8
+ echo "🚀 UniWRTC Cloudflare Setup (Pages / no Durable Objects)"
9
9
  echo "============================"
10
10
  echo ""
11
11
 
@@ -41,67 +41,24 @@ fi
41
41
  echo "✅ Authenticated with Cloudflare"
42
42
  echo ""
43
43
 
44
- # Domain configuration
45
- DOMAIN="peer.ooo"
46
- SUBDOMAIN="signal"
44
+ PROJECT_NAME=${1:-"signal-peer-ooo"}
47
45
 
48
- FULL_DOMAIN="${SUBDOMAIN}.${DOMAIN}"
46
+ echo "📦 Building static site..."
47
+ npm run build
49
48
 
50
- echo "📝 Updating wrangler.toml..."
51
- cat > wrangler.toml << EOF
52
- name = "uniwrtc"
53
- main = "src/index.js"
54
- compatibility_date = "2024-12-20"
49
+ echo "🚀 Deploying to Cloudflare Pages project: ${PROJECT_NAME}"
55
50
 
56
- assets = { directory = "./dist", binding = "ASSETS" }
51
+ # Create the project if it doesn't exist (ignore error if it already exists)
52
+ npx wrangler pages project create "${PROJECT_NAME}" --production-branch main 2>/dev/null || true
57
53
 
58
- [[durable_objects.bindings]]
59
- name = "ROOMS"
60
- class_name = "Room"
61
-
62
- [[migrations]]
63
- tag = "v1"
64
- new_classes = ["Room"]
65
-
66
- [env.production]
67
- routes = [
68
- { pattern = "${FULL_DOMAIN}/*", zone_name = "${DOMAIN}" }
69
- ]
70
-
71
- assets = { directory = "./dist", binding = "ASSETS" }
72
-
73
- [[env.production.durable_objects.bindings]]
74
- name = "ROOMS"
75
- class_name = "Room"
76
-
77
- [build]
78
- command = "npm install"
79
- EOF
80
-
81
- echo "✅ wrangler.toml updated"
82
- echo ""
83
-
84
- # Deploy
85
- echo "🚀 Deploying to Cloudflare..."
86
- echo ""
87
- echo "Deploying to production..."
88
- wrangler deploy --env production
54
+ # Deploy the built assets
55
+ npx wrangler pages deploy dist --project-name "${PROJECT_NAME}"
89
56
 
90
57
  echo ""
91
58
  echo "✅ Deployment Complete!"
92
59
  echo ""
93
- echo "🎉 Your UniWRTC signaling server is live at:"
94
- echo " https://${FULL_DOMAIN}/"
95
- echo ""
96
- echo "📊 Test it:"
97
- echo " curl https://${FULL_DOMAIN}/health"
98
- echo ""
99
- echo "🧪 Local testing:"
100
- echo " wrangler dev"
101
- echo ""
102
- echo "📊 View logs:"
103
- echo " wrangler tail --env production"
104
- echo ""
105
- echo "🛠️ Next: Update demo.html to use:"
106
- echo " const serverUrl = 'https://${FULL_DOMAIN}/';"
60
+ echo "🎉 Your Pages site is deployed."
61
+ echo "Next step for custom domain (manual in Cloudflare UI):"
62
+ echo " - Pages → ${PROJECT_NAME} → Custom domains → add signal.peer.ooo"
63
+ echo " - DNS: CNAME signal → ${PROJECT_NAME}.pages.dev"
107
64
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniwrtc",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A universal WebRTC signaling service",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -9,6 +9,8 @@
9
9
  "dev": "vite",
10
10
  "build": "vite build",
11
11
  "preview": "vite preview",
12
+ "deploy:cf:pages": "npm run build && npx wrangler pages deploy dist",
13
+ "deploy:cf:no-do": "npm run deploy:cf:pages",
12
14
  "server": "node server.js",
13
15
  "test": "node test.js"
14
16
  },
package/src/main.js CHANGED
@@ -7,10 +7,29 @@ window.UniWRTCClient = UniWRTCClient;
7
7
 
8
8
  // Nostr is the default transport (no toggle)
9
9
  let nostrClient = null;
10
+ let myPeerId = null;
11
+ let mySessionNonce = null;
12
+ const peerSessions = new Map();
13
+ const peerProbeState = new Map();
14
+ const readyPeers = new Set();
15
+ const deferredHelloPeers = new Set();
16
+
17
+ // Curated public relays (best-effort). Users can override in the UI.
18
+ const DEFAULT_RELAYS = [
19
+ 'wss://relay.primal.net',
20
+ 'wss://relay.nostr.band',
21
+ 'wss://nos.lol',
22
+ 'wss://relay.snort.social',
23
+ 'wss://nostr.wine',
24
+ 'wss://relay.damus.io',
25
+ ];
26
+
10
27
 
11
28
  let client = null;
12
29
  const peerConnections = new Map();
13
30
  const dataChannels = new Map();
31
+ const pendingIce = new Map();
32
+ const outboundIceBatches = new Map();
14
33
 
15
34
  // Initialize app
16
35
  document.getElementById('app').innerHTML = `
@@ -24,8 +43,8 @@ document.getElementById('app').innerHTML = `
24
43
  <h2>Connection</h2>
25
44
  <div class="connection-controls">
26
45
  <div>
27
- <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL</label>
28
- <input type="text" id="relayUrl" placeholder="wss://relay.damus.io" value="wss://relay.damus.io" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-family: monospace; font-size: 12px;">
46
+ <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL(s)</label>
47
+ <input type="text" id="relayUrl" placeholder="wss://relay.damus.io" value="${DEFAULT_RELAYS.join(', ')}" style="width: 100%; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-family: monospace; font-size: 12px;">
29
48
  </div>
30
49
  <div>
31
50
  <label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
@@ -93,6 +112,13 @@ if (urlRoom) {
93
112
  log(`Using default room ID: ${defaultRoom}`, 'info');
94
113
  }
95
114
 
115
+ // STUN-only ICE servers (no TURN)
116
+ const ICE_SERVERS = [
117
+ { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
118
+ { urls: ['stun:stun2.l.google.com:19302', 'stun:stun3.l.google.com:19302'] },
119
+ { urls: ['stun:stun.cloudflare.com:3478'] },
120
+ ];
121
+
96
122
  function log(message, type = 'info') {
97
123
  const logContainer = document.getElementById('logContainer');
98
124
  const entry = document.createElement('div');
@@ -154,12 +180,164 @@ window.connect = async function() {
154
180
  await connectNostr();
155
181
  };
156
182
 
183
+ function shouldInitiateWith(peerId) {
184
+ // Deterministic initiator to avoid offer glare
185
+ if (!myPeerId) return false;
186
+ return myPeerId.localeCompare(peerId) < 0;
187
+ }
188
+
189
+ function isPoliteFor(peerId) {
190
+ // In perfect negotiation, one side is "polite" (will accept/repair collisions)
191
+ return !shouldInitiateWith(peerId);
192
+ }
193
+
194
+ function sendSignal(to, payload) {
195
+ if (!nostrClient) throw new Error('Not connected to Nostr');
196
+
197
+ const toSession = peerSessions.get(to);
198
+ const type = payload?.type;
199
+ const needsToSession = type !== 'probe';
200
+
201
+ if (needsToSession && !toSession) throw new Error('No peer session yet');
202
+
203
+ return nostrClient.send({
204
+ ...payload,
205
+ to,
206
+ ...(needsToSession ? { toSession } : {}),
207
+ fromSession: mySessionNonce,
208
+ });
209
+ }
210
+
211
+ function sendSignalToSession(to, payload, toSession) {
212
+ if (!nostrClient) throw new Error('Not connected to Nostr');
213
+ if (!toSession) throw new Error('toSession is required');
214
+ return nostrClient.send({
215
+ ...payload,
216
+ to,
217
+ toSession,
218
+ fromSession: mySessionNonce,
219
+ });
220
+ }
221
+
222
+ async function maybeProbePeer(peerId) {
223
+ if (!nostrClient) return;
224
+ const session = peerSessions.get(peerId);
225
+ if (!session) return;
226
+ if (!shouldInitiateWith(peerId)) return;
227
+
228
+ const last = peerProbeState.get(peerId);
229
+ if (last && last.session === session) return;
230
+
231
+ const probeId = Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
232
+ peerProbeState.set(peerId, { session, ts: Date.now(), probeId });
233
+ try {
234
+ await sendSignal(peerId, { type: 'probe', probeId });
235
+ log(`Probing peer ${peerId.substring(0, 6)}...`, 'info');
236
+ } catch (e) {
237
+ log(`Probe failed: ${e?.message || e}`, 'warning');
238
+ }
239
+ }
240
+
241
+ function logDrop(peerId, payload, reason) {
242
+ const t = payload?.type || 'unknown';
243
+ if (t !== 'signal-offer' && t !== 'signal-answer' && t !== 'signal-ice' && t !== 'signal-ice-batch' && t !== 'probe' && t !== 'probe-ack') return;
244
+ log(`Dropped ${t} from ${peerId.substring(0, 6)}... (${reason})`, 'warning');
245
+ }
246
+
247
+ async function resetPeerConnection(peerId) {
248
+ const existing = peerConnections.get(peerId);
249
+ if (existing instanceof RTCPeerConnection) {
250
+ try {
251
+ existing.close();
252
+ } catch {
253
+ // ignore
254
+ }
255
+ }
256
+ peerConnections.delete(peerId);
257
+ pendingIce.delete(peerId);
258
+
259
+ const dc = dataChannels.get(peerId);
260
+ if (dc) {
261
+ try {
262
+ dc.close();
263
+ } catch {
264
+ // ignore
265
+ }
266
+ }
267
+ dataChannels.delete(peerId);
268
+ updatePeerList();
269
+
270
+ return ensurePeerConnection(peerId);
271
+ }
272
+
273
+ async function ensurePeerConnection(peerId) {
274
+ if (!peerId || peerId === myPeerId) return null;
275
+ if (peerConnections.has(peerId) && peerConnections.get(peerId) instanceof RTCPeerConnection) {
276
+ return peerConnections.get(peerId);
277
+ }
278
+
279
+ const initiator = shouldInitiateWith(peerId);
280
+
281
+ // Initiator must wait until the peer proves it's live (probe-ack), otherwise we end up
282
+ // negotiating with stale peers from relay history.
283
+ if (initiator && !readyPeers.has(peerId)) {
284
+ return null;
285
+ }
286
+ const pc = await createPeerConnection(peerId, initiator);
287
+ return pc;
288
+ }
289
+
290
+ async function addIceCandidateSafely(peerId, candidate) {
291
+ const pc = peerConnections.get(peerId);
292
+ if (!(pc instanceof RTCPeerConnection)) return;
293
+
294
+ if (!pc.remoteDescription) {
295
+ const list = pendingIce.get(peerId) || [];
296
+ list.push(candidate);
297
+ pendingIce.set(peerId, list);
298
+ return;
299
+ }
300
+
301
+ await pc.addIceCandidate(new RTCIceCandidate(candidate));
302
+ }
303
+
304
+ async function flushPendingIce(peerId) {
305
+ const pc = peerConnections.get(peerId);
306
+ if (!(pc instanceof RTCPeerConnection)) return;
307
+ if (!pc.remoteDescription) return;
308
+
309
+ const list = pendingIce.get(peerId);
310
+ if (!list || list.length === 0) return;
311
+ pendingIce.delete(peerId);
312
+
313
+ for (const c of list) {
314
+ try {
315
+ await pc.addIceCandidate(new RTCIceCandidate(c));
316
+ } catch (e) {
317
+ log(`Failed to add queued ICE candidate: ${e?.message || e}`, 'warning');
318
+ }
319
+ }
320
+ }
321
+
157
322
  async function connectNostr() {
158
- const relayUrl = document.getElementById('relayUrl').value.trim();
323
+ const relayUrlRaw = document.getElementById('relayUrl').value.trim();
159
324
  const roomIdInput = document.getElementById('roomId');
160
325
  const roomId = roomIdInput.value.trim();
161
326
 
162
- if (!relayUrl) {
327
+ const relayCandidatesRaw = relayUrlRaw.toLowerCase() === 'auto'
328
+ ? DEFAULT_RELAYS
329
+ : Array.from(
330
+ new Set(
331
+ relayUrlRaw
332
+ .split(/[\s,]+/)
333
+ .map((s) => s.trim())
334
+ .filter(Boolean)
335
+ )
336
+ );
337
+
338
+ const relayCandidates = relayCandidatesRaw.length ? relayCandidatesRaw : DEFAULT_RELAYS;
339
+
340
+ if (relayCandidates.length === 0) {
163
341
  log('Please enter a relay URL', 'error');
164
342
  return;
165
343
  }
@@ -168,39 +346,318 @@ async function connectNostr() {
168
346
  if (!roomId) roomIdInput.value = effectiveRoom;
169
347
 
170
348
  try {
171
- log(`Connecting to Nostr relay: ${relayUrl}...`, 'info');
349
+ log(`Connecting to Nostr relay...`, 'info');
172
350
 
173
351
  if (nostrClient) {
174
352
  await nostrClient.disconnect();
175
353
  nostrClient = null;
176
354
  }
177
355
 
178
- nostrClient = createNostrClient({
356
+ // Reset local peer state to avoid stale sessions targeting the wrong browser tab.
357
+ myPeerId = null;
358
+ mySessionNonce = null;
359
+ peerSessions.clear();
360
+ peerProbeState.clear();
361
+ readyPeers.clear();
362
+ pendingIce.clear();
363
+ peerConnections.forEach((pc) => {
364
+ if (pc instanceof RTCPeerConnection) pc.close();
365
+ });
366
+ peerConnections.clear();
367
+ dataChannels.clear();
368
+ updatePeerList();
369
+
370
+ const wireHandlers = (client) => {
371
+ client.__handlers = {
372
+ onState: (state) => {
373
+ if (state === 'connected') updateStatus(true);
374
+ if (state === 'disconnected') updateStatus(false);
375
+ },
376
+ onNotice: (notice) => {
377
+ log(`Relay NOTICE: ${String(notice)}`, 'warning');
378
+ },
379
+ onOk: ({ id, ok, message }) => {
380
+ if (ok === false) log(`Relay rejected event ${String(id).slice(0, 8)}...: ${String(message)}`, 'error');
381
+ },
382
+ };
383
+ };
384
+
385
+ // Try relays async (in small parallel batches) and pick the first that accepts publishes.
386
+ const relayBatchSize = 3;
387
+ let lastError = null;
388
+ let selected = null;
389
+
390
+ // Identity must be ready before we connect/subscribe; inbound events can arrive immediately.
391
+ // Any client instance will derive the same per-tab keypair.
392
+ const identityClient = createNostrClient({ relayUrl: relayCandidates[0], room: effectiveRoom });
393
+ const myPubkey = identityClient.getPublicKey();
394
+ myPeerId = myPubkey;
395
+ mySessionNonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
396
+ document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
397
+ document.getElementById('sessionId').textContent = effectiveRoom;
398
+
399
+ const makeClient = (relayUrl) => createNostrClient({
179
400
  relayUrl,
180
401
  room: effectiveRoom,
181
402
  onState: (state) => {
182
403
  if (state === 'connected') updateStatus(true);
183
404
  if (state === 'disconnected') updateStatus(false);
184
405
  },
185
- onMessage: ({ from, text }) => {
186
- displayChatMessage(text, from, false);
406
+ onNotice: (notice) => {
407
+ log(`Relay NOTICE (${relayUrl}): ${String(notice)}`, 'warning');
187
408
  },
188
- onPeer: ({ peerId }) => {
409
+ onOk: ({ id, ok, message }) => {
410
+ if (ok === false) log(`Relay rejected event ${String(id).slice(0, 8)}...: ${String(message)}`, 'error');
411
+ },
412
+ onPayload: async ({ from, payload }) => {
413
+ const peerId = from;
414
+ if (!peerId || peerId === myPeerId) return;
415
+
189
416
  // Use the existing peer list UI as a simple "seen peers" list
190
417
  if (!peerConnections.has(peerId)) {
191
418
  peerConnections.set(peerId, null);
192
419
  updatePeerList();
193
420
  log(`Peer seen: ${peerId.substring(0, 6)}...`, 'success');
194
421
  }
195
- }
422
+
423
+ if (!payload || typeof payload !== 'object') return;
424
+
425
+ // NOTE: Do NOT learn/update peerSessions from arbitrary relay history.
426
+ // Only trust:
427
+ // - `hello` (broadcast presence)
428
+ // - messages targeted to this tab via `toSession === mySessionNonce`
429
+
430
+ // Presence
431
+ if (payload.type === 'hello') {
432
+ if (!myPeerId || !mySessionNonce) return;
433
+ if (typeof payload.session !== 'string' || payload.session.length < 6) return;
434
+ const prev = peerSessions.get(peerId);
435
+ peerSessions.set(peerId, payload.session);
436
+ if (!prev || prev !== payload.session) {
437
+ log(`Peer session updated: ${peerId.substring(0, 6)}...`, 'info');
438
+ }
439
+
440
+ if (prev && prev !== payload.session) {
441
+ readyPeers.delete(peerId);
442
+ }
443
+
444
+ // We may receive peer presence while still selecting a relay.
445
+ // Store and probe once we have a selected/connected `nostrClient`.
446
+ deferredHelloPeers.add(peerId);
447
+ await maybeProbePeer(peerId);
448
+ return;
449
+ }
450
+
451
+ if (payload.type === 'probe') {
452
+ // Learn the peer's session from a live message.
453
+ if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
454
+ const prev = peerSessions.get(peerId);
455
+ if (!prev || prev !== payload.fromSession) {
456
+ peerSessions.set(peerId, payload.fromSession);
457
+ log(`Peer session ${prev ? 'rotated' : 'learned'}: ${peerId.substring(0, 6)}...`, 'info');
458
+ readyPeers.delete(peerId);
459
+ }
460
+ }
461
+
462
+ // Reply directly to the sender's session (fromSession) so the initiator doesn't drop it.
463
+ try {
464
+ await sendSignalToSession(peerId, { type: 'probe-ack', probeId: payload.probeId }, payload.fromSession);
465
+ log(`Probe ack -> ${peerId.substring(0, 6)}...`, 'info');
466
+ } catch (e) {
467
+ log(`Probe-ack failed: ${e?.message || e}`, 'warning');
468
+ }
469
+ return;
470
+ }
471
+
472
+ // Only accept signaling intended for THIS browser session
473
+ if (payload.toSession && payload.toSession !== mySessionNonce) {
474
+ logDrop(peerId, payload, 'toSession mismatch');
475
+ return;
476
+ }
477
+
478
+ // Signaling messages are always targeted
479
+ if (payload.to && payload.to !== myPeerId) {
480
+ logDrop(peerId, payload, 'to mismatch');
481
+ return;
482
+ }
483
+
484
+ // Now that we know it's targeted to this session, we can safely learn peer session.
485
+ if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
486
+ const prev = peerSessions.get(peerId);
487
+ if (!prev || prev !== payload.fromSession) {
488
+ peerSessions.set(peerId, payload.fromSession);
489
+ log(`Peer session ${prev ? 'rotated' : 'learned'}: ${peerId.substring(0, 6)}...`, 'info');
490
+ readyPeers.delete(peerId);
491
+ }
492
+ }
493
+
494
+ if (payload.type === 'probe-ack') {
495
+ const last = peerProbeState.get(peerId);
496
+ if (!last || !last.probeId || !payload.probeId || payload.probeId !== last.probeId) {
497
+ logDrop(peerId, payload, 'probeId mismatch');
498
+ return;
499
+ }
500
+ // Peer session can legitimately rotate between hello/probe/ack (reloads, relay history).
501
+ // Since this message is already targeted to our toSession, accept it and update our view.
502
+ if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
503
+ if (peerSessions.get(peerId) !== payload.fromSession) {
504
+ peerSessions.set(peerId, payload.fromSession);
505
+ }
506
+ if (last.session !== payload.fromSession) {
507
+ last.session = payload.fromSession;
508
+ }
509
+ }
510
+ if (Date.now() - last.ts > 30000) {
511
+ logDrop(peerId, payload, 'stale probe-ack');
512
+ return;
513
+ }
514
+
515
+ readyPeers.add(peerId);
516
+ if (shouldInitiateWith(peerId)) {
517
+ log(`Probe ack <- ${peerId.substring(0, 6)}...`, 'info');
518
+ await ensurePeerConnection(peerId);
519
+ }
520
+ return;
521
+ }
522
+
523
+ if (payload.type === 'signal-offer' && payload.sdp) {
524
+ log(`Received offer from ${peerId.substring(0, 6)}...`, 'info');
525
+ let pc = await ensurePeerConnection(peerId);
526
+ if (!pc) {
527
+ // As the receiver we should always accept an offer even if probe logic didn't run.
528
+ pc = await resetPeerConnection(peerId);
529
+ }
530
+ if (!(pc instanceof RTCPeerConnection)) return;
531
+
532
+ // Offer collision handling: if we're not stable, decide whether to ignore or reset.
533
+ const offerCollision = pc.signalingState !== 'stable';
534
+ if (offerCollision && !isPoliteFor(peerId)) {
535
+ log(`Ignoring offer collision from ${peerId.substring(0, 6)}...`, 'warning');
536
+ return;
537
+ }
538
+ if (offerCollision && isPoliteFor(peerId)) {
539
+ log(`Offer collision; resetting connection with ${peerId.substring(0, 6)}...`, 'warning');
540
+ await resetPeerConnection(peerId);
541
+ const next = peerConnections.get(peerId);
542
+ if (!(next instanceof RTCPeerConnection)) return;
543
+ pc = next;
544
+ }
545
+
546
+ const pc2 = peerConnections.get(peerId);
547
+ if (!(pc2 instanceof RTCPeerConnection)) return;
548
+
549
+ // Only accept offers here.
550
+ if (payload.sdp?.type && payload.sdp.type !== 'offer') {
551
+ log(`Ignoring non-offer in signal-offer from ${peerId.substring(0, 6)}...`, 'warning');
552
+ return;
553
+ }
554
+
555
+ await pc2.setRemoteDescription(new RTCSessionDescription(payload.sdp));
556
+ await flushPendingIce(peerId);
557
+ if (pc2.signalingState !== 'have-remote-offer') {
558
+ log(`Not answering; unexpected state: ${pc2.signalingState}`, 'warning');
559
+ return;
560
+ }
561
+ const answer = await pc2.createAnswer();
562
+ await pc2.setLocalDescription(answer);
563
+ try {
564
+ await sendSignal(peerId, { type: 'signal-answer', sdp: { type: pc2.localDescription.type, sdp: pc2.localDescription.sdp } });
565
+ log(`Sent answer to ${peerId.substring(0, 6)}...`, 'success');
566
+ } catch (e) {
567
+ log(`Failed to send answer: ${e?.message || e}`, 'error');
568
+ }
569
+ return;
570
+ }
571
+
572
+ if (payload.type === 'signal-answer' && payload.sdp) {
573
+ log(`Received answer from ${peerId.substring(0, 6)}...`, 'info');
574
+ const pc = peerConnections.get(peerId);
575
+ if (pc instanceof RTCPeerConnection) {
576
+ if (payload.sdp?.type && payload.sdp.type !== 'answer') {
577
+ log(`Ignoring non-answer in signal-answer from ${peerId.substring(0, 6)}...`, 'warning');
578
+ return;
579
+ }
580
+ await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
581
+ await flushPendingIce(peerId);
582
+ }
583
+ return;
584
+ }
585
+
586
+ if (payload.type === 'signal-ice' && payload.candidate) {
587
+ log(`Received ICE candidate from ${peerId.substring(0, 6)}...`, 'info');
588
+ try {
589
+ await addIceCandidateSafely(peerId, payload.candidate);
590
+ } catch (e) {
591
+ log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
592
+ }
593
+ return;
594
+ }
595
+
596
+ if (payload.type === 'signal-ice-batch' && Array.isArray(payload.candidates)) {
597
+ log(`Received ICE batch (${payload.candidates.length}) from ${peerId.substring(0, 6)}...`, 'info');
598
+ for (const c of payload.candidates) {
599
+ try {
600
+ await addIceCandidateSafely(peerId, c);
601
+ } catch (e) {
602
+ log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
603
+ }
604
+ }
605
+ }
606
+ },
196
607
  });
197
608
 
198
- await nostrClient.connect();
609
+ const tryOneRelay = async (relayUrl) => {
610
+ const candidateClient = makeClient(relayUrl);
611
+ await candidateClient.connect();
612
+ const ok = await candidateClient.sendWithOk({ type: 'relay-check', session: mySessionNonce }, { timeoutMs: 3500 });
613
+ if (ok.ok !== true) throw new Error(ok.message || 'Relay rejected publish');
614
+ return { relayUrl, client: candidateClient };
615
+ };
616
+
617
+ for (let i = 0; i < relayCandidates.length && !selected; i += relayBatchSize) {
618
+ const batch = relayCandidates.slice(i, i + relayBatchSize);
619
+ batch.forEach((u) => log(`Trying relay: ${u}`, 'info'));
620
+
621
+ const clientsInBatch = new Map();
622
+ const attempts = batch.map((relayUrl) => (async () => {
623
+ const result = await tryOneRelay(relayUrl);
624
+ clientsInBatch.set(relayUrl, result.client);
625
+ return result;
626
+ })());
627
+
628
+ try {
629
+ selected = await Promise.any(attempts);
630
+ } catch (e) {
631
+ lastError = e;
632
+ } finally {
633
+ // Close any batch clients that were created but not selected.
634
+ for (const [url, c] of clientsInBatch.entries()) {
635
+ if (selected && selected.relayUrl === url) continue;
636
+ try {
637
+ await c.disconnect();
638
+ } catch {
639
+ // ignore
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ if (!selected) {
646
+ throw lastError || new Error('No relay candidates available');
647
+ }
648
+
649
+ nostrClient = selected.client;
650
+ document.getElementById('relayUrl').value = selected.relayUrl;
651
+ log(`Selected relay: ${selected.relayUrl}`, 'success');
199
652
 
200
- const myPubkey = nostrClient.getPublicKey();
201
- document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
202
- document.getElementById('sessionId').textContent = effectiveRoom;
203
653
  log(`Joined Nostr room: ${effectiveRoom}`, 'success');
654
+ await nostrClient.send({ type: 'hello', session: mySessionNonce });
655
+
656
+ // Kick any peers we saw while selecting relays.
657
+ for (const peerId of deferredHelloPeers) {
658
+ await maybeProbePeer(peerId);
659
+ }
660
+ deferredHelloPeers.clear();
204
661
 
205
662
  updateStatus(true);
206
663
 
@@ -336,6 +793,18 @@ window.disconnect = function() {
336
793
  if (nostrClient) {
337
794
  nostrClient.disconnect().catch(() => {});
338
795
  nostrClient = null;
796
+ myPeerId = null;
797
+ mySessionNonce = null;
798
+ peerSessions.clear();
799
+ peerProbeState.clear();
800
+ readyPeers.clear();
801
+ pendingIce.clear();
802
+ peerConnections.forEach((pc) => {
803
+ if (pc instanceof RTCPeerConnection) pc.close();
804
+ });
805
+ peerConnections.clear();
806
+ dataChannels.clear();
807
+ updatePeerList();
339
808
  }
340
809
  if (client) {
341
810
  client.disconnect();
@@ -351,25 +820,71 @@ window.disconnect = function() {
351
820
 
352
821
  async function createPeerConnection(peerId, shouldInitiate) {
353
822
  if (peerConnections.has(peerId)) {
823
+ const existing = peerConnections.get(peerId);
824
+ if (existing instanceof RTCPeerConnection) {
354
825
  log(`Peer connection already exists for ${peerId.substring(0, 6)}...`, 'warning');
355
- return peerConnections.get(peerId);
826
+ return existing;
827
+ }
828
+
829
+ // Placeholder entry (e.g., peer "seen" list). Replace it with a real RTCPeerConnection.
830
+ peerConnections.delete(peerId);
356
831
  }
357
832
 
358
833
  log(`Creating peer connection with ${peerId.substring(0, 6)}... (shouldInitiate: ${shouldInitiate})`, 'info');
359
834
 
360
835
  const pc = new RTCPeerConnection({
361
- iceServers: [
362
- { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }
363
- ]
836
+ iceServers: ICE_SERVERS,
364
837
  });
365
838
 
839
+ // Register early to avoid races where answer/ICE arrives before this function finishes.
840
+ peerConnections.set(peerId, pc);
841
+ pendingIce.delete(peerId);
842
+ updatePeerList();
843
+
366
844
  pc.onicecandidate = (event) => {
367
- if (event.candidate) {
368
- log(`Sending ICE candidate to ${peerId.substring(0, 6)}...`, 'info');
845
+ if (!nostrClient && client && event.candidate) {
369
846
  client.sendIceCandidate(event.candidate, peerId);
847
+ return;
848
+ }
849
+
850
+ if (!nostrClient) return;
851
+
852
+ const entry = outboundIceBatches.get(peerId) || { candidates: [], timer: null };
853
+ outboundIceBatches.set(peerId, entry);
854
+
855
+ if (event.candidate) {
856
+ entry.candidates.push(event.candidate.toJSON?.() || event.candidate);
857
+ }
858
+
859
+ const flush = () => {
860
+ entry.timer = null;
861
+ if (!entry.candidates.length) return;
862
+ const batch = entry.candidates.splice(0, entry.candidates.length);
863
+ log(`Sending ICE batch (${batch.length}) to ${peerId.substring(0, 6)}...`, 'info');
864
+ sendSignal(peerId, { type: 'signal-ice-batch', candidates: batch }).catch((e) => {
865
+ log(`Failed to send ICE batch: ${e?.message || e}`, 'warning');
866
+ });
867
+ };
868
+
869
+ // If end-of-candidates, flush immediately; otherwise debounce.
870
+ if (!event.candidate) {
871
+ flush();
872
+ return;
873
+ }
874
+
875
+ if (!entry.timer) {
876
+ entry.timer = setTimeout(flush, 250);
370
877
  }
371
878
  };
372
879
 
880
+ pc.oniceconnectionstatechange = () => {
881
+ log(`ICE state (${peerId.substring(0, 6)}...): ${pc.iceConnectionState}`, 'info');
882
+ };
883
+
884
+ pc.onconnectionstatechange = () => {
885
+ log(`Conn state (${peerId.substring(0, 6)}...): ${pc.connectionState}`, 'info');
886
+ };
887
+
373
888
  pc.ondatachannel = (event) => {
374
889
  log(`Received data channel from ${peerId.substring(0, 6)}`, 'info');
375
890
  setupDataChannel(peerId, event.channel);
@@ -381,14 +896,20 @@ async function createPeerConnection(peerId, shouldInitiate) {
381
896
 
382
897
  const offer = await pc.createOffer();
383
898
  await pc.setLocalDescription(offer);
384
- client.sendOffer(offer, peerId);
899
+ if (nostrClient) {
900
+ try {
901
+ await sendSignal(peerId, { type: 'signal-offer', sdp: { type: pc.localDescription.type, sdp: pc.localDescription.sdp } });
902
+ log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
903
+ } catch (e) {
904
+ log(`Failed to send offer: ${e?.message || e}`, 'warning');
905
+ }
906
+ } else if (client) {
907
+ client.sendOffer(offer, peerId);
385
908
  log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
909
+ }
386
910
  } else {
387
911
  log(`Waiting for offer from ${peerId.substring(0, 6)}...`, 'info');
388
912
  }
389
-
390
- peerConnections.set(peerId, pc);
391
- updatePeerList();
392
913
  return pc;
393
914
  }
394
915
 
@@ -416,17 +937,8 @@ window.sendChatMessage = function() {
416
937
  return;
417
938
  }
418
939
 
419
- if (nostrClient) {
420
- nostrClient.sendMessage(message).catch((e) => {
421
- log(`Failed to send via Nostr: ${e?.message || e}`, 'error');
422
- });
423
- displayChatMessage(message, 'You', true);
424
- document.getElementById('chatMessage').value = '';
425
- return;
426
- }
427
-
428
940
  if (dataChannels.size === 0) {
429
- log('No peer connections available. Wait for data channels to open.', 'error');
941
+ log('No data channels yet. Open this room in another tab/browser and wait for WebRTC to connect.', 'error');
430
942
  return;
431
943
  }
432
944
 
@@ -15,7 +15,7 @@ function isHex64(s) {
15
15
  * - Publishes kind:1 events tagged with ['t', room] and ['room', room]
16
16
  * - Subscribes to kind:1 events filtered by #t
17
17
  */
18
- export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState } = {}) {
18
+ export function createNostrClient({ relayUrl, room, onPayload, onState, onNotice, onOk } = {}) {
19
19
  if (!relayUrl) throw new Error('relayUrl is required');
20
20
  if (!room) throw new Error('room is required');
21
21
 
@@ -29,20 +29,27 @@ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState }
29
29
  seen: new Set(),
30
30
  };
31
31
 
32
+ // Track NIP-20 OK responses by event id
33
+ const okWaiters = new Map();
34
+
32
35
  function ensureKeys() {
33
36
  if (state.pubkey && state.secretKeyHex) return;
34
37
 
35
- let stored = localStorage.getItem('nostr-secret-key');
38
+ // IMPORTANT: For this demo we want each browser tab to have a distinct peer ID.
39
+ // sessionStorage is per-tab, while localStorage is shared across tabs.
40
+ const storageKey = 'nostr-secret-key-tab';
41
+ let stored = sessionStorage.getItem(storageKey);
42
+
36
43
  // If stored value looks like an array string from prior buggy storage, clear it
37
44
  if (stored && stored.includes(',')) {
38
- localStorage.removeItem('nostr-secret-key');
45
+ sessionStorage.removeItem(storageKey);
39
46
  stored = null;
40
47
  }
41
48
 
42
49
  if (!isHex64(stored)) {
43
50
  const secretBytes = generateSecretKey();
44
51
  state.secretKeyHex = bytesToHex(secretBytes);
45
- localStorage.setItem('nostr-secret-key', state.secretKeyHex);
52
+ sessionStorage.setItem(storageKey, state.secretKeyHex);
46
53
  } else {
47
54
  state.secretKeyHex = stored;
48
55
  }
@@ -74,12 +81,44 @@ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState }
74
81
  if (!Array.isArray(msg) || msg.length < 2) return;
75
82
  const [type] = msg;
76
83
 
84
+ if (type === 'NOTICE') {
85
+ try {
86
+ onNotice?.(msg[1]);
87
+ } catch {
88
+ // ignore
89
+ }
90
+ return;
91
+ }
92
+
93
+ // NIP-20: ["OK", <event_id>, <true|false>, <message>]
94
+ if (type === 'OK') {
95
+ const id = msg[1];
96
+ const ok = msg[2];
97
+ const message = msg[3];
98
+
99
+ const waiter = okWaiters.get(id);
100
+ if (waiter) {
101
+ okWaiters.delete(id);
102
+ waiter.resolve({ id, ok, message });
103
+ }
104
+ try {
105
+ onOk?.({ id, ok, message });
106
+ } catch {
107
+ // ignore
108
+ }
109
+ return;
110
+ }
111
+
77
112
  if (type === 'EVENT') {
78
113
  const nostrEvent = msg[2];
79
114
  if (!nostrEvent || typeof nostrEvent !== 'object') return;
80
115
  if (nostrEvent.id && state.seen.has(nostrEvent.id)) return;
81
116
  if (nostrEvent.id) state.seen.add(nostrEvent.id);
82
117
 
118
+ // Ignore our own events
119
+ if (nostrEvent.pubkey && nostrEvent.pubkey === state.pubkey) return;
120
+
121
+
83
122
  // Ensure it's for our room
84
123
  const tags = Array.isArray(nostrEvent.tags) ? nostrEvent.tags : [];
85
124
  const roomTag = tags.find((t) => Array.isArray(t) && t[0] === 'room');
@@ -93,22 +132,15 @@ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState }
93
132
  return;
94
133
  }
95
134
 
96
- if (payload?.type === 'peer-join' && payload.peerId) {
97
- try {
98
- onPeer?.({ peerId: payload.peerId });
99
- } catch {
100
- // ignore
101
- }
102
- return;
103
- }
104
-
105
- if (payload?.type === 'message' && typeof payload.message === 'string') {
106
- const from = (payload.peerId || nostrEvent.pubkey || 'peer').substring(0, 8) + '...';
107
- try {
108
- onMessage?.({ from, text: payload.message });
109
- } catch {
110
- // ignore
111
- }
135
+ try {
136
+ onPayload?.({
137
+ from: nostrEvent.pubkey,
138
+ payload,
139
+ eventId: nostrEvent.id,
140
+ createdAt: nostrEvent.created_at,
141
+ });
142
+ } catch {
143
+ // ignore
112
144
  }
113
145
  }
114
146
  }
@@ -153,44 +185,24 @@ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState }
153
185
  const filter = {
154
186
  kinds: [1],
155
187
  '#t': [state.room],
156
- since: now - 3600,
188
+ // Use a wider window to tolerate clock skew; app layer filters stale via session nonces.
189
+ since: now - 600,
157
190
  limit: 200,
158
191
  };
159
192
 
160
193
  sendRaw(['REQ', state.subId, filter]);
161
194
 
162
- // Announce presence
163
- await sendJoin();
164
-
165
195
  setState('connected');
166
196
  }
167
197
 
168
- async function sendJoin() {
198
+ async function send(payload) {
169
199
  ensureKeys();
170
- const created_at = Math.floor(Date.now() / 1000);
171
- const tags = [
172
- ['room', state.room],
173
- ['t', state.room],
174
- ];
175
-
176
- const eventTemplate = {
177
- kind: 1,
178
- created_at,
179
- tags,
180
- content: JSON.stringify({
181
- type: 'peer-join',
182
- peerId: state.pubkey,
183
- room: state.room,
184
- timestamp: Date.now(),
185
- }),
186
- pubkey: state.pubkey,
187
- };
188
-
189
- const signed = finalizeEvent(eventTemplate, state.secretKeyHex);
200
+ const signed = buildSignedEvent(payload);
190
201
  sendRaw(['EVENT', signed]);
202
+ return signed.id;
191
203
  }
192
204
 
193
- async function sendMessage(text) {
205
+ function buildSignedEvent(payload) {
194
206
  ensureKeys();
195
207
  const created_at = Math.floor(Date.now() / 1000);
196
208
  const tags = [
@@ -203,17 +215,36 @@ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState }
203
215
  created_at,
204
216
  tags,
205
217
  content: JSON.stringify({
206
- type: 'message',
207
- message: text,
208
- peerId: state.pubkey,
218
+ ...payload,
219
+ from: state.pubkey,
209
220
  room: state.room,
210
221
  timestamp: Date.now(),
211
222
  }),
212
223
  pubkey: state.pubkey,
213
224
  };
214
225
 
215
- const signed = finalizeEvent(eventTemplate, state.secretKeyHex);
226
+ return finalizeEvent(eventTemplate, state.secretKeyHex);
227
+ }
228
+
229
+ async function sendWithOk(payload, { timeoutMs = 4000 } = {}) {
230
+ const signed = buildSignedEvent(payload);
231
+
232
+ const okPromise = new Promise((resolve, reject) => {
233
+ const timer = setTimeout(() => {
234
+ okWaiters.delete(signed.id);
235
+ reject(new Error('Timed out waiting for relay OK'));
236
+ }, timeoutMs);
237
+
238
+ okWaiters.set(signed.id, {
239
+ resolve: (v) => {
240
+ clearTimeout(timer);
241
+ resolve(v);
242
+ },
243
+ });
244
+ });
245
+
216
246
  sendRaw(['EVENT', signed]);
247
+ return await okPromise;
217
248
  }
218
249
 
219
250
  async function disconnect() {
@@ -237,7 +268,8 @@ export function createNostrClient({ relayUrl, room, onMessage, onPeer, onState }
237
268
  return {
238
269
  connect,
239
270
  disconnect,
240
- sendMessage,
271
+ send,
272
+ sendWithOk,
241
273
  getPublicKey: getPublicKeyHex,
242
274
  };
243
275
  }
package/wrangler.toml CHANGED
@@ -1,27 +1,2 @@
1
- name = "uniwrtc"
2
- main = "src/index.js"
3
- compatibility_date = "2024-12-20"
4
-
5
- assets = { directory = "./dist", binding = "ASSETS" }
6
-
7
- [[durable_objects.bindings]]
8
- name = "ROOMS"
9
- class_name = "Room"
10
-
11
- [[migrations]]
12
- tag = "v1"
13
- new_classes = ["Room"]
14
-
15
- [env.production]
16
- routes = [
17
- { pattern = "signal.peer.ooo/*", zone_name = "peer.ooo" }
18
- ]
19
-
20
- assets = { directory = "./dist", binding = "ASSETS" }
21
-
22
- [[env.production.durable_objects.bindings]]
23
- name = "ROOMS"
24
- class_name = "Room"
25
-
26
- [build]
27
- command = "npm install"
1
+ name = "signal-peer-ooo"
2
+ pages_build_output_dir = "dist"