javascript-solid-server 0.0.114 → 0.0.115

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.
@@ -328,7 +328,11 @@
328
328
  "Bash(mongosh --eval \"db.runCommand\\({ ping: 1 }\\)\" 2>&1 | head -5)",
329
329
  "Bash(which jss && jss --version 2>&1)",
330
330
  "Bash(jss start --help 2>&1 | grep -i mongo)",
331
- "Bash(grep -A5 '\"\"files\"\"' package.json)"
331
+ "Bash(grep -A5 '\"\"files\"\"' package.json)",
332
+ "Bash(mkdir -p /tmp/wt-check)",
333
+ "Bash(npm init:*)",
334
+ "WebFetch(domain:webtorrent.io)",
335
+ "Bash(TORRENT=/home/melvin/.claude/projects/-home-melvin-remote-github-com-JavaScriptSolidServer-JavaScriptSolidServer/a05da419-92b7-4056-93b8-e97b2035d4ae/tool-results/webfetch-1774004425803-fce7mx.bin npx -y parse-torrent $TORRENT)"
332
336
  ]
333
337
  }
334
338
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.114",
3
+ "version": "0.0.115",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -45,6 +45,10 @@ const MAX_OFFERS_PER_ANNOUNCE = 10;
45
45
  const MAX_RESOURCES_PER_PEER = 50;
46
46
  const RESOURCE_HASH_RE = /^[a-fA-F0-9]{8,128}$/;
47
47
 
48
+ // WebTorrent tracker uses 20-byte binary strings for info_hash and peer_id
49
+ function bin2hex(s) { return Buffer.from(s, 'binary').toString('hex'); }
50
+ function hex2bin(s) { return Buffer.from(s, 'hex').toString('binary'); }
51
+
48
52
  /**
49
53
  * Register WebRTC signaling routes on Fastify instance
50
54
  *
@@ -215,50 +219,120 @@ export async function webrtcPlugin(fastify, options = {}) {
215
219
  removePeerFromResource(peerId, hash);
216
220
  }
217
221
 
222
+ // --- WebTorrent tracker protocol handler ---
223
+
224
+ function handleWebtorrentAnnounce(socket, peerId, msg) {
225
+ const infoHash = typeof msg.info_hash === 'string' && msg.info_hash.length === 20
226
+ ? bin2hex(msg.info_hash) : msg.info_hash;
227
+ const msgPeerId = typeof msg.peer_id === 'string' && msg.peer_id.length === 20
228
+ ? bin2hex(msg.peer_id) : msg.peer_id;
229
+
230
+ if (!infoHash || typeof infoHash !== 'string') {
231
+ socket.send(JSON.stringify({ action: 'announce', 'failure reason': 'invalid info_hash' }));
232
+ return;
233
+ }
234
+
235
+ // Use info_hash as resource hash for our swarm infrastructure
236
+ const group = getResourcePeers(infoHash);
237
+ addPeerToResource(peerId, socket, infoHash);
238
+ socket._wtPeerId = msgPeerId || peerId;
239
+
240
+ // Handle answer relay
241
+ if (msg.answer && msg.to_peer_id) {
242
+ const toPeerId = typeof msg.to_peer_id === 'string' && msg.to_peer_id.length === 20
243
+ ? bin2hex(msg.to_peer_id) : msg.to_peer_id;
244
+
245
+ // Find the target peer by their WebTorrent peer_id
246
+ for (const [id, peerSocket] of group) {
247
+ if (peerSocket._wtPeerId === toPeerId && peerSocket.readyState === 1) {
248
+ try {
249
+ peerSocket.send(JSON.stringify({
250
+ action: 'announce',
251
+ answer: msg.answer,
252
+ offer_id: msg.offer_id,
253
+ peer_id: typeof msg.peer_id === 'string' && msg.peer_id.length === 20
254
+ ? msg.peer_id : hex2bin(msgPeerId || peerId),
255
+ info_hash: typeof msg.info_hash === 'string' && msg.info_hash.length === 20
256
+ ? msg.info_hash : hex2bin(infoHash)
257
+ }));
258
+ } catch { /* peer gone */ }
259
+ break;
260
+ }
261
+ }
262
+ return; // Don't send response for answers
263
+ }
264
+
265
+ // Relay offers to existing peers in the group
266
+ if (Array.isArray(msg.offers) && msg.offers.length > 0) {
267
+ const existingPeers = [...group.entries()].filter(([id]) => id !== peerId);
268
+ for (let i = 0; i < msg.offers.length && i < existingPeers.length; i++) {
269
+ const [, targetSocket] = existingPeers[i];
270
+ if (targetSocket.readyState !== 1) continue;
271
+ try {
272
+ targetSocket.send(JSON.stringify({
273
+ action: 'announce',
274
+ offer: msg.offers[i].offer,
275
+ offer_id: msg.offers[i].offer_id,
276
+ peer_id: typeof msg.peer_id === 'string' && msg.peer_id.length === 20
277
+ ? msg.peer_id : hex2bin(msgPeerId || peerId),
278
+ info_hash: typeof msg.info_hash === 'string' && msg.info_hash.length === 20
279
+ ? msg.info_hash : hex2bin(infoHash)
280
+ }));
281
+ } catch { /* peer gone */ }
282
+ }
283
+ }
284
+
285
+ // Send announce response
286
+ const response = {
287
+ action: 'announce',
288
+ info_hash: typeof msg.info_hash === 'string' && msg.info_hash.length === 20
289
+ ? msg.info_hash : hex2bin(infoHash),
290
+ complete: 0,
291
+ incomplete: group.size,
292
+ interval: 120
293
+ };
294
+ socket.send(JSON.stringify(response));
295
+ }
296
+
218
297
  // --- WebSocket handler ---
