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.
- package/CLOUDFLARE_DEPLOYMENT.md +16 -110
- package/QUICKSTART_CLOUDFLARE.md +23 -33
- package/deploy-cloudflare.bat +17 -61
- package/deploy-cloudflare.sh +15 -58
- package/package.json +3 -1
- package/src/main.js +546 -34
- package/src/nostr/nostrClient.js +83 -51
- package/wrangler.toml +2 -27
package/CLOUDFLARE_DEPLOYMENT.md
CHANGED
|
@@ -1,126 +1,32 @@
|
|
|
1
1
|
# UniWRTC Cloudflare Deployment Guide
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Deploy Without Durable Objects (Cloudflare Pages)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
### Prerequisites
|
|
54
8
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
13
|
+
### Deploy
|
|
98
14
|
|
|
99
15
|
```bash
|
|
100
|
-
|
|
16
|
+
npm install
|
|
17
|
+
npm run deploy:cf:no-do
|
|
101
18
|
```
|
|
102
19
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
24
|
+
## Custom Domain (signal.peer.ooo)
|
|
117
25
|
|
|
118
|
-
|
|
26
|
+
To serve the Pages project at `https://signal.peer.ooo`:
|
|
119
27
|
|
|
120
|
-
1.
|
|
121
|
-
2.
|
|
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
|
|
package/QUICKSTART_CLOUDFLARE.md
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
# Quick Start - Deploy to Cloudflare
|
|
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
|
|
22
|
-
1. ✅
|
|
23
|
-
2. ✅
|
|
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
|
-
|
|
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
|
|
45
|
+
That's it! Your demo is now on Cloudflare Pages (no Durable Objects).
|
package/deploy-cloudflare.bat
CHANGED
|
@@ -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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
98
|
-
echo
|
|
99
|
-
echo.
|
|
100
|
-
echo
|
|
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
|
package/deploy-cloudflare.sh
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
|
-
# UniWRTC Cloudflare Automated Setup Script
|
|
4
|
-
#
|
|
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
|
-
|
|
45
|
-
DOMAIN="peer.ooo"
|
|
46
|
-
SUBDOMAIN="signal"
|
|
44
|
+
PROJECT_NAME=${1:-"signal-peer-ooo"}
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
echo "📦 Building static site..."
|
|
47
|
+
npm run build
|
|
49
48
|
|
|
50
|
-
echo "
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
name
|
|
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
|
|
94
|
-
echo "
|
|
95
|
-
echo ""
|
|
96
|
-
echo "
|
|
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.
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
406
|
+
onNotice: (notice) => {
|
|
407
|
+
log(`Relay NOTICE (${relayUrl}): ${String(notice)}`, 'warning');
|
|
187
408
|
},
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/nostr/nostrClient.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
198
|
+
async function send(payload) {
|
|
169
199
|
ensureKeys();
|
|
170
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
+
send,
|
|
272
|
+
sendWithOk,
|
|
241
273
|
getPublicKey: getPublicKeyHex,
|
|
242
274
|
};
|
|
243
275
|
}
|
package/wrangler.toml
CHANGED
|
@@ -1,27 +1,2 @@
|
|
|
1
|
-
name = "
|
|
2
|
-
|
|
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"
|