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.
@@ -1,126 +1,32 @@
1
1
  # UniWRTC Cloudflare Deployment Guide
2
2
 
3
- ## Prerequisites
3
+ ## Deploy Without Durable Objects (Cloudflare Pages)
4
4
 
5
- 1. **Cloudflare Account** - Free tier is sufficient
6
- 2. **Wrangler CLI** - Install: `npm install -g wrangler`
7
- 3. **Node.js** - v16 or higher
8
- 4. **Your Domain** - Domain must be on Cloudflare (e.g., `peer.ooo`)
9
-
10
- ## Setup Steps
11
-
12
- ### 1. Install Wrangler
13
-
14
- ```bash
15
- npm install -g wrangler
16
- ```
17
-
18
- ### 2. Authenticate with Cloudflare
19
-
20
- ```bash
21
- wrangler login
22
- ```
23
-
24
- This will open your browser to authorize the CLI.
25
-
26
- ### 3. Update wrangler.toml
27
-
28
- Replace the zone configuration with your domain:
29
-
30
- ```toml
31
- [env.production]
32
- routes = [
33
- { pattern = "signal.peer.ooo/*", zone_name = "peer.ooo" }
34
- ]
35
- ```
36
-
37
- ### 4. Deploy to Cloudflare
38
-
39
- ```bash
40
- # Deploy to production
41
- wrangler publish --env production
42
-
43
- # Or deploy to staging first
44
- wrangler publish
45
- ```
46
-
47
- ### 5. Access Your Signaling Server
48
-
49
- Your server will be available at:
50
- - **Production**: `https://signal.peer.ooo/`
51
- - **Development**: `https://uniwrtc.<subdomain>.workers.dev/`
5
+ If you only want to host the demo UI (static assets) and do NOT want Durable Objects, deploy the Vite build to Cloudflare Pages.
52
6
 
53
- ## Using with UniWRTC Demo
7
+ ### Prerequisites
54
8
 
55
- Update the demo to use your Cloudflare endpoint:
56
-
57
- ```javascript
58
- // In demo.html or client
59
- const serverUrl = 'https://signal.peer.ooo/';
60
- const roomId = 'my-room';
61
-
62
- const client = new UniWRTCClient(serverUrl, {
63
- roomId: roomId,
64
- customPeerId: 'optional-id'
65
- });
66
-
67
- await client.connect();
68
- ```
69
-
70
- ## How It Works
71
-
72
- 1. **Durable Objects** - One per room, manages peer discovery
73
- 2. **HTTP polling** - Browsers connect for signaling
74
- 3. **Signaling Only** - Offers/answers/ICE via Worker
75
- 4. **P2P Data** - WebRTC data channels bypass Cloudflare
76
- 5. **Free Tier** - Plenty of capacity for small deployments
77
-
78
- ## Cost
79
-
80
- - **Requests**: 100,000 free per day (signaling only)
81
- - **Compute**: Included in free tier
82
- - **Durable Objects**: ~$0.15/million operations (minimal for signaling)
83
- - **Total**: Free to very low cost
84
-
85
- ## Monitoring
86
-
87
- Check deployment status:
88
-
89
- ```bash
90
- wrangler tail --env production
91
- ```
92
-
93
- View real-time logs from your Worker.
94
-
95
- ## Local Development
9
+ 1. **Cloudflare Account** - Free tier is sufficient
10
+ 2. **Wrangler CLI** - Install: `npm install -g wrangler` (or use `npx`)
11
+ 3. **Node.js** - v16 or higher
96
12
 
97
- Test locally before deploying:
13
+ ### Deploy
98
14
 
99
15
  ```bash
100
- wrangler dev
16
+ npm install
17
+ npm run deploy:cf:no-do
101
18
  ```
102
19
 
103
- Your local server will run at `http://localhost:8787`
104
-
105
- Update demo to test:
106
- ```javascript
107
- const serverUrl = 'http://localhost:8787/';
108
- ```
109
-
110
- ## Troubleshooting
111
-
112
- **Connection errors**: Ensure your domain is on Cloudflare with SSL enabled
113
-
114
- **Connection refused**: Check the Worker route pattern in `wrangler.toml`
20
+ Notes:
21
+ - This deploys the `dist/` folder (static hosting).
22
+ - No server routes are deployed; Durable Objects are not used.
115
23
 
