javascript-solid-server 0.0.112 → 0.0.114

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
@@ -107,12 +107,12 @@ Full options: [docs/configuration.md](docs/configuration.md)
107
107
 
108
108
  ## Comparison
109
109
 
110
- | Server | Size | Deps | Notes |
111
- |--------|------|------|-------|
112
- | [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) | ~18K LoC | 15 | Minimal, JSON-LD native |
113
- | [NSS](https://github.com/nodeSolidServer/node-solid-server) | ~25K LoC | 58 | Original Solid server |
114
- | [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) | ~65K LoC | 70 | Modular, configurable |
115
- | [Pivot](https://github.com/solid-contrib/pivot) | ~70K LoC | 70+ | Built on CSS |
110
+ | Server | Package | Packages | node_modules |
111
+ |--------|---------|----------|-------------|
112
+ | [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) | ~1 MB | ~191 | ~77 MB |
113
+ | [CSS](https://github.com/CommunitySolidServer/CommunitySolidServer) | ~6 MB | ~311 | ~152 MB |
114
+ | [Pivot](https://github.com/solid-contrib/pivot) | ~6 MB | ~311+ | ~152 MB |
115
+ | [NSS](https://github.com/nodeSolidServer/node-solid-server) | ~7 MB | ~670 | ~539 MB |
116
116
 
117
117
  ## Performance
118
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.112",
3
+ "version": "0.0.114",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -2,13 +2,18 @@
2
2
  * WebRTC Signaling Server Plugin
3
3
  *
4
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.
5
+ * Supports two discovery modes:
6
+ *
7
+ * 1. Identity-based — connect to a specific peer by WebID
8
+ * 2. Content-addressed — find peers sharing the same resource hash
9
+ *
10
+ * Relays SDP offers/answers and ICE candidates between peers.
11
+ * The actual media/data flows directly between peers — JSS just introduces them.
7
12
  *
8
13
  * Usage: jss start --webrtc
9
14
  * Endpoint: wss://your.pod/.webrtc
10
15
  *
11
- * Protocol (JSON over WebSocket):
16
+ * Identity-based protocol (JSON over WebSocket):
12
17
  * → { type: "offer", to: "<webid>", sdp: "..." }
13
18
  * → { type: "answer", to: "<webid>", sdp: "..." }
14
19
  * → { type: "candidate", to: "<webid>", candidate: {...} }
@@ -21,6 +26,14 @@
21
26
  * ← { type: "peers", you: "<webid>", peers: ["<webid>", ...] }
22
27
  * ← { type: "peer-joined", webId: "<webid>" }
23
28
  * ← { type: "peer-left", webId: "<webid>" }
29
+ *
30
+ * Content-addressed protocol (JSON over WebSocket):
31
+ * → { type: "announce", resource: "<hash>", offers: [{ sdp: "...", offer_id: "..." }, ...] }
32
+ * → { type: "answer", resource: "<hash>", to: "<peer_id>", offer_id: "...", sdp: "..." }
33
+ * → { type: "leave", resource: "<hash>" }
34
+ * ← { type: "offer", resource: "<hash>", from: "<peer_id>", offer_id: "...", sdp: "..." }
35
+ * ← { type: "answer", resource: "<hash>", from: "<peer_id>", offer_id: "...", sdp: "..." }
36
+ * ← { type: "resource-peers", resource: "<hash>", count: <n> }
24
37
  */
25
38
 
26
39
  import websocket from '@fastify/websocket';
@@ -28,6 +41,9 @@ import { getWebIdFromRequestAsync } from '../auth/token.js';
28
41
 
29
42
  const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']);
30
43
  const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
44
+ const MAX_OFFERS_PER_ANNOUNCE = 10;
45
+ const MAX_RESOURCES_PER_PEER = 50;
46
+ const RESOURCE_HASH_RE = /^[a-fA-F0-9]{8,128}$/;
31
47
 
32
48
  /**
33
49
  * Register WebRTC signaling routes on Fastify instance
@@ -39,9 +55,20 @@ const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB
39
55
  export async function webrtcPlugin(fastify, options = {}) {
40
56
  const path = options.path || '/.webrtc';
41
57
 
42
- // Instance-scoped peer state
58
+ // Instance-scoped peer state (identity-based)
43
59
  const peers = new Map();
44
60
 
61
+ // Instance-scoped resource state (content-addressed)
62
+ // Map<resourceHash, Map<peerId, socket>>
63
+ const resources = new Map();
64
+
65
+ // Track which resources each peer has joined
66
+ // Map<peerId, Set<resourceHash>>
67
+ const peerResources = new Map();
68
+
69
+ // Peer ID counter for content-addressed mode
70
+ let nextPeerId = 1;
71
+
45
72
  // Only register @fastify/websocket if not already registered
46
73
  if (!fastify.websocketServer) {
47
74
  await fastify.register(websocket);
@@ -53,6 +80,8 @@ export async function webrtcPlugin(fastify, options = {}) {
53
80
  socket.close();
54
81
  }
55
82
  peers.clear();
83
+ resources.clear();
84
+ peerResources.clear();
56
85
  });
57
86
 
58
87
  function broadcast(senderWebId, msg) {
@@ -64,10 +93,138 @@ export async function webrtcPlugin(fastify, options = {}) {
64
93
  }
65
94
  }
66
95
 
96
+ // --- Content-addressed helpers ---
97
+
98
+ function getResourcePeers(resourceHash) {
99
+ let group = resources.get(resourceHash);
100
+ if (!group) {
101
+ group = new Map();
102
+ resources.set(resourceHash, group);
103
+ }
104
+ return group;
105
+ }
106
+
107
+ function addPeerToResource(peerId, socket, resourceHash) {
108
+ const group = getResourcePeers(resourceHash);
109
+ group.set(peerId, socket);
110
+
111
+ let tracked = peerResources.get(peerId);
112
+ if (!tracked) {
113
+ tracked = new Set();
114
+ peerResources.set(peerId, tracked);
115
+ }
116
+ tracked.add(resourceHash);
117
+ }
118
+
119
+ function removePeerFromResource(peerId, resourceHash) {
120
+ const group = resources.get(resourceHash);
121
+ if (group) {
122
+ group.delete(peerId);
123
+ if (group.size === 0) resources.delete(resourceHash);
124
+ }
125
+ const tracked = peerResources.get(peerId);
126
+ if (tracked) {
127
+ tracked.delete(resourceHash);
128
+ if (tracked.size === 0) peerResources.delete(peerId);
129
+ }
130
+ }
131
+
132
+ function removePeerFromAllResources(peerId) {
133
+ const tracked = peerResources.get(peerId);
134
+ if (!tracked) return;
135
+ for (const hash of tracked) {
136
+ const group = resources.get(hash);
137
+ if (group) {
138
+ group.delete(peerId);
139
+ if (group.size === 0) resources.delete(hash);
140
+ }
141
+ }
142
+ peerResources.delete(peerId);
143
+ }
144
+
145
+ function handleAnnounce(socket, peerId, msg) {
146
+ const hash = msg.resource;
147
+ if (!hash || typeof hash !== 'string' || !RESOURCE_HASH_RE.test(hash)) {
148
+ socket.send(JSON.stringify({ type: 'error', message: 'Invalid resource hash' }));
149
+ return;
150
+ }
151
+
152
+ // Limit resources per peer
153
+ const tracked = peerResources.get(peerId);
154
+ if (tracked && tracked.size >= MAX_RESOURCES_PER_PEER && !tracked.has(hash)) {
155
+ socket.send(JSON.stringify({ type: 'error', message: 'Too many resources' }));
156
+ return;
157
+ }
158
+
159
+ const group = getResourcePeers(hash);
160
+ addPeerToResource(peerId, socket, hash);
161
+
162
+ // Relay offers to existing peers in the group
163
+ const offers = Array.isArray(msg.offers) ? msg.offers.slice(0, MAX_OFFERS_PER_ANNOUNCE) : [];
164
+ const existingPeers = [...group.entries()].filter(([id]) => id !== peerId);
165
+
166
+ for (let i = 0; i < offers.length && i < existingPeers.length; i++) {
167
+ const offer = offers[i];
168
+ const [targetId, targetSocket] = existingPeers[i];
169
+ if (targetSocket.readyState !== 1) continue;
170
+ if (typeof offer.sdp !== 'string') continue;
171
+
172
+ const relay = Object.create(null);
173
+ relay.type = 'offer';
174
+ relay.resource = hash;
175
+ relay.from = peerId;
176
+ relay.offer_id = typeof offer.offer_id === 'string' ? offer.offer_id : String(i);
177
+ relay.sdp = offer.sdp;
178
+ try { targetSocket.send(JSON.stringify(relay)); } catch { /* peer gone */ }
179
+ }
180
+
181
+ // Tell the announcer how many peers are in the group
182
+ socket.send(JSON.stringify({
183
+ type: 'resource-peers',
184
+ resource: hash,
185
+ count: group.size - 1
186
+ }));
187
+ }
188
+
189
+ function handleResourceAnswer(socket, peerId, msg) {
190
+ const hash = msg.resource;
191
+ if (!hash || typeof hash !== 'string') return;
192
+
193
+ const group = resources.get(hash);
194
+ if (!group) return;
195
+
196
+ const targetId = msg.to;
197
+ const targetSocket = group.get(targetId);
198
+ if (!targetSocket || targetSocket.readyState !== 1) {
199
+ socket.send(JSON.stringify({ type: 'error', message: 'Peer not in resource group' }));
200
+ return;
201
+ }
202
+
203
+ const relay = Object.create(null);
204
+ relay.type = 'answer';
205
+ relay.resource = hash;
206
+ relay.from = peerId;
207
+ if (typeof msg.offer_id === 'string') relay.offer_id = msg.offer_id;
208
+ if (typeof msg.sdp === 'string') relay.sdp = msg.sdp;
209
+ try { targetSocket.send(JSON.stringify(relay)); } catch { /* peer gone */ }
210
+ }
211
+
212
+ function handleLeave(socket, peerId, msg) {
213
+ const hash = msg.resource;
214
+ if (!hash || typeof hash !== 'string') return;
215
+ removePeerFromResource(peerId, hash);
216
+ }
217
+
218
+ // --- WebSocket handler ---
219
+
67
220
  fastify.get(path, { websocket: true }, async (connection, request) => {
68
221
  const socket = connection.socket;
69
222
 
70
- // Authenticate the connection
223
+ // Authenticate the connection (support query param for browser WebSocket which can't set headers)
224
+ const queryToken = request.query?.token;
225
+ if (queryToken && !request.headers.authorization) {
226
+ request.headers.authorization = `Bearer ${queryToken}`;
227
+ }
71
228
  const { webId } = await getWebIdFromRequestAsync(request);
72
229
  if (!webId) {
73
230
  socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
@@ -75,10 +232,16 @@ export async function webrtcPlugin(fastify, options = {}) {
75
232
  return;
76
233
  }
77
234
 
235
+ // Assign a stable peer ID for content-addressed mode
236
+ const peerId = String(nextPeerId++);
237
+ socket._peerId = peerId;
238
+
78
239
  // Register this peer (close old connection if reconnecting)
79
240
  const existing = peers.get(webId);
80
241
  const isReconnect = !!existing;
81
242
  if (existing) {
243
+ // Clean up old connection's resource memberships
244
+ if (existing._peerId) removePeerFromAllResources(existing._peerId);
82
245
  peers.delete(webId);
83
246
  existing.close();
84
247
  }
@@ -89,6 +252,7 @@ export async function webrtcPlugin(fastify, options = {}) {
89
252
  socket.send(JSON.stringify({
90
253
  type: 'peers',
91
254
  you: webId,
255
+ peerId: peerId,
92
256
  peers: [...peers.keys()].filter(id => id !== webId)
93
257
  }));
94
258
 
@@ -113,8 +277,28 @@ export async function webrtcPlugin(fastify, options = {}) {
113
277
  return;
114
278
  }
115
279
 
116
- if (!msg.to || !msg.type) {
117
- socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' }));
280
+ if (!msg.type) {
281
+ socket.send(JSON.stringify({ type: 'error', message: 'Missing "type" field' }));
282
+ return;
283
+ }
284
+
285
+ // Content-addressed messages
286
+ if (msg.type === 'announce') {
287
+ handleAnnounce(socket, peerId, msg);
288
+ return;
289
+ }
290
+ if (msg.type === 'answer' && msg.resource) {
291
+ handleResourceAnswer(socket, peerId, msg);
292
+ return;
293
+ }
294
+ if (msg.type === 'leave') {
295
+ handleLeave(socket, peerId, msg);
296
+ return;
297
+ }
298
+
299
+ // Identity-based messages require "to" field
300
+ if (!msg.to) {
301
+ socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" field' }));
118
302
  return;
119
303
  }
120
304
 
@@ -144,6 +328,9 @@ export async function webrtcPlugin(fastify, options = {}) {
144
328
  });
145
329
 
146
330
  socket.on('close', () => {
331
+ // Clean up content-addressed resource memberships
332
+ removePeerFromAllResources(peerId);
333
+
147
334
  // Only remove if this socket is still the registered one (not replaced by reconnect)
148
335
  if (peers.get(webId) === socket) {
149
336
  peers.delete(webId);
@@ -209,4 +209,158 @@ describe('WebRTC Signaling', () => {
209
209
  await new Promise(r => setTimeout(r, 50));
210
210
  });
211
211
  });
212
+
213
+ describe('Content-Addressed Peer Discovery', () => {
214
+ const RESOURCE_HASH = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
215
+
216
+ it('should return peer count on announce', async () => {
217
+ const { ws: alice } = await connectAndWait('alice');
218
+
219
+ alice.send(JSON.stringify({
220
+ type: 'announce',
221
+ resource: RESOURCE_HASH,
222
+ offers: []
223
+ }));
224
+
225
+ const msg = await waitForMessage(alice, 'resource-peers');
226
+ assert.strictEqual(msg.resource, RESOURCE_HASH);
227
+ assert.strictEqual(msg.count, 0);
228
+
229
+ alice.close();
230
+ await new Promise(r => setTimeout(r, 50));
231
+ });
232
+
233
+ it('should relay offers between peers sharing a resource', async () => {
234
+ const { ws: alice, peerId: alicePeerId } = await connectAndWait('alice');
235
+
236
+ // Alice announces with no offers (first in group)
237
+ alice.send(JSON.stringify({
238
+ type: 'announce',
239
+ resource: RESOURCE_HASH,
240
+ offers: []
241
+ }));
242
+ await waitForMessage(alice, 'resource-peers');
243
+
244
+ // Bob announces with an offer — should be relayed to Alice
245
+ const offerPromise = waitForMessage(alice, 'offer');
246
+ const { ws: bob, peerId: bobPeerId } = await connectAndWait('bob');
247
+
248
+ bob.send(JSON.stringify({
249
+ type: 'announce',
250
+ resource: RESOURCE_HASH,
251
+ offers: [{ sdp: 'v=0\r\nbob-offer', offer_id: 'offer1' }]
252
+ }));
253
+
254
+ const offer = await offerPromise;
255
+ assert.strictEqual(offer.type, 'offer');
256
+ assert.strictEqual(offer.resource, RESOURCE_HASH);
257
+ assert.strictEqual(offer.from, bobPeerId);
258
+ assert.strictEqual(offer.offer_id, 'offer1');
259
+ assert.ok(offer.sdp.includes('bob-offer'));
260
+
261
+ // Alice answers Bob
262
+ const answerPromise = waitForMessage(bob, 'answer');
263
+ alice.send(JSON.stringify({
264
+ type: 'answer',
265
+ resource: RESOURCE_HASH,
266
+ to: bobPeerId,
267
+ offer_id: 'offer1',
268
+ sdp: 'v=0\r\nalice-answer'
269
+ }));
270
+
271
+ const answer = await answerPromise;
272
+ assert.strictEqual(answer.type, 'answer');
273
+ assert.strictEqual(answer.resource, RESOURCE_HASH);
274
+ assert.strictEqual(answer.from, alicePeerId);
275
+ assert.ok(answer.sdp.includes('alice-answer'));
276
+
277
+ alice.close();
278
+ bob.close();
279
+ await new Promise(r => setTimeout(r, 50));
280
+ });
281
+
282
+ it('should clean up resources on disconnect', async () => {
283
+ const { ws: alice } = await connectAndWait('alice');
284
+
285
+ alice.send(JSON.stringify({
286
+ type: 'announce',
287
+ resource: RESOURCE_HASH,
288
+ offers: []
289
+ }));
290
+ await waitForMessage(alice, 'resource-peers');
291
+
292
+ // Bob joins the resource group
293
+ const { ws: bob } = await connectAndWait('bob');
294
+ bob.send(JSON.stringify({
295
+ type: 'announce',
296
+ resource: RESOURCE_HASH,
297
+ offers: []
298
+ }));
299
+ const bobPeers = await waitForMessage(bob, 'resource-peers');
300
+ assert.strictEqual(bobPeers.count, 1); // alice is there
301
+
302
+ // Alice disconnects
303
+ alice.close();
304
+ await new Promise(r => setTimeout(r, 200));
305
+
306
+ // Charlie joins — should see only bob
307
+ const { ws: charlie } = await connectAndWait('bob'); // reuse bob pod
308
+ charlie.send(JSON.stringify({
309
+ type: 'announce',
310
+ resource: RESOURCE_HASH,
311
+ offers: []
312
+ }));
313
+ const charliePeers = await waitForMessage(charlie, 'resource-peers');
314
+ // bob was reconnected (old connection closed), so count depends on timing
315
+ assert.ok(charliePeers.count >= 0);
316
+
317
+ bob.close();
318
+ charlie.close();
319
+ await new Promise(r => setTimeout(r, 50));
320
+ });
321
+
322
+ it('should handle leave message', async () => {
323
+ const { ws: alice } = await connectAndWait('alice');
324
+
325
+ alice.send(JSON.stringify({
326
+ type: 'announce',
327
+ resource: RESOURCE_HASH,
328
+ offers: []
329
+ }));
330
+ await waitForMessage(alice, 'resource-peers');
331
+
332
+ // Leave the resource group
333
+ alice.send(JSON.stringify({ type: 'leave', resource: RESOURCE_HASH }));
334
+
335
+ // Bob joins — should see 0 peers (alice left)
336
+ const { ws: bob } = await connectAndWait('bob');
337
+ bob.send(JSON.stringify({
338
+ type: 'announce',
339
+ resource: RESOURCE_HASH,
340
+ offers: []
341
+ }));
342
+ const msg = await waitForMessage(bob, 'resource-peers');
343
+ assert.strictEqual(msg.count, 0);
344
+
345
+ alice.close();
346
+ bob.close();
347
+ await new Promise(r => setTimeout(r, 50));
348
+ });
349
+
350
+ it('should reject invalid resource hash', async () => {
351
+ const { ws: alice } = await connectAndWait('alice');
352
+
353
+ alice.send(JSON.stringify({
354
+ type: 'announce',
355
+ resource: 'not-a-hex-hash!',
356
+ offers: []
357
+ }));
358
+
359
+ const err = await waitForMessage(alice, 'error');
360
+ assert.ok(err.message.includes('Invalid resource hash'));
361
+
362
+ alice.close();
363
+ await new Promise(r => setTimeout(r, 50));
364
+ });
365
+ });
212
366
  });