uniwrtc 1.0.1 → 1.0.3
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 +41 -40
- package/client-browser.js +31 -24
- package/client.js +19 -17
- package/demo.html +52 -28
- package/package.json +1 -1
- package/server.js +45 -41
- package/src/client-cloudflare.js +17 -15
- package/src/room.js +10 -14
package/README.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A universal WebRTC signaling service that provides a simple and flexible WebSocket-based signaling server for WebRTC applications.
|
|
4
4
|
|
|
5
|
+
Available on npm: https://www.npmjs.com/package/uniwrtc
|
|
6
|
+
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
9
|
- 🚀 **Simple WebSocket-based signaling** - Easy to integrate with any WebRTC application
|
|
8
|
-
- 🏠 **
|
|
10
|
+
- 🏠 **Session-based architecture** - Support for multiple sessions with isolated peer groups
|
|
9
11
|
- 🔌 **Flexible client library** - Ready-to-use JavaScript client for browser and Node.js
|
|
10
12
|
- 📡 **Real-time messaging** - Efficient message routing between peers
|
|
11
13
|
- 🔄 **Auto-reconnection** - Built-in reconnection logic for reliable connections
|
|
@@ -56,9 +58,9 @@ Open `demo.html` in your web browser to try the interactive demo:
|
|
|
56
58
|
1. Start the server with `npm start` (local signaling at `ws://localhost:8080`), **or** use the deployed Workers endpoint `wss://signal.peer.ooo`.
|
|
57
59
|
2. Open `demo.html` in your browser.
|
|
58
60
|
3. Click "Connect" to connect to the signaling server.
|
|
59
|
-
4. Enter a
|
|
61
|
+
4. Enter a session ID and click "Join Session".
|
|
60
62
|
5. Open another browser window/tab with the same demo page.
|
|
61
|
-
6. Join the same
|
|
63
|
+
6. Join the same session to see peer connections in action and P2P data channels open.
|
|
62
64
|
|
|
63
65
|
## Usage
|
|
64
66
|
|
|
@@ -68,19 +70,19 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
68
70
|
|
|
69
71
|
#### Client → Server Messages
|
|
70
72
|
|
|
71
|
-
**Join a
|
|
73
|
+
**Join a session:**
|
|
72
74
|
```json
|
|
73
75
|
{
|
|
74
76
|
"type": "join",
|
|
75
|
-
"
|
|
77
|
+
"sessionId": "session-123"
|
|
76
78
|
}
|
|
77
79
|
```
|
|
78
80
|
|
|
79
|
-
**Leave a
|
|
81
|
+
**Leave a session:**
|
|
80
82
|
```json
|
|
81
83
|
{
|
|
82
84
|
"type": "leave",
|
|
83
|
-
"
|
|
85
|
+
"sessionId": "session-123"
|
|
84
86
|
}
|
|
85
87
|
```
|
|
86
88
|
|
|
@@ -90,7 +92,7 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
90
92
|
"type": "offer",
|
|
91
93
|
"offer": { /* RTCSessionDescription */ },
|
|
92
94
|
"targetId": "peer-client-id",
|
|
93
|
-
"
|
|
95
|
+
"sessionId": "session-123"
|
|
94
96
|
}
|
|
95
97
|
```
|
|
96
98
|
|
|
@@ -100,7 +102,7 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
100
102
|
"type": "answer",
|
|
101
103
|
"answer": { /* RTCSessionDescription */ },
|
|
102
104
|
"targetId": "peer-client-id",
|
|
103
|
-
"
|
|
105
|
+
"sessionId": "session-123"
|
|
104
106
|
}
|
|
105
107
|
```
|
|
106
108
|
|
|
@@ -110,7 +112,7 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
110
112
|
"type": "ice-candidate",
|
|
111
113
|
"candidate": { /* RTCIceCandidate */ },
|
|
112
114
|
"targetId": "peer-client-id",
|
|
113
|
-
"
|
|
115
|
+
"sessionId": "session-123"
|
|
114
116
|
}
|
|
115
117
|
```
|
|
116
118
|
|
|
@@ -132,11 +134,11 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
132
134
|
}
|
|
133
135
|
```
|
|
134
136
|
|
|
135
|
-
**
|
|
137
|
+
**Session joined confirmation:**
|
|
136
138
|
```json
|
|
137
139
|
{
|
|
138
140
|
"type": "joined",
|
|
139
|
-
"
|
|
141
|
+
"sessionId": "session-123",
|
|
140
142
|
"clientId": "abc123",
|
|
141
143
|
"clients": ["xyz789", "def456"]
|
|
142
144
|
}
|
|
@@ -146,8 +148,8 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
146
148
|
```json
|
|
147
149
|
{
|
|
148
150
|
"type": "peer-joined",
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
+
"sessionId": "session-123",
|
|
152
|
+
"peerId": "new-peer-id"
|
|
151
153
|
}
|
|
152
154
|
```
|
|
153
155
|
|
|
@@ -155,8 +157,8 @@ The signaling server accepts WebSocket connections and supports the following me
|
|
|
155
157
|
```json
|
|
156
158
|
{
|
|
157
159
|
"type": "peer-left",
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
+
"sessionId": "session-123",
|
|
161
|
+
"peerId": "departed-peer-id"
|
|
160
162
|
}
|
|
161
163
|
```
|
|
162
164
|
|
|
@@ -166,9 +168,8 @@ Use directly from npm:
|
|
|
166
168
|
```javascript
|
|
167
169
|
// ESM
|
|
168
170
|
import { UniWRTCClient } from 'uniwrtc/client-browser.js';
|
|
169
|
-
// or CommonJS
|
|
170
|
-
const { UniWRTCClient } = require('uniwrtc/client
|
|
171
|
-
// For Node.js signaling client use 'uniwrtc/client.js'
|
|
171
|
+
// or CommonJS (Node)
|
|
172
|
+
const { UniWRTCClient } = require('uniwrtc/client.js');
|
|
172
173
|
```
|
|
173
174
|
|
|
174
175
|
The `client.js` library provides a convenient wrapper for the signaling protocol:
|
|
@@ -183,27 +184,27 @@ client.on('connected', (data) => {
|
|
|
183
184
|
});
|
|
184
185
|
|
|
185
186
|
client.on('joined', (data) => {
|
|
186
|
-
console.log('Joined
|
|
187
|
+
console.log('Joined session:', data.sessionId);
|
|
187
188
|
console.log('Existing peers:', data.clients);
|
|
188
189
|
});
|
|
189
190
|
|
|
190
191
|
client.on('peer-joined', (data) => {
|
|
191
|
-
console.log('New peer joined:', data.peerId
|
|
192
|
+
console.log('New peer joined:', data.peerId);
|
|
192
193
|
// Initiate WebRTC connection with new peer
|
|
193
194
|
});
|
|
194
195
|
|
|
195
196
|
client.on('offer', (data) => {
|
|
196
|
-
console.log('Received offer from:', data.peerId
|
|
197
|
+
console.log('Received offer from:', data.peerId);
|
|
197
198
|
// Handle WebRTC offer
|
|
198
199
|
});
|
|
199
200
|
|
|
200
201
|
client.on('answer', (data) => {
|
|
201
|
-
console.log('Received answer from:', data.peerId
|
|
202
|
+
console.log('Received answer from:', data.peerId);
|
|
202
203
|
// Handle WebRTC answer
|
|
203
204
|
});
|
|
204
205
|
|
|
205
206
|
client.on('ice-candidate', (data) => {
|
|
206
|
-
console.log('Received ICE candidate from:', data.peerId
|
|
207
|
+
console.log('Received ICE candidate from:', data.peerId);
|
|
207
208
|
// Add ICE candidate to peer connection
|
|
208
209
|
});
|
|
209
210
|
|
|
@@ -253,7 +254,7 @@ function createPeerConnection(peerId) {
|
|
|
253
254
|
|
|
254
255
|
// Handle new peer
|
|
255
256
|
client.on('peer-joined', async (data) => {
|
|
256
|
-
const pc = createPeerConnection(data.
|
|
257
|
+
const pc = createPeerConnection(data.peerId);
|
|
257
258
|
|
|
258
259
|
// Add local tracks
|
|
259
260
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
@@ -265,22 +266,22 @@ client.on('peer-joined', async (data) => {
|
|
|
265
266
|
// Create and send offer
|
|
266
267
|
const offer = await pc.createOffer();
|
|
267
268
|
await pc.setLocalDescription(offer);
|
|
268
|
-
client.sendOffer(offer, data.
|
|
269
|
+
client.sendOffer(offer, data.peerId);
|
|
269
270
|
});
|
|
270
271
|
|
|
271
272
|
// Handle incoming offer
|
|
272
273
|
client.on('offer', async (data) => {
|
|
273
|
-
const pc = createPeerConnection(data.
|
|
274
|
+
const pc = createPeerConnection(data.peerId);
|
|
274
275
|
|
|
275
276
|
await pc.setRemoteDescription(data.offer);
|
|
276
277
|
const answer = await pc.createAnswer();
|
|
277
278
|
await pc.setLocalDescription(answer);
|
|
278
|
-
client.sendAnswer(answer, data.
|
|
279
|
+
client.sendAnswer(answer, data.peerId);
|
|
279
280
|
});
|
|
280
281
|
|
|
281
282
|
// Handle incoming answer
|
|
282
283
|
client.on('answer', async (data) => {
|
|
283
|
-
const pc = peerConnections.get(data.
|
|
284
|
+
const pc = peerConnections.get(data.peerId);
|
|
284
285
|
if (pc) {
|
|
285
286
|
await pc.setRemoteDescription(data.answer);
|
|
286
287
|
}
|
|
@@ -288,15 +289,15 @@ client.on('answer', async (data) => {
|
|
|
288
289
|
|
|
289
290
|
// Handle ICE candidates
|
|
290
291
|
client.on('ice-candidate', async (data) => {
|
|
291
|
-
const pc = peerConnections.get(data.
|
|
292
|
+
const pc = peerConnections.get(data.peerId);
|
|
292
293
|
if (pc) {
|
|
293
294
|
await pc.addIceCandidate(data.candidate);
|
|
294
295
|
}
|
|
295
296
|
});
|
|
296
297
|
|
|
297
|
-
// Connect and join
|
|
298
|
+
// Connect and join session
|
|
298
299
|
await client.connect();
|
|
299
|
-
client.
|
|
300
|
+
client.joinSession('my-video-session');
|
|
300
301
|
```
|
|
301
302
|
|
|
302
303
|
## API Reference
|
|
@@ -318,8 +319,8 @@ new UniWRTCClient(serverUrl, options)
|
|
|
318
319
|
|
|
319
320
|
- `connect()`: Connect to the signaling server (returns Promise)
|
|
320
321
|
- `disconnect()`: Disconnect from the server
|
|
321
|
-
- `
|
|
322
|
-
- `
|
|
322
|
+
- `joinSession(sessionId)`: Join a specific session
|
|
323
|
+
- `leaveSession()`: Leave the current session
|
|
323
324
|
- `sendOffer(offer, targetId)`: Send a WebRTC offer
|
|
324
325
|
- `sendAnswer(answer, targetId)`: Send a WebRTC answer
|
|
325
326
|
- `sendIceCandidate(candidate, targetId)`: Send an ICE candidate
|
|
@@ -342,7 +343,7 @@ new UniWRTCClient(serverUrl, options)
|
|
|
342
343
|
|
|
343
344
|
## Health Check
|
|
344
345
|
|
|
345
|
-
The server provides an HTTP health check endpoint:
|
|
346
|
+
The server provides an HTTP health check endpoint for monitoring:
|
|
346
347
|
|
|
347
348
|
```bash
|
|
348
349
|
curl http://localhost:8080/health
|
|
@@ -358,12 +359,12 @@ Response:
|
|
|
358
359
|
|
|
359
360
|
## Architecture
|
|
360
361
|
|
|
361
|
-
###
|
|
362
|
+
### Session Management
|
|
362
363
|
|
|
363
|
-
- Each
|
|
364
|
-
- Clients can join/leave
|
|
365
|
-
- Messages can be sent to specific peers or broadcast to all peers in a
|
|
366
|
-
- Empty
|
|
364
|
+
- Each session is identified by a unique session ID (string)
|
|
365
|
+
- Clients can join/leave sessions dynamically
|
|
366
|
+
- Messages can be sent to specific peers or broadcast to all peers in a session
|
|
367
|
+
- Empty sessions are automatically cleaned up
|
|
367
368
|
|
|
368
369
|
### Message Flow
|
|
369
370
|
|
package/client-browser.js
CHANGED
|
@@ -8,7 +8,7 @@ class UniWRTCClient {
|
|
|
8
8
|
this.serverUrl = serverUrl;
|
|
9
9
|
this.ws = null;
|
|
10
10
|
this.clientId = null;
|
|
11
|
-
this.
|
|
11
|
+
this.sessionId = null;
|
|
12
12
|
this.peers = new Map();
|
|
13
13
|
this._connectedOnce = false;
|
|
14
14
|
this.options = {
|
|
@@ -93,24 +93,26 @@ class UniWRTCClient {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
// Prevent duplicate join calls for the same
|
|
98
|
-
if (this.
|
|
99
|
-
this.
|
|
96
|
+
joinSession(sessionId) {
|
|
97
|
+
// Prevent duplicate join calls for the same session
|
|
98
|
+
if (this.sessionId === sessionId) return;
|
|
99
|
+
this.sessionId = sessionId;
|
|
100
|
+
|
|
101
|
+
// Send join message
|
|
100
102
|
this.send({
|
|
101
103
|
type: 'join',
|
|
102
|
-
|
|
104
|
+
sessionId: sessionId,
|
|
103
105
|
peerId: this.clientId
|
|
104
106
|
});
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
if (this.
|
|
109
|
+
leaveSession() {
|
|
110
|
+
if (this.sessionId) {
|
|
109
111
|
this.send({
|
|
110
112
|
type: 'leave',
|
|
111
|
-
|
|
113
|
+
sessionId: this.sessionId
|
|
112
114
|
});
|
|
113
|
-
this.
|
|
115
|
+
this.sessionId = null;
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -122,30 +124,30 @@ class UniWRTCClient {
|
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
sendOffer(
|
|
127
|
+
sendOffer(offer, targetId) {
|
|
126
128
|
this.send({
|
|
127
129
|
type: 'offer',
|
|
128
130
|
offer: offer,
|
|
129
131
|
targetId: targetId,
|
|
130
|
-
|
|
132
|
+
sessionId: this.sessionId
|
|
131
133
|
});
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
sendAnswer(
|
|
136
|
+
sendAnswer(answer, targetId) {
|
|
135
137
|
this.send({
|
|
136
138
|
type: 'answer',
|
|
137
139
|
answer: answer,
|
|
138
140
|
targetId: targetId,
|
|
139
|
-
|
|
141
|
+
sessionId: this.sessionId
|
|
140
142
|
});
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
sendIceCandidate(
|
|
145
|
+
sendIceCandidate(candidate, targetId) {
|
|
144
146
|
this.send({
|
|
145
147
|
type: 'ice-candidate',
|
|
146
148
|
candidate: candidate,
|
|
147
149
|
targetId: targetId,
|
|
148
|
-
|
|
150
|
+
sessionId: this.sessionId
|
|
149
151
|
});
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -187,9 +189,9 @@ class UniWRTCClient {
|
|
|
187
189
|
console.log('[UniWRTC] If this helps, consider donating ❤️ → https://coff.ee/draederg');
|
|
188
190
|
break;
|
|
189
191
|
case 'joined':
|
|
190
|
-
this.
|
|
192
|
+
this.sessionId = message.sessionId;
|
|
191
193
|
this.emit('joined', {
|
|
192
|
-
|
|
194
|
+
sessionId: message.sessionId,
|
|
193
195
|
peerId: message.peerId,
|
|
194
196
|
clientId: message.clientId,
|
|
195
197
|
clients: message.clients
|
|
@@ -197,14 +199,14 @@ class UniWRTCClient {
|
|
|
197
199
|
break;
|
|
198
200
|
case 'peer-joined':
|
|
199
201
|
this.emit('peer-joined', {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
sessionId: message.sessionId,
|
|
203
|
+
peerId: message.peerId
|
|
202
204
|
});
|
|
203
205
|
break;
|
|
204
206
|
case 'peer-left':
|
|
205
207
|
this.emit('peer-left', {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
sessionId: message.sessionId,
|
|
209
|
+
peerId: message.peerId
|
|
208
210
|
});
|
|
209
211
|
break;
|
|
210
212
|
case 'offer':
|
|
@@ -238,8 +240,8 @@ class UniWRTCClient {
|
|
|
238
240
|
case 'chat':
|
|
239
241
|
this.emit('chat', {
|
|
240
242
|
text: message.text,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
+
peerId: message.peerId,
|
|
244
|
+
sessionId: message.sessionId
|
|
243
245
|
});
|
|
244
246
|
break;
|
|
245
247
|
default:
|
|
@@ -247,3 +249,8 @@ class UniWRTCClient {
|
|
|
247
249
|
}
|
|
248
250
|
}
|
|
249
251
|
}
|
|
252
|
+
|
|
253
|
+
// Attach to window for non-module script usage
|
|
254
|
+
if (typeof window !== 'undefined') {
|
|
255
|
+
window.UniWRTCClient = UniWRTCClient;
|
|
256
|
+
}
|
package/client.js
CHANGED
|
@@ -8,7 +8,7 @@ class UniWRTCClient {
|
|
|
8
8
|
this.serverUrl = serverUrl;
|
|
9
9
|
this.ws = null;
|
|
10
10
|
this.clientId = null;
|
|
11
|
-
this.
|
|
11
|
+
this.sessionId = null;
|
|
12
12
|
this.peers = new Map();
|
|
13
13
|
this.options = {
|
|
14
14
|
autoReconnect: true,
|
|
@@ -85,21 +85,21 @@ class UniWRTCClient {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
this.
|
|
88
|
+
joinSession(sessionId) {
|
|
89
|
+
this.sessionId = sessionId;
|
|
90
90
|
this.send({
|
|
91
91
|
type: 'join',
|
|
92
|
-
|
|
92
|
+
sessionId: sessionId
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
if (this.
|
|
96
|
+
leaveSession() {
|
|
97
|
+
if (this.sessionId) {
|
|
98
98
|
this.send({
|
|
99
99
|
type: 'leave',
|
|
100
|
-
|
|
100
|
+
sessionId: this.sessionId
|
|
101
101
|
});
|
|
102
|
-
this.
|
|
102
|
+
this.sessionId = null;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -108,7 +108,7 @@ class UniWRTCClient {
|
|
|
108
108
|
type: 'offer',
|
|
109
109
|
offer: offer,
|
|
110
110
|
targetId: targetId,
|
|
111
|
-
|
|
111
|
+
sessionId: this.sessionId
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -117,7 +117,7 @@ class UniWRTCClient {
|
|
|
117
117
|
type: 'answer',
|
|
118
118
|
answer: answer,
|
|
119
119
|
targetId: targetId,
|
|
120
|
-
|
|
120
|
+
sessionId: this.sessionId
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
|
|
@@ -126,7 +126,7 @@ class UniWRTCClient {
|
|
|
126
126
|
type: 'ice-candidate',
|
|
127
127
|
candidate: candidate,
|
|
128
128
|
targetId: targetId,
|
|
129
|
-
|
|
129
|
+
sessionId: this.sessionId
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -152,36 +152,38 @@ class UniWRTCClient {
|
|
|
152
152
|
break;
|
|
153
153
|
case 'joined':
|
|
154
154
|
this.emit('joined', {
|
|
155
|
-
|
|
155
|
+
sessionId: message.sessionId,
|
|
156
156
|
clientId: message.clientId,
|
|
157
157
|
clients: message.clients
|
|
158
158
|
});
|
|
159
159
|
break;
|
|
160
160
|
case 'peer-joined':
|
|
161
161
|
this.emit('peer-joined', {
|
|
162
|
-
|
|
162
|
+
sessionId: message.sessionId,
|
|
163
|
+
peerId: message.peerId
|
|
163
164
|
});
|
|
164
165
|
break;
|
|
165
166
|
case 'peer-left':
|
|
166
167
|
this.emit('peer-left', {
|
|
167
|
-
|
|
168
|
+
sessionId: message.sessionId,
|
|
169
|
+
peerId: message.peerId
|
|
168
170
|
});
|
|
169
171
|
break;
|
|
170
172
|
case 'offer':
|
|
171
173
|
this.emit('offer', {
|
|
172
|
-
|
|
174
|
+
peerId: message.peerId,
|
|
173
175
|
offer: message.offer
|
|
174
176
|
});
|
|
175
177
|
break;
|
|
176
178
|
case 'answer':
|
|
177
179
|
this.emit('answer', {
|
|
178
|
-
|
|
180
|
+
peerId: message.peerId,
|
|
179
181
|
answer: message.answer
|
|
180
182
|
});
|
|
181
183
|
break;
|
|
182
184
|
case 'ice-candidate':
|
|
183
185
|
this.emit('ice-candidate', {
|
|
184
|
-
|
|
186
|
+
peerId: message.peerId,
|
|
185
187
|
candidate: message.candidate
|
|
186
188
|
});
|
|
187
189
|
break;
|
package/demo.html
CHANGED
|
@@ -282,11 +282,11 @@
|
|
|
282
282
|
</div>
|
|
283
283
|
|
|
284
284
|
<div class="card">
|
|
285
|
-
<h2>Room
|
|
285
|
+
<h2>Room / Session</h2>
|
|
286
286
|
|
|
287
287
|
<div class="connection-controls" style="margin-bottom: 15px;">
|
|
288
|
-
<input type="text" id="roomId" placeholder="Enter room ID" value="demo-room">
|
|
289
|
-
<button class="btn-primary" id="joinBtn" onclick="
|
|
288
|
+
<input type="text" id="roomId" placeholder="Enter room/session ID" value="demo-room">
|
|
289
|
+
<button class="btn-primary" id="joinBtn" onclick="joinSession()" disabled>Join</button>
|
|
290
290
|
</div>
|
|
291
291
|
|
|
292
292
|
<div id="roomInfo">
|
|
@@ -430,18 +430,26 @@
|
|
|
430
430
|
log('Connecting to signaling server...', 'info');
|
|
431
431
|
|
|
432
432
|
const customPeerId = document.getElementById('customPeerId').value.trim();
|
|
433
|
-
|
|
433
|
+
|
|
434
|
+
// For Cloudflare (signal.peer.ooo), append room param to URL
|
|
435
|
+
let finalUrl = serverUrl;
|
|
436
|
+
const roomId = document.getElementById('roomId').value;
|
|
437
|
+
if (serverUrl.includes('signal.peer.ooo') && roomId) {
|
|
438
|
+
finalUrl = serverUrl + (serverUrl.includes('?') ? '&' : '?') + `room=${roomId}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
client = new UniWRTCClient(finalUrl, { customPeerId: customPeerId || null });
|
|
434
442
|
|
|
435
443
|
client.on('connected', (data) => {
|
|
436
444
|
log(`Connected with ID: ${data.clientId}`, 'success');
|
|
437
445
|
updateStatus(true);
|
|
438
446
|
|
|
439
|
-
// Auto-join
|
|
447
|
+
// Auto-join session after connecting
|
|
440
448
|
const roomId = document.getElementById('roomId').value;
|
|
441
449
|
if (roomId) {
|
|
442
450
|
setTimeout(() => {
|
|
443
|
-
log(`Auto-joining
|
|
444
|
-
client.
|
|
451
|
+
log(`Auto-joining session: ${roomId}...`, 'info');
|
|
452
|
+
client.joinSession(roomId);
|
|
445
453
|
}, 500);
|
|
446
454
|
}
|
|
447
455
|
});
|
|
@@ -455,15 +463,21 @@
|
|
|
455
463
|
let knownPeers = [];
|
|
456
464
|
|
|
457
465
|
client.on('joined', (data) => {
|
|
458
|
-
log(`Joined
|
|
466
|
+
log(`Joined session: ${data.sessionId}`, 'success');
|
|
459
467
|
knownPeers = data.clients || [];
|
|
460
468
|
updatePeersList(knownPeers);
|
|
461
469
|
|
|
462
|
-
// Update
|
|
463
|
-
updateRoomInfo(data.
|
|
470
|
+
// Update session info with dynamic peer count
|
|
471
|
+
updateRoomInfo(data.sessionId, client.clientId);
|
|
464
472
|
});
|
|
465
473
|
|
|
466
474
|
client.on('peer-joined', (data) => {
|
|
475
|
+
// Only connect to peers in the same session (if we have one set)
|
|
476
|
+
if (client.sessionId && data.sessionId !== client.sessionId) {
|
|
477
|
+
log(`Ignoring peer from different session: ${data.peerId}`, 'warning');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
467
481
|
log(`Peer joined: ${data.peerId}`, 'success');
|
|
468
482
|
// Add to known peers if not already there
|
|
469
483
|
if (data.peerId && !knownPeers.includes(data.peerId)) {
|
|
@@ -471,8 +485,8 @@
|
|
|
471
485
|
updatePeersList(knownPeers);
|
|
472
486
|
}
|
|
473
487
|
|
|
474
|
-
// Update
|
|
475
|
-
updateRoomInfo(
|
|
488
|
+
// Update session info with new peer count
|
|
489
|
+
updateRoomInfo(data.sessionId, client.clientId);
|
|
476
490
|
|
|
477
491
|
// Auto-initiate P2P connection
|
|
478
492
|
setTimeout(() => {
|
|
@@ -481,14 +495,19 @@
|
|
|
481
495
|
});
|
|
482
496
|
|
|
483
497
|
client.on('peer-left', (data) => {
|
|
484
|
-
|
|
498
|
+
// Only process if from current session (if we have one set)
|
|
499
|
+
if (client.sessionId && data.sessionId !== client.sessionId) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const peerId = data.peerId;
|
|
485
504
|
log(`Peer left: ${peerId}`, 'warning');
|
|
486
505
|
// Remove from known peers
|
|
487
506
|
knownPeers = knownPeers.filter(id => id !== peerId);
|
|
488
507
|
updatePeersList(knownPeers);
|
|
489
508
|
|
|
490
|
-
// Update
|
|
491
|
-
updateRoomInfo(
|
|
509
|
+
// Update session info with new peer count
|
|
510
|
+
updateRoomInfo(data.sessionId, client.clientId);
|
|
492
511
|
|
|
493
512
|
// Close peer connection
|
|
494
513
|
const pc = peerConnections.get(peerId);
|
|
@@ -511,7 +530,7 @@
|
|
|
511
530
|
await pc.setLocalDescription(answer);
|
|
512
531
|
|
|
513
532
|
// Send answer back
|
|
514
|
-
client.sendAnswer(data.peerId
|
|
533
|
+
client.sendAnswer(answer, data.peerId);
|
|
515
534
|
} catch (e) {
|
|
516
535
|
log(`Offer error: ${e.message}`, 'error');
|
|
517
536
|
}
|
|
@@ -552,8 +571,8 @@
|
|
|
552
571
|
});
|
|
553
572
|
|
|
554
573
|
client.on('chat', (data) => {
|
|
555
|
-
displayChatMessage(data.text, `${data.
|
|
556
|
-
log(`Chat from ${data.
|
|
574
|
+
displayChatMessage(data.text, `${data.peerId.substring(0, 6)}...`, false);
|
|
575
|
+
log(`Chat from ${data.peerId}: ${data.text}`, 'info');
|
|
557
576
|
});
|
|
558
577
|
|
|
559
578
|
await client.connect();
|
|
@@ -580,8 +599,8 @@
|
|
|
580
599
|
return;
|
|
581
600
|
}
|
|
582
601
|
|
|
583
|
-
if (!client || !client.
|
|
584
|
-
log('Not connected to a
|
|
602
|
+
if (!client || !client.sessionId) {
|
|
603
|
+
log('Not connected to a session', 'error');
|
|
585
604
|
return;
|
|
586
605
|
}
|
|
587
606
|
|
|
@@ -590,10 +609,10 @@
|
|
|
590
609
|
document.getElementById('manualPeerId').value = '';
|
|
591
610
|
}
|
|
592
611
|
|
|
593
|
-
function
|
|
594
|
-
const
|
|
612
|
+
function joinSession() {
|
|
613
|
+
const sessionId = document.getElementById('roomId').value;
|
|
595
614
|
|
|
596
|
-
if (!
|
|
615
|
+
if (!sessionId) {
|
|
597
616
|
log('Please enter a room ID', 'error');
|
|
598
617
|
return;
|
|
599
618
|
}
|
|
@@ -603,8 +622,8 @@
|
|
|
603
622
|
return;
|
|
604
623
|
}
|
|
605
624
|
|
|
606
|
-
log(`Joining
|
|
607
|
-
client.
|
|
625
|
+
log(`Joining session: ${sessionId}...`, 'info');
|
|
626
|
+
client.joinSession(sessionId);
|
|
608
627
|
}
|
|
609
628
|
|
|
610
629
|
function listRooms() {
|
|
@@ -619,12 +638,15 @@
|
|
|
619
638
|
|
|
620
639
|
async function createPeerConnection(peerId, initiator = false) {
|
|
621
640
|
if (peerConnections.has(peerId)) {
|
|
641
|
+
log(`Peer connection already exists for ${peerId.substring(0, 6)}`, 'info');
|
|
622
642
|
return peerConnections.get(peerId);
|
|
623
643
|
}
|
|
624
644
|
|
|
625
645
|
// Determine who should initiate based on peer IDs (lexicographic comparison)
|
|
626
646
|
// Only the peer with smaller ID creates the offer
|
|
627
647
|
const shouldInitiate = client.clientId < peerId;
|
|
648
|
+
|
|
649
|
+
log(`Creating peer connection with ${peerId.substring(0, 6)} (shouldInitiate: ${shouldInitiate})`, 'info');
|
|
628
650
|
|
|
629
651
|
const pc = new RTCPeerConnection({
|
|
630
652
|
iceServers: [
|
|
@@ -635,11 +657,12 @@
|
|
|
635
657
|
pc.onicecandidate = (event) => {
|
|
636
658
|
if (event.candidate) {
|
|
637
659
|
log(`Sending ICE candidate to ${peerId.substring(0, 6)}...`, 'info');
|
|
638
|
-
client.sendIceCandidate(
|
|
660
|
+
client.sendIceCandidate(event.candidate, peerId);
|
|
639
661
|
}
|
|
640
662
|
};
|
|
641
663
|
|
|
642
664
|
pc.ondatachannel = (event) => {
|
|
665
|
+
log(`Received data channel from ${peerId.substring(0, 6)}`, 'info');
|
|
643
666
|
setupDataChannel(peerId, event.channel);
|
|
644
667
|
};
|
|
645
668
|
|
|
@@ -649,8 +672,10 @@
|
|
|
649
672
|
|
|
650
673
|
const offer = await pc.createOffer();
|
|
651
674
|
await pc.setLocalDescription(offer);
|
|
652
|
-
client.sendOffer(
|
|
675
|
+
client.sendOffer(offer, peerId);
|
|
653
676
|
log(`Sent offer to ${peerId.substring(0, 6)}...`, 'success');
|
|
677
|
+
} else {
|
|
678
|
+
log(`Waiting for offer from ${peerId.substring(0, 6)}...`, 'info');
|
|
654
679
|
}
|
|
655
680
|
|
|
656
681
|
peerConnections.set(peerId, pc);
|
|
@@ -660,7 +685,6 @@
|
|
|
660
685
|
function setupDataChannel(peerId, dataChannel) {
|
|
661
686
|
dataChannel.onopen = () => {
|
|
662
687
|
log(`Data channel open with ${peerId.substring(0, 6)}...`, 'success');
|
|
663
|
-
dataChannels.set(peerId, dataChannel);
|
|
664
688
|
};
|
|
665
689
|
|
|
666
690
|
dataChannel.onmessage = (event) => {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -54,9 +54,12 @@ function log(message, data = '') {
|
|
|
54
54
|
console.log(`[${timestamp}] ${message}`, data);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function broadcastToRoom(
|
|
58
|
-
const room = rooms.get(
|
|
59
|
-
if (!room)
|
|
57
|
+
function broadcastToRoom(sessionId, message, excludeClient = null) {
|
|
58
|
+
const room = rooms.get(sessionId);
|
|
59
|
+
if (!room) {
|
|
60
|
+
log(`WARNING: Attempted to broadcast to non-existent session: ${sessionId}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
60
63
|
|
|
61
64
|
room.clients.forEach(client => {
|
|
62
65
|
if (client !== excludeClient && client.readyState === WebSocket.OPEN) {
|
|
@@ -164,33 +167,33 @@ function handleSetId(ws, message) {
|
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
function handleJoin(ws, message) {
|
|
167
|
-
const {
|
|
170
|
+
const { sessionId } = message;
|
|
168
171
|
|
|
169
|
-
if (!
|
|
170
|
-
sendToClient(ws, { type: 'error', message: '
|
|
172
|
+
if (!sessionId) {
|
|
173
|
+
sendToClient(ws, { type: 'error', message: 'Session ID is required' });
|
|
171
174
|
return;
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
// Leave current
|
|
177
|
+
// Leave current session if in one
|
|
175
178
|
if (ws.room) {
|
|
176
|
-
handleLeave(ws, {
|
|
179
|
+
handleLeave(ws, { sessionId: ws.room });
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
// Create
|
|
180
|
-
if (!rooms.has(
|
|
181
|
-
rooms.set(
|
|
182
|
-
id:
|
|
182
|
+
// Create session if it doesn't exist
|
|
183
|
+
if (!rooms.has(sessionId)) {
|
|
184
|
+
rooms.set(sessionId, {
|
|
185
|
+
id: sessionId,
|
|
183
186
|
clients: new Set(),
|
|
184
187
|
createdAt: Date.now()
|
|
185
188
|
});
|
|
186
|
-
log('
|
|
189
|
+
log('Session created:', sessionId);
|
|
187
190
|
}
|
|
188
191
|
|
|
189
|
-
const room = rooms.get(
|
|
192
|
+
const room = rooms.get(sessionId);
|
|
190
193
|
room.clients.add(ws);
|
|
191
|
-
ws.room =
|
|
194
|
+
ws.room = sessionId;
|
|
192
195
|
|
|
193
|
-
log(`Client ${ws.clientId} joined
|
|
196
|
+
log(`Client ${ws.clientId} joined session ${sessionId}`);
|
|
194
197
|
|
|
195
198
|
// Get list of existing clients in the room
|
|
196
199
|
const existingClients = Array.from(room.clients)
|
|
@@ -200,51 +203,52 @@ function handleJoin(ws, message) {
|
|
|
200
203
|
// Notify the joining client
|
|
201
204
|
sendToClient(ws, {
|
|
202
205
|
type: 'joined',
|
|
203
|
-
|
|
206
|
+
sessionId: sessionId,
|
|
204
207
|
clientId: ws.clientId,
|
|
205
208
|
clients: existingClients
|
|
206
209
|
});
|
|
207
210
|
|
|
208
|
-
// Notify other clients in the
|
|
209
|
-
broadcastToRoom(
|
|
211
|
+
// Notify other clients in the session
|
|
212
|
+
broadcastToRoom(sessionId, {
|
|
210
213
|
type: 'peer-joined',
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
sessionId: sessionId,
|
|
215
|
+
peerId: ws.clientId
|
|
213
216
|
}, ws);
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
function handleLeave(ws, message) {
|
|
217
|
-
const {
|
|
220
|
+
const { sessionId } = message;
|
|
218
221
|
|
|
219
|
-
if (!
|
|
222
|
+
if (!sessionId || !rooms.has(sessionId)) {
|
|
220
223
|
return;
|
|
221
224
|
}
|
|
222
225
|
|
|
223
|
-
const room = rooms.get(
|
|
226
|
+
const room = rooms.get(sessionId);
|
|
224
227
|
room.clients.delete(ws);
|
|
225
228
|
|
|
226
|
-
log(`Client ${ws.clientId} left
|
|
229
|
+
log(`Client ${ws.clientId} left session ${sessionId}`);
|
|
227
230
|
|
|
228
231
|
// Notify other clients
|
|
229
|
-
broadcastToRoom(
|
|
232
|
+
broadcastToRoom(sessionId, {
|
|
230
233
|
type: 'peer-left',
|
|
231
|
-
|
|
234
|
+
sessionId: sessionId,
|
|
235
|
+
peerId: ws.clientId
|
|
232
236
|
});
|
|
233
237
|
|
|
234
|
-
// Clean up empty
|
|
238
|
+
// Clean up empty sessions
|
|
235
239
|
if (room.clients.size === 0) {
|
|
236
|
-
rooms.delete(
|
|
237
|
-
log('
|
|
240
|
+
rooms.delete(sessionId);
|
|
241
|
+
log('Session deleted:', sessionId);
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
ws.room = null;
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
function handleSignaling(ws, message) {
|
|
244
|
-
const { targetId,
|
|
248
|
+
const { targetId, sessionId } = message;
|
|
245
249
|
|
|
246
250
|
if (!ws.room) {
|
|
247
|
-
sendToClient(ws, { type: 'error', message: 'Not in a
|
|
251
|
+
sendToClient(ws, { type: 'error', message: 'Not in a session' });
|
|
248
252
|
return;
|
|
249
253
|
}
|
|
250
254
|
|
|
@@ -273,29 +277,29 @@ function handleSignaling(ws, message) {
|
|
|
273
277
|
}
|
|
274
278
|
|
|
275
279
|
function handleChat(ws, message) {
|
|
276
|
-
const {
|
|
280
|
+
const { sessionId, text } = message;
|
|
277
281
|
|
|
278
|
-
if (!
|
|
279
|
-
sendToClient(ws, { type: 'error', message: '
|
|
282
|
+
if (!sessionId || !text) {
|
|
283
|
+
sendToClient(ws, { type: 'error', message: 'Session ID and text are required' });
|
|
280
284
|
return;
|
|
281
285
|
}
|
|
282
286
|
|
|
283
|
-
log(`Chat message in
|
|
287
|
+
log(`Chat message in session ${sessionId}: ${text.substring(0, 50)}`);
|
|
284
288
|
|
|
285
|
-
const room = rooms.get(
|
|
289
|
+
const room = rooms.get(sessionId);
|
|
286
290
|
if (!room) {
|
|
287
|
-
sendToClient(ws, { type: 'error', message: '
|
|
291
|
+
sendToClient(ws, { type: 'error', message: 'Session not found' });
|
|
288
292
|
return;
|
|
289
293
|
}
|
|
290
294
|
|
|
291
|
-
// Broadcast chat to ALL clients in the
|
|
295
|
+
// Broadcast chat to ALL clients in the session (including sender)
|
|
292
296
|
room.clients.forEach(client => {
|
|
293
297
|
if (client.readyState === WebSocket.OPEN) {
|
|
294
298
|
client.send(JSON.stringify({
|
|
295
299
|
type: 'chat',
|
|
296
300
|
text: text,
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
peerId: ws.clientId,
|
|
302
|
+
sessionId: sessionId
|
|
299
303
|
}));
|
|
300
304
|
}
|
|
301
305
|
});
|
package/src/client-cloudflare.js
CHANGED
|
@@ -109,14 +109,14 @@ class UniWRTCClient {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
// Durable Objects handle
|
|
112
|
+
joinSession(sessionId) {
|
|
113
|
+
this.sessionId = sessionId;
|
|
114
|
+
// Durable Objects handle session joining automatically via room parameter
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
if (this.
|
|
119
|
-
this.
|
|
117
|
+
leaveSession() {
|
|
118
|
+
if (this.sessionId) {
|
|
119
|
+
this.sessionId = null;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -128,7 +128,7 @@ class UniWRTCClient {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
sendOffer(
|
|
131
|
+
sendOffer(offer, targetId) {
|
|
132
132
|
this.send({
|
|
133
133
|
type: 'offer',
|
|
134
134
|
offer: offer,
|
|
@@ -136,7 +136,7 @@ class UniWRTCClient {
|
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
sendAnswer(
|
|
139
|
+
sendAnswer(answer, targetId) {
|
|
140
140
|
this.send({
|
|
141
141
|
type: 'answer',
|
|
142
142
|
answer: answer,
|
|
@@ -144,7 +144,7 @@ class UniWRTCClient {
|
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
sendIceCandidate(
|
|
147
|
+
sendIceCandidate(candidate, targetId) {
|
|
148
148
|
this.send({
|
|
149
149
|
type: 'ice-candidate',
|
|
150
150
|
candidate: candidate,
|
|
@@ -188,21 +188,23 @@ class UniWRTCClient {
|
|
|
188
188
|
console.log('[UniWRTC] If this helps, consider donating ❤️ → https://coff.ee/draederg');
|
|
189
189
|
break;
|
|
190
190
|
case 'joined':
|
|
191
|
-
this.
|
|
191
|
+
this.sessionId = message.sessionId;
|
|
192
192
|
this.emit('joined', {
|
|
193
|
-
|
|
193
|
+
sessionId: message.sessionId,
|
|
194
194
|
clientId: message.clientId,
|
|
195
195
|
clients: message.clients
|
|
196
196
|
});
|
|
197
197
|
break;
|
|
198
198
|
case 'peer-joined':
|
|
199
199
|
this.emit('peer-joined', {
|
|
200
|
-
|
|
200
|
+
sessionId: message.sessionId,
|
|
201
|
+
peerId: message.peerId
|
|
201
202
|
});
|
|
202
203
|
break;
|
|
203
204
|
case 'peer-left':
|
|
204
205
|
this.emit('peer-left', {
|
|
205
|
-
|
|
206
|
+
sessionId: message.sessionId,
|
|
207
|
+
peerId: message.peerId
|
|
206
208
|
});
|
|
207
209
|
break;
|
|
208
210
|
case 'offer':
|
|
@@ -236,8 +238,8 @@ class UniWRTCClient {
|
|
|
236
238
|
case 'chat':
|
|
237
239
|
this.emit('chat', {
|
|
238
240
|
text: message.text,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
peerId: message.peerId,
|
|
242
|
+
sessionId: message.sessionId
|
|
241
243
|
});
|
|
242
244
|
break;
|
|
243
245
|
default:
|
package/src/room.js
CHANGED
|
@@ -17,7 +17,7 @@ export class Room {
|
|
|
17
17
|
const clientId = crypto.randomUUID().substring(0, 9);
|
|
18
18
|
this.clients.set(clientId, server);
|
|
19
19
|
|
|
20
|
-
console.log(`[Room] Client ${clientId}
|
|
20
|
+
console.log(`[Room] Client ${clientId} connected (total: ${this.clients.size})`);
|
|
21
21
|
|
|
22
22
|
// Send welcome message
|
|
23
23
|
server.send(JSON.stringify({
|
|
@@ -26,11 +26,7 @@ export class Room {
|
|
|
26
26
|
message: 'Connected to UniWRTC signaling room'
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
this.broadcast({
|
|
31
|
-
type: 'peer-joined',
|
|
32
|
-
clientId: clientId
|
|
33
|
-
}, clientId);
|
|
29
|
+
// NOTE: peer-joined is sent when client explicitly joins via 'join' message
|
|
34
30
|
|
|
35
31
|
server.onmessage = async (event) => {
|
|
36
32
|
try {
|
|
@@ -45,10 +41,10 @@ export class Room {
|
|
|
45
41
|
server.onclose = () => {
|
|
46
42
|
console.log(`[Room] Client ${clientId} left`);
|
|
47
43
|
this.clients.delete(clientId);
|
|
44
|
+
// Note: sessionId should be tracked per client if needed
|
|
48
45
|
this.broadcast({
|
|
49
46
|
type: 'peer-left',
|
|
50
|
-
peerId: clientId
|
|
51
|
-
clientId: clientId
|
|
47
|
+
peerId: clientId
|
|
52
48
|
});
|
|
53
49
|
};
|
|
54
50
|
|
|
@@ -81,7 +77,7 @@ export class Room {
|
|
|
81
77
|
}
|
|
82
78
|
|
|
83
79
|
async handleJoin(clientId, message) {
|
|
84
|
-
const {
|
|
80
|
+
const { sessionId, peerId } = message;
|
|
85
81
|
|
|
86
82
|
// Get list of other peers
|
|
87
83
|
const peers = Array.from(this.clients.keys())
|
|
@@ -89,11 +85,11 @@ export class Room {
|
|
|
89
85
|
|
|
90
86
|
const client = this.clients.get(clientId);
|
|
91
87
|
if (client && client.readyState === WebSocket.OPEN) {
|
|
92
|
-
// Send joined confirmation
|
|
88
|
+
// Send joined confirmation (align with server schema)
|
|
93
89
|
client.send(JSON.stringify({
|
|
94
90
|
type: 'joined',
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
sessionId: sessionId,
|
|
92
|
+
clientId: clientId,
|
|
97
93
|
clients: peers
|
|
98
94
|
}));
|
|
99
95
|
}
|
|
@@ -101,8 +97,8 @@ export class Room {
|
|
|
101
97
|
// Notify other peers
|
|
102
98
|
this.broadcast({
|
|
103
99
|
type: 'peer-joined',
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
sessionId: sessionId,
|
|
101
|
+
peerId: clientId
|
|
106
102
|
}, clientId);
|
|
107
103
|
}
|
|
108
104
|
|