uniwrtc 1.3.0 → 1.5.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 +4 -4
- package/QUICKSTART_CLOUDFLARE.md +3 -20
- package/README.md +83 -38
- package/deploy-cloudflare.bat +2 -2
- package/deploy-cloudflare.sh +2 -2
- package/package-cf.json +1 -1
- package/package.json +2 -3
- package/playwright.config.js +10 -3
- package/server.js +1 -1
- package/src/client-cloudflare.js +2 -3
- package/src/index.js +1 -2
- package/src/main.js +70 -22
- package/src/room.js +1 -1
- package/tests/e2e.spec.js +37 -60
package/CLOUDFLARE_DEPLOYMENT.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# UniWRTC Cloudflare Deployment Guide
|
|
2
2
|
|
|
3
|
-
## Deploy
|
|
3
|
+
## Deploy (Cloudflare Pages)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This repo’s current demo works client-side (Nostr), so you can deploy just the static site to Cloudflare Pages.
|
|
6
6
|
|
|
7
7
|
### Prerequisites
|
|
8
8
|
|
|
@@ -14,12 +14,12 @@ If you only want to host the demo UI (static assets) and do NOT want Durable Obj
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npm install
|
|
17
|
-
npm run deploy:cf:
|
|
17
|
+
npm run deploy:cf:pages
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Notes:
|
|
21
21
|
- This deploys the `dist/` folder (static hosting).
|
|
22
|
-
- No server routes are deployed
|
|
22
|
+
- No server routes are deployed.
|
|
23
23
|
|
|
24
24
|
## Custom Domain (signal.peer.ooo)
|
|
25
25
|
|
package/QUICKSTART_CLOUDFLARE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Quick Start - Deploy to Cloudflare
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Cloudflare Pages (static)
|
|
4
4
|
|
|
5
5
|
This repo’s current demo works client-side (Nostr), so you can deploy just the static site to Cloudflare Pages.
|
|
6
6
|
|
|
@@ -12,28 +12,11 @@ This repo’s current demo works client-side (Nostr), so you can deploy just the
|
|
|
12
12
|
### Deploy
|
|
13
13
|
```bash
|
|
14
14
|
npm install
|
|
15
|
-
npm run deploy:cf:
|
|
15
|
+
npm run deploy:cf:pages
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Wrangler will prompt you to pick/create a Pages project the first time.
|
|
19
19
|
|
|
20
|
-
## Prerequisites
|
|
21
|
-
- Cloudflare account (free tier works)
|
|
22
|
-
- Node.js installed
|
|
23
|
-
|
|
24
|
-
## Deploy
|
|
25
|
-
|
|
26
|
-
### macOS / Linux
|
|
27
|
-
```bash
|
|
28
|
-
chmod +x deploy-cloudflare.sh
|
|
29
|
-
./deploy-cloudflare.sh
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Windows
|
|
33
|
-
```bash
|
|
34
|
-
deploy-cloudflare.bat
|
|
35
|
-
```
|
|
36
|
-
|
|
37
20
|
## What this does
|
|
38
21
|
1. ✅ Builds the Vite site into `dist/`
|
|
39
22
|
2. ✅ Deploys `dist/` to Cloudflare Pages
|
|
@@ -42,4 +25,4 @@ deploy-cloudflare.bat
|
|
|
42
25
|
|
|
43
26
|
Then set your custom domain in Cloudflare Pages (and point `signal` to `<project>.pages.dev`).
|
|
44
27
|
|
|
45
|
-
That's it! Your demo is now on Cloudflare Pages
|
|
28
|
+
That's it! Your demo is now on Cloudflare Pages.
|
package/README.md
CHANGED
|
@@ -1,53 +1,88 @@
|
|
|
1
1
|
# UniWRTC
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A WebRTC demo + signaling utilities.
|
|
4
|
+
|
|
5
|
+
The default demo flow uses **Nostr relays for WebRTC signaling** (offer/answer/ICE + presence) and a **WebRTC data channel for app data**.
|
|
6
|
+
|
|
7
|
+
This repo also includes an optional **legacy HTTP polling signaling server** (useful for local development), but the live demo is now fully static/serverless.
|
|
4
8
|
|
|
5
9
|
Available on npm: https://www.npmjs.com/package/uniwrtc
|
|
6
10
|
|
|
7
11
|
## Features
|
|
8
12
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- 📊 **Health monitoring** - HTTP health check endpoint for monitoring
|
|
15
|
-
- 🎯 **Minimal dependencies** - Lightweight implementation with minimal runtime deps
|
|
13
|
+
- 🤝 **Nostr signaling (demo default)** - WebRTC offer/answer/ICE + presence over Nostr relays
|
|
14
|
+
- 📦 **WebRTC for data** - App/chat data rides the data channel (not the relay)
|
|
15
|
+
- 🏠 **Session-based rooms** - Multiple sessions with isolated peer groups
|
|
16
|
+
- 🔌 **Optional legacy server** - HTTP polling signaling server (local/dev)
|
|
17
|
+
- 🧊 **STUN-only** - No TURN in the default demo
|
|
16
18
|
|
|
17
19
|
## Quick Start
|
|
18
20
|
|
|
19
|
-
## Using with `simple-peer` (
|
|
21
|
+
## Using with `simple-peer` (Nostr signaling)
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
`simple-peer` uses `{ type, sdp }` objects, so use the adapter in [simple-peer-adapter.js](simple-peer-adapter.js).
|
|
23
|
+
The repo’s default demo uses Nostr relays for signaling. Here’s a minimal `simple-peer` example that uses the built-in Nostr client for signaling messages.
|
|
23
24
|
|
|
24
|
-
Example (browser):
|
|
25
|
+
Example (browser, two tabs):
|
|
25
26
|
|
|
26
27
|
```js
|
|
27
28
|
import Peer from 'simple-peer';
|
|
28
|
-
import
|
|
29
|
-
|
|
29
|
+
import { createNostrClient } from './src/nostr/nostrClient.js';
|
|
30
|
+
|
|
31
|
+
const relayUrl = 'wss://relay.damus.io';
|
|
32
|
+
const room = 'my-room';
|
|
33
|
+
|
|
34
|
+
const nostr = createNostrClient({
|
|
35
|
+
relayUrl,
|
|
36
|
+
room,
|
|
37
|
+
onPayload: ({ from, payload }) => {
|
|
38
|
+
// Only accept signals intended for us
|
|
39
|
+
if (payload?.type !== 'sp-signal') return;
|
|
40
|
+
if (payload?.to !== myId) return;
|
|
41
|
+
if (targetId && from !== targetId) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
peer.signal(payload.signal);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn('Failed to apply signal:', e);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
await nostr.connect();
|
|
52
|
+
|
|
53
|
+
const myId = nostr.getPublicKey();
|
|
54
|
+
console.log('My Peer ID:', myId);
|
|
33
55
|
|
|
34
|
-
//
|
|
35
|
-
|
|
56
|
+
// Set this in each tab (copy/paste from the other tab)
|
|
57
|
+
const targetId = prompt('Paste the other Peer ID:')?.trim();
|
|
58
|
+
if (!targetId) throw new Error('Missing targetId');
|
|
59
|
+
|
|
60
|
+
// Deterministic initiator prevents offer collisions
|
|
61
|
+
const initiator = myId.localeCompare(targetId) < 0;
|
|
36
62
|
|
|
37
|
-
// Ensure exactly ONE side initiates for a given pair
|
|
38
|
-
const initiator = chooseDeterministicInitiator(client.clientId, targetId);
|
|
39
63
|
const peer = new Peer({ initiator, trickle: true });
|
|
40
|
-
const cleanup = attachUniWRTCToSimplePeer(client, peer);
|
|
41
64
|
|
|
42
|
-
peer.on('signal', (signal) => {
|
|
43
|
-
//
|
|
44
|
-
|
|
65
|
+
peer.on('signal', async (signal) => {
|
|
66
|
+
// Send signaling via Nostr; WebRTC data stays P2P.
|
|
67
|
+
await nostr.send({
|
|
68
|
+
type: 'sp-signal',
|
|
69
|
+
to: targetId,
|
|
70
|
+
signal,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
peer.on('connect', () => {
|
|
75
|
+
console.log('WebRTC connected');
|
|
76
|
+
peer.send('hello over datachannel');
|
|
45
77
|
});
|
|
46
78
|
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
peer.on('data', (data) => {
|
|
80
|
+
console.log('Got data:', data.toString());
|
|
81
|
+
});
|
|
49
82
|
```
|
|
50
83
|
|
|
84
|
+
Note: Nostr relays are generally public. Don’t send secrets in signaling payloads.
|
|
85
|
+
|
|
51
86
|
### Installation
|
|
52
87
|
|
|
53
88
|
#### From npm (recommended)
|
|
@@ -85,7 +120,11 @@ PORT=8080
|
|
|
85
120
|
|
|
86
121
|
### Try the Demo
|
|
87
122
|
|
|
88
|
-
The interactive demo is available live at **https://signal.peer.ooo/** (Cloudflare
|
|
123
|
+
The interactive demo is available live at **https://signal.peer.ooo/** (Cloudflare Pages static site) or run locally.
|
|
124
|
+
|
|
125
|
+
The demo uses:
|
|
126
|
+
- Nostr relays for signaling
|
|
127
|
+
- WebRTC data channels for data/chat
|
|
89
128
|
|
|
90
129
|
**Using the deployed demo (recommended):**
|
|
91
130
|
1. Open https://signal.peer.ooo/ in two browser tabs
|
|
@@ -95,15 +134,15 @@ The interactive demo is available live at **https://signal.peer.ooo/** (Cloudfla
|
|
|
95
134
|
5. Open the P2P chat and send messages between tabs
|
|
96
135
|
|
|
97
136
|
**Or run locally:**
|
|
98
|
-
1.
|
|
99
|
-
2. Start
|
|
137
|
+
1. Install deps: `npm install`
|
|
138
|
+
2. Start Vite: `npm run dev` (demo at `http://localhost:5173/`)
|
|
100
139
|
3. Open the demo in two browser tabs
|
|
101
140
|
4. Enter the same session ID in both, then Connect
|
|
102
141
|
5. Chat P2P once data channels open
|
|
103
142
|
|
|
104
143
|
## Usage
|
|
105
144
|
|
|
106
|
-
### Server API
|
|
145
|
+
### Legacy HTTP Signaling Server API (optional)
|
|
107
146
|
|
|
108
147
|
The signaling server supports:
|
|
109
148
|
- HTTP polling signaling (no WebSockets)
|
|
@@ -342,10 +381,8 @@ client.on('ice-candidate', async (data) => {
|
|
|
342
381
|
await client.connect();
|
|
343
382
|
client.joinSession('my-video-session');
|
|
344
383
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
await cfClient.connect();
|
|
348
|
-
cfClient.joinSession('my-session');
|
|
384
|
+
// Note: https://signal.peer.ooo is the static demo site (Nostr signaling),
|
|
385
|
+
// not an HTTP polling signaling server endpoint.
|
|
349
386
|
```
|
|
350
387
|
|
|
351
388
|
## API Reference
|
|
@@ -407,25 +444,33 @@ Response:
|
|
|
407
444
|
|
|
408
445
|
## Architecture
|
|
409
446
|
|
|
447
|
+
### Nostr Signaling (Demo Default)
|
|
448
|
+
|
|
449
|
+
- Peers publish signaling messages (offer/answer/ICE/presence) to a Nostr relay.
|
|
450
|
+
- Messages are scoped to a room/session and targeted to a specific peer session.
|
|
451
|
+
- Once the WebRTC data channel opens, application data/chat is sent P2P.
|
|
452
|
+
- This is designed to work without running your own signaling server.
|
|
453
|
+
|
|
410
454
|
### Session-based Peer Isolation
|
|
411
455
|
|
|
412
456
|
- **Sessions**: Each session is identified by a unique string ID (also called "room" in the UI)
|
|
413
457
|
- **Peer routing**: Each peer gets a unique client ID; signaling messages are routed only to intended targets
|
|
414
458
|
- **Session isolation**: Peers in different sessions cannot see or communicate with each other
|
|
415
|
-
- **Cloudflare Durable Objects**: Uses DO state to isolate sessions; routing by `?room=` query param per session
|
|
416
459
|
- Clients join with `joinSession(sessionId)` and receive notifications when other peers join the same session
|
|
417
460
|
|
|
418
461
|
### Message Flow
|
|
419
462
|
|
|
420
|
-
|
|
421
|
-
|
|
463
|
+
This section applies to the legacy HTTP polling server:
|
|
464
|
+
|
|
465
|
+
1. Client connects via HTTP(S)
|
|
466
|
+
2. Server assigns a unique client ID
|
|
422
467
|
3. Client sends join message with session ID
|
|
423
468
|
4. Server broadcasts `peer-joined` to other peers in the same session only
|
|
424
469
|
5. Peers exchange WebRTC offers/answers/ICE candidates via the server
|
|
425
470
|
6. Server routes signaling messages to specific peers by target ID (unicast, not broadcast)
|
|
426
471
|
|
|
427
472
|
Notes:
|
|
428
|
-
-
|
|
473
|
+
- Server signaling uses JSON over HTTPS requests to `/api` (polling).
|
|
429
474
|
- Offers/answers are transmitted as SDP strings (text-only) in the `offer`/`answer` fields.
|
|
430
475
|
- ICE candidates are transmitted as a compact text string: `candidate|sdpMid|sdpMLineIndex`.
|
|
431
476
|
|
package/deploy-cloudflare.bat
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
@echo off
|
|
2
|
-
REM UniWRTC Cloudflare Automated Setup Script (Windows
|
|
2
|
+
REM UniWRTC Cloudflare Automated Setup Script (Windows)
|
|
3
3
|
REM Deploys the static demo to Cloudflare Pages.
|
|
4
4
|
|
|
5
5
|
setlocal enabledelayedexpansion
|
|
6
6
|
|
|
7
|
-
echo 🚀 UniWRTC Cloudflare Setup (Pages
|
|
7
|
+
echo 🚀 UniWRTC Cloudflare Setup (Cloudflare Pages)
|
|
8
8
|
echo ============================
|
|
9
9
|
echo.
|
|
10
10
|
|
package/deploy-cloudflare.sh
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
|
-
# UniWRTC Cloudflare Automated Setup Script
|
|
3
|
+
# UniWRTC Cloudflare Automated Setup Script
|
|
4
4
|
# Deploys the static demo to Cloudflare Pages.
|
|
5
5
|
|
|
6
6
|
set -e
|
|
7
7
|
|
|
8
|
-
echo "🚀 UniWRTC Cloudflare Setup (Pages
|
|
8
|
+
echo "🚀 UniWRTC Cloudflare Setup (Cloudflare Pages)"
|
|
9
9
|
echo "============================"
|
|
10
10
|
echo ""
|
|
11
11
|
|
package/package-cf.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniwrtc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "A universal WebRTC signaling service",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "module",
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
"build": "vite build",
|
|
11
11
|
"preview": "vite preview",
|
|
12
12
|
"deploy:cf:pages": "npm run build && npx wrangler pages deploy dist",
|
|
13
|
-
"deploy:cf:no-do": "npm run deploy:cf:pages",
|
|
14
13
|
"server": "node server.js",
|
|
15
|
-
"test": "
|
|
14
|
+
"test": "playwright test"
|
|
16
15
|
},
|
|
17
16
|
"keywords": [
|
|
18
17
|
"webrtc",
|
package/playwright.config.js
CHANGED
|
@@ -3,17 +3,24 @@ import { defineConfig, devices } from '@playwright/test';
|
|
|
3
3
|
export default defineConfig({
|
|
4
4
|
testDir: './tests',
|
|
5
5
|
// Run tests IN PARALLEL - all browsers at the same time
|
|
6
|
-
fullyParallel:
|
|
6
|
+
fullyParallel: false,
|
|
7
7
|
forbidOnly: !!process.env.CI,
|
|
8
8
|
retries: process.env.CI ? 2 : 0,
|
|
9
|
-
workers:
|
|
9
|
+
workers: process.env.CI ? 2 : 1,
|
|
10
10
|
reporter: 'html',
|
|
11
11
|
use: {
|
|
12
|
-
baseURL: '
|
|
12
|
+
baseURL: 'http://127.0.0.1:5173',
|
|
13
13
|
trace: 'on-first-retry',
|
|
14
14
|
screenshot: 'only-on-failure',
|
|
15
15
|
},
|
|
16
16
|
|
|
17
|
+
webServer: {
|
|
18
|
+
command: 'npm run dev -- --host 127.0.0.1 --port 5173',
|
|
19
|
+
url: 'http://127.0.0.1:5173',
|
|
20
|
+
reuseExistingServer: !process.env.CI,
|
|
21
|
+
timeout: 120_000,
|
|
22
|
+
},
|
|
23
|
+
|
|
17
24
|
projects: [
|
|
18
25
|
{
|
|
19
26
|
name: 'chromium',
|
package/server.js
CHANGED
|
@@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename);
|
|
|
10
10
|
|
|
11
11
|
const CLIENT_TTL_MS = 60_000;
|
|
12
12
|
|
|
13
|
-
// Top-level rooms are keyed by ?room=
|
|
13
|
+
// Top-level rooms are keyed by ?room=
|
|
14
14
|
// Each room contains multiple sessions keyed by sessionId.
|
|
15
15
|
const rooms = new Map();
|
|
16
16
|
|
package/src/client-cloudflare.js
CHANGED
|
@@ -111,7 +111,7 @@ class UniWRTCClient {
|
|
|
111
111
|
|
|
112
112
|
joinSession(sessionId) {
|
|
113
113
|
this.sessionId = sessionId;
|
|
114
|
-
//
|
|
114
|
+
// Session is provided via the room query param on connection
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
leaveSession() {
|
|
@@ -153,8 +153,7 @@ class UniWRTCClient {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
listRooms() {
|
|
156
|
-
|
|
157
|
-
console.log('Room listing not available with Durable Objects');
|
|
156
|
+
console.log('Room listing is not available for this transport');
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
on(event, handler) {
|
package/src/index.js
CHANGED
package/src/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import './style.css';
|
|
2
2
|
import UniWRTCClient from '../client-browser.js';
|
|
3
3
|
import { createNostrClient } from './nostr/nostrClient.js';
|
|
4
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
4
5
|
|
|
5
6
|
// Make UniWRTCClient available globally for backwards compatibility
|
|
6
7
|
window.UniWRTCClient = UniWRTCClient;
|
|
@@ -31,6 +32,35 @@ const dataChannels = new Map();
|
|
|
31
32
|
const pendingIce = new Map();
|
|
32
33
|
const outboundIceBatches = new Map();
|
|
33
34
|
|
|
35
|
+
function bytesToHex(bytes) {
|
|
36
|
+
return Array.from(bytes)
|
|
37
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isHex64(s) {
|
|
42
|
+
return typeof s === 'string' && /^[0-9a-fA-F]{64}$/.test(s);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureIdentity() {
|
|
46
|
+
// Must match the key used in createNostrClient() so the pubkey stays consistent.
|
|
47
|
+
const storageKey = 'nostr-secret-key-tab';
|
|
48
|
+
let stored = sessionStorage.getItem(storageKey);
|
|
49
|
+
|
|
50
|
+
if (stored && stored.includes(',')) {
|
|
51
|
+
sessionStorage.removeItem(storageKey);
|
|
52
|
+
stored = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isHex64(stored)) {
|
|
56
|
+
const secretBytes = generateSecretKey();
|
|
57
|
+
stored = bytesToHex(secretBytes);
|
|
58
|
+
sessionStorage.setItem(storageKey, stored);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return getPublicKey(stored);
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
// Initialize app
|
|
35
65
|
document.getElementById('app').innerHTML = `
|
|
36
66
|
<div class="container">
|
|
@@ -44,7 +74,7 @@ document.getElementById('app').innerHTML = `
|
|
|
44
74
|
<div class="connection-controls">
|
|
45
75
|
<div>
|
|
46
76
|
<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;">
|
|
77
|
+
<input type="text" id="relayUrl" data-testid="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;">
|
|
48
78
|
</div>
|
|
49
79
|
<div>
|
|
50
80
|
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
|
|
@@ -112,12 +142,33 @@ if (urlRoom) {
|
|
|
112
142
|
log(`Using default room ID: ${defaultRoom}`, 'info');
|
|
113
143
|
}
|
|
114
144
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
145
|
+
// Client/session identity should not depend on relay connectivity.
|
|
146
|
+
try {
|
|
147
|
+
myPeerId = ensureIdentity();
|
|
148
|
+
document.getElementById('clientId').textContent = myPeerId.substring(0, 16) + '...';
|
|
149
|
+
} catch {
|
|
150
|
+
// Leave as-is if identity init fails.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
document.getElementById('sessionId').textContent = roomInput.value || 'Not joined';
|
|
154
|
+
roomInput.addEventListener('input', () => {
|
|
155
|
+
document.getElementById('sessionId').textContent = roomInput.value.trim() || 'Not joined';
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ICE servers: STUN-only by default (no TURN). For deterministic local testing,
|
|
159
|
+
// support host-only ICE via URL flag: ?ice=host (or ?ice=none)
|
|
160
|
+
const iceMode = (params.get('ice') || '').toLowerCase();
|
|
161
|
+
const ICE_SERVERS = iceMode === 'host' || iceMode === 'none'
|
|
162
|
+
? []
|
|
163
|
+
: [
|
|
164
|
+
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
|
|
165
|
+
{ urls: ['stun:stun2.l.google.com:19302', 'stun:stun3.l.google.com:19302'] },
|
|
166
|
+
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
if (ICE_SERVERS.length === 0) {
|
|
170
|
+
log('Using host-only ICE candidates (no STUN)', 'info');
|
|
171
|
+
}
|
|
121
172
|
|
|
122
173
|
function log(message, type = 'info') {
|
|
123
174
|
const logContainer = document.getElementById('logContainer');
|
|
@@ -127,11 +178,11 @@ function log(message, type = 'info') {
|
|
|
127
178
|
entry.textContent = `[${timestamp}] ${message}`;
|
|
128
179
|
|
|
129
180
|
// Add testid for specific log messages
|
|
130
|
-
if (message.includes('Connected with client ID')) {
|
|
181
|
+
if (message.includes('Connected with client ID') || message.includes('Nostr connection established')) {
|
|
131
182
|
entry.setAttribute('data-testid', 'log-connected');
|
|
132
|
-
} else if (message.includes('Joined session')) {
|
|
183
|
+
} else if (message.includes('Joined session') || message.includes('Joined Nostr room')) {
|
|
133
184
|
entry.setAttribute('data-testid', 'log-joined');
|
|
134
|
-
} else if (message.includes('Peer joined')) {
|
|
185
|
+
} else if (message.includes('Peer joined') || message.includes('Peer seen')) {
|
|
135
186
|
entry.setAttribute('data-testid', 'log-peer-joined');
|
|
136
187
|
} else if (message.includes('Data channel open')) {
|
|
137
188
|
entry.setAttribute('data-testid', 'log-data-channel');
|
|
@@ -156,8 +207,7 @@ function updateStatus(connected) {
|
|
|
156
207
|
badge.className = 'status-badge status-disconnected';
|
|
157
208
|
connectBtn.disabled = false;
|
|
158
209
|
disconnectBtn.disabled = true;
|
|
159
|
-
|
|
160
|
-
document.getElementById('sessionId').textContent = 'Not joined';
|
|
210
|
+
// Keep client/session labels stable; identity and room are local state.
|
|
161
211
|
}
|
|
162
212
|
}
|
|
163
213
|
|
|
@@ -323,6 +373,11 @@ async function connectNostr() {
|
|
|
323
373
|
const relayUrlRaw = document.getElementById('relayUrl').value.trim();
|
|
324
374
|
const roomIdInput = document.getElementById('roomId');
|
|
325
375
|
const roomId = roomIdInput.value.trim();
|
|
376
|
+
|
|
377
|
+
if (!relayUrlRaw) {
|
|
378
|
+
log('Please enter a relay URL', 'error');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
326
381
|
|
|
327
382
|
const relayCandidatesRaw = relayUrlRaw.toLowerCase() === 'auto'
|
|
328
383
|
? DEFAULT_RELAYS
|
|
@@ -337,11 +392,6 @@ async function connectNostr() {
|
|
|
337
392
|
|
|
338
393
|
const relayCandidates = relayCandidatesRaw.length ? relayCandidatesRaw : DEFAULT_RELAYS;
|
|
339
394
|
|
|
340
|
-
if (relayCandidates.length === 0) {
|
|
341
|
-
log('Please enter a relay URL', 'error');
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
395
|
const effectiveRoom = roomId || `room-${Math.random().toString(36).substring(2, 10)}`;
|
|
346
396
|
if (!roomId) roomIdInput.value = effectiveRoom;
|
|
347
397
|
|
|
@@ -354,7 +404,7 @@ async function connectNostr() {
|
|
|
354
404
|
}
|
|
355
405
|
|
|
356
406
|
// Reset local peer state to avoid stale sessions targeting the wrong browser tab.
|
|
357
|
-
myPeerId =
|
|
407
|
+
myPeerId = myPeerId || ensureIdentity();
|
|
358
408
|
mySessionNonce = null;
|
|
359
409
|
peerSessions.clear();
|
|
360
410
|
peerProbeState.clear();
|
|
@@ -389,8 +439,7 @@ async function connectNostr() {
|
|
|
389
439
|
|
|
390
440
|
// Identity must be ready before we connect/subscribe; inbound events can arrive immediately.
|
|
391
441
|
// Any client instance will derive the same per-tab keypair.
|
|
392
|
-
const
|
|
393
|
-
const myPubkey = identityClient.getPublicKey();
|
|
442
|
+
const myPubkey = myPeerId || ensureIdentity();
|
|
394
443
|
myPeerId = myPubkey;
|
|
395
444
|
mySessionNonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
396
445
|
document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
|
|
@@ -689,7 +738,7 @@ async function connectWebRTC() {
|
|
|
689
738
|
let finalUrl = serverUrl;
|
|
690
739
|
if (serverUrl.includes('signal.peer.ooo')) {
|
|
691
740
|
finalUrl = `${serverUrl.replace(/\/$/, '')}/ws?room=${roomId}`;
|
|
692
|
-
log(`Using
|
|
741
|
+
log(`Using hosted endpoint with session: ${roomId}`, 'info');
|
|
693
742
|
}
|
|
694
743
|
|
|
695
744
|
client = new UniWRTCClient(finalUrl, { autoReconnect: false });
|
|
@@ -793,7 +842,6 @@ window.disconnect = function() {
|
|
|
793
842
|
if (nostrClient) {
|
|
794
843
|
nostrClient.disconnect().catch(() => {});
|
|
795
844
|
nostrClient = null;
|
|
796
|
-
myPeerId = null;
|
|
797
845
|
mySessionNonce = null;
|
|
798
846
|
peerSessions.clear();
|
|
799
847
|
peerProbeState.clear();
|
package/src/room.js
CHANGED
package/tests/e2e.spec.js
CHANGED
|
@@ -1,38 +1,30 @@
|
|
|
1
1
|
import { test, expect } from '@playwright/test';
|
|
2
2
|
|
|
3
|
-
function failOnWebSocket(page) {
|
|
4
|
-
page.on('websocket', (ws) => {
|
|
5
|
-
throw new Error(`WebSockets are disabled, but a WebSocket was opened: ${ws.url()}`);
|
|
6
|
-
});
|
|
7
|
-
}
|
|
8
|
-
|
|
9
3
|
test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
10
|
-
const BASE_URL = 'https://signal.peer.ooo';
|
|
11
4
|
const ROOM_ID = 'test';
|
|
12
5
|
|
|
13
6
|
test.describe('Connection and Session Management', () => {
|
|
14
7
|
test('should load demo page and display UI', async ({ page }) => {
|
|
15
|
-
|
|
16
|
-
await page.goto(BASE_URL);
|
|
8
|
+
await page.goto('/');
|
|
17
9
|
|
|
18
10
|
// Check main elements exist
|
|
19
11
|
await expect(page.locator('h1')).toContainText('UniWRTC Demo');
|
|
20
12
|
await expect(page.locator('text=Connection')).toBeVisible();
|
|
21
|
-
await expect(page.getByTestId('
|
|
13
|
+
await expect(page.getByTestId('relayUrl')).toBeVisible();
|
|
22
14
|
await expect(page.getByTestId('roomId')).toHaveValue('demo-room');
|
|
23
15
|
await expect(page.getByTestId('connectBtn')).toBeVisible();
|
|
24
16
|
});
|
|
25
17
|
|
|
26
|
-
test('should connect to
|
|
27
|
-
|
|
28
|
-
await page.goto(
|
|
18
|
+
test('should connect to relay and join room', async ({ page }) => {
|
|
19
|
+
test.setTimeout(60_000);
|
|
20
|
+
await page.goto('/');
|
|
29
21
|
|
|
30
22
|
// Click connect
|
|
31
23
|
await page.getByTestId('connectBtn').click();
|
|
32
24
|
|
|
33
25
|
// Wait for connection success log
|
|
34
|
-
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout:
|
|
35
|
-
await expect(page.getByTestId('log-joined')).toBeVisible({ timeout:
|
|
26
|
+
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout: 30000 });
|
|
27
|
+
await expect(page.getByTestId('log-joined')).toBeVisible({ timeout: 30000 });
|
|
36
28
|
|
|
37
29
|
// Check status changed to connected
|
|
38
30
|
const badge = page.getByTestId('statusBadge');
|
|
@@ -46,12 +38,12 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
46
38
|
});
|
|
47
39
|
|
|
48
40
|
test('should handle disconnect', async ({ page }) => {
|
|
49
|
-
|
|
50
|
-
await page.goto(
|
|
41
|
+
test.setTimeout(60_000);
|
|
42
|
+
await page.goto('/');
|
|
51
43
|
|
|
52
44
|
// Connect first
|
|
53
45
|
await page.getByTestId('connectBtn').click();
|
|
54
|
-
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout:
|
|
46
|
+
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout: 30000 });
|
|
55
47
|
|
|
56
48
|
// Now disconnect
|
|
57
49
|
await page.getByTestId('disconnectBtn').click();
|
|
@@ -59,7 +51,6 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
59
51
|
// Check status changed back to disconnected
|
|
60
52
|
const badge = page.getByTestId('statusBadge');
|
|
61
53
|
await expect(badge).toContainText('Disconnected');
|
|
62
|
-
await expect(page.getByTestId('clientId')).toContainText('Not connected');
|
|
63
54
|
});
|
|
64
55
|
});
|
|
65
56
|
|
|
@@ -68,21 +59,18 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
68
59
|
// Open three browser contexts (simulating three users)
|
|
69
60
|
const context1 = await browser.newContext();
|
|
70
61
|
const page1 = await context1.newPage();
|
|
71
|
-
failOnWebSocket(page1);
|
|
72
62
|
|
|
73
63
|
const context2 = await browser.newContext();
|
|
74
64
|
const page2 = await context2.newPage();
|
|
75
|
-
failOnWebSocket(page2);
|
|
76
65
|
|
|
77
66
|
const context3 = await browser.newContext();
|
|
78
67
|
const page3 = await context3.newPage();
|
|
79
|
-
failOnWebSocket(page3);
|
|
80
68
|
|
|
81
69
|
try {
|
|
82
70
|
// Connect all three peers to same room
|
|
83
|
-
await page1.goto(
|
|
84
|
-
await page2.goto(
|
|
85
|
-
await page3.goto(
|
|
71
|
+
await page1.goto('/?ice=host');
|
|
72
|
+
await page2.goto('/?ice=host');
|
|
73
|
+
await page3.goto('/?ice=host');
|
|
86
74
|
|
|
87
75
|
// Use shared room ID for all three peers
|
|
88
76
|
await page1.getByTestId('roomId').fill(ROOM_ID);
|
|
@@ -127,21 +115,18 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
127
115
|
test.setTimeout(90_000);
|
|
128
116
|
const context1 = await browser.newContext();
|
|
129
117
|
const page1 = await context1.newPage();
|
|
130
|
-
failOnWebSocket(page1);
|
|
131
118
|
|
|
132
119
|
const context2 = await browser.newContext();
|
|
133
120
|
const page2 = await context2.newPage();
|
|
134
|
-
failOnWebSocket(page2);
|
|
135
121
|
|
|
136
122
|
const context3 = await browser.newContext();
|
|
137
123
|
const page3 = await context3.newPage();
|
|
138
|
-
failOnWebSocket(page3);
|
|
139
124
|
|
|
140
125
|
try {
|
|
141
126
|
// Connect all three peers
|
|
142
|
-
await page1.goto(
|
|
143
|
-
await page2.goto(
|
|
144
|
-
await page3.goto(
|
|
127
|
+
await page1.goto('/?ice=host');
|
|
128
|
+
await page2.goto('/?ice=host');
|
|
129
|
+
await page3.goto('/?ice=host');
|
|
145
130
|
|
|
146
131
|
await page1.getByTestId('roomId').fill(ROOM_ID);
|
|
147
132
|
await page2.getByTestId('roomId').fill(ROOM_ID);
|
|
@@ -172,9 +157,9 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
172
157
|
}
|
|
173
158
|
|
|
174
159
|
// Wait for data channels to open - wait for at least one data channel log on each peer with extended timeout
|
|
175
|
-
await expect(page1.getByTestId('log-data-channel').first()).toBeVisible({ timeout:
|
|
176
|
-
await expect(page2.getByTestId('log-data-channel').first()).toBeVisible({ timeout:
|
|
177
|
-
await expect(page3.getByTestId('log-data-channel').first()).toBeVisible({ timeout:
|
|
160
|
+
await expect(page1.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 60000 });
|
|
161
|
+
await expect(page2.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 60000 });
|
|
162
|
+
await expect(page3.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 60000 });
|
|
178
163
|
|
|
179
164
|
// Wait longer to ensure all data channels are fully established
|
|
180
165
|
await page1.waitForTimeout(3000);
|
|
@@ -199,21 +184,18 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
199
184
|
test.setTimeout(90_000);
|
|
200
185
|
const context1 = await browser.newContext();
|
|
201
186
|
const page1 = await context1.newPage();
|
|
202
|
-
failOnWebSocket(page1);
|
|
203
187
|
|
|
204
188
|
const context2 = await browser.newContext();
|
|
205
189
|
const page2 = await context2.newPage();
|
|
206
|
-
failOnWebSocket(page2);
|
|
207
190
|
|
|
208
191
|
const context3 = await browser.newContext();
|
|
209
192
|
const page3 = await context3.newPage();
|
|
210
|
-
failOnWebSocket(page3);
|
|
211
193
|
|
|
212
194
|
try {
|
|
213
195
|
// Connect all three peers
|
|
214
|
-
await page1.goto(
|
|
215
|
-
await page2.goto(
|
|
216
|
-
await page3.goto(
|
|
196
|
+
await page1.goto('/?ice=host');
|
|
197
|
+
await page2.goto('/?ice=host');
|
|
198
|
+
await page3.goto('/?ice=host');
|
|
217
199
|
|
|
218
200
|
await page1.getByTestId('roomId').fill(ROOM_ID);
|
|
219
201
|
await page2.getByTestId('roomId').fill(ROOM_ID);
|
|
@@ -229,9 +211,9 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
229
211
|
await expect(page3.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
230
212
|
|
|
231
213
|
// Wait for data channels to open on all peers first with extended timeout
|
|
232
|
-
await expect(page1.getByTestId('log-data-channel').first()).toBeVisible({ timeout:
|
|
233
|
-
await expect(page2.getByTestId('log-data-channel').first()).toBeVisible({ timeout:
|
|
234
|
-
await expect(page3.getByTestId('log-data-channel').first()).toBeVisible({ timeout:
|
|
214
|
+
await expect(page1.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 60000 });
|
|
215
|
+
await expect(page2.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 60000 });
|
|
216
|
+
await expect(page3.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 60000 });
|
|
235
217
|
|
|
236
218
|
// Give the browser a moment to fully wire up data channel handlers.
|
|
237
219
|
await page1.waitForTimeout(3000);
|
|
@@ -287,16 +269,14 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
287
269
|
test('should not connect peers from different sessions', async ({ browser }) => {
|
|
288
270
|
const context1 = await browser.newContext();
|
|
289
271
|
const page1 = await context1.newPage();
|
|
290
|
-
failOnWebSocket(page1);
|
|
291
272
|
|
|
292
273
|
const context2 = await browser.newContext();
|
|
293
274
|
const page2 = await context2.newPage();
|
|
294
|
-
failOnWebSocket(page2);
|
|
295
275
|
|
|
296
276
|
try {
|
|
297
277
|
// Connect to different rooms
|
|
298
|
-
await page1.goto(
|
|
299
|
-
await page2.goto(
|
|
278
|
+
await page1.goto('/');
|
|
279
|
+
await page2.goto('/');
|
|
300
280
|
|
|
301
281
|
// Use different unique room IDs to ensure isolation
|
|
302
282
|
const roomA = `iso-a-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
@@ -328,24 +308,21 @@ test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
|
328
308
|
});
|
|
329
309
|
|
|
330
310
|
test.describe('Error Handling', () => {
|
|
331
|
-
test('should show error when
|
|
332
|
-
|
|
333
|
-
await page.
|
|
334
|
-
|
|
335
|
-
await page.getByTestId('serverUrl').fill('');
|
|
311
|
+
test('should show error when relay URL is empty', async ({ page }) => {
|
|
312
|
+
await page.goto('/');
|
|
313
|
+
await page.getByTestId('relayUrl').fill('');
|
|
336
314
|
await page.getByTestId('connectBtn').click();
|
|
337
|
-
|
|
338
|
-
await expect(page.locator('text=Please enter a server URL')).toBeVisible({ timeout: 5000 });
|
|
315
|
+
await expect(page.locator('text=Please enter a relay URL')).toBeVisible({ timeout: 5000 });
|
|
339
316
|
});
|
|
340
317
|
|
|
341
|
-
test('should
|
|
342
|
-
|
|
343
|
-
await page.goto(
|
|
344
|
-
|
|
318
|
+
test('should auto-fill a room when room ID is empty', async ({ page }) => {
|
|
319
|
+
test.setTimeout(60_000);
|
|
320
|
+
await page.goto('/');
|
|
345
321
|
await page.getByTestId('roomId').fill('');
|
|
346
322
|
await page.getByTestId('connectBtn').click();
|
|
347
|
-
|
|
348
|
-
await expect(page.
|
|
323
|
+
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout: 30000 });
|
|
324
|
+
await expect(page.getByTestId('roomId')).not.toHaveValue('');
|
|
325
|
+
await expect(page.getByTestId('sessionId')).not.toContainText('Not joined');
|
|
349
326
|
});
|
|
350
327
|
});
|
|
351
328
|
});
|