javascript-solid-server 0.0.107 → 0.0.109

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,10 @@ 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 |
167
+ | `--tunnel` | Enable tunnel proxy (decentralized ngrok) | false |
168
+ | `--tunnel-path <path>` | Tunnel WebSocket path | /.tunnel |
165
169
  | `-q, --quiet` | Suppress logs | false |
166
170
 
167
171
  ### Environment Variables
@@ -195,6 +199,7 @@ export JSS_PAY_RATE=10
195
199
  export JSS_MONGO=true
196
200
  export JSS_MONGO_URL=mongodb://localhost:27017
197
201
  export JSS_MONGO_DATABASE=solid
202
+ export JSS_WEBRTC=true
198
203
  jss start
199
204
  ```
200
205
 
@@ -823,6 +828,72 @@ curl -X DELETE http://localhost:3000/db/alice/notes/1 \
823
828
 
824
829
  Supported formats: `50MB`, `1GB`, `500KB`, `1TB`
825
830
 
831
+ ## WebRTC Signaling
832
+
833
+ Peer-to-peer communication via WebRTC, using JSS as the signaling server. Once peers are connected, all media and data flows directly between them.
834
+
835
+ ```bash
836
+ jss start --webrtc
837
+ ```
838
+
839
+ ### How It Works
840
+
841
+ 1. Both peers connect to `wss://your.pod/.webrtc` (WebID auth required)
842
+ 2. Caller sends an SDP offer targeting the callee's WebID
843
+ 3. JSS relays the offer/answer and ICE candidates between peers
844
+ 4. Once a direct path is found, the peer-to-peer connection is established
845
+ 5. JSS steps out — video, audio, files, and data flow directly between peers
846
+
847
+ ### Protocol
848
+
849
+ Messages are JSON over WebSocket:
850
+
851
+ ```js
852
+ // Send an offer to another user
853
+ { "type": "offer", "to": "https://bob.example/profile/card#me", "sdp": "..." }
854
+
855
+ // Receive an offer from another user
856
+ { "type": "offer", "from": "https://alice.example/profile/card#me", "sdp": "..." }
857
+
858
+ // ICE candidate exchange
859
+ { "type": "candidate", "to": "https://bob.example/profile/card#me", "candidate": {...} }
860
+
861
+ // Hang up
862
+ { "type": "hangup", "to": "https://bob.example/profile/card#me" }
863
+ ```
864
+
865
+ On connect, peers receive a list of online users and get notified when others join or leave.
866
+
867
+ ## Tunnel Proxy (Decentralized ngrok)
868
+
869
+ Expose a local dev server to the internet through your JSS pod. A tunnel client connects via WebSocket, registers a name, and receives proxied HTTP requests.
870
+
871
+ ```bash
872
+ jss start --tunnel
873
+ ```
874
+
875
+ ### How It Works
876
+
877
+ 1. Tunnel client connects to `wss://your.pod/.tunnel` (WebID auth required)
878
+ 2. Client registers a name: `{ "type": "register", "name": "myapp" }`
879
+ 3. Public URL becomes available at `https://your.pod/tunnel/myapp/`
880
+ 4. HTTP requests to that URL are serialized and sent to the tunnel client over WebSocket
881
+ 5. Tunnel client forwards to localhost, returns the response
882
+
883
+ ### Tunnel Client Protocol
884
+
885
+ ```js
886
+ // 1. Register a tunnel
887
+ → { "type": "register", "name": "myapp" }
888
+ ← { "type": "registered", "name": "myapp", "url": "/tunnel/myapp/" }
889
+
890
+ // 2. Receive proxied HTTP requests
891
+ ← { "type": "request", "id": "uuid", "method": "GET", "path": "/api/hello", "headers": {...} }
892
+
893
+ // 3. Return the response
894
+ → { "type": "response", "id": "uuid", "status": 200, "headers": {...}, "body": "..." }
895
+ ```
896
+
826
897
  ## HTTP 402 Paid Access
827
898
 
828
899
  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,12 @@ 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)')
71
+ .option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
72
+ .option('--no-tunnel', 'Disable tunnel proxy')
73
+ .option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
68
74
  .option('--activitypub', 'Enable ActivityPub federation')
