uniwrtc 1.0.4 β 1.0.7
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/README.md +44 -30
- package/client-browser.js +3 -0
- package/index.html +12 -0
- package/package.json +10 -2
- package/playwright.config.js +33 -0
- package/public/favicon.ico +1 -0
- package/src/index.js +50 -8
- package/src/main.js +393 -0
- package/src/style.css +210 -0
- package/tests/e2e.spec.js +332 -0
- package/vite.config.js +13 -0
- package/wrangler.toml +10 -1
- package/test.js +0 -62
package/README.md
CHANGED
|
@@ -53,14 +53,21 @@ PORT=8080
|
|
|
53
53
|
|
|
54
54
|
### Try the Demo
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
The interactive demo is available live at **https://signal.peer.ooo/** (Cloudflare Workers deployment) or run locally:
|
|
57
|
+
|
|
58
|
+
**Using the deployed demo (recommended):**
|
|
59
|
+
1. Open https://signal.peer.ooo/ in two browser tabs
|
|
60
|
+
2. Default room is `demo-room`βboth tabs will auto-connect
|
|
61
|
+
3. Click "Connect" to join
|
|
62
|
+
4. Watch the activity log to see peers connecting
|
|
63
|
+
5. Open the P2P chat and send messages between tabs
|
|
64
|
+
|
|
65
|
+
**Or run locally:**
|
|
66
|
+
1. Start the server: `npm start` (signaling at `ws://localhost:8080`)
|
|
67
|
+
2. Start the Vite dev server: `npm run dev` (demo at `http://localhost:5173/`)
|
|
68
|
+
3. Open the demo in two browser tabs
|
|
69
|
+
4. Enter the same session ID in both, then Connect
|
|
70
|
+
5. Chat P2P once data channels open
|
|
64
71
|
|
|
65
72
|
## Usage
|
|
66
73
|
|
|
@@ -166,10 +173,11 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
166
173
|
|
|
167
174
|
Use directly from npm:
|
|
168
175
|
```javascript
|
|
169
|
-
// ESM
|
|
170
|
-
import
|
|
171
|
-
|
|
172
|
-
|
|
176
|
+
// ESM (browser)
|
|
177
|
+
import UniWRTCClient from 'uniwrtc/client-browser.js';
|
|
178
|
+
|
|
179
|
+
// CommonJS (Node.js)
|
|
180
|
+
const UniWRTCClient = require('uniwrtc/client.js');
|
|
173
181
|
```
|
|
174
182
|
|
|
175
183
|
The `client.js` library provides a convenient wrapper for the signaling protocol:
|
|
@@ -211,8 +219,8 @@ client.on('ice-candidate', (data) => {
|
|
|
211
219
|
// Connect to the server
|
|
212
220
|
await client.connect();
|
|
213
221
|
|
|
214
|
-
// Join a
|
|
215
|
-
client.
|
|
222
|
+
// Join a session
|
|
223
|
+
client.joinSession('my-session');
|
|
216
224
|
|
|
217
225
|
// Send WebRTC signaling messages
|
|
218
226
|
client.sendOffer(offerObject, targetPeerId);
|
|
@@ -298,6 +306,11 @@ client.on('ice-candidate', async (data) => {
|
|
|
298
306
|
// Connect and join session
|
|
299
307
|
await client.connect();
|
|
300
308
|
client.joinSession('my-video-session');
|
|
309
|
+
|
|
310
|
+
// Or use Cloudflare Durable Objects deployment
|
|
311
|
+
const cfClient = new UniWRTCClient('wss://signal.peer.ooo?room=my-session');
|
|
312
|
+
await cfClient.connect();
|
|
313
|
+
cfClient.joinSession('my-session');
|
|
301
314
|
```
|
|
302
315
|
|
|
303
316
|
## API Reference
|
|
@@ -319,12 +332,12 @@ new UniWRTCClient(serverUrl, options)
|
|
|
319
332
|
|
|
320
333
|
- `connect()`: Connect to the signaling server (returns Promise)
|
|
321
334
|
- `disconnect()`: Disconnect from the server
|
|
322
|
-
- `joinSession(sessionId)`: Join a specific session
|
|
335
|
+
- `joinSession(sessionId)`: Join a specific session (peers isolated by session)
|
|
323
336
|
- `leaveSession()`: Leave the current session
|
|
324
|
-
- `sendOffer(offer, targetId)`: Send a WebRTC offer
|
|
325
|
-
- `sendAnswer(answer, targetId)`: Send a WebRTC answer
|
|
326
|
-
- `sendIceCandidate(candidate, targetId)`: Send an ICE candidate
|
|
327
|
-
- `listRooms()`: Request list of available
|
|
337
|
+
- `sendOffer(offer, targetId)`: Send a WebRTC offer to a specific peer
|
|
338
|
+
- `sendAnswer(answer, targetId)`: Send a WebRTC answer to a specific peer
|
|
339
|
+
- `sendIceCandidate(candidate, targetId)`: Send an ICE candidate to a specific peer
|
|
340
|
+
- `listRooms()`: Request list of available sessions (legacy)
|
|
328
341
|
- `on(event, handler)`: Register event handler
|
|
329
342
|
- `off(event, handler)`: Unregister event handler
|
|
330
343
|
|
|
@@ -359,21 +372,22 @@ Response:
|
|
|
359
372
|
|
|
360
373
|
## Architecture
|
|
361
374
|
|
|
362
|
-
### Session
|
|
375
|
+
### Session-based Peer Isolation
|
|
363
376
|
|
|
364
|
-
- Each session is identified by a unique
|
|
365
|
-
-
|
|
366
|
-
-
|
|
367
|
-
-
|
|
377
|
+
- **Sessions**: Each session is identified by a unique string ID (also called "room" in the UI)
|
|
378
|
+
- **Peer routing**: Each peer gets a unique client ID; signaling messages are routed only to intended targets
|
|
379
|
+
- **Session isolation**: Peers in different sessions cannot see or communicate with each other
|
|
380
|
+
- **Cloudflare Durable Objects**: Uses DO state to isolate sessions; routing by `?room=` query param per session
|
|
381
|
+
- Clients join with `joinSession(sessionId)` and receive notifications when other peers join the same session
|
|
368
382
|
|
|
369
383
|
### Message Flow
|
|
370
384
|
|
|
371
|
-
1. Client connects via WebSocket
|
|
372
|
-
2. Server assigns a unique client ID
|
|
373
|
-
3. Client
|
|
374
|
-
4. Server
|
|
375
|
-
5. Peers exchange WebRTC
|
|
376
|
-
6. Server routes messages
|
|
385
|
+
1. Client connects via WebSocket (or WS-over-HTTP for Cloudflare)
|
|
386
|
+
2. Server/Durable Object assigns a unique client ID
|
|
387
|
+
3. Client sends join message with session ID
|
|
388
|
+
4. Server broadcasts `peer-joined` to other peers in the same session only
|
|
389
|
+
5. Peers exchange WebRTC offers/answers/ICE candidates via the server
|
|
390
|
+
6. Server routes signaling messages to specific peers by target ID (unicast, not broadcast)
|
|
377
391
|
|
|
378
392
|
## Security Considerations
|
|
379
393
|
|
package/client-browser.js
CHANGED
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>UniWRTC Demo - WebRTC Signaling</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.js"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniwrtc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "A universal WebRTC signaling service",
|
|
5
5
|
"main": "server.js",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"scripts": {
|
|
7
8
|
"start": "node server.js",
|
|
8
|
-
"dev": "
|
|
9
|
+
"dev": "vite",
|
|
10
|
+
"build": "vite build",
|
|
11
|
+
"preview": "vite preview",
|
|
12
|
+
"server": "node server.js",
|
|
9
13
|
"test": "node test.js"
|
|
10
14
|
},
|
|
11
15
|
"keywords": [
|
|
@@ -20,6 +24,10 @@
|
|
|
20
24
|
"dependencies": {
|
|
21
25
|
"ws": "^8.17.1"
|
|
22
26
|
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@playwright/test": "^1.57.0",
|
|
29
|
+
"vite": "^6.0.6"
|
|
30
|
+
},
|
|
23
31
|
"engines": {
|
|
24
32
|
"node": ">=14.0.0"
|
|
25
33
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
testDir: './tests',
|
|
5
|
+
// Run tests IN PARALLEL - all browsers at the same time
|
|
6
|
+
fullyParallel: true,
|
|
7
|
+
forbidOnly: !!process.env.CI,
|
|
8
|
+
retries: process.env.CI ? 2 : 0,
|
|
9
|
+
workers: 4, // Multiple workers - run browsers in parallel
|
|
10
|
+
reporter: 'html',
|
|
11
|
+
use: {
|
|
12
|
+
baseURL: 'https://signal.peer.ooo',
|
|
13
|
+
trace: 'on-first-retry',
|
|
14
|
+
screenshot: 'only-on-failure',
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
projects: [
|
|
18
|
+
{
|
|
19
|
+
name: 'chromium',
|
|
20
|
+
use: { ...devices['Desktop Chrome'] },
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
name: 'firefox',
|
|
25
|
+
use: { ...devices['Desktop Firefox'] },
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
name: 'webkit',
|
|
30
|
+
use: { ...devices['Desktop Safari'] },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
UNI WRTC ICON
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* UniWRTC Cloudflare Worker
|
|
3
3
|
* WebRTC Signaling Service using Durable Objects
|
|
4
|
+
* Serves both static assets and WebSocket signaling
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { Room } from './room.js';
|
|
@@ -10,6 +11,7 @@ export { Room };
|
|
|
10
11
|
export default {
|
|
11
12
|
async fetch(request, env) {
|
|
12
13
|
const url = new URL(request.url);
|
|
14
|
+
console.log(`[Worker] ${request.method} ${url.pathname}${url.search}`);
|
|
13
15
|
|
|
14
16
|
// Health check
|
|
15
17
|
if (url.pathname === '/health') {
|
|
@@ -18,16 +20,56 @@ export default {
|
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
// WebSocket
|
|
22
|
-
if (url.pathname === '/
|
|
23
|
-
|
|
23
|
+
// Handle WebSocket upgrade on /ws endpoint
|
|
24
|
+
if (url.pathname === '/ws') {
|
|
25
|
+
if (request.headers.get('Upgrade') === 'websocket') {
|
|
26
|
+
console.log(`[Worker] WebSocket upgrade detected for room: ${url.searchParams.get('room')}`);
|
|
27
|
+
const roomId = url.searchParams.get('room') || 'default';
|
|
28
|
+
const id = env.ROOMS.idFromName(roomId);
|
|
29
|
+
const roomStub = env.ROOMS.get(id);
|
|
30
|
+
console.log(`[Worker] Routing to Durable Object: ${roomId}`);
|
|
31
|
+
return roomStub.fetch(request);
|
|
32
|
+
}
|
|
33
|
+
return new Response('WebSocket upgrade required', { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Serve root as index.html
|
|
37
|
+
if (url.pathname === '/') {
|
|
38
|
+
try {
|
|
39
|
+
const asset = await env.ASSETS.get('index.html');
|
|
40
|
+
if (asset) {
|
|
41
|
+
return new Response(asset, {
|
|
42
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// ASSETS binding may not exist in local dev
|
|
47
|
+
}
|
|
48
|
+
}
|
|
24
49
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
// Serve static assets (CSS, JS, images, etc.)
|
|
51
|
+
if (request.method === 'GET') {
|
|
52
|
+
try {
|
|
53
|
+
const pathname = url.pathname.replace(/^\//, '');
|
|
54
|
+
const asset = await env.ASSETS.get(pathname);
|
|
55
|
+
if (asset) {
|
|
56
|
+
let contentType = 'application/octet-stream';
|
|
57
|
+
if (pathname.endsWith('.html')) contentType = 'text/html; charset=utf-8';
|
|
58
|
+
else if (pathname.endsWith('.js')) contentType = 'application/javascript';
|
|
59
|
+
else if (pathname.endsWith('.css')) contentType = 'text/css';
|
|
60
|
+
else if (pathname.endsWith('.json')) contentType = 'application/json';
|
|
61
|
+
else if (pathname.endsWith('.svg')) contentType = 'image/svg+xml';
|
|
62
|
+
else if (pathname.endsWith('.png')) contentType = 'image/png';
|
|
63
|
+
else if (pathname.endsWith('.jpg')) contentType = 'image/jpeg';
|
|
64
|
+
else if (pathname.endsWith('.ico')) contentType = 'image/x-icon';
|
|
28
65
|
|
|
29
|
-
|
|
30
|
-
|
|
66
|
+
return new Response(asset, {
|
|
67
|
+
headers: { 'Content-Type': contentType }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// ASSETS binding may not exist in local dev
|
|
72
|
+
}
|
|
31
73
|
}
|
|
32
74
|
|
|
33
75
|
return new Response('Not Found', { status: 404 });
|
package/src/main.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import './style.css';
|
|
2
|
+
import UniWRTCClient from '../client-browser.js';
|
|
3
|
+
|
|
4
|
+
// Make UniWRTCClient available globally for backwards compatibility
|
|
5
|
+
window.UniWRTCClient = UniWRTCClient;
|
|
6
|
+
|
|
7
|
+
let client = null;
|
|
8
|
+
const peerConnections = new Map();
|
|
9
|
+
const dataChannels = new Map();
|
|
10
|
+
|
|
11
|
+
// Initialize app
|
|
12
|
+
document.getElementById('app').innerHTML = `
|
|
13
|
+
<div class="container">
|
|
14
|
+
<div class="header">
|
|
15
|
+
<h1>π UniWRTC Demo</h1>
|
|
16
|
+
<p>WebRTC Signaling made simple</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="card">
|
|
20
|
+
<h2>Connection</h2>
|
|
21
|
+
<div class="connection-controls">
|
|
22
|
+
<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">
|
|
25
|
+
</div>
|
|
26
|
+
<div>
|
|
27
|
+
<label style="display: block; margin-bottom: 5px; color: #64748b; font-size: 13px;">Room / Session ID</label>
|
|
28
|
+
<input type="text" id="roomId" data-testid="roomId" placeholder="my-room">
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div style="display: flex; gap: 10px; align-items: center;">
|
|
32
|
+
<button onclick="window.connect()" class="btn-primary" id="connectBtn" data-testid="connectBtn">Connect</button>
|
|
33
|
+
<button onclick="window.disconnect()" class="btn-danger" id="disconnectBtn" data-testid="disconnectBtn" disabled>Disconnect</button>
|
|
34
|
+
<span id="statusBadge" data-testid="statusBadge" class="status-badge status-disconnected">Disconnected</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="card">
|
|
39
|
+
<h2>Client Info</h2>
|
|
40
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
|
41
|
+
<div>
|
|
42
|
+
<strong style="color: #64748b;">Client ID:</strong>
|
|
43
|
+
<div id="clientId" data-testid="clientId" style="font-family: monospace; color: #333; margin-top: 5px;">Not connected</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<strong style="color: #64748b;">Session ID:</strong>
|
|
47
|
+
<div id="sessionId" data-testid="sessionId" style="font-family: monospace; color: #333; margin-top: 5px;">Not joined</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="card">
|
|
53
|
+
<h2>Connected Peers</h2>
|
|
54
|
+
<div id="peerList" data-testid="peerList" class="peer-list">
|
|
55
|
+
<p style="color: #94a3b8;">No peers connected</p>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="card">
|
|
60
|
+
<h2>Peer-to-Peer Chat</h2>
|
|
61
|
+
<div id="chatContainer" data-testid="chatContainer">
|
|
62
|
+
<p>Connect to a room and wait for peers to start chatting</p>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="chat-controls">
|
|
65
|
+
<input type="text" id="chatMessage" data-testid="chatMessage" placeholder="Type a message..." onkeypress="if(event.key === 'Enter') window.sendChatMessage()">
|
|
66
|
+
<button onclick="window.sendChatMessage()" class="btn-primary" data-testid="sendBtn">Send</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="card">
|
|
71
|
+
<h2>Activity Log</h2>
|
|
72
|
+
<div id="logContainer" data-testid="logContainer" class="log-container">
|
|
73
|
+
<div class="log-entry success">UniWRTC Demo ready</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
// Prefill room input from URL (?room= or ?session=); otherwise set a visible default the user can override
|
|
80
|
+
const roomInput = document.getElementById('roomId');
|
|
81
|
+
const params = new URLSearchParams(window.location.search);
|
|
82
|
+
const urlRoom = params.get('room') || params.get('session');
|
|
83
|
+
if (urlRoom) {
|
|
84
|
+
roomInput.value = urlRoom;
|
|
85
|
+
log(`Prefilled room ID from URL: ${urlRoom}`, 'info');
|
|
86
|
+
} else {
|
|
87
|
+
const defaultRoom = 'demo-room';
|
|
88
|
+
roomInput.value = defaultRoom;
|
|
89
|
+
log(`Using default room ID: ${defaultRoom}`, 'info');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function log(message, type = 'info') {
|
|
93
|
+
const logContainer = document.getElementById('logContainer');
|
|
94
|
+
const entry = document.createElement('div');
|
|
95
|
+
entry.className = `log-entry ${type}`;
|
|
96
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
97
|
+
entry.textContent = `[${timestamp}] ${message}`;
|
|
98
|
+
|
|
99
|
+
// Add testid for specific log messages
|
|
100
|
+
if (message.includes('Connected with client ID')) {
|
|
101
|
+
entry.setAttribute('data-testid', 'log-connected');
|
|
102
|
+
} else if (message.includes('Joined session')) {
|
|
103
|
+
entry.setAttribute('data-testid', 'log-joined');
|
|
104
|
+
} else if (message.includes('Peer joined')) {
|
|
105
|
+
entry.setAttribute('data-testid', 'log-peer-joined');
|
|
106
|
+
} else if (message.includes('Data channel open')) {
|
|
107
|
+
entry.setAttribute('data-testid', 'log-data-channel');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
logContainer.appendChild(entry);
|
|
111
|
+
logContainer.scrollTop = logContainer.scrollHeight;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updateStatus(connected) {
|
|
115
|
+
const badge = document.getElementById('statusBadge');
|
|
116
|
+
const connectBtn = document.getElementById('connectBtn');
|
|
117
|
+
const disconnectBtn = document.getElementById('disconnectBtn');
|
|
118
|
+
|
|
119
|
+
if (connected) {
|
|
120
|
+
badge.textContent = 'Connected';
|
|
121
|
+
badge.className = 'status-badge status-connected';
|
|
122
|
+
connectBtn.disabled = true;
|
|
123
|
+
disconnectBtn.disabled = false;
|
|
124
|
+
} else {
|
|
125
|
+
badge.textContent = 'Disconnected';
|
|
126
|
+
badge.className = 'status-badge status-disconnected';
|
|
127
|
+
connectBtn.disabled = false;
|
|
128
|
+
disconnectBtn.disabled = true;
|
|
129
|
+
document.getElementById('clientId').textContent = 'Not connected';
|
|
130
|
+
document.getElementById('sessionId').textContent = 'Not joined';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updatePeerList() {
|
|
135
|
+
const peerList = document.getElementById('peerList');
|
|
136
|
+
if (peerConnections.size === 0) {
|
|
137
|
+
peerList.innerHTML = '<p style="color: #94a3b8;">No peers connected</p>';
|
|
138
|
+
} else {
|
|
139
|
+
peerList.innerHTML = '';
|
|
140
|
+
peerConnections.forEach((pc, peerId) => {
|
|
141
|
+
const peerItem = document.createElement('div');
|
|
142
|
+
peerItem.className = 'peer-item';
|
|
143
|
+
peerItem.textContent = peerId.substring(0, 8) + '...';
|
|
144
|
+
peerList.appendChild(peerItem);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
window.connect = async function() {
|
|
150
|
+
const serverUrl = document.getElementById('serverUrl').value.trim();
|
|
151
|
+
const roomId = document.getElementById('roomId').value.trim();
|
|
152
|
+
|
|
153
|
+
if (!serverUrl) {
|
|
154
|
+
log('Please enter a server URL', 'error');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!roomId) {
|
|
159
|
+
log('Please enter a room ID', 'error');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
log(`Connecting to ${serverUrl}...`, 'info');
|
|
165
|
+
|
|
166
|
+
// For Cloudflare, use /ws endpoint with room ID query param
|
|
167
|
+
let finalUrl = serverUrl;
|
|
168
|
+
if (serverUrl.includes('signal.peer.ooo')) {
|
|
169
|
+
finalUrl = `${serverUrl.replace(/\/$/, '')}/ws?room=${roomId}`;
|
|
170
|
+
log(`Using Cloudflare Durable Objects with session: ${roomId}`, 'info');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
client = new UniWRTCClient(finalUrl, { autoReconnect: false });
|
|
174
|
+
|
|
175
|
+
client.on('connected', (data) => {
|
|
176
|
+
log(`Connected with client ID: ${data.clientId}`, 'success');
|
|
177
|
+
document.getElementById('clientId').textContent = data.clientId;
|
|
178
|
+
updateStatus(true);
|
|
179
|
+
|
|
180
|
+
// Auto-join the room
|
|
181
|
+
log(`Joining session: ${roomId}`, 'info');
|
|
182
|
+
client.joinSession(roomId);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
client.on('joined', (data) => {
|
|
186
|
+
log(`Joined session: ${data.sessionId}`, 'success');
|
|
187
|
+
document.getElementById('sessionId').textContent = data.sessionId;
|
|
188
|
+
|
|
189
|
+
if (data.clients && data.clients.length > 0) {
|
|
190
|
+
log(`Found ${data.clients.length} existing peers`, 'info');
|
|
191
|
+
data.clients.forEach(peerId => {
|
|
192
|
+
log(`Creating connection to existing peer: ${peerId.substring(0, 6)}...`, 'info');
|
|
193
|
+
createPeerConnection(peerId, true);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
client.on('peer-joined', (data) => {
|
|
199
|
+
// Only handle peers in our session
|
|
200
|
+
if (client.sessionId && data.sessionId !== client.sessionId) {
|
|
201
|
+
log(`Ignoring peer from different session: ${data.sessionId}`, 'warning');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
log(`Peer joined: ${data.peerId.substring(0, 6)}...`, 'success');
|
|
206
|
+
|
|
207
|
+
// Wait a bit to ensure both peers are ready
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
createPeerConnection(data.peerId, false);
|
|
210
|
+
}, 100);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
client.on('peer-left', (data) => {
|
|
214
|
+
log(`Peer left: ${data.peerId.substring(0, 6)}...`, 'warning');
|
|
215
|
+
const pc = peerConnections.get(data.peerId);
|
|
216
|
+
if (pc) {
|
|
217
|
+
pc.close();
|
|
218
|
+
peerConnections.delete(data.peerId);
|
|
219
|
+
dataChannels.delete(data.peerId);
|
|
220
|
+
updatePeerList();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
client.on('offer', async (data) => {
|
|
225
|
+
log(`Received offer from ${data.peerId.substring(0, 6)}...`, 'info');
|
|
226
|
+
const pc = peerConnections.get(data.peerId) || await createPeerConnection(data.peerId, false);
|
|
227
|
+
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
|
228
|
+
const answer = await pc.createAnswer();
|
|
229
|
+
await pc.setLocalDescription(answer);
|
|
230
|
+
client.sendAnswer(answer, data.peerId);
|
|
231
|
+
log(`Sent answer to ${data.peerId.substring(0, 6)}...`, 'success');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
client.on('answer', async (data) => {
|
|
235
|
+
log(`Received answer from ${data.peerId.substring(0, 6)}...`, 'info');
|
|
236
|
+
const pc = peerConnections.get(data.peerId);
|
|
237
|
+
if (pc) {
|
|
238
|
+
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
client.on('ice-candidate', async (data) => {
|
|
243
|
+
log(`Received ICE candidate from ${data.peerId.substring(0, 6)}...`, 'info');
|
|
244
|
+
const pc = peerConnections.get(data.peerId);
|
|
245
|
+
if (pc && data.candidate) {
|
|
246
|
+
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
client.on('disconnected', () => {
|
|
251
|
+
log('Disconnected from server', 'error');
|
|
252
|
+
updateStatus(false);
|
|
253
|
+
peerConnections.forEach(pc => pc.close());
|
|
254
|
+
peerConnections.clear();
|
|
255
|
+
dataChannels.clear();
|
|
256
|
+
updatePeerList();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
client.on('error', (data) => {
|
|
260
|
+
log(`Error: ${data.message}`, 'error');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await client.connect();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
log(`Connection error: ${error.message}`, 'error');
|
|
266
|
+
updateStatus(false);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
window.disconnect = function() {
|
|
271
|
+
if (client) {
|
|
272
|
+
client.disconnect();
|
|
273
|
+
client = null;
|
|
274
|
+
peerConnections.forEach(pc => pc.close());
|
|
275
|
+
peerConnections.clear();
|
|
276
|
+
dataChannels.clear();
|
|
277
|
+
updatePeerList();
|
|
278
|
+
updateStatus(false);
|
|
279
|
+
log('Disconnected', 'warning');
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
async function createPeerConnection(peerId, shouldInitiate) {
|
|
284
|
+
if (peerConnections.has(peerId)) {
|
|
285
|
+
log(`Peer connection already exists for ${peerId.substring(0, 6)}...`, 'warning');
|
|
286
|
+
return peerConnections.get(peerId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
log(`Creating peer connection with ${peerId.substring(0, 6)}... (shouldInitiate: ${shouldInitiate})`, 'info');
|
|
290
|
+
|
|
291
|
+
const pc = new RTCPeerConnection({
|
|
292
|
+
iceServers: [
|
|
293
|
+
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }
|
|
294
|
+
]
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
pc.onicecandidate = (event) => {
|
|
298
|
+
if (event.candidate) {
|
|
299
|
+
log(`Sending ICE candidate to ${peerId.substring(0, 6)}...`, 'info');
|
|
300
|
+
client.sendIceCandidate(event.candidate, peerId);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
pc.ondatachannel = (event) => {
|
|
305
|
+
log(`Received data channel from ${peerId.substring(0, 6)}`, 'info');
|
|
306
|
+
setupDataChannel(peerId, event.channel);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (shouldInitiate) {
|
|
310
|
+
const dc = pc.createDataChannel('chat');
|
|
311
|
+
setupDataChannel(peerId, dc);
|
|
312
|
+
|
|
313
|
+
const offer = await pc.createOffer();
|
|
314
|
+
await pc.setLocalDescription(offer);
|
|
315
|
+
client.sendOffer(offer, peerId);
|
|
316
|
+
log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
|
|
317
|
+
} else {
|
|
318
|
+
log(`Waiting for offer from ${peerId.substring(0, 6)}...`, 'info');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
peerConnections.set(peerId, pc);
|
|
322
|
+
updatePeerList();
|
|
323
|
+
return pc;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function setupDataChannel(peerId, dataChannel) {
|
|
327
|
+
dataChannel.onopen = () => {
|
|
328
|
+
log(`Data channel open with ${peerId.substring(0, 6)}...`, 'success');
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
dataChannel.onmessage = (event) => {
|
|
332
|
+
displayChatMessage(event.data, `${peerId.substring(0, 6)}...`, false);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
dataChannel.onclose = () => {
|
|
336
|
+
log(`Data channel closed with ${peerId.substring(0, 6)}...`, 'warning');
|
|
337
|
+
dataChannels.delete(peerId);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
dataChannels.set(peerId, dataChannel);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
window.sendChatMessage = function() {
|
|
344
|
+
const message = document.getElementById('chatMessage').value.trim();
|
|
345
|
+
|
|
346
|
+
if (!message) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (dataChannels.size === 0) {
|
|
351
|
+
log('No peer connections available. Wait for data channels to open.', 'error');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Send to all connected peers
|
|
356
|
+
let sent = 0;
|
|
357
|
+
dataChannels.forEach((dc, peerId) => {
|
|
358
|
+
if (dc.readyState === 'open') {
|
|
359
|
+
dc.send(message);
|
|
360
|
+
sent++;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
if (sent > 0) {
|
|
365
|
+
displayChatMessage(message, 'You', true);
|
|
366
|
+
document.getElementById('chatMessage').value = '';
|
|
367
|
+
} else {
|
|
368
|
+
log('No open connections to send message', 'error');
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function displayChatMessage(message, sender, isLocal) {
|
|
373
|
+
const chatContainer = document.getElementById('chatContainer');
|
|
374
|
+
|
|
375
|
+
// Clear placeholder if needed
|
|
376
|
+
if (chatContainer.querySelector('p')) {
|
|
377
|
+
chatContainer.innerHTML = '';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const messageEl = document.createElement('div');
|
|
381
|
+
messageEl.className = 'log-entry';
|
|
382
|
+
messageEl.style.background = isLocal ? 'rgba(16, 185, 129, 0.1)' : 'rgba(100, 116, 139, 0.1)';
|
|
383
|
+
messageEl.innerHTML = `
|
|
384
|
+
<span style="color: ${isLocal ? '#10b981' : '#64748b'}; font-weight: bold;">${sender}:</span>
|
|
385
|
+
<span style="margin-left: 8px;">${message}</span>
|
|
386
|
+
`;
|
|
387
|
+
chatContainer.appendChild(messageEl);
|
|
388
|
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Initialize
|
|
392
|
+
updateStatus(false);
|
|
393
|
+
log('UniWRTC Demo ready', 'success');
|
package/src/style.css
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
9
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
10
|
+
min-height: 100vh;
|
|
11
|
+
padding: 20px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.container {
|
|
15
|
+
max-width: 1200px;
|
|
16
|
+
margin: 0 auto;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.header {
|
|
20
|
+
text-align: center;
|
|
21
|
+
color: white;
|
|
22
|
+
margin-bottom: 30px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.header h1 {
|
|
26
|
+
font-size: 2.5em;
|
|
27
|
+
margin-bottom: 10px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.header p {
|
|
31
|
+
font-size: 1.1em;
|
|
32
|
+
opacity: 0.9;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.card {
|
|
36
|
+
background: white;
|
|
37
|
+
border-radius: 12px;
|
|
38
|
+
padding: 25px;
|
|
39
|
+
margin-bottom: 20px;
|
|
40
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.card h2 {
|
|
44
|
+
margin-bottom: 15px;
|
|
45
|
+
color: #333;
|
|
46
|
+
font-size: 1.5em;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.connection-controls {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: 1fr 1fr;
|
|
52
|
+
gap: 15px;
|
|
53
|
+
margin-bottom: 15px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
input {
|
|
57
|
+
width: 100%;
|
|
58
|
+
padding: 12px;
|
|
59
|
+
border: 2px solid #e0e0e0;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
transition: border-color 0.3s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
input:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
border-color: #667eea;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
button {
|
|
71
|
+
padding: 12px 24px;
|
|
72
|
+
border: none;
|
|
73
|
+
border-radius: 6px;
|
|
74
|
+
font-size: 14px;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
transition: all 0.3s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.btn-primary {
|
|
81
|
+
background: #667eea;
|
|
82
|
+
color: white;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.btn-primary:hover {
|
|
86
|
+
background: #5568d3;
|
|
87
|
+
transform: translateY(-2px);
|
|
88
|
+
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.btn-secondary {
|
|
92
|
+
background: #e0e0e0;
|
|
93
|
+
color: #333;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.btn-secondary:hover {
|
|
97
|
+
background: #d0d0d0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.btn-danger {
|
|
101
|
+
background: #ef4444;
|
|
102
|
+
color: white;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.btn-danger:hover {
|
|
106
|
+
background: #dc2626;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
button:disabled {
|
|
110
|
+
opacity: 0.5;
|
|
111
|
+
cursor: not-allowed;
|
|
112
|
+
transform: none !important;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.status-badge {
|
|
116
|
+
display: inline-block;
|
|
117
|
+
padding: 6px 12px;
|
|
118
|
+
border-radius: 20px;
|
|
119
|
+
font-size: 13px;
|
|
120
|
+
font-weight: 600;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.status-connected {
|
|
124
|
+
background: #10b981;
|
|
125
|
+
color: white;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.status-disconnected {
|
|
129
|
+
background: #ef4444;
|
|
130
|
+
color: white;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.log-container {
|
|
134
|
+
height: 250px;
|
|
135
|
+
overflow-y: auto;
|
|
136
|
+
background: #f9fafb;
|
|
137
|
+
border-radius: 6px;
|
|
138
|
+
padding: 15px;
|
|
139
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
140
|
+
font-size: 13px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.log-entry {
|
|
144
|
+
padding: 6px 10px;
|
|
145
|
+
margin-bottom: 4px;
|
|
146
|
+
border-radius: 4px;
|
|
147
|
+
background: rgba(100, 116, 139, 0.05);
|
|
148
|
+
line-height: 1.5;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.log-entry.success {
|
|
152
|
+
color: #059669;
|
|
153
|
+
background: rgba(16, 185, 129, 0.1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.log-entry.error {
|
|
157
|
+
color: #dc2626;
|
|
158
|
+
background: rgba(239, 68, 68, 0.1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.log-entry.info {
|
|
162
|
+
color: #2563eb;
|
|
163
|
+
background: rgba(37, 99, 235, 0.1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.log-entry.warning {
|
|
167
|
+
color: #d97706;
|
|
168
|
+
background: rgba(245, 158, 11, 0.1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.peer-list {
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-wrap: wrap;
|
|
174
|
+
gap: 10px;
|
|
175
|
+
margin-top: 10px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.peer-item {
|
|
179
|
+
background: #f3f4f6;
|
|
180
|
+
padding: 8px 16px;
|
|
181
|
+
border-radius: 6px;
|
|
182
|
+
font-size: 13px;
|
|
183
|
+
font-weight: 500;
|
|
184
|
+
color: #374151;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.chat-controls {
|
|
188
|
+
display: flex;
|
|
189
|
+
gap: 10px;
|
|
190
|
+
margin-top: 15px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.chat-controls input {
|
|
194
|
+
flex: 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#chatContainer {
|
|
198
|
+
height: 300px;
|
|
199
|
+
overflow-y: auto;
|
|
200
|
+
background: #f9fafb;
|
|
201
|
+
border-radius: 6px;
|
|
202
|
+
padding: 15px;
|
|
203
|
+
margin-bottom: 15px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#chatContainer p {
|
|
207
|
+
color: #64748b;
|
|
208
|
+
text-align: center;
|
|
209
|
+
padding: 20px;
|
|
210
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
test.describe('UniWRTC Demo - Full Integration Tests', () => {
|
|
4
|
+
const BASE_URL = 'https://signal.peer.ooo';
|
|
5
|
+
const ROOM_ID = 'test';
|
|
6
|
+
|
|
7
|
+
test.describe('Connection and Session Management', () => {
|
|
8
|
+
test('should load demo page and display UI', async ({ page }) => {
|
|
9
|
+
await page.goto(BASE_URL);
|
|
10
|
+
|
|
11
|
+
// Check main elements exist
|
|
12
|
+
await expect(page.locator('h1')).toContainText('UniWRTC Demo');
|
|
13
|
+
await expect(page.locator('text=Connection')).toBeVisible();
|
|
14
|
+
await expect(page.getByTestId('serverUrl')).toHaveValue('wss://signal.peer.ooo');
|
|
15
|
+
await expect(page.getByTestId('roomId')).toHaveValue('demo-room');
|
|
16
|
+
await expect(page.getByTestId('connectBtn')).toBeVisible();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('should connect to signaling server and join session', async ({ page }) => {
|
|
20
|
+
await page.goto(BASE_URL);
|
|
21
|
+
|
|
22
|
+
// Click connect
|
|
23
|
+
await page.getByTestId('connectBtn').click();
|
|
24
|
+
|
|
25
|
+
// Wait for connection success log
|
|
26
|
+
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
27
|
+
await expect(page.getByTestId('log-joined')).toBeVisible({ timeout: 10000 });
|
|
28
|
+
|
|
29
|
+
// Check status changed to connected
|
|
30
|
+
const badge = page.getByTestId('statusBadge');
|
|
31
|
+
await expect(badge).toContainText('Connected');
|
|
32
|
+
|
|
33
|
+
// Client ID should be populated
|
|
34
|
+
const clientId = page.getByTestId('clientId');
|
|
35
|
+
const clientIdText = await clientId.textContent();
|
|
36
|
+
expect(clientIdText).not.toContain('Not connected');
|
|
37
|
+
expect(clientIdText?.length).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('should handle disconnect', async ({ page }) => {
|
|
41
|
+
await page.goto(BASE_URL);
|
|
42
|
+
|
|
43
|
+
// Connect first
|
|
44
|
+
await page.getByTestId('connectBtn').click();
|
|
45
|
+
await expect(page.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
46
|
+
|
|
47
|
+
// Now disconnect
|
|
48
|
+
await page.getByTestId('disconnectBtn').click();
|
|
49
|
+
|
|
50
|
+
// Check status changed back to disconnected
|
|
51
|
+
const badge = page.getByTestId('statusBadge');
|
|
52
|
+
await expect(badge).toContainText('Disconnected');
|
|
53
|
+
await expect(page.getByTestId('clientId')).toContainText('Not connected');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test.describe('Multi-peer Session', () => {
|
|
58
|
+
test('should connect three peers and see peer-joined notifications', async ({ browser }) => {
|
|
59
|
+
// Open three browser contexts (simulating three users)
|
|
60
|
+
const context1 = await browser.newContext();
|
|
61
|
+
const page1 = await context1.newPage();
|
|
62
|
+
|
|
63
|
+
const context2 = await browser.newContext();
|
|
64
|
+
const page2 = await context2.newPage();
|
|
65
|
+
|
|
66
|
+
const context3 = await browser.newContext();
|
|
67
|
+
const page3 = await context3.newPage();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Connect all three peers to same room
|
|
71
|
+
await page1.goto(BASE_URL);
|
|
72
|
+
await page2.goto(BASE_URL);
|
|
73
|
+
await page3.goto(BASE_URL);
|
|
74
|
+
|
|
75
|
+
// Use shared room ID for all three peers
|
|
76
|
+
await page1.getByTestId('roomId').fill(ROOM_ID);
|
|
77
|
+
await page2.getByTestId('roomId').fill(ROOM_ID);
|
|
78
|
+
await page3.getByTestId('roomId').fill(ROOM_ID);
|
|
79
|
+
|
|
80
|
+
// Connect peer 1
|
|
81
|
+
await page1.getByTestId('connectBtn').click();
|
|
82
|
+
await expect(page1.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
83
|
+
|
|
84
|
+
// Connect peer 2
|
|
85
|
+
await page2.getByTestId('connectBtn').click();
|
|
86
|
+
await expect(page2.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
87
|
+
|
|
88
|
+
// Peer 1 should see peer 2 joined (use .last() to get most recent)
|
|
89
|
+
await expect(page1.getByTestId('log-peer-joined').last()).toBeVisible({ timeout: 10000 });
|
|
90
|
+
|
|
91
|
+
// Connect peer 3
|
|
92
|
+
await page3.getByTestId('connectBtn').click();
|
|
93
|
+
await expect(page3.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
94
|
+
|
|
95
|
+
// Peer 2 should see peer 3 joined (use first() to avoid strict mode with multiple peer-joined logs)
|
|
96
|
+
await expect(page2.getByTestId('log-peer-joined').first()).toBeVisible({ timeout: 10000 });
|
|
97
|
+
|
|
98
|
+
// All three should show peers in connected peers list
|
|
99
|
+
const peerList1 = page1.getByTestId('peerList');
|
|
100
|
+
const peerList2 = page2.getByTestId('peerList');
|
|
101
|
+
const peerList3 = page3.getByTestId('peerList');
|
|
102
|
+
|
|
103
|
+
await expect(peerList1).not.toContainText('No peers connected');
|
|
104
|
+
await expect(peerList2).not.toContainText('No peers connected');
|
|
105
|
+
await expect(peerList3).not.toContainText('No peers connected');
|
|
106
|
+
|
|
107
|
+
} finally {
|
|
108
|
+
await context1.close();
|
|
109
|
+
await context2.close();
|
|
110
|
+
await context3.close();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('should open P2P data channels between three peers', async ({ browser }) => {
|
|
115
|
+
const context1 = await browser.newContext();
|
|
116
|
+
const page1 = await context1.newPage();
|
|
117
|
+
|
|
118
|
+
const context2 = await browser.newContext();
|
|
119
|
+
const page2 = await context2.newPage();
|
|
120
|
+
|
|
121
|
+
const context3 = await browser.newContext();
|
|
122
|
+
const page3 = await context3.newPage();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Connect all three peers
|
|
126
|
+
await page1.goto(BASE_URL);
|
|
127
|
+
await page2.goto(BASE_URL);
|
|
128
|
+
await page3.goto(BASE_URL);
|
|
129
|
+
|
|
130
|
+
await page1.getByTestId('roomId').fill(ROOM_ID);
|
|
131
|
+
await page2.getByTestId('roomId').fill(ROOM_ID);
|
|
132
|
+
await page3.getByTestId('roomId').fill(ROOM_ID);
|
|
133
|
+
|
|
134
|
+
await page1.getByTestId('connectBtn').click();
|
|
135
|
+
await expect(page1.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
136
|
+
|
|
137
|
+
await page2.getByTestId('connectBtn').click();
|
|
138
|
+
await expect(page2.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
139
|
+
|
|
140
|
+
await page3.getByTestId('connectBtn').click();
|
|
141
|
+
await expect(page3.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
142
|
+
|
|
143
|
+
// Give peers time to discover each other (wait a bit before checking peer-joined)
|
|
144
|
+
await page1.waitForTimeout(500);
|
|
145
|
+
|
|
146
|
+
// Wait for peer-joined on all sides (use .last() to get most recent)
|
|
147
|
+
// For peer3, check if it has at least one peer-joined (it may not join from earlier connections)
|
|
148
|
+
await expect(page1.getByTestId('log-peer-joined').last()).toBeVisible({ timeout: 20000 });
|
|
149
|
+
await expect(page2.getByTestId('log-peer-joined').last()).toBeVisible({ timeout: 20000 });
|
|
150
|
+
|
|
151
|
+
// Peer3 might have peer-joined logs, but even if not, it should eventually see data channels
|
|
152
|
+
try {
|
|
153
|
+
await expect(page3.getByTestId('log-peer-joined').last()).toBeVisible({ timeout: 20000 });
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// It's OK if peer3 doesn't have peer-joined log initially, data channels will still establish
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Wait for data channels to open - wait for at least one data channel log on each peer with extended timeout
|
|
159
|
+
await expect(page1.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 25000 });
|
|
160
|
+
await expect(page2.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 25000 });
|
|
161
|
+
await expect(page3.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 25000 });
|
|
162
|
+
|
|
163
|
+
// Wait longer to ensure all data channels are fully established
|
|
164
|
+
await page1.waitForTimeout(2000);
|
|
165
|
+
|
|
166
|
+
// Check that we have at least 2 data channels (accumulation from parallel tests OK)
|
|
167
|
+
const dc1Count = await page1.getByTestId('logContainer').locator('[data-testid="log-data-channel"]').count();
|
|
168
|
+
const dc2Count = await page2.getByTestId('logContainer').locator('[data-testid="log-data-channel"]').count();
|
|
169
|
+
const dc3Count = await page3.getByTestId('logContainer').locator('[data-testid="log-data-channel"]').count();
|
|
170
|
+
|
|
171
|
+
expect(dc1Count).toBeGreaterThanOrEqual(2);
|
|
172
|
+
expect(dc2Count).toBeGreaterThanOrEqual(2);
|
|
173
|
+
expect(dc3Count).toBeGreaterThanOrEqual(2);
|
|
174
|
+
|
|
175
|
+
} finally {
|
|
176
|
+
await context1.close();
|
|
177
|
+
await context2.close();
|
|
178
|
+
await context3.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('should send P2P chat messages between three peers', async ({ browser }) => {
|
|
183
|
+
const context1 = await browser.newContext();
|
|
184
|
+
const page1 = await context1.newPage();
|
|
185
|
+
|
|
186
|
+
const context2 = await browser.newContext();
|
|
187
|
+
const page2 = await context2.newPage();
|
|
188
|
+
|
|
189
|
+
const context3 = await browser.newContext();
|
|
190
|
+
const page3 = await context3.newPage();
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// Connect all three peers
|
|
194
|
+
await page1.goto(BASE_URL);
|
|
195
|
+
await page2.goto(BASE_URL);
|
|
196
|
+
await page3.goto(BASE_URL);
|
|
197
|
+
|
|
198
|
+
await page1.getByTestId('roomId').fill(ROOM_ID);
|
|
199
|
+
await page2.getByTestId('roomId').fill(ROOM_ID);
|
|
200
|
+
await page3.getByTestId('roomId').fill(ROOM_ID);
|
|
201
|
+
|
|
202
|
+
await page1.getByTestId('connectBtn').click();
|
|
203
|
+
await expect(page1.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
204
|
+
|
|
205
|
+
await page2.getByTestId('connectBtn').click();
|
|
206
|
+
await expect(page2.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
207
|
+
|
|
208
|
+
await page3.getByTestId('connectBtn').click();
|
|
209
|
+
await expect(page3.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
210
|
+
|
|
211
|
+
// Wait for data channels to open on all peers first with extended timeout
|
|
212
|
+
await expect(page1.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 25000 });
|
|
213
|
+
await expect(page2.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 25000 });
|
|
214
|
+
await expect(page3.getByTestId('log-data-channel').first()).toBeVisible({ timeout: 25000 });
|
|
215
|
+
|
|
216
|
+
// Wait longer to ensure all data channels are fully established
|
|
217
|
+
await page1.waitForTimeout(2000);
|
|
218
|
+
|
|
219
|
+
// Check that we have at least 2 data channels
|
|
220
|
+
const chat1Count = await page1.getByTestId('logContainer').locator('[data-testid="log-data-channel"]').count();
|
|
221
|
+
const chat2Count = await page2.getByTestId('logContainer').locator('[data-testid="log-data-channel"]').count();
|
|
222
|
+
const chat3Count = await page3.getByTestId('logContainer').locator('[data-testid="log-data-channel"]').count();
|
|
223
|
+
|
|
224
|
+
expect(chat1Count).toBeGreaterThanOrEqual(2);
|
|
225
|
+
expect(chat2Count).toBeGreaterThanOrEqual(2);
|
|
226
|
+
expect(chat3Count).toBeGreaterThanOrEqual(2);
|
|
227
|
+
|
|
228
|
+
// Send message from peer 1
|
|
229
|
+
const testMessage = 'Hello from Peer 1! ' + Date.now();
|
|
230
|
+
await page1.getByTestId('chatMessage').fill(testMessage);
|
|
231
|
+
await page1.getByTestId('sendBtn').click();
|
|
232
|
+
|
|
233
|
+
// Wait for message to appear on all three with extended timeout
|
|
234
|
+
await expect(page1.locator(`text=${testMessage}`)).toBeVisible({ timeout: 15000 });
|
|
235
|
+
await expect(page2.locator(`text=${testMessage}`)).toBeVisible({ timeout: 15000 });
|
|
236
|
+
await expect(page3.locator(`text=${testMessage}`)).toBeVisible({ timeout: 15000 });
|
|
237
|
+
|
|
238
|
+
// Send message from peer 2
|
|
239
|
+
const testMessage2 = 'Response from Peer 2! ' + Date.now();
|
|
240
|
+
await page2.getByTestId('chatMessage').fill(testMessage2);
|
|
241
|
+
await page2.getByTestId('sendBtn').click();
|
|
242
|
+
|
|
243
|
+
// Wait for message to appear on all three
|
|
244
|
+
await expect(page1.locator(`text=${testMessage2}`)).toBeVisible({ timeout: 15000 });
|
|
245
|
+
await expect(page2.locator(`text=${testMessage2}`)).toBeVisible({ timeout: 15000 });
|
|
246
|
+
await expect(page3.locator(`text=${testMessage2}`)).toBeVisible({ timeout: 15000 });
|
|
247
|
+
|
|
248
|
+
// Send message from peer 3
|
|
249
|
+
const testMessage3 = 'Third message from Peer 3! ' + Date.now();
|
|
250
|
+
await page3.getByTestId('chatMessage').fill(testMessage3);
|
|
251
|
+
await page3.getByTestId('sendBtn').click();
|
|
252
|
+
|
|
253
|
+
// Wait for message to appear on all three
|
|
254
|
+
await expect(page1.locator(`text=${testMessage3}`)).toBeVisible({ timeout: 15000 });
|
|
255
|
+
await expect(page2.locator(`text=${testMessage3}`)).toBeVisible({ timeout: 15000 });
|
|
256
|
+
await expect(page3.locator(`text=${testMessage3}`)).toBeVisible({ timeout: 15000 });
|
|
257
|
+
|
|
258
|
+
// Wait for message to appear on all three
|
|
259
|
+
await expect(page1.locator(`text=${testMessage3}`)).toBeVisible({ timeout: 5000 });
|
|
260
|
+
await expect(page2.locator(`text=${testMessage3}`)).toBeVisible({ timeout: 5000 });
|
|
261
|
+
await expect(page3.locator(`text=${testMessage3}`)).toBeVisible({ timeout: 5000 });
|
|
262
|
+
|
|
263
|
+
} finally {
|
|
264
|
+
await context1.close();
|
|
265
|
+
await context2.close();
|
|
266
|
+
await context3.close();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test.describe('Session Isolation', () => {
|
|
272
|
+
test('should not connect peers from different sessions', async ({ browser }) => {
|
|
273
|
+
const context1 = await browser.newContext();
|
|
274
|
+
const page1 = await context1.newPage();
|
|
275
|
+
|
|
276
|
+
const context2 = await browser.newContext();
|
|
277
|
+
const page2 = await context2.newPage();
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
// Connect to different rooms
|
|
281
|
+
await page1.goto(BASE_URL);
|
|
282
|
+
await page2.goto(BASE_URL);
|
|
283
|
+
|
|
284
|
+
// Use different unique room IDs to ensure isolation
|
|
285
|
+
const roomA = `iso-a-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
286
|
+
const roomB = `iso-b-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
287
|
+
await page1.getByTestId('roomId').fill(roomA);
|
|
288
|
+
await page2.getByTestId('roomId').fill(roomB);
|
|
289
|
+
|
|
290
|
+
await page1.getByTestId('connectBtn').click();
|
|
291
|
+
await expect(page1.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
292
|
+
|
|
293
|
+
await page2.getByTestId('connectBtn').click();
|
|
294
|
+
await expect(page2.getByTestId('log-connected')).toBeVisible({ timeout: 10000 });
|
|
295
|
+
|
|
296
|
+
// Wait a bit to see if peer-joined appears (it shouldn't)
|
|
297
|
+
await page1.waitForTimeout(3000);
|
|
298
|
+
|
|
299
|
+
// Neither should see the other's peer-joined
|
|
300
|
+
const peerList1 = page1.getByTestId('peerList');
|
|
301
|
+
const peerList2 = page2.getByTestId('peerList');
|
|
302
|
+
|
|
303
|
+
await expect(peerList1).toContainText('No peers connected');
|
|
304
|
+
await expect(peerList2).toContainText('No peers connected');
|
|
305
|
+
|
|
306
|
+
} finally {
|
|
307
|
+
await context1.close();
|
|
308
|
+
await context2.close();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test.describe('Error Handling', () => {
|
|
314
|
+
test('should show error when server URL is empty', async ({ page }) => {
|
|
315
|
+
await page.goto(BASE_URL);
|
|
316
|
+
|
|
317
|
+
await page.getByTestId('serverUrl').fill('');
|
|
318
|
+
await page.getByTestId('connectBtn').click();
|
|
319
|
+
|
|
320
|
+
await expect(page.locator('text=Please enter a server URL')).toBeVisible({ timeout: 5000 });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('should show error when room ID is empty', async ({ page }) => {
|
|
324
|
+
await page.goto(BASE_URL);
|
|
325
|
+
|
|
326
|
+
await page.getByTestId('roomId').fill('');
|
|
327
|
+
await page.getByTestId('connectBtn').click();
|
|
328
|
+
|
|
329
|
+
await expect(page.locator('text=Please enter a room ID')).toBeVisible({ timeout: 5000 });
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
package/vite.config.js
ADDED
package/wrangler.toml
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
name = "uniwrtc"
|
|
2
2
|
main = "src/index.js"
|
|
3
3
|
compatibility_date = "2024-12-20"
|
|
4
|
+
workers_dev = false
|
|
4
5
|
|
|
5
6
|
[[migrations]]
|
|
6
7
|
tag = "v1"
|
|
@@ -19,5 +20,13 @@ routes = [
|
|
|
19
20
|
name = "ROOMS"
|
|
20
21
|
class_name = "Room"
|
|
21
22
|
|
|
23
|
+
[env.production.assets]
|
|
24
|
+
binding = "ASSETS"
|
|
25
|
+
directory = "dist"
|
|
26
|
+
|
|
22
27
|
[build]
|
|
23
|
-
command = "npm
|
|
28
|
+
command = "npm run build"
|
|
29
|
+
|
|
30
|
+
[build.upload]
|
|
31
|
+
format = "modules"
|
|
32
|
+
directory = "dist"
|
package/test.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Simple test script for UniWRTC
|
|
5
|
-
* Runs two clients and tests peer connections
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const { UniWRTCClient } = require('./client.js');
|
|
9
|
-
|
|
10
|
-
async function sleep(ms) {
|
|
11
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function runTest() {
|
|
15
|
-
console.log('π Starting UniWRTC Test...\n');
|
|
16
|
-
|
|
17
|
-
// Create two clients
|
|
18
|
-
const client1 = new UniWRTCClient('ws://localhost:8080');
|
|
19
|
-
const client2 = new UniWRTCClient('ws://localhost:8080');
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
// Connect first client
|
|
23
|
-
console.log('π± Connecting Client 1...');
|
|
24
|
-
const id1 = await client1.connect();
|
|
25
|
-
console.log(`β
Client 1 connected with ID: ${id1}\n`);
|
|
26
|
-
|
|
27
|
-
await sleep(500);
|
|
28
|
-
|
|
29
|
-
// Connect second client
|
|
30
|
-
console.log('π± Connecting Client 2...');
|
|
31
|
-
const id2 = await client2.connect();
|
|
32
|
-
console.log(`β
Client 2 connected with ID: ${id2}\n`);
|
|
33
|
-
|
|
34
|
-
await sleep(500);
|
|
35
|
-
|
|
36
|
-
// Join same room
|
|
37
|
-
console.log('π Client 1 joining room: test-room');
|
|
38
|
-
client1.joinRoom('test-room');
|
|
39
|
-
|
|
40
|
-
await sleep(500);
|
|
41
|
-
|
|
42
|
-
console.log('π Client 2 joining room: test-room');
|
|
43
|
-
client2.joinRoom('test-room');
|
|
44
|
-
|
|
45
|
-
await sleep(1000);
|
|
46
|
-
|
|
47
|
-
console.log('\n⨠Test complete! Both clients joined the room.');
|
|
48
|
-
console.log('π Peers in Client 1:', Array.from(client1.peers.keys()));
|
|
49
|
-
console.log('π Peers in Client 2:', Array.from(client2.peers.keys()));
|
|
50
|
-
|
|
51
|
-
// Cleanup
|
|
52
|
-
client1.disconnect();
|
|
53
|
-
client2.disconnect();
|
|
54
|
-
|
|
55
|
-
process.exit(0);
|
|
56
|
-
} catch (error) {
|
|
57
|
-
console.error('β Test failed:', error.message);
|
|
58
|
-
process.exit(1);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
runTest();
|