uniwrtc 1.0.9 → 1.2.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/NOSTR_INTEGRATION.md +6 -0
- package/QUICKSTART_CLOUDFLARE.md +23 -33
- package/deploy-cloudflare.bat +17 -61
- package/deploy-cloudflare.sh +15 -58
- package/package.json +6 -2
- package/src/main.js +176 -23
- package/src/nostr/nostrClient.js +213 -0
- package/src/nostr/useNostr.js +6 -0
- package/src/nostr/useWebRTC.js +6 -0
- package/src/services/nostrService.js +129 -0
- 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.0
|
|
3
|
+
"version": "1.2.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
|
},
|
|
@@ -20,7 +22,9 @@
|
|
|
20
22
|
],
|
|
21
23
|
"author": "",
|
|
22
24
|
"license": "MIT",
|
|
23
|
-
"dependencies": {
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"nostr-tools": "^2.9.0"
|
|
27
|
+
},
|
|
24
28
|
"devDependencies": {
|
|
25
29
|
"@playwright/test": "^1.57.0",
|
|
26
30
|
"vite": "^6.0.6"
|
package/src/main.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import './style.css';
|
|
2
2
|
import UniWRTCClient from '../client-browser.js';
|
|
3
|
+
import { createNostrClient } from './nostr/nostrClient.js';
|
|
3
4
|
|
|
4
5
|
// Make UniWRTCClient available globally for backwards compatibility
|
|
5
6
|
window.UniWRTCClient = UniWRTCClient;
|
|
6
7
|
|
|
8
|
+
// Nostr is the default transport (no toggle)
|
|
9
|
+
let nostrClient = null;
|
|
10
|
+
let myPeerId = null;
|
|
11
|
+
|
|
7
12
|
let client = null;
|
|
8
13
|
const peerConnections = new Map();
|
|
9
14
|
const dataChannels = new Map();
|
|
@@ -20,8 +25,8 @@ document.getElementById('app').innerHTML = `
|
|
|
20
25
|
<h2>Connection</h2>
|
|
21
26
|
<div class="connection-controls">
|
|
22
27
|
<div>
|
|
23
|
-
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">
|
|
24
|
-
<input type="text" id="
|
|
28
|
+
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Relay URL</label>
|
|
29
|
+
<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;">
|
|
25
30
|
</div>
|
|
26
31
|
<div>
|
|
27
32
|
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
|
|
@@ -147,6 +152,135 @@ function updatePeerList() {
|
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
window.connect = async function() {
|
|
155
|
+
await connectNostr();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function shouldInitiateWith(peerId) {
|
|
159
|
+
// Deterministic initiator to avoid offer glare
|
|
160
|
+
if (!myPeerId) return false;
|
|
161
|
+
return myPeerId.localeCompare(peerId) < 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sendSignal(to, payload) {
|
|
165
|
+
if (!nostrClient) throw new Error('Not connected to Nostr');
|
|
166
|
+
return nostrClient.send({
|
|
167
|
+
...payload,
|
|
168
|
+
to,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function ensurePeerConnection(peerId) {
|
|
173
|
+
if (!peerId || peerId === myPeerId) return null;
|
|
174
|
+
if (peerConnections.has(peerId) && peerConnections.get(peerId) instanceof RTCPeerConnection) {
|
|
175
|
+
return peerConnections.get(peerId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const initiator = shouldInitiateWith(peerId);
|
|
179
|
+
const pc = await createPeerConnection(peerId, initiator);
|
|
180
|
+
return pc;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function connectNostr() {
|
|
184
|
+
const relayUrl = document.getElementById('relayUrl').value.trim();
|
|
185
|
+
const roomIdInput = document.getElementById('roomId');
|
|
186
|
+
const roomId = roomIdInput.value.trim();
|
|
187
|
+
|
|
188
|
+
if (!relayUrl) {
|
|
189
|
+
log('Please enter a relay URL', 'error');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const effectiveRoom = roomId || `room-${Math.random().toString(36).substring(2, 10)}`;
|
|
194
|
+
if (!roomId) roomIdInput.value = effectiveRoom;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
log(`Connecting to Nostr relay: ${relayUrl}...`, 'info');
|
|
198
|
+
|
|
199
|
+
if (nostrClient) {
|
|
200
|
+
await nostrClient.disconnect();
|
|
201
|
+
nostrClient = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
nostrClient = createNostrClient({
|
|
205
|
+
relayUrl,
|
|
206
|
+
room: effectiveRoom,
|
|
207
|
+
onState: (state) => {
|
|
208
|
+
if (state === 'connected') updateStatus(true);
|
|
209
|
+
if (state === 'disconnected') updateStatus(false);
|
|
210
|
+
},
|
|
211
|
+
onPayload: async ({ from, payload }) => {
|
|
212
|
+
const peerId = from;
|
|
213
|
+
if (!peerId || peerId === myPeerId) return;
|
|
214
|
+
|
|
215
|
+
// Use the existing peer list UI as a simple "seen peers" list
|
|
216
|
+
if (!peerConnections.has(peerId)) {
|
|
217
|
+
peerConnections.set(peerId, null);
|
|
218
|
+
updatePeerList();
|
|
219
|
+
log(`Peer seen: ${peerId.substring(0, 6)}...`, 'success');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!payload || typeof payload !== 'object') return;
|
|
223
|
+
|
|
224
|
+
// Presence
|
|
225
|
+
if (payload.type === 'hello') {
|
|
226
|
+
await ensurePeerConnection(peerId);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Signaling messages are always targeted
|
|
231
|
+
if (payload.to && payload.to !== myPeerId) return;
|
|
232
|
+
|
|
233
|
+
if (payload.type === 'signal-offer' && payload.sdp) {
|
|
234
|
+
log(`Received offer from ${peerId.substring(0, 6)}...`, 'info');
|
|
235
|
+
const pc = await ensurePeerConnection(peerId);
|
|
236
|
+
await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
|
|
237
|
+
const answer = await pc.createAnswer();
|
|
238
|
+
await pc.setLocalDescription(answer);
|
|
239
|
+
await sendSignal(peerId, { type: 'signal-answer', sdp: pc.localDescription });
|
|
240
|
+
log(`Sent answer to ${peerId.substring(0, 6)}...`, 'success');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (payload.type === 'signal-answer' && payload.sdp) {
|
|
245
|
+
log(`Received answer from ${peerId.substring(0, 6)}...`, 'info');
|
|
246
|
+
const pc = peerConnections.get(peerId);
|
|
247
|
+
if (pc instanceof RTCPeerConnection) {
|
|
248
|
+
await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp));
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (payload.type === 'signal-ice' && payload.candidate) {
|
|
254
|
+
const pc = peerConnections.get(peerId);
|
|
255
|
+
if (pc instanceof RTCPeerConnection) {
|
|
256
|
+
try {
|
|
257
|
+
await pc.addIceCandidate(new RTCIceCandidate(payload.candidate));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
log(`Failed to add ICE candidate: ${e?.message || e}`, 'warning');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await nostrClient.connect();
|
|
267
|
+
|
|
268
|
+
const myPubkey = nostrClient.getPublicKey();
|
|
269
|
+
myPeerId = myPubkey;
|
|
270
|
+
document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
|
|
271
|
+
document.getElementById('sessionId').textContent = effectiveRoom;
|
|
272
|
+
log(`Joined Nostr room: ${effectiveRoom}`, 'success');
|
|
273
|
+
|
|
274
|
+
updateStatus(true);
|
|
275
|
+
|
|
276
|
+
log('Nostr connection established', 'success');
|
|
277
|
+
} catch (error) {
|
|
278
|
+
log(`Nostr connection error: ${error.message}`, 'error');
|
|
279
|
+
updateStatus(false);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function connectWebRTC() {
|
|
150
284
|
const serverUrl = document.getElementById('serverUrl').value.trim();
|
|
151
285
|
const roomId = document.getElementById('roomId').value.trim();
|
|
152
286
|
|
|
@@ -161,35 +295,35 @@ window.connect = async function() {
|
|
|
161
295
|
}
|
|
162
296
|
|
|
163
297
|
try {
|
|
164
|
-
|
|
298
|
+
log(`Connecting to ${serverUrl}...`, 'info');
|
|
165
299
|
|
|
166
300
|
// For Cloudflare, use /ws endpoint with room ID query param
|
|
167
301
|
let finalUrl = serverUrl;
|
|
168
302
|
if (serverUrl.includes('signal.peer.ooo')) {
|
|
169
|
-
|
|
170
|
-
|
|
303
|
+
finalUrl = `${serverUrl.replace(/\/$/, '')}/ws?room=${roomId}`;
|
|
304
|
+
log(`Using Cloudflare Durable Objects with session: ${roomId}`, 'info');
|
|
171
305
|
}
|
|
172
306
|
|
|
173
307
|
client = new UniWRTCClient(finalUrl, { autoReconnect: false });
|
|
174
308
|
|
|
175
309
|
client.on('connected', (data) => {
|
|
176
|
-
|
|
310
|
+
log(`Connected with client ID: ${data.clientId}`, 'success');
|
|
177
311
|
document.getElementById('clientId').textContent = data.clientId;
|
|
178
312
|
updateStatus(true);
|
|
179
313
|
|
|
180
314
|
// Auto-join the room
|
|
181
|
-
|
|
315
|
+
log(`Joining session: ${roomId}`, 'info');
|
|
182
316
|
client.joinSession(roomId);
|
|
183
317
|
});
|
|
184
318
|
|
|
185
319
|
client.on('joined', (data) => {
|
|
186
|
-
|
|
320
|
+
log(`Joined session: ${data.sessionId}`, 'success');
|
|
187
321
|
document.getElementById('sessionId').textContent = data.sessionId;
|
|
188
322
|
|
|
189
323
|
if (data.clients && data.clients.length > 0) {
|
|
190
|
-
|
|
324
|
+
log(`Found ${data.clients.length} existing peers`, 'info');
|
|
191
325
|
data.clients.forEach(peerId => {
|
|
192
|
-
|
|
326
|
+
log(`Creating connection to existing peer: ${peerId.substring(0, 6)}...`, 'info');
|
|
193
327
|
createPeerConnection(peerId, true);
|
|
194
328
|
});
|
|
195
329
|
}
|
|
@@ -198,11 +332,11 @@ window.connect = async function() {
|
|
|
198
332
|
client.on('peer-joined', (data) => {
|
|
199
333
|
// Only handle peers in our session
|
|
200
334
|
if (client.sessionId && data.sessionId !== client.sessionId) {
|
|
201
|
-
|
|
335
|
+
log(`Ignoring peer from different session: ${data.sessionId}`, 'warning');
|
|
202
336
|
return;
|
|
203
337
|
}
|
|
204
338
|
|
|
205
|
-
|
|
339
|
+
log(`Peer joined: ${data.peerId.substring(0, 6)}...`, 'success');
|
|
206
340
|
|
|
207
341
|
// Wait a bit to ensure both peers are ready
|
|
208
342
|
setTimeout(() => {
|
|
@@ -211,7 +345,7 @@ window.connect = async function() {
|
|
|
211
345
|
});
|
|
212
346
|
|
|
213
347
|
client.on('peer-left', (data) => {
|
|
214
|
-
|
|
348
|
+
log(`Peer left: ${data.peerId.substring(0, 6)}...`, 'warning');
|
|
215
349
|
const pc = peerConnections.get(data.peerId);
|
|
216
350
|
if (pc) {
|
|
217
351
|
pc.close();
|
|
@@ -222,17 +356,17 @@ window.connect = async function() {
|
|
|
222
356
|
});
|
|
223
357
|
|
|
224
358
|
client.on('offer', async (data) => {
|
|
225
|
-
|
|
359
|
+
log(`Received offer from ${data.peerId.substring(0, 6)}...`, 'info');
|
|
226
360
|
const pc = peerConnections.get(data.peerId) || await createPeerConnection(data.peerId, false);
|
|
227
361
|
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
|
228
362
|
const answer = await pc.createAnswer();
|
|
229
363
|
await pc.setLocalDescription(answer);
|
|
230
364
|
client.sendAnswer(answer, data.peerId);
|
|
231
|
-
|
|
365
|
+
log(`Sent answer to ${data.peerId.substring(0, 6)}...`, 'success');
|
|
232
366
|
});
|
|
233
367
|
|
|
234
368
|
client.on('answer', async (data) => {
|
|
235
|
-
|
|
369
|
+
log(`Received answer from ${data.peerId.substring(0, 6)}...`, 'info');
|
|
236
370
|
const pc = peerConnections.get(data.peerId);
|
|
237
371
|
if (pc) {
|
|
238
372
|
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
|
@@ -240,7 +374,7 @@ window.connect = async function() {
|
|
|
240
374
|
});
|
|
241
375
|
|
|
242
376
|
client.on('ice-candidate', async (data) => {
|
|
243
|
-
|
|
377
|
+
log(`Received ICE candidate from ${data.peerId.substring(0, 6)}...`, 'info');
|
|
244
378
|
const pc = peerConnections.get(data.peerId);
|
|
245
379
|
if (pc && data.candidate) {
|
|
246
380
|
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
|
@@ -257,17 +391,28 @@ window.connect = async function() {
|
|
|
257
391
|
});
|
|
258
392
|
|
|
259
393
|
client.on('error', (data) => {
|
|
260
|
-
|
|
394
|
+
log(`Error: ${data.message}`, 'error');
|
|
261
395
|
});
|
|
262
396
|
|
|
263
397
|
await client.connect();
|
|
264
398
|
} catch (error) {
|
|
265
|
-
|
|
399
|
+
log(`Connection error: ${error.message}`, 'error');
|
|
266
400
|
updateStatus(false);
|
|
267
401
|
}
|
|
268
|
-
}
|
|
402
|
+
}
|
|
269
403
|
|
|
270
404
|
window.disconnect = function() {
|
|
405
|
+
if (nostrClient) {
|
|
406
|
+
nostrClient.disconnect().catch(() => {});
|
|
407
|
+
nostrClient = null;
|
|
408
|
+
myPeerId = null;
|
|
409
|
+
peerConnections.forEach((pc) => {
|
|
410
|
+
if (pc instanceof RTCPeerConnection) pc.close();
|
|
411
|
+
});
|
|
412
|
+
peerConnections.clear();
|
|
413
|
+
dataChannels.clear();
|
|
414
|
+
updatePeerList();
|
|
415
|
+
}
|
|
271
416
|
if (client) {
|
|
272
417
|
client.disconnect();
|
|
273
418
|
client = null;
|
|
@@ -297,7 +442,11 @@ async function createPeerConnection(peerId, shouldInitiate) {
|
|
|
297
442
|
pc.onicecandidate = (event) => {
|
|
298
443
|
if (event.candidate) {
|
|
299
444
|
log(`Sending ICE candidate to ${peerId.substring(0, 6)}...`, 'info');
|
|
300
|
-
|
|
445
|
+
if (nostrClient) {
|
|
446
|
+
sendSignal(peerId, { type: 'signal-ice', candidate: event.candidate.toJSON?.() || event.candidate });
|
|
447
|
+
} else if (client) {
|
|
448
|
+
client.sendIceCandidate(event.candidate, peerId);
|
|
449
|
+
}
|
|
301
450
|
}
|
|
302
451
|
};
|
|
303
452
|
|
|
@@ -312,7 +461,11 @@ async function createPeerConnection(peerId, shouldInitiate) {
|
|
|
312
461
|
|
|
313
462
|
const offer = await pc.createOffer();
|
|
314
463
|
await pc.setLocalDescription(offer);
|
|
315
|
-
|
|
464
|
+
if (nostrClient) {
|
|
465
|
+
await sendSignal(peerId, { type: 'signal-offer', sdp: pc.localDescription });
|
|
466
|
+
} else if (client) {
|
|
467
|
+
client.sendOffer(offer, peerId);
|
|
468
|
+
}
|
|
316
469
|
log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
|
|
317
470
|
} else {
|
|
318
471
|
log(`Waiting for offer from ${peerId.substring(0, 6)}...`, 'info');
|
|
@@ -348,7 +501,7 @@ window.sendChatMessage = function() {
|
|
|
348
501
|
}
|
|
349
502
|
|
|
350
503
|
if (dataChannels.size === 0) {
|
|
351
|
-
log('No
|
|
504
|
+
log('No data channels yet. Open this room in another tab/browser and wait for WebRTC to connect.', 'error');
|
|
352
505
|
return;
|
|
353
506
|
}
|
|
354
507
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
|
|
2
|
+
|
|
3
|
+
function bytesToHex(bytes) {
|
|
4
|
+
return Array.from(bytes)
|
|
5
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
6
|
+
.join('');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isHex64(s) {
|
|
10
|
+
return typeof s === 'string' && /^[0-9a-fA-F]{64}$/.test(s);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal Nostr relay client using raw WebSocket protocol.
|
|
15
|
+
* - Publishes kind:1 events tagged with ['t', room] and ['room', room]
|
|
16
|
+
* - Subscribes to kind:1 events filtered by #t
|
|
17
|
+
*/
|
|
18
|
+
export function createNostrClient({ relayUrl, room, onPayload, onState } = {}) {
|
|
19
|
+
if (!relayUrl) throw new Error('relayUrl is required');
|
|
20
|
+
if (!room) throw new Error('room is required');
|
|
21
|
+
|
|
22
|
+
const state = {
|
|
23
|
+
relayUrl,
|
|
24
|
+
room,
|
|
25
|
+
ws: null,
|
|
26
|
+
subId: `sub-${room}-${Math.random().toString(36).slice(2, 8)}`,
|
|
27
|
+
secretKeyHex: null,
|
|
28
|
+
pubkey: null,
|
|
29
|
+
seen: new Set(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function ensureKeys() {
|
|
33
|
+
if (state.pubkey && state.secretKeyHex) return;
|
|
34
|
+
|
|
35
|
+
let stored = localStorage.getItem('nostr-secret-key');
|
|
36
|
+
// If stored value looks like an array string from prior buggy storage, clear it
|
|
37
|
+
if (stored && stored.includes(',')) {
|
|
38
|
+
localStorage.removeItem('nostr-secret-key');
|
|
39
|
+
stored = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!isHex64(stored)) {
|
|
43
|
+
const secretBytes = generateSecretKey();
|
|
44
|
+
state.secretKeyHex = bytesToHex(secretBytes);
|
|
45
|
+
localStorage.setItem('nostr-secret-key', state.secretKeyHex);
|
|
46
|
+
} else {
|
|
47
|
+
state.secretKeyHex = stored;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
state.pubkey = getPublicKey(state.secretKeyHex);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getPublicKeyHex() {
|
|
54
|
+
ensureKeys();
|
|
55
|
+
return state.pubkey;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setState(next) {
|
|
59
|
+
try {
|
|
60
|
+
onState?.(next);
|
|
61
|
+
} catch {
|
|
62
|
+
// ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseIncoming(event) {
|
|
67
|
+
let msg;
|
|
68
|
+
try {
|
|
69
|
+
msg = JSON.parse(event.data);
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!Array.isArray(msg) || msg.length < 2) return;
|
|
75
|
+
const [type] = msg;
|
|
76
|
+
|
|
77
|
+
if (type === 'EVENT') {
|
|
78
|
+
const nostrEvent = msg[2];
|
|
79
|
+
if (!nostrEvent || typeof nostrEvent !== 'object') return;
|
|
80
|
+
if (nostrEvent.id && state.seen.has(nostrEvent.id)) return;
|
|
81
|
+
if (nostrEvent.id) state.seen.add(nostrEvent.id);
|
|
82
|
+
|
|
83
|
+
// Ignore our own events
|
|
84
|
+
if (nostrEvent.pubkey && nostrEvent.pubkey === state.pubkey) return;
|
|
85
|
+
|
|
86
|
+
// Ensure it's for our room
|
|
87
|
+
const tags = Array.isArray(nostrEvent.tags) ? nostrEvent.tags : [];
|
|
88
|
+
const roomTag = tags.find((t) => Array.isArray(t) && t[0] === 'room');
|
|
89
|
+
if (!roomTag || roomTag[1] !== state.room) return;
|
|
90
|
+
|
|
91
|
+
// Content is JSON
|
|
92
|
+
let payload;
|
|
93
|
+
try {
|
|
94
|
+
payload = JSON.parse(nostrEvent.content);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
onPayload?.({
|
|
101
|
+
from: nostrEvent.pubkey,
|
|
102
|
+
payload,
|
|
103
|
+
eventId: nostrEvent.id,
|
|
104
|
+
createdAt: nostrEvent.created_at,
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sendRaw(frame) {
|
|
113
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
114
|
+
throw new Error('Relay not connected');
|
|
115
|
+
}
|
|
116
|
+
state.ws.send(JSON.stringify(frame));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function connect() {
|
|
120
|
+
ensureKeys();
|
|
121
|
+
|
|
122
|
+
setState('connecting');
|
|
123
|
+
|
|
124
|
+
const ws = new WebSocket(state.relayUrl);
|
|
125
|
+
state.ws = ws;
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
const onOpen = () => {
|
|
129
|
+
cleanup();
|
|
130
|
+
resolve();
|
|
131
|
+
};
|
|
132
|
+
const onError = () => {
|
|
133
|
+
cleanup();
|
|
134
|
+
reject(new Error('Failed to connect to relay'));
|
|
135
|
+
};
|
|
136
|
+
const cleanup = () => {
|
|
137
|
+
ws.removeEventListener('open', onOpen);
|
|
138
|
+
ws.removeEventListener('error', onError);
|
|
139
|
+
};
|
|
140
|
+
ws.addEventListener('open', onOpen);
|
|
141
|
+
ws.addEventListener('error', onError);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
ws.addEventListener('message', parseIncoming);
|
|
145
|
+
ws.addEventListener('close', () => setState('disconnected'));
|
|
146
|
+
|
|
147
|
+
// Subscribe to this room (topic-tag filtered)
|
|
148
|
+
const now = Math.floor(Date.now() / 1000);
|
|
149
|
+
const filter = {
|
|
150
|
+
kinds: [1],
|
|
151
|
+
'#t': [state.room],
|
|
152
|
+
since: now - 3600,
|
|
153
|
+
limit: 200,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
sendRaw(['REQ', state.subId, filter]);
|
|
157
|
+
|
|
158
|
+
// Announce presence
|
|
159
|
+
await send({ type: 'hello' });
|
|
160
|
+
|
|
161
|
+
setState('connected');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function send(payload) {
|
|
165
|
+
ensureKeys();
|
|
166
|
+
const created_at = Math.floor(Date.now() / 1000);
|
|
167
|
+
const tags = [
|
|
168
|
+
['room', state.room],
|
|
169
|
+
['t', state.room],
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
const eventTemplate = {
|
|
173
|
+
kind: 1,
|
|
174
|
+
created_at,
|
|
175
|
+
tags,
|
|
176
|
+
content: JSON.stringify({
|
|
177
|
+
...payload,
|
|
178
|
+
from: state.pubkey,
|
|
179
|
+
room: state.room,
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
}),
|
|
182
|
+
pubkey: state.pubkey,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const signed = finalizeEvent(eventTemplate, state.secretKeyHex);
|
|
186
|
+
sendRaw(['EVENT', signed]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function disconnect() {
|
|
190
|
+
const ws = state.ws;
|
|
191
|
+
state.ws = null;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
195
|
+
try {
|
|
196
|
+
ws.send(JSON.stringify(['CLOSE', state.subId]));
|
|
197
|
+
} catch {
|
|
198
|
+
// ignore
|
|
199
|
+
}
|
|
200
|
+
ws.close();
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
setState('disconnected');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
connect,
|
|
209
|
+
disconnect,
|
|
210
|
+
send,
|
|
211
|
+
getPublicKey: getPublicKeyHex,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { subscribe } from 'nostr-tools/relay';
|
|
2
|
+
import { SimplePool } from 'nostr-tools/pool';
|
|
3
|
+
|
|
4
|
+
// List of public Nostr relays
|
|
5
|
+
const DEFAULT_RELAYS = [
|
|
6
|
+
'wss://relay.damus.io',
|
|
7
|
+
'wss://relay.nostr.band',
|
|
8
|
+
'wss://nostr.wine',
|
|
9
|
+
'wss://relay.current.fyi'
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
let pool = new SimplePool();
|
|
13
|
+
let relayConnections = new Map();
|
|
14
|
+
let subscriptions = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Add or connect to relays
|
|
18
|
+
*/
|
|
19
|
+
export async function addRelays(relayUrls = DEFAULT_RELAYS) {
|
|
20
|
+
for (const url of relayUrls) {
|
|
21
|
+
if (!relayConnections.has(url)) {
|
|
22
|
+
try {
|
|
23
|
+
await pool.ensureRelay(url);
|
|
24
|
+
relayConnections.set(url, true);
|
|
25
|
+
console.log(`Connected to relay: ${url}`);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`Failed to connect to relay ${url}:`, error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ensure relay connection is established
|
|
35
|
+
*/
|
|
36
|
+
export async function ensureRelayConnection(relayUrl = DEFAULT_RELAYS[0]) {
|
|
37
|
+
if (!relayConnections.has(relayUrl)) {
|
|
38
|
+
await addRelays([relayUrl]);
|
|
39
|
+
}
|
|
40
|
+
return relayConnections.get(relayUrl);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Crawl available relays
|
|
45
|
+
*/
|
|
46
|
+
export async function crawlRelays() {
|
|
47
|
+
return DEFAULT_RELAYS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ensure connections to multiple relays
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureConnections(relayUrls = DEFAULT_RELAYS) {
|
|
54
|
+
await addRelays(relayUrls);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Publish an event to all connected relays
|
|
59
|
+
*/
|
|
60
|
+
export async function publishEvent(event) {
|
|
61
|
+
const relayUrls = Array.from(relayConnections.keys());
|
|
62
|
+
if (relayUrls.length === 0) {
|
|
63
|
+
await addRelays();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const publishPromises = Array.from(relayConnections.keys()).map(url =>
|
|
68
|
+
pool.publish(url, event).catch(err => {
|
|
69
|
+
console.error(`Failed to publish to ${url}:`, err);
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
await Promise.all(publishPromises);
|
|
73
|
+
console.log('Event published to all relays');
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('Error publishing event:', error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Subscribe to events with a filter
|
|
81
|
+
*/
|
|
82
|
+
export async function subscribeToEvents(filter, onEvent, subscriptionId = 'default') {
|
|
83
|
+
if (relayConnections.size === 0) {
|
|
84
|
+
await addRelays();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const relayUrls = Array.from(relayConnections.keys());
|
|
89
|
+
|
|
90
|
+
const subscription = pool.subscribeMany(relayUrls, [filter], {
|
|
91
|
+
onevent: (event) => onEvent(event),
|
|
92
|
+
onclose: () => console.log(`Subscription ${subscriptionId} closed`),
|
|
93
|
+
oneose: () => console.log(`Subscription ${subscriptionId} received all events`)
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
subscriptions.set(subscriptionId, subscription);
|
|
97
|
+
console.log(`Subscribed with filter:`, filter);
|
|
98
|
+
return subscriptionId;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('Error subscribing to events:', error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Unsubscribe from events
|
|
106
|
+
*/
|
|
107
|
+
export async function unsubscribeFromEvents(subscriptionId = 'default') {
|
|
108
|
+
const subscription = subscriptions.get(subscriptionId);
|
|
109
|
+
if (subscription) {
|
|
110
|
+
subscription.close();
|
|
111
|
+
subscriptions.delete(subscriptionId);
|
|
112
|
+
console.log(`Unsubscribed: ${subscriptionId}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Close all relay connections
|
|
118
|
+
*/
|
|
119
|
+
export async function closeAllConnections() {
|
|
120
|
+
subscriptions.forEach(sub => sub.close());
|
|
121
|
+
subscriptions.clear();
|
|
122
|
+
|
|
123
|
+
relayConnections.forEach((_, url) => {
|
|
124
|
+
pool.close(url);
|
|
125
|
+
});
|
|
126
|
+
relayConnections.clear();
|
|
127
|
+
|
|
128
|
+
console.log('Closed all relay connections');
|
|
129
|
+
}
|
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"
|