116
- **Durable Objects not found**: Run `wrangler publish` with migrations enabled
24
+ ## Custom Domain (signal.peer.ooo)
117
25
 
118
- ## Next Steps
26
+ To serve the Pages project at `https://signal.peer.ooo`:
119
27
 
120
- 1. Deploy the Worker
121
- 2. Update demo.html to use your Cloudflare endpoint
122
- 3. Test with multiple browsers
123
- 4. Scale up!
28
+ 1. Cloudflare Dashboard → Pages → your project → **Custom domains** → add `signal.peer.ooo`
29
+ 2. Cloudflare DNS set `signal` as a CNAME to `<your-pages-project>.pages.dev`
124
30
 
125
31
  ---
126
32
 
@@ -0,0 +1,6 @@
1
+ # Nostr notes
2
+
3
+ The demo page now defaults to Nostr (no UI toggle).
4
+
5
+ - Nostr client: [src/nostr/nostrClient.js](src/nostr/nostrClient.js)
6
+ - UI integration: [src/main.js](src/main.js)
@@ -1,8 +1,24 @@
1
- # Quick Start - Deploy to Cloudflare in 30 seconds
1
+ # Quick Start - Deploy to Cloudflare
2
+
3
+ ## Option A (No Durable Objects): Cloudflare Pages (static)
4
+
5
+ This repo’s current demo works client-side (Nostr), so you can deploy just the static site to Cloudflare Pages.
6
+
7
+ ### Prerequisites
8
+ - Cloudflare account (free tier works)
9
+ - Node.js installed
10
+ - Wrangler CLI authenticated (`npx wrangler login`)
11
+
12
+ ### Deploy
13
+ ```bash
14
+ npm install
15
+ npm run deploy:cf:no-do
16
+ ```
17
+
18
+ Wrangler will prompt you to pick/create a Pages project the first time.
2
19
 
3
20
  ## Prerequisites
4
21
  - Cloudflare account (free tier works)
5
- - Your domain on Cloudflare
6
22
  - Node.js installed
7
23
 
8
24
  ## Deploy
@@ -18,38 +34,12 @@ chmod +x deploy-cloudflare.sh
18
34
  deploy-cloudflare.bat
