javascript-solid-server 0.0.112 → 0.0.113

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.113",
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,6 +93,130 @@ 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
 
@@ -75,10 +228,16 @@ export async function webrtcPlugin(fastify, options = {}) {
75
228
  return;
76
229
  }
77
230
 
231
+ // Assign a stable peer ID for content-addressed mode
232
+ const peerId = String(nextPeerId++);
233
+ socket._peerId = peerId;
234
+
78
235
  // Register this peer (close old connection if reconnecting)
79
236
  const existing = peers.get(webId);
80
237
  const isReconnect = !!existing;
81
238
  if (existing) {
239
+ // Clean up old connection's resource memberships
240
+ if (existing._peerId) removePeerFromAllResources(existing._peerId);
82
241
  peers.delete(webId);
83
242
  existing.close();
84
243
  }
@@ -89,6 +248,7 @@ export async function webrtcPlugin(fastify, options = {}) {
89
248
  socket.send(JSON.stringify({
90
249
  type: 'peers',
91
250
  you: webId,
251
+ peerId: peerId,
92
252
  peers: [...peers.keys()].filter(id => id !== webId)
93
253
  }));
94
254
 
@@ -113,8 +273,28 @@ export async function webrtcPlugin(fastify, options = {}) {
113
273
  return;
114
274
  }
115
275
 
116
- if (!msg.to || !msg.type) {
117
- socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' }));
276
+ if (!msg.type) {
277
+ socket.send(JSON.stringify({ type: 'error', message: 'Missing "type" field' }));
278
+ return;
279
+ }
280
+
281
+ // Content-addressed messages
282
+ if (msg.type === 'announce') {
283
+ handleAnnounce(socket, peerId, msg);
284
+ return;
285
+ }
286
+ if (msg.type === 'answer' && msg.resource) {
287
+ handleResourceAnswer(socket, peerId, msg);
288
+ return;
289
+ }
290
+ if (msg.type === 'leave') {
291
+ handleLeave(socket, peerId, msg);
292
+ return;
293
+ }
294
+
295
+ // Identity-based messages require "to" field
296
+ if (!msg.to) {
297
+ socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" field' }));
118
298
  return;
119
299
  }
120
300
 
@@ -144,6 +324,9 @@ export async function webrtcPlugin(fastify, options = {}) {
144
324
  });
145
325
 
146
326
  socket.on('close', () => {
327
+ // Clean up content-addressed resource memberships
328
+ removePeerFromAllResources(peerId);
329
+
147
330
  // Only remove if this socket is still the registered one (not replaced by reconnect)
148
331
  if (peers.get(webId) === socket) {
149
332
  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
  });