javascript-solid-server 0.0.106 → 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/PAY.md +30 -4
- package/README.md +79 -1
- package/bin/jss.js +8 -0
- package/package.json +1 -1
- package/src/config.js +8 -0
- package/src/handlers/pay.js +341 -18
- package/src/server.js +18 -1
- package/src/webledger.js +43 -15
- package/src/webrtc/index.js +159 -0
- package/test/pay.test.js +145 -0
- package/test/webledger.test.js +62 -0
- package/test/webrtc.test.js +212 -0
- package/test-webrtc-smoke.mjs +90 -0
|
@@ -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;
|
package/test/pay.test.js
CHANGED
|
@@ -546,6 +546,151 @@ describe('HTTP 402 Pay Middleware', () => {
|
|
|
546
546
|
});
|
|
547
547
|
});
|
|
548
548
|
|
|
549
|
+
describe('AMM with multi-chain', () => {
|
|
550
|
+
let ammServer;
|
|
551
|
+
let ammUrl;
|
|
552
|
+
const ammPrivkey = crypto.randomBytes(32);
|
|
553
|
+
const ammPubkey = Buffer.from(schnorr.getPublicKey(ammPrivkey)).toString('hex');
|
|
554
|
+
const ammPrivkey2 = crypto.randomBytes(32);
|
|
555
|
+
const ammPubkey2 = Buffer.from(schnorr.getPublicKey(ammPrivkey2)).toString('hex');
|
|
556
|
+
|
|
557
|
+
function ammNip98(pk, url, method = 'GET') {
|
|
558
|
+
const event = {
|
|
559
|
+
pubkey: Buffer.from(schnorr.getPublicKey(pk)).toString('hex'),
|
|
560
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
561
|
+
kind: 27235,
|
|
562
|
+
tags: [['u', url], ['method', method]],
|
|
563
|
+
content: ''
|
|
564
|
+
};
|
|
565
|
+
const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
|
|
566
|
+
event.id = crypto.createHash('sha256').update(serialized).digest('hex');
|
|
567
|
+
event.sig = Buffer.from(schnorr.sign(event.id, pk)).toString('hex');
|
|
568
|
+
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
before(async () => {
|
|
572
|
+
const { createServer } = await import('../src/server.js');
|
|
573
|
+
ammServer = createServer({
|
|
574
|
+
logger: false,
|
|
575
|
+
forceCloseConnections: true,
|
|
576
|
+
pay: true,
|
|
577
|
+
payCost: 1,
|
|
578
|
+
payChains: 'tbtc3,tbtc4'
|
|
579
|
+
});
|
|
580
|
+
await ammServer.listen({ port: 0, host: '127.0.0.1' });
|
|
581
|
+
const addr = ammServer.server.address();
|
|
582
|
+
ammUrl = `http://127.0.0.1:${addr.port}`;
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
after(async () => {
|
|
586
|
+
if (ammServer) await ammServer.close();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('GET /pay/.info should include chains and pool', async () => {
|
|
590
|
+
const res = await fetch(`${ammUrl}/pay/.info`);
|
|
591
|
+
assertStatus(res, 200);
|
|
592
|
+
const body = await res.json();
|
|
593
|
+
assert.ok(body.chains);
|
|
594
|
+
assert.strictEqual(body.chains.length, 2);
|
|
595
|
+
assert.strictEqual(body.chains[0].id, 'tbtc3');
|
|
596
|
+
assert.strictEqual(body.chains[1].id, 'tbtc4');
|
|
597
|
+
assert.strictEqual(body.pool, '/pay/.pool');
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('GET /pay/.pool should return empty pool', async () => {
|
|
601
|
+
const res = await fetch(`${ammUrl}/pay/.pool`);
|
|
602
|
+
assertStatus(res, 200);
|
|
603
|
+
const body = await res.json();
|
|
604
|
+
assert.strictEqual(body.reserves.tbtc3, 0);
|
|
605
|
+
assert.strictEqual(body.reserves.tbtc4, 0);
|
|
606
|
+
assert.strictEqual(body.k, 0);
|
|
607
|
+
assert.strictEqual(body.totalShares, 0);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('GET /pay/.balance should include per-chain balances', async () => {
|
|
611
|
+
const url = `${ammUrl}/pay/.balance`;
|
|
612
|
+
const res = await fetch(url, {
|
|
613
|
+
headers: { 'Authorization': ammNip98(ammPrivkey, url) }
|
|
614
|
+
});
|
|
615
|
+
assertStatus(res, 200);
|
|
616
|
+
const body = await res.json();
|
|
617
|
+
assert.ok(body.balances);
|
|
618
|
+
assert.strictEqual(body.balances.tbtc3, 0);
|
|
619
|
+
assert.strictEqual(body.balances.tbtc4, 0);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('POST /pay/.pool swap should fail with no liquidity', async () => {
|
|
623
|
+
const url = `${ammUrl}/pay/.pool`;
|
|
624
|
+
const res = await fetch(url, {
|
|
625
|
+
method: 'POST',
|
|
626
|
+
headers: {
|
|
627
|
+
'Authorization': ammNip98(ammPrivkey, url, 'POST'),
|
|
628
|
+
'Content-Type': 'application/json'
|
|
629
|
+
},
|
|
630
|
+
body: JSON.stringify({ action: 'swap', sell: 'tbtc3', amount: 100 })
|
|
631
|
+
});
|
|
632
|
+
assertStatus(res, 400);
|
|
633
|
+
const body = await res.json();
|
|
634
|
+
assert.ok(body.error.includes('no liquidity'));
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('POST /pay/.pool add-liquidity should fail with zero balance', async () => {
|
|
638
|
+
const url = `${ammUrl}/pay/.pool`;
|
|
639
|
+
const res = await fetch(url, {
|
|
640
|
+
method: 'POST',
|
|
641
|
+
headers: {
|
|
642
|
+
'Authorization': ammNip98(ammPrivkey, url, 'POST'),
|
|
643
|
+
'Content-Type': 'application/json'
|
|
644
|
+
},
|
|
645
|
+
body: JSON.stringify({ action: 'add-liquidity', tbtc3: 1000, tbtc4: 5000 })
|
|
646
|
+
});
|
|
647
|
+
assertStatus(res, 402);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('POST /pay/.pool should reject unknown action', async () => {
|
|
651
|
+
const url = `${ammUrl}/pay/.pool`;
|
|
652
|
+
const res = await fetch(url, {
|
|
653
|
+
method: 'POST',
|
|
654
|
+
headers: {
|
|
655
|
+
'Authorization': ammNip98(ammPrivkey, url, 'POST'),
|
|
656
|
+
'Content-Type': 'application/json'
|
|
657
|
+
},
|
|
658
|
+
body: JSON.stringify({ action: 'invalid' })
|
|
659
|
+
});
|
|
660
|
+
assertStatus(res, 400);
|
|
661
|
+
const body = await res.json();
|
|
662
|
+
assert.ok(body.error.includes('Unknown action'));
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('POST /pay/.pool swap should reject invalid sell unit', async () => {
|
|
666
|
+
const url = `${ammUrl}/pay/.pool`;
|
|
667
|
+
const res = await fetch(url, {
|
|
668
|
+
method: 'POST',
|
|
669
|
+
headers: {
|
|
670
|
+
'Authorization': ammNip98(ammPrivkey, url, 'POST'),
|
|
671
|
+
'Content-Type': 'application/json'
|
|
672
|
+
},
|
|
673
|
+
body: JSON.stringify({ action: 'swap', sell: 'invalid', amount: 100 })
|
|
674
|
+
});
|
|
675
|
+
assertStatus(res, 400);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('POST /pay/.pool remove-liquidity should fail with no pool', async () => {
|
|
679
|
+
const url = `${ammUrl}/pay/.pool`;
|
|
680
|
+
const res = await fetch(url, {
|
|
681
|
+
method: 'POST',
|
|
682
|
+
headers: {
|
|
683
|
+
'Authorization': ammNip98(ammPrivkey, url, 'POST'),
|
|
684
|
+
'Content-Type': 'application/json'
|
|
685
|
+
},
|
|
686
|
+
body: JSON.stringify({ action: 'remove-liquidity', shares: 10 })
|
|
687
|
+
});
|
|
688
|
+
assertStatus(res, 400);
|
|
689
|
+
const body = await res.json();
|
|
690
|
+
assert.ok(body.error.includes('no liquidity'));
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
|
|
549
694
|
describe('Pay disabled', () => {
|
|
550
695
|
let noPayServer;
|
|
551
696
|
let noPayUrl;
|
package/test/webledger.test.js
CHANGED
|
@@ -152,6 +152,68 @@ describe('Web Ledger', () => {
|
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
describe('multi-currency', () => {
|
|
156
|
+
it('should credit and debit with specific currency', () => {
|
|
157
|
+
const ledger = createLedger();
|
|
158
|
+
const bal = credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
|
|
159
|
+
assert.strictEqual(bal, 1000);
|
|
160
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc3'), 1000);
|
|
161
|
+
// Default balance should be 0 (no satoshi credits)
|
|
162
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1'), 0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should track multiple currencies independently', () => {
|
|
166
|
+
const ledger = createLedger();
|
|
167
|
+
credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
|
|
168
|
+
credit(ledger, 'did:nostr:user1', 5000, 'tbtc4');
|
|
169
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc3'), 1000);
|
|
170
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc4'), 5000);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should debit specific currency', () => {
|
|
174
|
+
const ledger = createLedger();
|
|
175
|
+
credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
|
|
176
|
+
credit(ledger, 'did:nostr:user1', 5000, 'tbtc4');
|
|
177
|
+
const result = debit(ledger, 'did:nostr:user1', 300, 'tbtc3');
|
|
178
|
+
assert.strictEqual(result.success, true);
|
|
179
|
+
assert.strictEqual(result.balance, 700);
|
|
180
|
+
// tbtc4 unchanged
|
|
181
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc4'), 5000);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should fail debit when currency balance insufficient', () => {
|
|
185
|
+
const ledger = createLedger();
|
|
186
|
+
credit(ledger, 'did:nostr:user1', 100, 'tbtc3');
|
|
187
|
+
const result = debit(ledger, 'did:nostr:user1', 200, 'tbtc3');
|
|
188
|
+
assert.strictEqual(result.success, false);
|
|
189
|
+
assert.strictEqual(result.balance, 100);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should migrate simple string to array on currency credit', () => {
|
|
193
|
+
const ledger = createLedger();
|
|
194
|
+
// First set a simple balance
|
|
195
|
+
setBalance(ledger, 'did:nostr:user1', 500);
|
|
196
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1'), 500);
|
|
197
|
+
// Now add a currency-specific balance — should migrate to array
|
|
198
|
+
credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
|
|
199
|
+
assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc3'), 1000);
|
|
200
|
+
// Old satoshi balance should be preserved in array
|
|
201
|
+
const entry = ledger.entries.find(e => e.url === 'did:nostr:user1');
|
|
202
|
+
assert.ok(Array.isArray(entry.amount));
|
|
203
|
+
const satEntry = entry.amount.find(a => a.currency === 'satoshi');
|
|
204
|
+
assert.strictEqual(parseInt(satEntry.value), 500);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should use array format in entries', () => {
|
|
208
|
+
const ledger = createLedger();
|
|
209
|
+
credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
|
|
210
|
+
const entry = ledger.entries.find(e => e.url === 'did:nostr:user1');
|
|
211
|
+
assert.ok(Array.isArray(entry.amount));
|
|
212
|
+
assert.strictEqual(entry.amount[0].currency, 'tbtc3');
|
|
213
|
+
assert.strictEqual(entry.amount[0].value, '1000');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
155
217
|
describe('URI format support', () => {
|
|
156
218
|
it('should work with did:nostr URIs', () => {
|
|
157
219
|
const ledger = createLedger();
|
|
@@ -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);
|