javascript-solid-server 0.0.107 → 0.0.108
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 +39 -0
- package/bin/jss.js +6 -0
- package/package.json +1 -1
- package/src/config.js +6 -0
- package/src/handlers/pay.js +37 -13
- package/src/server.js +16 -0
- package/src/webrtc/index.js +159 -0
- package/test/webrtc.test.js +212 -0
- package/test-webrtc-smoke.mjs +90 -0
package/README.md
CHANGED
|
@@ -162,6 +162,8 @@ jss --help # Show help
|
|
|
162
162
|
| `--mongo` | Enable MongoDB-backed /db/ route | false |
|
|
163
163
|
| `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
|
|
164
164
|
| `--mongo-database <name>` | MongoDB database name | solid |
|
|
165
|
+
| `--webrtc` | Enable WebRTC signaling server | false |
|
|
166
|
+
| `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
|
|
165
167
|
| `-q, --quiet` | Suppress logs | false |
|
|
166
168
|
|
|
167
169
|
### Environment Variables
|
|
@@ -195,6 +197,7 @@ export JSS_PAY_RATE=10
|
|
|
195
197
|
export JSS_MONGO=true
|
|
196
198
|
export JSS_MONGO_URL=mongodb://localhost:27017
|
|
197
199
|
export JSS_MONGO_DATABASE=solid
|
|
200
|
+
export JSS_WEBRTC=true
|
|
198
201
|
jss start
|
|
199
202
|
```
|
|
200
203
|
|
|
@@ -823,6 +826,42 @@ curl -X DELETE http://localhost:3000/db/alice/notes/1 \
|
|
|
823
826
|
|
|
824
827
|
Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
|
|
825
828
|
|
|
829
|
+
## WebRTC Signaling
|
|
830
|
+
|
|
831
|
+
Peer-to-peer communication via WebRTC, using JSS as the signaling server. Once peers are connected, all media and data flows directly between them.
|
|
832
|
+
|
|
833
|
+
```bash
|
|
834
|
+
jss start --webrtc
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
### How It Works
|
|
838
|
+
|
|
839
|
+
1. Both peers connect to `wss://your.pod/.webrtc` (WebID auth required)
|
|
840
|
+
2. Caller sends an SDP offer targeting the callee's WebID
|
|
841
|
+
3. JSS relays the offer/answer and ICE candidates between peers
|
|
842
|
+
4. Once a direct path is found, the peer-to-peer connection is established
|
|
843
|
+
5. JSS steps out — video, audio, files, and data flow directly between peers
|
|
844
|
+
|
|
845
|
+
### Protocol
|
|
846
|
+
|
|
847
|
+
Messages are JSON over WebSocket:
|
|
848
|
+
|
|
849
|
+
```js
|
|
850
|
+
// Send an offer to another user
|
|
851
|
+
{ "type": "offer", "to": "https://bob.example/profile/card#me", "sdp": "..." }
|
|
852
|
+
|
|
853
|
+
// Receive an offer from another user
|
|
854
|
+
{ "type": "offer", "from": "https://alice.example/profile/card#me", "sdp": "..." }
|
|
855
|
+
|
|
856
|
+
// ICE candidate exchange
|
|
857
|
+
{ "type": "candidate", "to": "https://bob.example/profile/card#me", "candidate": {...} }
|
|
858
|
+
|
|
859
|
+
// Hang up
|
|
860
|
+
{ "type": "hangup", "to": "https://bob.example/profile/card#me" }
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
On connect, peers receive a list of online users and get notified when others join or leave.
|
|
864
|
+
|
|
826
865
|
## HTTP 402 Paid Access
|
|
827
866
|
|
|
828
867
|
Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance.
|
package/bin/jss.js
CHANGED
|
@@ -65,6 +65,9 @@ program
|
|
|
65
65
|
.option('--no-nostr', 'Disable Nostr relay')
|
|
66
66
|
.option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
|
|
67
67
|
.option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
|
|
68
|
+
.option('--webrtc', 'Enable WebRTC signaling server')
|
|
69
|
+
.option('--no-webrtc', 'Disable WebRTC signaling server')
|
|
70
|
+
.option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
|
|
68
71
|
.option('--activitypub', 'Enable ActivityPub federation')
|
|
69
72
|
.option('--no-activitypub', 'Disable ActivityPub federation')
|
|
70
73
|
.option('--ap-username <name>', 'ActivityPub username (default: me)')
|
|
@@ -142,6 +145,8 @@ program
|
|
|
142
145
|
nostr: config.nostr,
|
|
143
146
|
nostrPath: config.nostrPath,
|
|
144
147
|
nostrMaxEvents: config.nostrMaxEvents,
|
|
148
|
+
webrtc: config.webrtc,
|
|
149
|
+
webrtcPath: config.webrtcPath,
|
|
145
150
|
activitypub: config.activitypub,
|
|
146
151
|
apUsername: config.apUsername,
|
|
147
152
|
apDisplayName: config.apDisplayName,
|
|
@@ -186,6 +191,7 @@ program
|
|
|
186
191
|
if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
|
|
187
192
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
188
193
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
194
|
+
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
|
|
189
195
|
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
|
190
196
|
if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
|
|
191
197
|
else if (config.inviteOnly) console.log(' Registration: invite-only');
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -54,6 +54,10 @@ export const defaults = {
|
|
|
54
54
|
nostrPath: '/relay',
|
|
55
55
|
nostrMaxEvents: 1000,
|
|
56
56
|
|
|
57
|
+
// WebRTC signaling
|
|
58
|
+
webrtc: false,
|
|
59
|
+
webrtcPath: '/.webrtc',
|
|
60
|
+
|
|
57
61
|
// ActivityPub federation
|
|
58
62
|
activitypub: false,
|
|
59
63
|
apUsername: 'me',
|
|
@@ -134,6 +138,8 @@ const envMap = {
|
|
|
134
138
|
JSS_NOSTR: 'nostr',
|
|
135
139
|
JSS_NOSTR_PATH: 'nostrPath',
|
|
136
140
|
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
|
|
141
|
+
JSS_WEBRTC: 'webrtc',
|
|
142
|
+
JSS_WEBRTC_PATH: 'webrtcPath',
|
|
137
143
|
JSS_ACTIVITYPUB: 'activitypub',
|
|
138
144
|
JSS_AP_USERNAME: 'apUsername',
|
|
139
145
|
JSS_AP_DISPLAY_NAME: 'apDisplayName',
|
package/src/handlers/pay.js
CHANGED
|
@@ -424,13 +424,17 @@ export function createPayHandler(options = {}) {
|
|
|
424
424
|
return reply.code(400).send({ error: 'Amount must be positive' });
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
//
|
|
427
|
+
// Determine payment currency — chain-specific (e.g. "tbtc4") or generic "sat"
|
|
428
|
+
const currency = (body?.currency && payChains && payChains.includes(body.currency))
|
|
429
|
+
? body.currency : null;
|
|
430
|
+
|
|
431
|
+
// Check balance
|
|
428
432
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
429
433
|
const ledger = await readLedger();
|
|
430
|
-
const balance = getBalance(ledger, didUri);
|
|
434
|
+
const balance = getBalance(ledger, didUri, currency);
|
|
431
435
|
if (balance < satCost) {
|
|
432
436
|
return reply.code(402).send({
|
|
433
|
-
error:
|
|
437
|
+
error: `Insufficient ${currency || 'sat'} balance`,
|
|
434
438
|
balance,
|
|
435
439
|
cost: satCost,
|
|
436
440
|
rate: payRate,
|
|
@@ -457,8 +461,8 @@ export function createPayHandler(options = {}) {
|
|
|
457
461
|
return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
|
|
458
462
|
}
|
|
459
463
|
|
|
460
|
-
// Debit
|
|
461
|
-
debit(ledger, didUri, satCost);
|
|
464
|
+
// Debit from buyer
|
|
465
|
+
debit(ledger, didUri, satCost, currency);
|
|
462
466
|
await writeLedger(ledger);
|
|
463
467
|
|
|
464
468
|
return reply.send({
|
|
@@ -466,8 +470,8 @@ export function createPayHandler(options = {}) {
|
|
|
466
470
|
ticker,
|
|
467
471
|
cost: satCost,
|
|
468
472
|
rate: payRate,
|
|
469
|
-
balance: getBalance(ledger, didUri),
|
|
470
|
-
unit: 'sat',
|
|
473
|
+
balance: getBalance(ledger, didUri, currency),
|
|
474
|
+
unit: currency || 'sat',
|
|
471
475
|
txid: result.txid,
|
|
472
476
|
proof: {
|
|
473
477
|
state: result.state,
|
|
@@ -984,21 +988,41 @@ export function createPayHandler(options = {}) {
|
|
|
984
988
|
|
|
985
989
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
986
990
|
const ledger = await readLedger();
|
|
987
|
-
const { success, balance } = debit(ledger, didUri, cost);
|
|
988
991
|
|
|
989
|
-
|
|
990
|
-
|
|
992
|
+
// Try generic sat balance first, then fall back to chain balances
|
|
993
|
+
const currency = request.headers['x-pay-currency'] || null;
|
|
994
|
+
let payUnit = currency && payChains && payChains.includes(currency) ? currency : null;
|
|
995
|
+
let result = debit(ledger, didUri, cost, payUnit);
|
|
996
|
+
|
|
997
|
+
// If generic sat failed and no explicit currency, try each chain balance
|
|
998
|
+
if (!result.success && !payUnit && payChains) {
|
|
999
|
+
for (const chainId of payChains) {
|
|
1000
|
+
result = debit(ledger, didUri, cost, chainId);
|
|
1001
|
+
if (result.success) { payUnit = chainId; break; }
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (!result.success) {
|
|
1006
|
+
const response = {
|
|
991
1007
|
error: 'Payment Required',
|
|
992
|
-
balance,
|
|
1008
|
+
balance: result.balance,
|
|
993
1009
|
cost,
|
|
994
1010
|
unit: 'sat',
|
|
995
1011
|
deposit: '/pay/.deposit'
|
|
996
|
-
}
|
|
1012
|
+
};
|
|
1013
|
+
if (payChains) {
|
|
1014
|
+
response.balances = {};
|
|
1015
|
+
for (const chainId of payChains) {
|
|
1016
|
+
response.balances[chainId] = getBalance(ledger, didUri, chainId);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return reply.code(402).send(response);
|
|
997
1020
|
}
|
|
998
1021
|
|
|
999
1022
|
await writeLedger(ledger);
|
|
1000
|
-
reply.header('X-Balance', String(balance));
|
|
1023
|
+
reply.header('X-Balance', String(result.balance));
|
|
1001
1024
|
reply.header('X-Cost', String(cost));
|
|
1025
|
+
if (payUnit) reply.header('X-Pay-Currency', payUnit);
|
|
1002
1026
|
return; // continue to normal resource handler
|
|
1003
1027
|
}
|
|
1004
1028
|
|
package/src/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createPayHandler, isPayRequest } from './handlers/pay.js';
|
|
|
18
18
|
import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
19
19
|
import { remoteStoragePlugin } from './remotestorage.js';
|
|
20
20
|
import { dbPlugin } from './db/index.js';
|
|
21
|
+
import { webrtcPlugin } from './webrtc/index.js';
|
|
21
22
|
|
|
22
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
|
|
@@ -74,6 +75,9 @@ export function createServer(options = {}) {
|
|
|
74
75
|
const nostrEnabled = options.nostr ?? false;
|
|
75
76
|
const nostrPath = options.nostrPath ?? '/relay';
|
|
76
77
|
const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
|
|
78
|
+
// WebRTC signaling is OFF by default
|
|
79
|
+
const webrtcEnabled = options.webrtc ?? false;
|
|
80
|
+
const webrtcPath = options.webrtcPath ?? '/.webrtc';
|
|
77
81
|
// ActivityPub federation is OFF by default
|
|
78
82
|
const activitypubEnabled = options.activitypub ?? false;
|
|
79
83
|
const apUsername = options.apUsername ?? 'me';
|
|
@@ -240,6 +244,11 @@ export function createServer(options = {}) {
|
|
|
240
244
|
});
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
// Register WebRTC signaling if enabled
|
|
248
|
+
if (webrtcEnabled) {
|
|
249
|
+
fastify.register(webrtcPlugin, { path: webrtcPath });
|
|
250
|
+
}
|
|
251
|
+
|
|
243
252
|
// Register ActivityPub plugin if enabled
|
|
244
253
|
if (activitypubEnabled) {
|
|
245
254
|
fastify.register(activityPubPlugin, {
|
|
@@ -331,6 +340,12 @@ export function createServer(options = {}) {
|
|
|
331
340
|
return;
|
|
332
341
|
}
|
|
333
342
|
|
|
343
|
+
// Allow WebRTC signaling endpoint through when enabled
|
|
344
|
+
const urlNoQuery = request.url.split('?')[0];
|
|
345
|
+
if (webrtcEnabled && urlNoQuery === webrtcPath) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
334
349
|
const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
|
|
335
350
|
const hasForbiddenDotfile = segments.some(seg =>
|
|
336
351
|
seg.startsWith('.') &&
|
|
@@ -405,6 +420,7 @@ export function createServer(options = {}) {
|
|
|
405
420
|
request.url.startsWith('/storage/') ||
|
|
406
421
|
(payEnabled && isPayRequest(request.url)) ||
|
|
407
422
|
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
423
|
+
(webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
|
|
408
424
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
409
425
|
return;
|
|
410
426
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTC Signaling Server Plugin
|
|
3
|
+
*
|
|
4
|
+
* Lightweight signaling server for WebRTC peer-to-peer connections.
|
|
5
|
+
* Relays SDP offers/answers and ICE candidates between authenticated users.
|
|
6
|
+
* The actual media/data flow directly between peers — JSS just introduces them.
|
|
7
|
+
*
|
|
8
|
+
* Usage: jss start --webrtc
|
|
9
|
+
* Endpoint: wss://your.pod/.webrtc
|
|
10
|
+
*
|
|
11
|
+
* Protocol (JSON over WebSocket):
|
|
12
|
+
* → { type: "offer", to: "<webid>", sdp: "..." }
|
|
13
|
+
* → { type: "answer", to: "<webid>", sdp: "..." }
|
|
14
|
+
* → { type: "candidate", to: "<webid>", candidate: {...} }
|
|
15
|
+
* → { type: "hangup", to: "<webid>" }
|
|
16
|
+
* ← { type: "offer", from: "<webid>", sdp: "..." }
|
|
17
|
+
* ← { type: "answer", from: "<webid>", sdp: "..." }
|
|
18
|
+
* ← { type: "candidate", from: "<webid>", candidate: {...} }
|
|
19
|
+
* ← { type: "hangup", from: "<webid>" }
|
|
20
|
+
* ← { type: "error", message: "..." }
|
|
21
|
+
* ← { type: "peers", you: "<webid>", peers: ["<webid>", ...] }
|
|
22
|
+
* ← { type: "peer-joined", webId: "<webid>" }
|
|
23
|
+
* ← { type: "peer-left", webId: "<webid>" }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import websocket from '@fastify/websocket';
|
|
27
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
28
|
+
|
|
29
|
+
const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']);
|
|
30
|
+
const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register WebRTC signaling routes on Fastify instance
|
|
34
|
+
*
|
|
35
|
+
* @param {object} fastify - Fastify instance
|
|
36
|
+
* @param {object} options - Options
|
|
37
|
+
* @param {string} options.path - WebSocket path (default: '/.webrtc')
|
|
38
|
+
*/
|
|
39
|
+
export async function webrtcPlugin(fastify, options = {}) {
|
|
40
|
+
const path = options.path || '/.webrtc';
|
|
41
|
+
|
|
42
|
+
// Instance-scoped peer state
|
|
43
|
+
const peers = new Map();
|
|
44
|
+
|
|
45
|
+
// Only register @fastify/websocket if not already registered
|
|
46
|
+
if (!fastify.websocketServer) {
|
|
47
|
+
await fastify.register(websocket);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Clean up all connections on server close
|
|
51
|
+
fastify.addHook('onClose', async () => {
|
|
52
|
+
for (const [, socket] of peers) {
|
|
53
|
+
socket.close();
|
|
54
|
+
}
|
|
55
|
+
peers.clear();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function broadcast(senderWebId, msg) {
|
|
59
|
+
const data = JSON.stringify(msg);
|
|
60
|
+
for (const [id, socket] of peers) {
|
|
61
|
+
if (id !== senderWebId && socket.readyState === 1) {
|
|
62
|
+
try { socket.send(data); } catch { /* socket closed between check and send */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fastify.get(path, { websocket: true }, async (connection, request) => {
|
|
68
|
+
const socket = connection.socket;
|
|
69
|
+
|
|
70
|
+
// Authenticate the connection
|
|
71
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
72
|
+
if (!webId) {
|
|
73
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
|
74
|
+
socket.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Register this peer (close old connection if reconnecting)
|
|
79
|
+
const existing = peers.get(webId);
|
|
80
|
+
const isReconnect = !!existing;
|
|
81
|
+
if (existing) {
|
|
82
|
+
peers.delete(webId);
|
|
83
|
+
existing.close();
|
|
84
|
+
}
|
|
85
|
+
peers.set(webId, socket);
|
|
86
|
+
socket.webId = webId;
|
|
87
|
+
|
|
88
|
+
// Notify the peer of their identity and online peers
|
|
89
|
+
socket.send(JSON.stringify({
|
|
90
|
+
type: 'peers',
|
|
91
|
+
you: webId,
|
|
92
|
+
peers: [...peers.keys()].filter(id => id !== webId)
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
// Only broadcast peer-joined for new connections, not reconnects
|
|
96
|
+
if (!isReconnect) {
|
|
97
|
+
broadcast(webId, { type: 'peer-joined', webId });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
socket.on('message', (data) => {
|
|
101
|
+
// Enforce max message size (Buffer.byteLength for reliable byte count)
|
|
102
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
103
|
+
if (raw.byteLength > MAX_MESSAGE_SIZE) {
|
|
104
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let msg;
|
|
109
|
+
try {
|
|
110
|
+
msg = JSON.parse(raw.toString());
|
|
111
|
+
} catch {
|
|
112
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!msg.to || !msg.type) {
|
|
117
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only relay known signaling types
|
|
122
|
+
if (!ALLOWED_TYPES.has(msg.type)) {
|
|
123
|
+
socket.send(JSON.stringify({ type: 'error', message: `Unknown type "${msg.type}"` }));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const target = peers.get(msg.to);
|
|
128
|
+
if (!target || target.readyState !== 1) {
|
|
129
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build relay payload with whitelisted fields only (prevent prototype pollution)
|
|
134
|
+
const relay = Object.create(null);
|
|
135
|
+
relay.type = msg.type;
|
|
136
|
+
relay.from = webId;
|
|
137
|
+
if (typeof msg.sdp === 'string') relay.sdp = msg.sdp;
|
|
138
|
+
if (msg.candidate != null && typeof msg.candidate === 'object' && !Array.isArray(msg.candidate)) {
|
|
139
|
+
relay.candidate = msg.candidate;
|
|
140
|
+
}
|
|
141
|
+
try { target.send(JSON.stringify(relay)); } catch {
|
|
142
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to }));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
socket.on('close', () => {
|
|
147
|
+
// Only remove if this socket is still the registered one (not replaced by reconnect)
|
|
148
|
+
if (peers.get(webId) === socket) {
|
|
149
|
+
peers.delete(webId);
|
|
150
|
+
broadcast(webId, { type: 'peer-left', webId });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Error handler: close event will follow and handle cleanup
|
|
155
|
+
socket.on('error', () => {});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default webrtcPlugin;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTC Signaling Server Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import {
|
|
9
|
+
startTestServer,
|
|
10
|
+
stopTestServer,
|
|
11
|
+
createTestPod,
|
|
12
|
+
getBaseUrl,
|
|
13
|
+
getPodToken
|
|
14
|
+
} from './helpers.js';
|
|
15
|
+
|
|
16
|
+
describe('WebRTC Signaling', () => {
|
|
17
|
+
let wsUrl;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
await startTestServer({ webrtc: true });
|
|
21
|
+
await createTestPod('alice');
|
|
22
|
+
await createTestPod('bob');
|
|
23
|
+
const base = getBaseUrl();
|
|
24
|
+
wsUrl = base.replace('http', 'ws') + '/.webrtc';
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(async () => {
|
|
28
|
+
await stopTestServer();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Create an authenticated WebSocket for a pod user */
|
|
32
|
+
function connectPeer(podName) {
|
|
33
|
+
const token = getPodToken(podName);
|
|
34
|
+
const ws = new WebSocket(wsUrl, {
|
|
35
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
36
|
+
});
|
|
37
|
+
return ws;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Connect a peer and wait for the 'peers' welcome message */
|
|
41
|
+
async function connectAndWait(podName) {
|
|
42
|
+
const ws = connectPeer(podName);
|
|
43
|
+
const msg = await waitForMessage(ws, 'peers');
|
|
44
|
+
return { ws, ...msg };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Wait for a specific message type from a WebSocket */
|
|
48
|
+
function waitForMessage(ws, type, timeout = 3000) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
function handler(data) {
|
|
51
|
+
const msg = JSON.parse(data.toString());
|
|
52
|
+
if (msg.type === type) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
ws.removeListener('message', handler);
|
|
55
|
+
ws.removeListener('close', onClose);
|
|
56
|
+
resolve(msg);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function onClose() {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
ws.removeListener('message', handler);
|
|
62
|
+
reject(new Error(`WebSocket closed while waiting for "${type}"`));
|
|
63
|
+
}
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
ws.removeListener('message', handler);
|
|
66
|
+
ws.removeListener('close', onClose);
|
|
67
|
+
reject(new Error(`Timeout waiting for "${type}"`));
|
|
68
|
+
}, timeout);
|
|
69
|
+
ws.on('message', handler);
|
|
70
|
+
ws.on('close', onClose);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Collect messages from a WebSocket for a duration */
|
|
75
|
+
function collectMessages(ws, duration = 500) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const msgs = [];
|
|
78
|
+
const handler = (data) => msgs.push(JSON.parse(data.toString()));
|
|
79
|
+
ws.on('message', handler);
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
ws.removeListener('message', handler);
|
|
82
|
+
resolve(msgs);
|
|
83
|
+
}, duration);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('Authentication', () => {
|
|
88
|
+
it('should reject unauthenticated connections', async () => {
|
|
89
|
+
const ws = new WebSocket(wsUrl);
|
|
90
|
+
|
|
91
|
+
const msg = await waitForMessage(ws, 'error');
|
|
92
|
+
assert.strictEqual(msg.type, 'error');
|
|
93
|
+
assert.ok(msg.message.includes('Authentication'));
|
|
94
|
+
ws.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should accept authenticated connections', async () => {
|
|
98
|
+
const ws = connectPeer('alice');
|
|
99
|
+
|
|
100
|
+
const msg = await waitForMessage(ws, 'peers');
|
|
101
|
+
assert.strictEqual(msg.type, 'peers');
|
|
102
|
+
assert.ok(msg.you, 'Should include own WebID');
|
|
103
|
+
assert.ok(Array.isArray(msg.peers), 'Should include peers list');
|
|
104
|
+
ws.close();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Peer Presence and Signaling Relay', () => {
|
|
109
|
+
it('should handle full signaling lifecycle', async () => {
|
|
110
|
+
// Alice connects first — should see no peers
|
|
111
|
+
const { ws: alice, you: aliceId } = await connectAndWait('alice');
|
|
112
|
+
|
|
113
|
+
// Bob joins — set up listener for peer-joined before bob connects
|
|
114
|
+
const joinPromise = waitForMessage(alice, 'peer-joined');
|
|
115
|
+
const { ws: bob, you: bobId, peers: bobPeerList } = await connectAndWait('bob');
|
|
116
|
+
|
|
117
|
+
// Bob should see alice in the peer list
|
|
118
|
+
assert.strictEqual(bobPeerList.length, 1, 'Bob should see Alice');
|
|
119
|
+
|
|
120
|
+
// Alice should get peer-joined notification
|
|
121
|
+
const joinMsg = await joinPromise;
|
|
122
|
+
assert.strictEqual(joinMsg.type, 'peer-joined');
|
|
123
|
+
|
|
124
|
+
// 1. Alice sends offer to Bob
|
|
125
|
+
const offerPromise = waitForMessage(bob, 'offer');
|
|
126
|
+
alice.send(JSON.stringify({ type: 'offer', to: bobId, sdp: 'v=0\r\n' }));
|
|
127
|
+
|
|
128
|
+
const offer = await offerPromise;
|
|
129
|
+
assert.strictEqual(offer.type, 'offer');
|
|
130
|
+
assert.strictEqual(offer.from, aliceId);
|
|
131
|
+
assert.ok(offer.sdp, 'Should include SDP');
|
|
132
|
+
assert.strictEqual(offer.to, undefined, 'Should strip "to" field');
|
|
133
|
+
|
|
134
|
+
// 2. Bob sends answer to Alice
|
|
135
|
+
const answerPromise = waitForMessage(alice, 'answer');
|
|
136
|
+
bob.send(JSON.stringify({ type: 'answer', to: aliceId, sdp: 'v=0\r\n' }));
|
|
137
|
+
|
|
138
|
+
const answer = await answerPromise;
|
|
139
|
+
assert.strictEqual(answer.type, 'answer');
|
|
140
|
+
assert.strictEqual(answer.from, bobId);
|
|
141
|
+
|
|
142
|
+
// 3. Alice sends ICE candidate to Bob
|
|
143
|
+
const candidatePromise = waitForMessage(bob, 'candidate');
|
|
144
|
+
alice.send(JSON.stringify({
|
|
145
|
+
type: 'candidate', to: bobId,
|
|
146
|
+
candidate: { candidate: 'candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host', sdpMid: '0' }
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
const candidate = await candidatePromise;
|
|
150
|
+
assert.strictEqual(candidate.type, 'candidate');
|
|
151
|
+
assert.ok(candidate.candidate.candidate);
|
|
152
|
+
|
|
153
|
+
// 4. Alice sends hangup to Bob
|
|
154
|
+
const hangupPromise = waitForMessage(bob, 'hangup');
|
|
155
|
+
alice.send(JSON.stringify({ type: 'hangup', to: bobId }));
|
|
156
|
+
|
|
157
|
+
const hangup = await hangupPromise;
|
|
158
|
+
assert.strictEqual(hangup.type, 'hangup');
|
|
159
|
+
assert.strictEqual(hangup.from, aliceId);
|
|
160
|
+
|
|
161
|
+
// 5. Bob leaves — alice should get notified
|
|
162
|
+
const leavePromise = waitForMessage(alice, 'peer-left');
|
|
163
|
+
bob.close();
|
|
164
|
+
|
|
165
|
+
const leaveMsg = await leavePromise;
|
|
166
|
+
assert.strictEqual(leaveMsg.type, 'peer-left');
|
|
167
|
+
|
|
168
|
+
alice.close();
|
|
169
|
+
await new Promise(r => setTimeout(r, 100));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('Error Handling', () => {
|
|
174
|
+
it('should reject invalid JSON', async () => {
|
|
175
|
+
const alice = connectPeer('alice');
|
|
176
|
+
await waitForMessage(alice, 'peers');
|
|
177
|
+
|
|
178
|
+
alice.send('not json');
|
|
179
|
+
const err = await waitForMessage(alice, 'error');
|
|
180
|
+
assert.strictEqual(err.message, 'Invalid JSON');
|
|
181
|
+
|
|
182
|
+
alice.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should reject messages without "to" field', async () => {
|
|
186
|
+
const alice = connectPeer('alice');
|
|
187
|
+
await waitForMessage(alice, 'peers');
|
|
188
|
+
|
|
189
|
+
alice.send(JSON.stringify({ type: 'offer', sdp: '...' }));
|
|
190
|
+
const err = await waitForMessage(alice, 'error');
|
|
191
|
+
assert.ok(err.message.includes('Missing'));
|
|
192
|
+
|
|
193
|
+
alice.close();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should error when target peer is not online', async () => {
|
|
197
|
+
const alice = connectPeer('alice');
|
|
198
|
+
await waitForMessage(alice, 'peers');
|
|
199
|
+
|
|
200
|
+
alice.send(JSON.stringify({
|
|
201
|
+
type: 'offer',
|
|
202
|
+
to: 'https://nobody.example/profile/card#me',
|
|
203
|
+
sdp: '...'
|
|
204
|
+
}));
|
|
205
|
+
const err = await waitForMessage(alice, 'error');
|
|
206
|
+
assert.ok(err.message.includes('not online'));
|
|
207
|
+
|
|
208
|
+
alice.close();
|
|
209
|
+
await new Promise(r => setTimeout(r, 50));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createServer } from './src/server.js';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
|
|
4
|
+
const PORT = 9877;
|
|
5
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
6
|
+
|
|
7
|
+
// Start server
|
|
8
|
+
const server = createServer({ logger: false, webrtc: true, forceCloseConnections: true });
|
|
9
|
+
await server.listen({ port: PORT, host: '127.0.0.1' });
|
|
10
|
+
console.log(`Server running on port ${PORT}`);
|
|
11
|
+
|
|
12
|
+
// Create two pods and get tokens
|
|
13
|
+
const aliceRes = await (await fetch(`${BASE}/.pods`, {
|
|
14
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
body: JSON.stringify({ name: 'alice' })
|
|
16
|
+
})).json();
|
|
17
|
+
|
|
18
|
+
const bobRes = await (await fetch(`${BASE}/.pods`, {
|
|
19
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify({ name: 'bob' })
|
|
21
|
+
})).json();
|
|
22
|
+
|
|
23
|
+
console.log(`Alice WebID: ${aliceRes.webId}`);
|
|
24
|
+
console.log(`Bob WebID: ${bobRes.webId}`);
|
|
25
|
+
|
|
26
|
+
function connectWs(token) {
|
|
27
|
+
return new WebSocket(`ws://127.0.0.1:${PORT}/.webrtc`, {
|
|
28
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function waitMsg(ws, type) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const timer = setTimeout(() => { ws.removeListener('message', h); reject(new Error(`Timeout: ${type}`)); }, 5000);
|
|
35
|
+
function h(data) {
|
|
36
|
+
const msg = JSON.parse(data.toString());
|
|
37
|
+
if (msg.type === type) { clearTimeout(timer); ws.removeListener('message', h); resolve(msg); }
|
|
38
|
+
}
|
|
39
|
+
ws.on('message', h);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 1. Alice connects
|
|
44
|
+
const alice = connectWs(aliceRes.token);
|
|
45
|
+
const alicePeers = await waitMsg(alice, 'peers');
|
|
46
|
+
console.log(`\n1. Alice connected — you: ${alicePeers.you}, peers: [${alicePeers.peers}]`);
|
|
47
|
+
|
|
48
|
+
// 2. Bob connects — alice should get peer-joined
|
|
49
|
+
const joinPromise = waitMsg(alice, 'peer-joined');
|
|
50
|
+
const bob = connectWs(bobRes.token);
|
|
51
|
+
const bobPeers = await waitMsg(bob, 'peers');
|
|
52
|
+
console.log(`2. Bob connected — you: ${bobPeers.you}, peers: [${bobPeers.peers}]`);
|
|
53
|
+
|
|
54
|
+
const joined = await joinPromise;
|
|
55
|
+
console.log(` Alice got peer-joined: ${joined.webId}`);
|
|
56
|
+
|
|
57
|
+
// 3. Alice sends offer to Bob
|
|
58
|
+
const offerPromise = waitMsg(bob, 'offer');
|
|
59
|
+
alice.send(JSON.stringify({ type: 'offer', to: bobPeers.you, sdp: 'v=0\r\nfake-sdp-offer' }));
|
|
60
|
+
const offer = await offerPromise;
|
|
61
|
+
console.log(`3. Bob received offer from ${offer.from} — sdp: "${offer.sdp}"`);
|
|
62
|
+
|
|
63
|
+
// 4. Bob sends answer to Alice
|
|
64
|
+
const answerPromise = waitMsg(alice, 'answer');
|
|
65
|
+
bob.send(JSON.stringify({ type: 'answer', to: alicePeers.you, sdp: 'v=0\r\nfake-sdp-answer' }));
|
|
66
|
+
const answer = await answerPromise;
|
|
67
|
+
console.log(`4. Alice received answer from ${answer.from} — sdp: "${answer.sdp}"`);
|
|
68
|
+
|
|
69
|
+
// 5. Alice sends ICE candidate to Bob
|
|
70
|
+
const candPromise = waitMsg(bob, 'candidate');
|
|
71
|
+
alice.send(JSON.stringify({ type: 'candidate', to: bobPeers.you, candidate: { candidate: 'candidate:1 1 UDP 12345 192.168.1.1 9999 typ host' } }));
|
|
72
|
+
const cand = await candPromise;
|
|
73
|
+
console.log(`5. Bob received ICE candidate from ${cand.from}`);
|
|
74
|
+
|
|
75
|
+
// 6. Alice sends hangup
|
|
76
|
+
const hangPromise = waitMsg(bob, 'hangup');
|
|
77
|
+
alice.send(JSON.stringify({ type: 'hangup', to: bobPeers.you }));
|
|
78
|
+
const hang = await hangPromise;
|
|
79
|
+
console.log(`6. Bob received hangup from ${hang.from}`);
|
|
80
|
+
|
|
81
|
+
// 7. Bob disconnects — alice gets peer-left
|
|
82
|
+
const leftPromise = waitMsg(alice, 'peer-left');
|
|
83
|
+
bob.close();
|
|
84
|
+
const left = await leftPromise;
|
|
85
|
+
console.log(`7. Alice got peer-left: ${left.webId}`);
|
|
86
|
+
|
|
87
|
+
alice.close();
|
|
88
|
+
await server.close();
|
|
89
|
+
console.log(`\n✅ All signaling steps passed!`);
|
|
90
|
+
process.exit(0);
|