19
35
  ```
20
36
 
21
- ## What the script does:
22
- 1. ✅ Checks Node.js and installs Wrangler
23
- 2. ✅ Authenticates with Cloudflare
24
- 3. ✅ Asks for your domain (e.g., `signal.peer.ooo`)
25
- 4. ✅ Updates `wrangler.toml`
26
- 5. ✅ Deploys to Cloudflare Workers
27
- 6. ✅ Gives you the live URL
37
+ ## What this does
38
+ 1. ✅ Builds the Vite site into `dist/`
39
+ 2. ✅ Deploys `dist/` to Cloudflare Pages
28
40
 
29
41
  ## After deployment:
30
42
 
31
- Update demo.html:
32
- ```javascript
33
- const serverUrl = 'https://signal.peer.ooo/'; // Your domain
34
- ```
35
-
36
- Then reload the demo and it will connect to your Cloudflare Workers signaling server! 🚀
37
-
38
- ## Testing
39
-
40
- Test the server:
41
- ```bash
42
- curl https://signal.peer.ooo/health
43
- ```
44
-
45
- View logs:
46
- ```bash
47
- wrangler tail --env production
48
- ```
49
-
50
- Local development:
51
- ```bash
52
- wrangler dev
53
- ```
43
+ Then set your custom domain in Cloudflare Pages (and point `signal` to `<project>.pages.dev`).
54
44
 
55
- That's it! Your WebRTC signaling is now on Cloudflare! 🎉
45
+ That's it! Your demo is now on Cloudflare Pages (no Durable Objects).
@@ -1,9 +1,10 @@
1
1
  @echo off
2
- REM UniWRTC Cloudflare Automated Setup Script (Windows)
2
+ REM UniWRTC Cloudflare Automated Setup Script (Windows, NO Durable Objects)
3
+ REM Deploys the static demo to Cloudflare Pages.
3
4
 
4
5
  setlocal enabledelayedexpansion
5
6
 
6
- echo 🚀 UniWRTC Cloudflare Setup
7
+ echo 🚀 UniWRTC Cloudflare Setup (Pages / no Durable Objects)
7
8
  echo ============================
8
9
  echo.
9
10
 
@@ -40,74 +41,29 @@ if errorlevel 1 (
40
41
  echo ✅ Authenticated with Cloudflare
41
42
  echo.
42
43
 
43
- REM Ask for domain
44
- echo 🌐 Domain Configuration
45
- echo =====================
46
- set /p DOMAIN="Enter your Cloudflare domain (e.g., peer.ooo): "
47
- set /p SUBDOMAIN="Enter subdomain for signaling (e.g., signal): "
44
+ REM Project name
45
+ set PROJECT_NAME=signal-peer-ooo
46
+ if not "%~1"=="" set PROJECT_NAME=%~1
48
47
 
49
- if "!DOMAIN!"=="" (
50
- echo Domain required
51
- exit /b 1
52
- )
53
-
54
- if "!SUBDOMAIN!"=="" (
55
- echo ❌ Subdomain required
56
- exit /b 1
57
- )
58
-
59
- set FULL_DOMAIN=!SUBDOMAIN!.!DOMAIN!
60
-
61
- REM Update wrangler.toml
62
- echo 📝 Updating wrangler.toml...
63
- (
64
- echo name = "uniwrtc"
65
- echo main = "src/index.js"
66
- echo compatibility_date = "2024-12-20"
67
- echo.
68
- echo [env.production]
69
- echo routes = [
70
- echo { pattern = "!FULL_DOMAIN!/*", zone_name = "!DOMAIN!" }
71
- echo ]
72
- echo.
73
- echo [[durable_objects.bindings]]
74
- echo name = "ROOMS"
75
- echo class_name = "Room"
76
- echo.
77
- echo [durable_objects]
78
- echo migrations = [
79
- echo { tag = "v1", new_classes = ["Room"] }
80
- echo ]
81
- echo.
82
- echo [build]
83
- echo command = "npm install"
84
- ) > wrangler.toml
48
+ echo 📦 Building static site...
49
+ call npm run build
85
50
 
86
- echo wrangler.toml updated
51
+ echo 🚀 Deploying to Cloudflare Pages project: %PROJECT_NAME%
87
52
  echo.
88
53
 
54
+ REM Create project if needed (ignore errors)
55
+ call npx wrangler pages project create %PROJECT_NAME% --production-branch main >nul 2>nul
56
+
89
57
  REM Deploy
90
- echo 🚀 Deploying to Cloudflare...
91
- echo.
92
- call wrangler deploy --env production
58
+ call npx wrangler pages deploy dist --project-name %PROJECT_NAME%
93
59
 
94
60
  echo.
95
61
  echo ✅ Deployment Complete!
96
62
  echo.
97
- echo 🎉 Your UniWRTC signaling server is live at:
98
- echo https://!FULL_DOMAIN!/
99
- echo.
100
- echo 📊 Test it:
101
- echo curl https://!FULL_DOMAIN!/health
102
- echo.
103
- echo 🧪 Local testing:
104
- echo wrangler dev
105
- echo.
106
- echo 📊 View logs:
107
- echo wrangler tail --env production
108
- echo.
109
- echo 🛠️ Next: Update demo.html to use:
110
- echo const serverUrl = 'https://!FULL_DOMAIN!/';
63
+ echo 🎉 Your Pages site is deployed.
64
+ echo Next step for custom domain (manual in Cloudflare UI):
65
+ echo - Pages ^> %PROJECT_NAME% ^> Custom domains ^> add signal.peer.ooo
66
+ echo - DNS: CNAME signal ^> %PROJECT_NAME%.pages.dev
111
67
  echo.
112
68
 
113
69
  endlocal
@@ -1,11 +1,11 @@
1
1
  #!/bin/bash
2
2
 
3
- # UniWRTC Cloudflare Automated Setup Script
4
- # Run this to setup and deploy to Cloudflare
3
+ # UniWRTC Cloudflare Automated Setup Script (NO Durable Objects)
4
+ # Deploys the static demo to Cloudflare Pages.
5
5
 
6
6
  set -e
7
7
 
8
- echo "🚀 UniWRTC Cloudflare Setup"
8
+ echo "🚀 UniWRTC Cloudflare Setup (Pages / no Durable Objects)"
9
9
  echo "============================"
10
10
  echo ""
11
11
 
@@ -41,67 +41,24 @@ fi
41
41
  echo "✅ Authenticated with Cloudflare"
42
42
  echo ""
43
43
 
44
- # Domain configuration
45
- DOMAIN="peer.ooo"
46
- SUBDOMAIN="signal"
44
+ PROJECT_NAME=${1:-"signal-peer-ooo"}
47
45
 
48
- FULL_DOMAIN="${SUBDOMAIN}.${DOMAIN}"
46
+ echo "📦 Building static site..."
47
+ npm run build
49
48
 
50
- echo "📝 Updating wrangler.toml..."
51
- cat > wrangler.toml << EOF
52
- name = "uniwrtc"
53
- main = "src/index.js"
54
- compatibility_date = "2024-12-20"
49
+ echo "🚀 Deploying to Cloudflare Pages project: ${PROJECT_NAME}"
55
50
 
56
- assets = { directory = "./dist", binding = "ASSETS" }
51
+ # Create the project if it doesn't exist (ignore error if it already exists)
52
+ npx wrangler pages project create "${PROJECT_NAME}" --production-branch main 2>/dev/null || true
57
53
 
58
- [[durable_objects.bindings]]
59
- name = "ROOMS"
60
- class_name = "Room"
61
-
62
- [[migrations]]
63
- tag = "v1"
64
- new_classes = ["Room"]
65
-
66
- [env.production]
67
- routes = [
68
- { pattern = "${FULL_DOMAIN}/*", zone_name = "${DOMAIN}" }
69
- ]
70
-
71
- assets = { directory = "./dist", binding = "ASSETS" }
72
-
73
- [[env.production.durable_objects.bindings]]
74
- name = "ROOMS"
75
- class_name = "Room"
76
-
77
- [build]
78
- command = "npm install"
79
- EOF
80
-
81
- echo "✅ wrangler.toml updated"
82
- echo ""
83
-
84
- # Deploy
85
- echo "🚀 Deploying to Cloudflare..."
86
- echo ""
87
- echo "Deploying to production..."
88
- wrangler deploy --env production
54
+ # Deploy the built assets
55
+ npx wrangler pages deploy dist --project-name "${PROJECT_NAME}"
89
56
 
90
57
  echo ""
91
58
  echo "✅ Deployment Complete!"
92
59
  echo ""
93
- echo "🎉 Your UniWRTC signaling server is live at:"
94
- echo " https://${FULL_DOMAIN}/"
95
- echo ""
96
- echo "📊 Test it:"
97
- echo " curl https://${FULL_DOMAIN}/health"
98
- echo ""
99
- echo "🧪 Local testing:"
100
- echo " wrangler dev"
101
- echo ""
102
- echo "📊 View logs:"
103
- echo " wrangler tail --env production"
104
- echo ""
105
- echo "🛠️ Next: Update demo.html to use:"
106
- echo " const serverUrl = 'https://${FULL_DOMAIN}/';"
60
+ echo "🎉 Your Pages site is deployed."
61
+ echo "Next step for custom domain (manual in Cloudflare UI):"
62
+ echo " - Pages → ${PROJECT_NAME} → Custom domains → add signal.peer.ooo"
63
+ echo " - DNS: CNAME signal → ${PROJECT_NAME}.pages.dev"
107
64
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniwrtc",
3
- "version": "1.0.9",
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;">Server URL</label>
24
- <input type="text" id="serverUrl" data-testid="serverUrl" placeholder="wss://signal.peer.ooo or ws://localhost:8080" value="wss://signal.peer.ooo">
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
- log(`Connecting to ${serverUrl}...`, 'info');
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
- finalUrl = `${serverUrl.replace(/\/$/, '')}/ws?room=${roomId}`;
170
- log(`Using Cloudflare Durable Objects with session: ${roomId}`, 'info');
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
- log(`Connected with client ID: ${data.clientId}`, 'success');
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
- log(`Joining session: ${roomId}`, 'info');
315
+ log(`Joining session: ${roomId}`, 'info');
182
316
  client.joinSession(roomId);
183
317
  });
184
318
 
185
319
  client.on('joined', (data) => {
186
- log(`Joined session: ${data.sessionId}`, 'success');
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
- log(`Found ${data.clients.length} existing peers`, 'info');
324
+ log(`Found ${data.clients.length} existing peers`, 'info');
191
325
  data.clients.forEach(peerId => {
192
- log(`Creating connection to existing peer: ${peerId.substring(0, 6)}...`, 'info');
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
- log(`Ignoring peer from different session: ${data.sessionId}`, 'warning');
335
+ log(`Ignoring peer from different session: ${data.sessionId}`, 'warning');
202
336
  return;
203
337
  }
204
338
 
205
- log(`Peer joined: ${data.peerId.substring(0, 6)}...`, 'success');
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
- log(`Peer left: ${data.peerId.substring(0, 6)}...`, 'warning');
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
- log(`Received offer from ${data.peerId.substring(0, 6)}...`, 'info');
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
- log(`Sent answer to ${data.peerId.substring(0, 6)}...`, 'success');
365
+ log(`Sent answer to ${data.peerId.substring(0, 6)}...`, 'success');
232
366
  });
233
367
 
234
368
  client.on('answer', async (data) => {
235
- log(`Received answer from ${data.peerId.substring(0, 6)}...`, 'info');
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
- log(`Received ICE candidate from ${data.peerId.substring(0, 6)}...`, 'info');
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
- log(`Error: ${data.message}`, 'error');
394
+ log(`Error: ${data.message}`, 'error');
261
395
  });
262
396
 
263
397
  await client.connect();
264
398
  } catch (error) {
265
- log(`Connection error: ${error.message}`, 'error');
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
- client.sendIceCandidate(event.candidate, peerId);
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
- client.sendOffer(offer, peerId);
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 peer connections available. Wait for data channels to open.', 'error');
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,6 @@
1
+ export function useNostr() {
2
+ throw new Error(
3
+ 'useNostr() (Vue composable) is not wired into this demo. ' +
4
+ 'Use createNostrClient() from src/nostr/nostrClient.js instead.'
5
+ );
6
+ }
@@ -0,0 +1,6 @@
1
+ export function useWebRTC() {
2
+ throw new Error(
3
+ 'useWebRTC() (Vue composable) is not wired into this demo. ' +
4
+ 'The demo page uses src/main.js and (optionally) src/nostr/nostrClient.js.'
5
+ );
6
+ }
@@ -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 = "uniwrtc"
2
- main = "src/index.js"
3
- compatibility_date = "2024-12-20"
4
-
5
- assets = { directory = "./dist", binding = "ASSETS" }
6
-
7
- [[durable_objects.bindings]]
8
- name = "ROOMS"
9
- class_name = "Room"
10
-
11
- [[migrations]]
12
- tag = "v1"
13
- new_classes = ["Room"]
14
-
15
- [env.production]
16
- routes = [
17
- { pattern = "signal.peer.ooo/*", zone_name = "peer.ooo" }
18
- ]
19
-
20
- assets = { directory = "./dist", binding = "ASSETS" }
21
-
22
- [[env.production.durable_objects.bindings]]
23
- name = "ROOMS"
24
- class_name = "Room"
25
-
26
- [build]
27
- command = "npm install"
1
+ name = "signal-peer-ooo"
2
+ pages_build_output_dir = "dist"