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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.107",
3
+ "version": "0.0.108",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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',
@@ -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
- // Check sat balance
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: 'Insufficient sat balance',
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 sats from buyer
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
- if (!success) {
990
- return reply.code(402).send({
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);