diff --git a/README.md b/README.md index a7f29572..3a771bb8 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ jss --help # Show help | `--mongo` | Enable MongoDB-backed /db/ route | false | | `--mongo-url ` | MongoDB connection URL | mongodb://localhost:27017 | | `--mongo-database ` | MongoDB database name | solid | +| `--webrtc` | Enable WebRTC signaling server | false | +| `--webrtc-path ` | WebRTC signaling WebSocket path | /.webrtc | | `-q, --quiet` | Suppress logs | false | ### Environment Variables @@ -195,6 +197,7 @@ export JSS_PAY_RATE=10 export JSS_MONGO=true export JSS_MONGO_URL=mongodb://localhost:27017 export JSS_MONGO_DATABASE=solid +export JSS_WEBRTC=true jss start ``` @@ -823,6 +826,42 @@ curl -X DELETE http://localhost:3000/db/alice/notes/1 \ Supported formats: `50MB`, `1GB`, `500KB`, `1TB` +## WebRTC Signaling + +Peer-to-peer communication via WebRTC, using JSS as the signaling server. Once peers are connected, all media and data flows directly between them. + +```bash +jss start --webrtc +``` + +### How It Works + +1. Both peers connect to `wss://your.pod/.webrtc` (WebID auth required) +2. Caller sends an SDP offer targeting the callee's WebID +3. JSS relays the offer/answer and ICE candidates between peers +4. Once a direct path is found, the peer-to-peer connection is established +5. JSS steps out — video, audio, files, and data flow directly between peers + +### Protocol + +Messages are JSON over WebSocket: + +```js +// Send an offer to another user +{ "type": "offer", "to": "https://bob.example/profile/card#me", "sdp": "..." } + +// Receive an offer from another user +{ "type": "offer", "from": "https://alice.example/profile/card#me", "sdp": "..." } + +// ICE candidate exchange +{ "type": "candidate", "to": "https://bob.example/profile/card#me", "candidate": {...} } + +// Hang up +{ "type": "hangup", "to": "https://bob.example/profile/card#me" } +``` + +On connect, peers receive a list of online users and get notified when others join or leave. + ## HTTP 402 Paid Access Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance. diff --git a/bin/jss.js b/bin/jss.js index 3b225416..c02d7dc0 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -65,6 +65,9 @@ program .option('--no-nostr', 'Disable Nostr relay') .option('--nostr-path ', 'Nostr relay WebSocket path (default: /relay)') .option('--nostr-max-events ', 'Max events in relay memory (default: 1000)', parseInt) + .option('--webrtc', 'Enable WebRTC signaling server') + .option('--no-webrtc', 'Disable WebRTC signaling server') + .option('--webrtc-path ', 'WebRTC signaling WebSocket path (default: /.webrtc)') .option('--activitypub', 'Enable ActivityPub federation') .option('--no-activitypub', 'Disable ActivityPub federation') .option('--ap-username ', 'ActivityPub username (default: me)') @@ -142,6 +145,8 @@ program nostr: config.nostr, nostrPath: config.nostrPath, nostrMaxEvents: config.nostrMaxEvents, + webrtc: config.webrtc, + webrtcPath: config.webrtcPath, activitypub: config.activitypub, apUsername: config.apUsername, apDisplayName: config.apDisplayName, @@ -186,6 +191,7 @@ program if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)'); if (config.git) console.log(' Git: enabled (clone/push support)'); if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`); + if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`); if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`); if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`); else if (config.inviteOnly) console.log(' Registration: invite-only'); diff --git a/src/config.js b/src/config.js index 14cec6b6..7477a0f7 100644 --- a/src/config.js +++ b/src/config.js @@ -54,6 +54,10 @@ export const defaults = { nostrPath: '/relay', nostrMaxEvents: 1000, + // WebRTC signaling + webrtc: false, + webrtcPath: '/.webrtc', + // ActivityPub federation activitypub: false, apUsername: 'me', @@ -134,6 +138,8 @@ const envMap = { JSS_NOSTR: 'nostr', JSS_NOSTR_PATH: 'nostrPath', JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents', + JSS_WEBRTC: 'webrtc', + JSS_WEBRTC_PATH: 'webrtcPath', JSS_ACTIVITYPUB: 'activitypub', JSS_AP_USERNAME: 'apUsername', JSS_AP_DISPLAY_NAME: 'apDisplayName', diff --git a/src/server.js b/src/server.js index d4901180..92986b34 100644 --- a/src/server.js +++ b/src/server.js @@ -18,6 +18,7 @@ import { createPayHandler, isPayRequest } from './handlers/pay.js'; import { activityPubPlugin, getActorHandler } from './ap/index.js'; import { remoteStoragePlugin } from './remotestorage.js'; import { dbPlugin } from './db/index.js'; +import { webrtcPlugin } from './webrtc/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -74,6 +75,9 @@ export function createServer(options = {}) { const nostrEnabled = options.nostr ?? false; const nostrPath = options.nostrPath ?? '/relay'; const nostrMaxEvents = options.nostrMaxEvents ?? 1000; + // WebRTC signaling is OFF by default + const webrtcEnabled = options.webrtc ?? false; + const webrtcPath = options.webrtcPath ?? '/.webrtc'; // ActivityPub federation is OFF by default const activitypubEnabled = options.activitypub ?? false; const apUsername = options.apUsername ?? 'me'; @@ -240,6 +244,11 @@ export function createServer(options = {}) { }); } + // Register WebRTC signaling if enabled + if (webrtcEnabled) { + fastify.register(webrtcPlugin, { path: webrtcPath }); + } + // Register ActivityPub plugin if enabled if (activitypubEnabled) { fastify.register(activityPubPlugin, { @@ -331,6 +340,12 @@ export function createServer(options = {}) { return; } + // Allow WebRTC signaling endpoint through when enabled + const urlNoQuery = request.url.split('?')[0]; + if (webrtcEnabled && urlNoQuery === webrtcPath) { + return; + } + const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings const hasForbiddenDotfile = segments.some(seg => seg.startsWith('.') && @@ -405,6 +420,7 @@ export function createServer(options = {}) { request.url.startsWith('/storage/') || (payEnabled && isPayRequest(request.url)) || (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || + (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) || mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { return; } diff --git a/src/webrtc/index.js b/src/webrtc/index.js new file mode 100644 index 00000000..1cbfd869 --- /dev/null +++ b/src/webrtc/index.js @@ -0,0 +1,159 @@ +/** + * WebRTC Signaling Server Plugin + * + * Lightweight signaling server for WebRTC peer-to-peer connections. + * Relays SDP offers/answers and ICE candidates between authenticated users. + * The actual media/data flow directly between peers — JSS just introduces them. + * + * Usage: jss start --webrtc + * Endpoint: wss://your.pod/.webrtc + * + * Protocol (JSON over WebSocket): + * → { type: "offer", to: "", sdp: "..." } + * → { type: "answer", to: "", sdp: "..." } + * → { type: "candidate", to: "", candidate: {...} } + * → { type: "hangup", to: "" } + * ← { type: "offer", from: "", sdp: "..." } + * ← { type: "answer", from: "", sdp: "..." } + * ← { type: "candidate", from: "", candidate: {...} } + * ← { type: "hangup", from: "" } + * ← { type: "error", message: "..." } + * ← { type: "peers", you: "", peers: ["", ...] } + * ← { type: "peer-joined", webId: "" } + * ← { type: "peer-left", webId: "" } + */ + +import websocket from '@fastify/websocket'; +import { getWebIdFromRequestAsync } from '../auth/token.js'; + +const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']); +const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB + +/** + * Register WebRTC signaling routes on Fastify instance + * + * @param {object} fastify - Fastify instance + * @param {object} options - Options + * @param {string} options.path - WebSocket path (default: '/.webrtc') + */ +export async function webrtcPlugin(fastify, options = {}) { + const path = options.path || '/.webrtc'; + + // Instance-scoped peer state + const peers = new Map(); + + // Only register @fastify/websocket if not already registered + if (!fastify.websocketServer) { + await fastify.register(websocket); + } + + // Clean up all connections on server close + fastify.addHook('onClose', async () => { + for (const [, socket] of peers) { + socket.close(); + } + peers.clear(); + }); + + function broadcast(senderWebId, msg) { + const data = JSON.stringify(msg); + for (const [id, socket] of peers) { + if (id !== senderWebId && socket.readyState === 1) { + try { socket.send(data); } catch { /* socket closed between check and send */ } + } + } + } + + fastify.get(path, { websocket: true }, async (connection, request) => { + const socket = connection.socket; + + // Authenticate the connection + const { webId } = await getWebIdFromRequestAsync(request); + if (!webId) { + socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' })); + socket.close(); + return; + } + + // Register this peer (close old connection if reconnecting) + const existing = peers.get(webId); + const isReconnect = !!existing; + if (existing) { + peers.delete(webId); + existing.close(); + } + peers.set(webId, socket); + socket.webId = webId; + + // Notify the peer of their identity and online peers + socket.send(JSON.stringify({ + type: 'peers', + you: webId, + peers: [...peers.keys()].filter(id => id !== webId) + })); + + // Only broadcast peer-joined for new connections, not reconnects + if (!isReconnect) { + broadcast(webId, { type: 'peer-joined', webId }); + } + + socket.on('message', (data) => { + // Enforce max message size (Buffer.byteLength for reliable byte count) + const raw = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (raw.byteLength > MAX_MESSAGE_SIZE) { + socket.send(JSON.stringify({ type: 'error', message: 'Message too large' })); + return; + } + + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); + return; + } + + if (!msg.to || !msg.type) { + socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' })); + return; + } + + // Only relay known signaling types + if (!ALLOWED_TYPES.has(msg.type)) { + socket.send(JSON.stringify({ type: 'error', message: `Unknown type "${msg.type}"` })); + return; + } + + const target = peers.get(msg.to); + if (!target || target.readyState !== 1) { + socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to })); + return; + } + + // Build relay payload with whitelisted fields only (prevent prototype pollution) + const relay = Object.create(null); + relay.type = msg.type; + relay.from = webId; + if (typeof msg.sdp === 'string') relay.sdp = msg.sdp; + if (msg.candidate != null && typeof msg.candidate === 'object' && !Array.isArray(msg.candidate)) { + relay.candidate = msg.candidate; + } + try { target.send(JSON.stringify(relay)); } catch { + socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to })); + } + }); + + socket.on('close', () => { + // Only remove if this socket is still the registered one (not replaced by reconnect) + if (peers.get(webId) === socket) { + peers.delete(webId); + broadcast(webId, { type: 'peer-left', webId }); + } + }); + + // Error handler: close event will follow and handle cleanup + socket.on('error', () => {}); + }); +} + +export default webrtcPlugin; diff --git a/test/webrtc.test.js b/test/webrtc.test.js new file mode 100644 index 00000000..47d5c7b0 --- /dev/null +++ b/test/webrtc.test.js @@ -0,0 +1,212 @@ +/** + * WebRTC Signaling Server Tests + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { WebSocket } from 'ws'; +import { + startTestServer, + stopTestServer, + createTestPod, + getBaseUrl, + getPodToken +} from './helpers.js'; + +describe('WebRTC Signaling', () => { + let wsUrl; + + before(async () => { + await startTestServer({ webrtc: true }); + await createTestPod('alice'); + await createTestPod('bob'); + const base = getBaseUrl(); + wsUrl = base.replace('http', 'ws') + '/.webrtc'; + }); + + after(async () => { + await stopTestServer(); + }); + + /** Create an authenticated WebSocket for a pod user */ + function connectPeer(podName) { + const token = getPodToken(podName); + const ws = new WebSocket(wsUrl, { + headers: { 'Authorization': `Bearer ${token}` } + }); + return ws; + } + + /** Connect a peer and wait for the 'peers' welcome message */ + async function connectAndWait(podName) { + const ws = connectPeer(podName); + const msg = await waitForMessage(ws, 'peers'); + return { ws, ...msg }; + } + + /** Wait for a specific message type from a WebSocket */ + function waitForMessage(ws, type, timeout = 3000) { + return new Promise((resolve, reject) => { + function handler(data) { + const msg = JSON.parse(data.toString()); + if (msg.type === type) { + clearTimeout(timer); + ws.removeListener('message', handler); + ws.removeListener('close', onClose); + resolve(msg); + } + } + function onClose() { + clearTimeout(timer); + ws.removeListener('message', handler); + reject(new Error(`WebSocket closed while waiting for "${type}"`)); + } + const timer = setTimeout(() => { + ws.removeListener('message', handler); + ws.removeListener('close', onClose); + reject(new Error(`Timeout waiting for "${type}"`)); + }, timeout); + ws.on('message', handler); + ws.on('close', onClose); + }); + } + + /** Collect messages from a WebSocket for a duration */ + function collectMessages(ws, duration = 500) { + return new Promise((resolve) => { + const msgs = []; + const handler = (data) => msgs.push(JSON.parse(data.toString())); + ws.on('message', handler); + setTimeout(() => { + ws.removeListener('message', handler); + resolve(msgs); + }, duration); + }); + } + + describe('Authentication', () => { + it('should reject unauthenticated connections', async () => { + const ws = new WebSocket(wsUrl); + + const msg = await waitForMessage(ws, 'error'); + assert.strictEqual(msg.type, 'error'); + assert.ok(msg.message.includes('Authentication')); + ws.close(); + }); + + it('should accept authenticated connections', async () => { + const ws = connectPeer('alice'); + + const msg = await waitForMessage(ws, 'peers'); + assert.strictEqual(msg.type, 'peers'); + assert.ok(msg.you, 'Should include own WebID'); + assert.ok(Array.isArray(msg.peers), 'Should include peers list'); + ws.close(); + }); + }); + + describe('Peer Presence and Signaling Relay', () => { + it('should handle full signaling lifecycle', async () => { + // Alice connects first — should see no peers + const { ws: alice, you: aliceId } = await connectAndWait('alice'); + + // Bob joins — set up listener for peer-joined before bob connects + const joinPromise = waitForMessage(alice, 'peer-joined'); + const { ws: bob, you: bobId, peers: bobPeerList } = await connectAndWait('bob'); + + // Bob should see alice in the peer list + assert.strictEqual(bobPeerList.length, 1, 'Bob should see Alice'); + + // Alice should get peer-joined notification + const joinMsg = await joinPromise; + assert.strictEqual(joinMsg.type, 'peer-joined'); + + // 1. Alice sends offer to Bob + const offerPromise = waitForMessage(bob, 'offer'); + alice.send(JSON.stringify({ type: 'offer', to: bobId, sdp: 'v=0\r\n' })); + + const offer = await offerPromise; + assert.strictEqual(offer.type, 'offer'); + assert.strictEqual(offer.from, aliceId); + assert.ok(offer.sdp, 'Should include SDP'); + assert.strictEqual(offer.to, undefined, 'Should strip "to" field'); + + // 2. Bob sends answer to Alice + const answerPromise = waitForMessage(alice, 'answer'); + bob.send(JSON.stringify({ type: 'answer', to: aliceId, sdp: 'v=0\r\n' })); + + const answer = await answerPromise; + assert.strictEqual(answer.type, 'answer'); + assert.strictEqual(answer.from, bobId); + + // 3. Alice sends ICE candidate to Bob + const candidatePromise = waitForMessage(bob, 'candidate'); + alice.send(JSON.stringify({ + type: 'candidate', to: bobId, + candidate: { candidate: 'candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host', sdpMid: '0' } + })); + + const candidate = await candidatePromise; + assert.strictEqual(candidate.type, 'candidate'); + assert.ok(candidate.candidate.candidate); + + // 4. Alice sends hangup to Bob + const hangupPromise = waitForMessage(bob, 'hangup'); + alice.send(JSON.stringify({ type: 'hangup', to: bobId })); + + const hangup = await hangupPromise; + assert.strictEqual(hangup.type, 'hangup'); + assert.strictEqual(hangup.from, aliceId); + + // 5. Bob leaves — alice should get notified + const leavePromise = waitForMessage(alice, 'peer-left'); + bob.close(); + + const leaveMsg = await leavePromise; + assert.strictEqual(leaveMsg.type, 'peer-left'); + + alice.close(); + await new Promise(r => setTimeout(r, 100)); + }); + }); + + describe('Error Handling', () => { + it('should reject invalid JSON', async () => { + const alice = connectPeer('alice'); + await waitForMessage(alice, 'peers'); + + alice.send('not json'); + const err = await waitForMessage(alice, 'error'); + assert.strictEqual(err.message, 'Invalid JSON'); + + alice.close(); + }); + + it('should reject messages without "to" field', async () => { + const alice = connectPeer('alice'); + await waitForMessage(alice, 'peers'); + + alice.send(JSON.stringify({ type: 'offer', sdp: '...' })); + const err = await waitForMessage(alice, 'error'); + assert.ok(err.message.includes('Missing')); + + alice.close(); + }); + + it('should error when target peer is not online', async () => { + const alice = connectPeer('alice'); + await waitForMessage(alice, 'peers'); + + alice.send(JSON.stringify({ + type: 'offer', + to: 'https://nobody.example/profile/card#me', + sdp: '...' + })); + const err = await waitForMessage(alice, 'error'); + assert.ok(err.message.includes('not online')); + + alice.close(); + await new Promise(r => setTimeout(r, 50)); + }); + }); +});