69
75
  .option('--no-activitypub', 'Disable ActivityPub federation')
70
76
  .option('--ap-username <name>', 'ActivityPub username (default: me)')
@@ -142,6 +148,10 @@ program
142
148
  nostr: config.nostr,
143
149
  nostrPath: config.nostrPath,
144
150
  nostrMaxEvents: config.nostrMaxEvents,
151
+ webrtc: config.webrtc,
152
+ webrtcPath: config.webrtcPath,
153
+ tunnel: config.tunnel,
154
+ tunnelPath: config.tunnelPath,
145
155
  activitypub: config.activitypub,
146
156
  apUsername: config.apUsername,
147
157
  apDisplayName: config.apDisplayName,
@@ -186,6 +196,8 @@ program
186
196
  if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
187
197
  if (config.git) console.log(' Git: enabled (clone/push support)');
188
198
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
199
+ if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
200
+ if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
189
201
  if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
190
202
  if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
191
203
  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.109",
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,14 @@ export const defaults = {
54
54
  nostrPath: '/relay',
55
55
  nostrMaxEvents: 1000,
56
56
 
57
+ // WebRTC signaling
58
+ webrtc: false,
59
+ webrtcPath: '/.webrtc',
60
+
61
+ // Tunnel (decentralized ngrok)
62
+ tunnel: false,
63
+ tunnelPath: '/.tunnel',
64
+
57
65
  // ActivityPub federation
58
66
  activitypub: false,
59
67
  apUsername: 'me',
@@ -134,6 +142,10 @@ const envMap = {
134
142
  JSS_NOSTR: 'nostr',
135
143
  JSS_NOSTR_PATH: 'nostrPath',
136
144
  JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
145
+ JSS_WEBRTC: 'webrtc',
146
+ JSS_WEBRTC_PATH: 'webrtcPath',
147
+ JSS_TUNNEL: 'tunnel',
148
+ JSS_TUNNEL_PATH: 'tunnelPath',
137
149
  JSS_ACTIVITYPUB: 'activitypub',
138
150
  JSS_AP_USERNAME: 'apUsername',
139
151
  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,8 @@ 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';
22
+ import { tunnelPlugin } from './tunnel/index.js';
21
23
 
22
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
25
 
@@ -74,6 +76,12 @@ export function createServer(options = {}) {
74
76
  const nostrEnabled = options.nostr ?? false;
75
77
  const nostrPath = options.nostrPath ?? '/relay';
76
78
  const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
79
+ // WebRTC signaling is OFF by default
80
+ const webrtcEnabled = options.webrtc ?? false;
81
+ const webrtcPath = options.webrtcPath ?? '/.webrtc';
82
+ // Tunnel proxy is OFF by default
83
+ const tunnelEnabled = options.tunnel ?? false;
84
+ const tunnelPath = options.tunnelPath ?? '/.tunnel';
77
85
  // ActivityPub federation is OFF by default
78
86
  const activitypubEnabled = options.activitypub ?? false;
79
87
  const apUsername = options.apUsername ?? 'me';
@@ -240,6 +248,16 @@ export function createServer(options = {}) {
240
248
  });
241
249
  }
242
250
 
251
+ // Register WebRTC signaling if enabled
252
+ if (webrtcEnabled) {
253
+ fastify.register(webrtcPlugin, { path: webrtcPath });
254
+ }
255
+
256
+ // Register tunnel proxy if enabled
257
+ if (tunnelEnabled) {
258
+ fastify.register(tunnelPlugin, { path: tunnelPath });
259
+ }
260
+
243
261
  // Register ActivityPub plugin if enabled
244
262
  if (activitypubEnabled) {
245
263
  fastify.register(activityPubPlugin, {
@@ -331,6 +349,15 @@ export function createServer(options = {}) {
331
349
  return;
332
350
  }
333
351
 
352
+ // Allow WebRTC and tunnel endpoints through when enabled
353
+ const urlNoQuery = request.url.split('?')[0];
354
+ if (tunnelEnabled && (urlNoQuery === tunnelPath || urlNoQuery.startsWith('/tunnel/'))) {
355
+ return;
356
+ }
357
+ if (webrtcEnabled && urlNoQuery === webrtcPath) {
358
+ return;
359
+ }
360
+
334
361
  const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
335
362
  const hasForbiddenDotfile = segments.some(seg =>
336
363
  seg.startsWith('.') &&
@@ -405,6 +432,8 @@ export function createServer(options = {}) {
405
432
  request.url.startsWith('/storage/') ||
406
433
  (payEnabled && isPayRequest(request.url)) ||
407
434
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
435
+ (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
436
+ (tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
408
437
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
409
438
  return;
410
439
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tunnel Plugin — Decentralized ngrok
3
+ *
4
+ * Tunnels HTTP traffic to a local dev server through JSS via WebSocket.
5
+ * A tunnel client connects over WebSocket, registers a name, and receives
6
+ * proxied HTTP requests which it forwards to localhost.
7
+ *
8
+ * Usage: jss start --tunnel
9
+ * Tunnel client connects to: wss://your.pod/.tunnel
10
+ * Public URL: https://your.pod/tunnel/{name}/path
11
+ *
12
+ * Tunnel client protocol (JSON over WebSocket):
13
+ * → { type: "register", name: "myapp" }
14
+ * ← { type: "registered", name: "myapp", url: "/tunnel/myapp/" }
15
+ * ← { type: "request", id: "<uuid>", method: "GET", path: "/api/hello", headers: {...}, body: "..." }
16
+ * → { type: "response", id: "<uuid>", status: 200, headers: {...}, body: "..." }
17
+ * ← { type: "error", message: "..." }
18
+ */
19
+
20
+ import websocket from '@fastify/websocket';
21
+ import { getWebIdFromRequestAsync } from '../auth/token.js';
22
+ import { randomUUID } from 'crypto';
23
+
24
+ const REQUEST_TIMEOUT = 30000; // 30s timeout for tunnel responses
25
+ const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
26
+
27
+ /**
28
+ * @param {object} fastify - Fastify instance
29
+ * @param {object} options - Options
30
+ * @param {string} options.path - WebSocket path for tunnel clients (default: '/.tunnel')
31
+ */
32
+ export async function tunnelPlugin(fastify, options = {}) {
33
+ const wsPath = options.path || '/.tunnel';
34
+
35
+ // Instance-scoped: tunnel name → { socket, webId }
36
+ const tunnels = new Map();
37
+ // Pending HTTP requests waiting for tunnel response: id → { resolve, timer }
38
+ const pending = new Map();
39
+
40
+ if (!fastify.websocketServer) {
41
+ await fastify.register(websocket);
42
+ }
43
+
44
+ fastify.addHook('onClose', async () => {
45
+ for (const [, tunnel] of tunnels) {
46
+ tunnel.socket.close();
47
+ }
48
+ tunnels.clear();
49
+ for (const [, p] of pending) {
50
+ clearTimeout(p.timer);
51
+ p.resolve({ status: 502, headers: {}, body: 'Tunnel shutting down' });
52
+ }
53
+ pending.clear();
54
+ });
55
+
56
+ // WebSocket endpoint for tunnel clients
57
+ fastify.get(wsPath, { websocket: true }, async (connection, request) => {
58
+ const socket = connection.socket;
59
+
60
+ // Authenticate
61
+ const { webId } = await getWebIdFromRequestAsync(request);
62
+ if (!webId) {
63
+ socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
64
+ socket.close();
65
+ return;
66
+ }
67
+
68
+ let tunnelName = null;
69
+
70
+ socket.on('message', (data) => {
71
+ const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
72
+ if (raw.byteLength > MAX_MESSAGE_SIZE) {
73
+ socket.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
74
+ return;
75
+ }
76
+
77
+ let msg;
78
+ try {
79
+ msg = JSON.parse(raw.toString());
80
+ } catch {
81
+ socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
82
+ return;
83
+ }
84
+
85
+ if (msg.type === 'register') {
86
+ // Register a tunnel name
87
+ const name = (msg.name || '').replace(/[^a-zA-Z0-9_-]/g, '');
88
+ if (!name) {
89
+ socket.send(JSON.stringify({ type: 'error', message: 'Invalid tunnel name' }));
90
+ return;
91
+ }
92
+
93
+ const existing = tunnels.get(name);
94
+ if (existing && existing.webId !== webId) {
95
+ socket.send(JSON.stringify({ type: 'error', message: 'Tunnel name taken by another user' }));
96
+ return;
97
+ }
98
+
99
+ // Close old tunnel with same name from same user
100
+ if (existing) {
101
+ existing.socket.close();
102
+ }
103
+
104
+ tunnelName = name;
105
+ tunnels.set(name, { socket, webId });
106
+ socket.send(JSON.stringify({ type: 'registered', name, url: `/tunnel/${name}/` }));
107
+
108
+ } else if (msg.type === 'response') {
109
+ // Tunnel client returning an HTTP response
110
+ if (!msg.id) return;
111
+ const p = pending.get(msg.id);
112
+ if (p) {
113
+ clearTimeout(p.timer);
114
+ pending.delete(msg.id);
115
+ p.resolve({
116
+ status: msg.status || 502,
117
+ headers: msg.headers || {},
118
+ body: msg.body || '',
119
+ bodyEncoding: msg.bodyEncoding
120
+ });
121
+ }
122
+ }
123
+ });
124
+
125
+ socket.on('close', () => {
126
+ if (tunnelName && tunnels.get(tunnelName)?.socket === socket) {
127
+ tunnels.delete(tunnelName);
128
+ // Resolve pending requests for this tunnel only with 502
129
+ for (const [id, p] of pending) {
130
+ if (p.tunnelName === tunnelName) {
131
+ clearTimeout(p.timer);
132
+ pending.delete(id);
133
+ p.resolve({ status: 502, headers: {}, body: 'Tunnel disconnected' });
134
+ }
135
+ }
136
+ }
137
+ });
138
+
139
+ socket.on('error', () => {});
140
+ });
141
+
142
+ // HTTP proxy: /tunnel/{name}/*
143
+ fastify.all('/tunnel/:name/*', async (request, reply) => {
144
+ const { name } = request.params;
145
+ const tunnel = tunnels.get(name);
146
+
147
+ if (!tunnel || tunnel.socket.readyState !== 1) {
148
+ return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
149
+ }
150
+
151
+ // Build the downstream path (strip /tunnel/{name} prefix)
152
+ const fullPath = request.url.replace(`/tunnel/${name}`, '') || '/';
153
+ const id = randomUUID();
154
+
155
+ // Serialize the HTTP request
156
+ const tunnelReq = Object.create(null);
157
+ tunnelReq.type = 'request';
158
+ tunnelReq.id = id;
159
+ tunnelReq.method = request.method;
160
+ tunnelReq.path = fullPath;
161
+ tunnelReq.headers = Object.create(null);
162
+ // Forward relevant headers (skip hop-by-hop)
163
+ const skipHeaders = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'cookie', 'authorization', 'proxy-authorization']);
164
+ for (const [k, v] of Object.entries(request.headers)) {
165
+ if (!skipHeaders.has(k.toLowerCase())) {
166
+ tunnelReq.headers[k] = v;
167
+ }
168
+ }
169
+ // Forward body if present
170
+ if (request.body) {
171
+ tunnelReq.body = Buffer.isBuffer(request.body)
172
+ ? request.body.toString('base64')
173
+ : typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
174
+ tunnelReq.bodyEncoding = Buffer.isBuffer(request.body) ? 'base64' : 'utf8';
175
+ }
176
+
177
+ // Send to tunnel client and wait for response
178
+ const responsePromise = new Promise((resolve) => {
179
+ const timer = setTimeout(() => {
180
+ pending.delete(id);
181
+ resolve({ status: 504, headers: {}, body: 'Gateway Timeout' });
182
+ }, REQUEST_TIMEOUT);
183
+ pending.set(id, { resolve, timer, tunnelName: name });
184
+ });
185
+
186
+ try {
187
+ tunnel.socket.send(JSON.stringify(tunnelReq));
188
+ } catch {
189
+ const p = pending.get(id);
190
+ if (p) { clearTimeout(p.timer); pending.delete(id); }
191
+ return reply.code(502).send({ error: 'Bad Gateway', message: 'Failed to reach tunnel client' });
192
+ }
193
+
194
+ const res = await responsePromise;
195
+
196
+ // Set response headers
197
+ const hopHeaders = new Set(['connection', 'transfer-encoding', 'keep-alive', 'set-cookie']);
198
+ for (const [k, v] of Object.entries(res.headers)) {
199
+ if (!hopHeaders.has(k.toLowerCase())) {
200
+ reply.header(k, v);
201
+ }
202
+ }
203
+
204
+ // Decode body if base64
205
+ const body = res.bodyEncoding === 'base64' && res.body
206
+ ? Buffer.from(res.body, 'base64')
207
+ : res.body || '';
208
+
209
+ return reply.code(res.status).send(body);
210
+ });
211
+
212
+ // Also handle /tunnel/{name} without trailing path
213
+ fastify.all('/tunnel/:name', async (request, reply) => {
214
+ // Redirect to add trailing slash, or proxy as root
215
+ const { name } = request.params;
216
+ const tunnel = tunnels.get(name);
217
+
218
+ if (!tunnel || tunnel.socket.readyState !== 1) {
219
+ return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
220
+ }
221
+
222
+ return reply.redirect(308, `/tunnel/${name}/`);
223
+ });
224
+ }
225
+
226
+ export default tunnelPlugin;
@@ -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,202 @@
1
+ /**
2
+ * Tunnel Proxy 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('Tunnel Proxy', () => {
17
+ let wsUrl, baseUrl;
18
+
19
+ before(async () => {
20
+ await startTestServer({ tunnel: true });
21
+ await createTestPod('tunneler');
22
+ baseUrl = getBaseUrl();
23
+ wsUrl = baseUrl.replace('http', 'ws') + '/.tunnel';
24
+ });
25
+
26
+ after(async () => {
27
+ await stopTestServer();
28
+ });
29
+
30
+ function connectTunnel() {
31
+ const token = getPodToken('tunneler');
32
+ return new WebSocket(wsUrl, {
33
+ headers: { 'Authorization': `Bearer ${token}` }
34
+ });
35
+ }
36
+
37
+ function waitMsg(ws, type, timeout = 5000) {
38
+ return new Promise((resolve, reject) => {
39
+ function handler(data) {
40
+ const msg = JSON.parse(data.toString());
41
+ if (msg.type === type) {
42
+ clearTimeout(timer);
43
+ ws.removeListener('message', handler);
44
+ ws.removeListener('close', onClose);
45
+ resolve(msg);
46
+ }
47
+ }
48
+ function onClose() {
49
+ clearTimeout(timer);
50
+ ws.removeListener('message', handler);
51
+ reject(new Error(`WebSocket closed while waiting for "${type}"`));
52
+ }
53
+ const timer = setTimeout(() => {
54
+ ws.removeListener('message', handler);
55
+ ws.removeListener('close', onClose);
56
+ reject(new Error(`Timeout waiting for "${type}"`));
57
+ }, timeout);
58
+ ws.on('message', handler);
59
+ ws.on('close', onClose);
60
+ });
61
+ }
62
+
63
+ describe('Registration', () => {
64
+ it('should reject unauthenticated connections', async () => {
65
+ const ws = new WebSocket(wsUrl);
66
+ const msg = await waitMsg(ws, 'error');
67
+ assert.ok(msg.message.includes('Authentication'));
68
+ ws.close();
69
+ });
70
+
71
+ it('should register a tunnel name', async () => {
72
+ const ws = connectTunnel();
73
+ await new Promise(r => ws.on('open', r));
74
+
75
+ ws.send(JSON.stringify({ type: 'register', name: 'myapp' }));
76
+ const msg = await waitMsg(ws, 'registered');
77
+ assert.strictEqual(msg.name, 'myapp');
78
+ assert.strictEqual(msg.url, '/tunnel/myapp/');
79
+
80
+ ws.close();
81
+ await new Promise(r => setTimeout(r, 50));
82
+ });
83
+
84
+ it('should reject invalid tunnel names', async () => {
85
+ const ws = connectTunnel();
86
+ await new Promise(r => ws.on('open', r));
87
+
88
+ ws.send(JSON.stringify({ type: 'register', name: '...' }));
89
+ const msg = await waitMsg(ws, 'error');
90
+ assert.ok(msg.message.includes('Invalid'));
91
+
92
+ ws.close();
93
+ await new Promise(r => setTimeout(r, 50));
94
+ });
95
+ });
96
+
97
+ describe('HTTP Proxying', () => {
98
+ it('should proxy GET requests through the tunnel', async () => {
99
+ const ws = connectTunnel();
100
+ await new Promise(r => ws.on('open', r));
101
+
102
+ // Register tunnel
103
+ ws.send(JSON.stringify({ type: 'register', name: 'testapp' }));
104
+ await waitMsg(ws, 'registered');
105
+
106
+ // Listen for tunnel requests and respond
107
+ ws.on('message', (data) => {
108
+ const msg = JSON.parse(data.toString());
109
+ if (msg.type === 'request') {
110
+ ws.send(JSON.stringify({
111
+ type: 'response',
112
+ id: msg.id,
113
+ status: 200,
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({ hello: 'world', path: msg.path })
116
+ }));
117
+ }
118
+ });
119
+
120
+ // Make HTTP request through the tunnel
121
+ const res = await fetch(`${baseUrl}/tunnel/testapp/api/hello`);
122
+ assert.strictEqual(res.status, 200);
123
+ const body = await res.json();
124
+ assert.strictEqual(body.hello, 'world');
125
+ assert.strictEqual(body.path, '/api/hello');
126
+
127
+ ws.close();
128
+ await new Promise(r => setTimeout(r, 50));
129
+ });
130
+
131
+ it('should proxy POST requests with body', async () => {
132
+ const ws = connectTunnel();
133
+ await new Promise(r => ws.on('open', r));
134
+
135
+ ws.send(JSON.stringify({ type: 'register', name: 'postapp' }));
136
+ await waitMsg(ws, 'registered');
137
+
138
+ ws.on('message', (data) => {
139
+ const msg = JSON.parse(data.toString());
140
+ if (msg.type === 'request') {
141
+ assert.strictEqual(msg.method, 'POST');
142
+ ws.send(JSON.stringify({
143
+ type: 'response',
144
+ id: msg.id,
145
+ status: 201,
146
+ headers: { 'content-type': 'text/plain' },
147
+ body: 'Created'
148
+ }));
149
+ }
150
+ });
151
+
152
+ const res = await fetch(`${baseUrl}/tunnel/postapp/items`, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({ name: 'test' })
156
+ });
157
+ assert.strictEqual(res.status, 201);
158
+ assert.strictEqual(await res.text(), 'Created');
159
+
160
+ ws.close();
161
+ await new Promise(r => setTimeout(r, 50));
162
+ });
163
+
164
+ it('should return 502 for unregistered tunnel', async () => {
165
+ const res = await fetch(`${baseUrl}/tunnel/nonexistent/path`);
166
+ assert.strictEqual(res.status, 502);
167
+ });
168
+
169
+ it('should forward custom headers', async () => {
170
+ const ws = connectTunnel();
171
+ await new Promise(r => ws.on('open', r));
172
+
173
+ ws.send(JSON.stringify({ type: 'register', name: 'headerapp' }));
174
+ await waitMsg(ws, 'registered');
175
+
176
+ let receivedHeaders;
177
+ ws.on('message', (data) => {
178
+ const msg = JSON.parse(data.toString());
179
+ if (msg.type === 'request') {
180
+ receivedHeaders = msg.headers;
181
+ ws.send(JSON.stringify({
182
+ type: 'response',
183
+ id: msg.id,
184
+ status: 200,
185
+ headers: { 'x-custom-response': 'from-tunnel' },
186
+ body: 'ok'
187
+ }));
188
+ }
189
+ });
190
+
191
+ const res = await fetch(`${baseUrl}/tunnel/headerapp/`, {
192
+ headers: { 'X-Custom-Request': 'to-tunnel' }
193
+ });
194
+ assert.strictEqual(res.status, 200);
195
+ assert.strictEqual(res.headers.get('x-custom-response'), 'from-tunnel');
196
+ assert.strictEqual(receivedHeaders['x-custom-request'], 'to-tunnel');
197
+
198
+ ws.close();
199
+ await new Promise(r => setTimeout(r, 50));
200
+ });
201
+ });
202
+ });
@@ -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
+ });