javascript-solid-server 0.0.113 → 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.113",
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,46 +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
- // Authenticate the connection
224
- const { webId } = await getWebIdFromRequestAsync(request);
225
- if (!webId) {
226
- socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
227
- socket.close();
228
- return;
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
304
+ const queryToken = request.query?.token;
305
+ if (queryToken && !request.headers.authorization) {
306
+ request.headers.authorization = `Bearer ${queryToken}`;
229
307
  }
308
+ const { webId } = await getWebIdFromRequestAsync(request);
230
309
 
231
- // Assign a stable peer ID for content-addressed mode
310
+ // Assign a stable peer ID for content-addressed/tracker mode
232
311
  const peerId = String(nextPeerId++);
233
312
  socket._peerId = peerId;
234
313
 
235
- // Register this peer (close old connection if reconnecting)
236
- const existing = peers.get(webId);
237
- const isReconnect = !!existing;
238
- if (existing) {
239
- // Clean up old connection's resource memberships
240
- if (existing._peerId) removePeerFromAllResources(existing._peerId);
241
- peers.delete(webId);
242
- existing.close();
243
- }
244
- peers.set(webId, socket);
245
- socket.webId = webId;
246
-
247
- // Notify the peer of their identity and online peers
248
- socket.send(JSON.stringify({
249
- type: 'peers',
250
- you: webId,
251
- peerId: peerId,
252
- peers: [...peers.keys()].filter(id => id !== webId)
253
- }));
254
-
255
- // Only broadcast peer-joined for new connections, not reconnects
256
- if (!isReconnect) {
257
- 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
+ }
258
336
  }
259
337
 
260
338
  socket.on('message', (data) => {
@@ -273,6 +351,12 @@ export async function webrtcPlugin(fastify, options = {}) {
273
351
  return;
274
352
  }
275
353
 
354
+ // WebTorrent tracker protocol (uses 'action' instead of 'type')
355
+ if (msg.action === 'announce') {
356
+ handleWebtorrentAnnounce(socket, peerId, msg);
357
+ return;
358
+ }
359
+
276
360
  if (!msg.type) {
277
361
  socket.send(JSON.stringify({ type: 'error', message: 'Missing "type" field' }));
278
362
  return;
@@ -292,7 +376,11 @@ export async function webrtcPlugin(fastify, options = {}) {
292
376
  return;
293
377
  }
294
378
 
295
- // 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
+ }
296
384
  if (!msg.to) {
297
385
  socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" field' }));
298
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 () => {