219
298
 
220
299
  fastify.get(path, { websocket: true }, async (connection, request) => {
221
300
  const socket = connection.socket;
222
301
 
223
302
  // Authenticate the connection (support query param for browser WebSocket which can't set headers)
303
+ // Auth is optional — unauthenticated clients can use the tracker protocol but not identity-based signaling
224
304
  const queryToken = request.query?.token;
225
305
  if (queryToken && !request.headers.authorization) {
226
306
  request.headers.authorization = `Bearer ${queryToken}`;
227
307
  }
228
308
  const { webId } = await getWebIdFromRequestAsync(request);
229
- if (!webId) {
230
- socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
231
- socket.close();
232
- return;
233
- }
234
309
 
235
- // Assign a stable peer ID for content-addressed mode
310
+ // Assign a stable peer ID for content-addressed/tracker mode
236
311
  const peerId = String(nextPeerId++);
237
312
  socket._peerId = peerId;
238
313
 
239
- // Register this peer (close old connection if reconnecting)
240
- const existing = peers.get(webId);
241
- const isReconnect = !!existing;
242
- if (existing) {
243
- // Clean up old connection's resource memberships
244
- if (existing._peerId) removePeerFromAllResources(existing._peerId);
245
- peers.delete(webId);
246
- existing.close();
247
- }
248
- peers.set(webId, socket);
249
- socket.webId = webId;
250
-
251
- // Notify the peer of their identity and online peers
252
- socket.send(JSON.stringify({
253
- type: 'peers',
254
- you: webId,
255
- peerId: peerId,
256
- peers: [...peers.keys()].filter(id => id !== webId)
257
- }));
258
-
259
- // Only broadcast peer-joined for new connections, not reconnects
260
- if (!isReconnect) {
261
- broadcast(webId, { type: 'peer-joined', webId });
314
+ if (webId) {
315
+ // Authenticated: register for identity-based signaling
316
+ const existing = peers.get(webId);
317
+ const isReconnect = !!existing;
318
+ if (existing) {
319
+ if (existing._peerId) removePeerFromAllResources(existing._peerId);
320
+ peers.delete(webId);
321
+ existing.close();
322
+ }
323
+ peers.set(webId, socket);
324
+ socket.webId = webId;
325
+
326
+ socket.send(JSON.stringify({
327
+ type: 'peers',
328
+ you: webId,
329
+ peerId: peerId,
330
+ peers: [...peers.keys()].filter(id => id !== webId)
331
+ }));
332
+
333
+ if (!isReconnect) {
334
+ broadcast(webId, { type: 'peer-joined', webId });
335
+ }
262
336
  }
263
337
 
264
338
  socket.on('message', (data) => {
@@ -277,6 +351,12 @@ export async function webrtcPlugin(fastify, options = {}) {
277
351
  return;
278
352
  }
279
353
 
354
+ // WebTorrent tracker protocol (uses 'action' instead of 'type')
355
+ if (msg.action === 'announce') {
356
+ handleWebtorrentAnnounce(socket, peerId, msg);
357
+ return;
358
+ }
359
+
280
360
  if (!msg.type) {
281
361
  socket.send(JSON.stringify({ type: 'error', message: 'Missing "type" field' }));
282
362
  return;
@@ -296,7 +376,11 @@ export async function webrtcPlugin(fastify, options = {}) {
296
376
  return;
297
377
  }
298
378
 
299
- // Identity-based messages require "to" field
379
+ // Identity-based messages require authentication and "to" field
380
+ if (!webId) {
381
+ socket.send(JSON.stringify({ type: 'error', message: 'Authentication required for identity-based signaling' }));
382
+ return;
383
+ }
300
384
  if (!msg.to) {
301
385
  socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" field' }));
302
386
  return;
@@ -85,13 +85,27 @@ describe('WebRTC Signaling', () => {
85
85
  }
86
86
 
87
87
  describe('Authentication', () => {
88
- it('should reject unauthenticated connections', async () => {
88
+ it('should allow unauthenticated connections for tracker protocol', async () => {
89
89
  const ws = new WebSocket(wsUrl);
90
+ await new Promise((resolve) => { ws.onopen = resolve; });
90
91
 
92
+ // Unauthenticated clients can use tracker protocol
93
+ ws.send(JSON.stringify({ action: 'announce', info_hash: '01234567890123456789', peer_id: '98765432109876543210', offers: [] }));
94
+ const msg = await waitForMessage(ws, 'announce', 3000).catch(() => null);
95
+ // Should get a response (not get disconnected)
96
+ ws.close();
97
+ await new Promise(r => setTimeout(r, 50));
98
+ });
99
+
100
+ it('should reject unauthenticated identity-based signaling', async () => {
101
+ const ws = new WebSocket(wsUrl);
102
+ await new Promise((resolve) => { ws.onopen = resolve; });
103
+
104
+ ws.send(JSON.stringify({ type: 'offer', to: 'someone', sdp: 'test' }));
91
105
  const msg = await waitForMessage(ws, 'error');
92
- assert.strictEqual(msg.type, 'error');
93
106
  assert.ok(msg.message.includes('Authentication'));
94
107
  ws.close();
108
+ await new Promise(r => setTimeout(r, 50));
95
109
  });
96
110
 
97
111
  it('should accept authenticated connections', async () => {