Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ jss --help # Show help
| `--mongo` | Enable MongoDB-backed /db/ route | false |
| `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
| `--mongo-database <name>` | MongoDB database name | solid |
| `--webrtc` | Enable WebRTC signaling server | false |
| `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
| `-q, --quiet` | Suppress logs | false |

### Environment Variables
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ program
.option('--no-nostr', 'Disable Nostr relay')
.option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
.option('--nostr-max-events <n>', 'Max events in relay memory (default: 1000)', parseInt)
.option('--webrtc', 'Enable WebRTC signaling server')
.option('--no-webrtc', 'Disable WebRTC signaling server')
.option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
.option('--activitypub', 'Enable ActivityPub federation')
.option('--no-activitypub', 'Disable ActivityPub federation')
.option('--ap-username <name>', 'ActivityPub username (default: me)')
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export const defaults = {
nostrPath: '/relay',
nostrMaxEvents: 1000,

// WebRTC signaling
webrtc: false,
webrtcPath: '/.webrtc',

// ActivityPub federation
activitypub: false,
apUsername: 'me',
Expand Down Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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
Comment on lines +78 to 81
const activitypubEnabled = options.activitypub ?? false;
const apUsername = options.apUsername ?? 'me';
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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;
}
Comment on lines +343 to +347

const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
const hasForbiddenDotfile = segments.some(seg =>
seg.startsWith('.') &&
Expand Down Expand Up @@ -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;
}
Expand Down
159 changes: 159 additions & 0 deletions src/webrtc/index.js
Original file line number Diff line number Diff line change
@@ -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: "<webid>", sdp: "..." }
* → { type: "answer", to: "<webid>", sdp: "..." }
* → { type: "candidate", to: "<webid>", candidate: {...} }
* → { type: "hangup", to: "<webid>" }
* ← { type: "offer", from: "<webid>", sdp: "..." }
* ← { type: "answer", from: "<webid>", sdp: "..." }
* ← { type: "candidate", from: "<webid>", candidate: {...} }
* ← { type: "hangup", from: "<webid>" }
* ← { type: "error", message: "..." }
* ← { type: "peers", you: "<webid>", peers: ["<webid>", ...] }
* ← { type: "peer-joined", webId: "<webid>" }
* ← { type: "peer-left", webId: "<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 });
}

Comment on lines +78 to +99
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;
}
Comment on lines +100 to +106
Comment on lines +101 to +106

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;
}
Comment on lines +116 to +119

// Only relay known signaling types
if (!ALLOWED_TYPES.has(msg.type)) {
socket.send(JSON.stringify({ type: 'error', message: `Unknown type "${msg.type}"` }));
return;
}
Comment on lines +121 to +125

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;
Comment on lines +100 to +130
}

// 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;
}
Comment on lines +137 to +140
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', () => {});
});
Comment on lines +154 to +156
}

export default webrtcPlugin;
Loading
Loading