freertc 0.1.0

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.
@@ -0,0 +1,44 @@
1
+ -- D1 schema for freertc signaling relay
2
+
3
+ CREATE TABLE IF NOT EXISTS psp_announcements (
4
+ network TEXT NOT NULL,
5
+ peer_id TEXT NOT NULL,
6
+ session_id TEXT,
7
+ expires_at_ms INTEGER NOT NULL,
8
+ updated_at_ms INTEGER NOT NULL,
9
+ PRIMARY KEY (network, peer_id)
10
+ );
11
+
12
+ CREATE INDEX IF NOT EXISTS idx_announcements_network_expires
13
+ ON psp_announcements (network, expires_at_ms);
14
+
15
+ CREATE TABLE IF NOT EXISTS psp_relay (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ network TEXT NOT NULL,
18
+ to_peer_id TEXT NOT NULL,
19
+ type TEXT NOT NULL,
20
+ session_id TEXT,
21
+ message_json TEXT NOT NULL,
22
+ expires_at_ms INTEGER NOT NULL,
23
+ created_at_ms INTEGER NOT NULL
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_relay_lookup
27
+ ON psp_relay (network, to_peer_id, created_at_ms);
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_relay_lookup_ordered
30
+ ON psp_relay (network, to_peer_id, created_at_ms, id);
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_relay_expires
33
+ ON psp_relay (expires_at_ms);
34
+
35
+ -- Federated relay registry (populated on hub workers)
36
+ CREATE TABLE IF NOT EXISTS psp_relays (
37
+ url TEXT PRIMARY KEY,
38
+ name TEXT,
39
+ registered_at_ms INTEGER NOT NULL,
40
+ last_seen_ms INTEGER NOT NULL
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_relays_last_seen
44
+ ON psp_relays (last_seen_ms);
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { resolveWranglerCommand } from './project-bootstrap.mjs';
8
+
9
+ const ROOT = process.cwd();
10
+ const CARGO_BIN = path.join(os.homedir(), '.cargo', 'bin');
11
+ const PATH_WITH_CARGO = `${CARGO_BIN}${path.delimiter}${process.env.PATH || ''}`;
12
+ const WASM_TARGET = 'wasm32-unknown-unknown';
13
+
14
+ function run(command, args, options = {}) {
15
+ return spawnSync(command, args, {
16
+ cwd: ROOT,
17
+ stdio: 'inherit',
18
+ env: { ...process.env, PATH: PATH_WITH_CARGO },
19
+ ...options
20
+ });
21
+ }
22
+
23
+ function runCapture(command, args) {
24
+ return spawnSync(command, args, {
25
+ cwd: ROOT,
26
+ stdio: 'pipe',
27
+ encoding: 'utf8',
28
+ env: { ...process.env, PATH: PATH_WITH_CARGO }
29
+ });
30
+ }
31
+
32
+ function commandExists(command, args = ['--version']) {
33
+ const result = spawnSync(command, args, {
34
+ cwd: ROOT,
35
+ stdio: 'ignore',
36
+ env: { ...process.env, PATH: PATH_WITH_CARGO }
37
+ });
38
+ return result.status === 0;
39
+ }
40
+
41
+ function fail(message) {
42
+ console.error(`\n[dev-setup] ${message}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ function hasWasmTargetInstalled() {
47
+ const sysroot = runCapture('rustc', ['--print', 'sysroot']);
48
+ if (sysroot.status !== 0) return false;
49
+ const sysrootPath = (sysroot.stdout || '').trim();
50
+ if (!sysrootPath) return false;
51
+
52
+ const targetDir = path.join(sysrootPath, 'lib', 'rustlib', WASM_TARGET);
53
+ return fs.existsSync(targetDir);
54
+ }
55
+
56
+ function ensureWorkerBuild() {
57
+ if (commandExists('worker-build')) return;
58
+
59
+ if (!commandExists('cargo')) {
60
+ fail('Missing Cargo. Install Rust toolchain first: https://rustup.rs');
61
+ }
62
+
63
+ console.log('[dev-setup] Installing worker-build via Cargo...');
64
+ const installed = run('cargo', ['install', 'worker-build']);
65
+ if (installed.status !== 0) {
66
+ fail('Failed to install worker-build.');
67
+ }
68
+ }
69
+
70
+ function ensureWasmTarget() {
71
+ if (!commandExists('rustc')) {
72
+ fail('Missing Rust compiler. Install Rust toolchain first: https://rustup.rs');
73
+ }
74
+
75
+ if (hasWasmTargetInstalled()) return;
76
+
77
+ if (!commandExists('rustup')) {
78
+ fail(
79
+ 'Missing WebAssembly Rust target, and rustup is not available to auto-install it.\n' +
80
+ 'Install rustup, then run: rustup target add wasm32-unknown-unknown'
81
+ );
82
+ }
83
+
84
+ console.log('[dev-setup] Installing WebAssembly Rust target...');
85
+ const installed = run('rustup', ['target', 'add', WASM_TARGET]);
86
+ if (installed.status !== 0 || !hasWasmTargetInstalled()) {
87
+ fail('Failed to install WebAssembly Rust target.');
88
+ }
89
+ }
90
+
91
+ function resolveWranglerArgs() {
92
+ const localConfig = path.join(ROOT, 'wrangler.jsonc');
93
+ const workersDevConfig = path.join(ROOT, 'wrangler.workers-dev.jsonc');
94
+
95
+ if (fs.existsSync(localConfig)) {
96
+ return {
97
+ args: ['dev'],
98
+ configPath: localConfig
99
+ };
100
+ }
101
+ if (fs.existsSync(workersDevConfig)) {
102
+ return {
103
+ args: ['dev', '--config', 'wrangler.workers-dev.jsonc'],
104
+ configPath: workersDevConfig
105
+ };
106
+ }
107
+
108
+ fail('No Wrangler config found. Create wrangler.jsonc or keep wrangler.workers-dev.jsonc.');
109
+ }
110
+
111
+ function configUsesWorkerBuild(configPath) {
112
+ try {
113
+ const text = fs.readFileSync(configPath, 'utf8');
114
+ return /worker-build/.test(text);
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ const resolved = resolveWranglerArgs();
121
+ const wrangler = resolveWranglerCommand(ROOT);
122
+
123
+ if (configUsesWorkerBuild(resolved.configPath)) {
124
+ ensureWorkerBuild();
125
+ ensureWasmTarget();
126
+ }
127
+
128
+ const started = run(wrangler.command, [...wrangler.baseArgs, ...resolved.args]);
129
+ process.exit(started.status ?? 1);
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createServer } from 'node:http';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { WebSocketServer } from 'ws';
8
+
9
+ const PSP_VERSION = '1.0';
10
+ const DEFAULT_TTL_MS = 30_000;
11
+ const MAX_TTL_MS = 120_000;
12
+ const MAX_MESSAGE_SIZE = 64 * 1024;
13
+ const MAX_BATCH = 50;
14
+
15
+ const DISCOVERY_TYPES = new Set(['announce', 'withdraw', 'discover', 'peer_list', 'redirect']);
16
+ const NEGOTIATION_TYPES = new Set(['connect_request', 'connect_accept', 'connect_reject', 'offer', 'answer', 'ice_candidate', 'ice_end', 'renegotiate']);
17
+ const CONTROL_TYPES = new Set(['ping', 'pong', 'bye', 'error', 'ack']);
18
+ const EXTENSION_TYPES = new Set(['ext']);
19
+ const MESSAGE_TYPES = new Set([...DISCOVERY_TYPES, ...NEGOTIATION_TYPES, ...CONTROL_TYPES, ...EXTENSION_TYPES]);
20
+
21
+ const RELAY_TYPES = new Set([
22
+ 'connect_request', 'connect_accept', 'connect_reject',
23
+ 'offer', 'answer', 'ice_candidate', 'ice_end', 'renegotiate',
24
+ 'bye', 'error', 'ack', 'ext', 'peer_list', 'redirect'
25
+ ]);
26
+
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+ const ROOT = path.resolve(__dirname, '..');
30
+ const PUBLIC_DIR = path.join(ROOT, 'public');
31
+ const HOST = process.env.HOST || '127.0.0.1';
32
+ const PORT = Number(process.env.PORT || 8788);
33
+
34
+ const livePeers = new Map(); // network:peerId -> { socket, peerId, network, lastSeen }
35
+ const networkSubscribers = new Map(); // network -> Set<WebSocket>
36
+ const announcements = new Map(); // network:peerId -> { sessionId, expiresAtMs, updatedAtMs }
37
+ const relayQueue = new Map(); // network:toPeerId -> [{ id, message, expiresAtMs, createdAtMs }]
38
+
39
+ let relayMessageId = 1;
40
+
41
+ function makePeerKey(network, peerId) {
42
+ return `${network}:${peerId}`;
43
+ }
44
+
45
+ function normalizeTtl(ttlMs) {
46
+ const value = Number(ttlMs);
47
+ if (!Number.isFinite(value) || value <= 0) {
48
+ return DEFAULT_TTL_MS;
49
+ }
50
+ return Math.min(value, MAX_TTL_MS);
51
+ }
52
+
53
+ function json(res, body, status = 200) {
54
+ const payload = JSON.stringify(body);
55
+ res.writeHead(status, {
56
+ 'Content-Type': 'application/json; charset=utf-8',
57
+ 'Access-Control-Allow-Origin': '*',
58
+ 'Content-Length': Buffer.byteLength(payload)
59
+ });
60
+ res.end(payload);
61
+ }
62
+
63
+ function validEnvelope(msg) {
64
+ return (
65
+ typeof msg === 'object' && msg !== null &&
66
+ msg.psp_version === PSP_VERSION &&
67
+ typeof msg.type === 'string' && MESSAGE_TYPES.has(msg.type) &&
68
+ typeof msg.from === 'string' && msg.from.trim() &&
69
+ typeof msg.network === 'string' && msg.network.trim() &&
70
+ typeof msg.message_id === 'string' &&
71
+ typeof msg.timestamp === 'number'
72
+ );
73
+ }
74
+
75
+ function cleanExpired() {
76
+ const now = Date.now();
77
+
78
+ for (const [key, row] of announcements.entries()) {
79
+ if (row.expiresAtMs <= now) {
80
+ announcements.delete(key);
81
+ livePeers.delete(key);
82
+ }
83
+ }
84
+
85
+ for (const [queueKey, entries] of relayQueue.entries()) {
86
+ const remaining = entries.filter((item) => item.expiresAtMs > now);
87
+ if (remaining.length === 0) {
88
+ relayQueue.delete(queueKey);
89
+ } else {
90
+ relayQueue.set(queueKey, remaining);
91
+ }
92
+ }
93
+ }
94
+
95
+ function listPeers(network, requesterPeerId = null) {
96
+ const now = Date.now();
97
+ const out = [];
98
+ for (const [key, row] of announcements.entries()) {
99
+ const [rowNetwork, rowPeerId] = key.split(':');
100
+ if (rowNetwork !== network || row.expiresAtMs <= now) {
101
+ continue;
102
+ }
103
+ if (requesterPeerId && rowPeerId === requesterPeerId) {
104
+ continue;
105
+ }
106
+ out.push({
107
+ peer_id: rowPeerId,
108
+ session_id: row.sessionId,
109
+ timestamp: row.updatedAtMs
110
+ });
111
+ if (out.length >= MAX_BATCH) {
112
+ break;
113
+ }
114
+ }
115
+ out.sort((a, b) => a.peer_id.localeCompare(b.peer_id));
116
+ return out;
117
+ }
118
+
119
+ function sendSafe(socket, payloadObj) {
120
+ try {
121
+ socket.send(JSON.stringify(payloadObj));
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function sendPeerList(network) {
129
+ const sockets = networkSubscribers.get(network);
130
+ if (!sockets || sockets.size === 0) {
131
+ return;
132
+ }
133
+ const peers = listPeers(network);
134
+ const message = {
135
+ psp_version: PSP_VERSION,
136
+ type: 'peer_list',
137
+ network,
138
+ from: 'bootstrap-relay',
139
+ to: null,
140
+ message_id: crypto.randomUUID(),
141
+ timestamp: Date.now(),
142
+ ttl_ms: DEFAULT_TTL_MS,
143
+ body: { peers }
144
+ };
145
+
146
+ for (const socket of sockets) {
147
+ if (socket.readyState !== socket.OPEN) {
148
+ sockets.delete(socket);
149
+ continue;
150
+ }
151
+ sendSafe(socket, message);
152
+ }
153
+ }
154
+
155
+ function queueRelayMessage(message) {
156
+ const key = makePeerKey(message.network, message.to);
157
+ const now = Date.now();
158
+ const ttl = normalizeTtl(message.ttl_ms);
159
+ const list = relayQueue.get(key) || [];
160
+ list.push({
161
+ id: relayMessageId++,
162
+ message,
163
+ createdAtMs: now,
164
+ expiresAtMs: now + ttl
165
+ });
166
+ relayQueue.set(key, list);
167
+ }
168
+
169
+ function deliverQueued(network, peerId, socket) {
170
+ const key = makePeerKey(network, peerId);
171
+ const queue = relayQueue.get(key) || [];
172
+ if (queue.length === 0) {
173
+ return 0;
174
+ }
175
+
176
+ const now = Date.now();
177
+ const fresh = [];
178
+ let delivered = 0;
179
+ for (const item of queue) {
180
+ if (item.expiresAtMs <= now) {
181
+ continue;
182
+ }
183
+ if (delivered < MAX_BATCH && sendSafe(socket, item.message)) {
184
+ delivered += 1;
185
+ } else {
186
+ fresh.push(item);
187
+ }
188
+ }
189
+
190
+ if (fresh.length === 0) {
191
+ relayQueue.delete(key);
192
+ } else {
193
+ relayQueue.set(key, fresh);
194
+ }
195
+
196
+ return delivered;
197
+ }
198
+
199
+ function upsertAnnouncement(message) {
200
+ const key = makePeerKey(message.network, message.from);
201
+ const now = Date.now();
202
+ const ttl = normalizeTtl(message.ttl_ms);
203
+ announcements.set(key, {
204
+ sessionId: message.session_id || null,
205
+ expiresAtMs: now + ttl,
206
+ updatedAtMs: now
207
+ });
208
+ }
209
+
210
+ function cleanupSocketState(socket) {
211
+ const state = socket.__peerState;
212
+ if (!state?.network || !state.peerId) {
213
+ return;
214
+ }
215
+
216
+ const { network, peerId } = state;
217
+ const key = makePeerKey(network, peerId);
218
+ announcements.delete(key);
219
+ livePeers.delete(key);
220
+
221
+ const sockets = networkSubscribers.get(network);
222
+ if (sockets) {
223
+ sockets.delete(socket);
224
+ if (sockets.size === 0) {
225
+ networkSubscribers.delete(network);
226
+ }
227
+ }
228
+
229
+ socket.__peerState = null;
230
+ sendPeerList(network);
231
+ }
232
+
233
+ function subscribeSocket(socket, network) {
234
+ const previous = socket.__peerState?.network;
235
+ if (previous && previous !== network) {
236
+ const oldSet = networkSubscribers.get(previous);
237
+ if (oldSet) {
238
+ oldSet.delete(socket);
239
+ if (oldSet.size === 0) {
240
+ networkSubscribers.delete(previous);
241
+ }
242
+ }
243
+ }
244
+
245
+ if (!networkSubscribers.has(network)) {
246
+ networkSubscribers.set(network, new Set());
247
+ }
248
+ networkSubscribers.get(network).add(socket);
249
+ }
250
+
251
+ function attachSocketHandlers(socket) {
252
+ socket.__peerState = null;
253
+
254
+ socket.on('message', (raw) => {
255
+ cleanExpired();
256
+
257
+ const rawString = raw.toString();
258
+ if (!rawString || rawString.length > MAX_MESSAGE_SIZE) {
259
+ return;
260
+ }
261
+
262
+ let message;
263
+ try {
264
+ message = JSON.parse(rawString);
265
+ } catch {
266
+ sendSafe(socket, {
267
+ psp_version: PSP_VERSION,
268
+ type: 'error',
269
+ from: 'relay',
270
+ to: 'client',
271
+ body: { error: 'Invalid JSON' }
272
+ });
273
+ return;
274
+ }
275
+
276
+ if (!validEnvelope(message)) {
277
+ sendSafe(socket, {
278
+ psp_version: PSP_VERSION,
279
+ type: 'error',
280
+ from: 'relay',
281
+ to: message?.from || 'unknown',
282
+ body: { error: 'Invalid PSP envelope' }
283
+ });
284
+ return;
285
+ }
286
+
287
+ const { network, from: peerId, type } = message;
288
+ const key = makePeerKey(network, peerId);
289
+ const previousKey = socket.__peerState ? makePeerKey(socket.__peerState.network, socket.__peerState.peerId) : null;
290
+
291
+ subscribeSocket(socket, network);
292
+ socket.__peerState = { network, peerId };
293
+ livePeers.set(key, { socket, network, peerId, lastSeen: Date.now() });
294
+
295
+ if (type === 'announce') {
296
+ upsertAnnouncement(message);
297
+ deliverQueued(network, peerId, socket);
298
+
299
+ const isHeartbeat = previousKey === key;
300
+ if (!isHeartbeat) {
301
+ sendPeerList(network);
302
+ }
303
+ return;
304
+ }
305
+
306
+ if (type === 'withdraw' || type === 'bye') {
307
+ announcements.delete(key);
308
+ livePeers.delete(key);
309
+ sendPeerList(network);
310
+ return;
311
+ }
312
+
313
+ if (type === 'discover') {
314
+ sendPeerList(network);
315
+ return;
316
+ }
317
+
318
+ if (type === 'ping') {
319
+ sendSafe(socket, {
320
+ psp_version: PSP_VERSION,
321
+ type: 'pong',
322
+ network,
323
+ from: 'relay',
324
+ to: peerId,
325
+ message_id: crypto.randomUUID(),
326
+ timestamp: Date.now(),
327
+ ttl_ms: DEFAULT_TTL_MS,
328
+ body: {}
329
+ });
330
+ deliverQueued(network, peerId, socket);
331
+ return;
332
+ }
333
+
334
+ if (RELAY_TYPES.has(type) && message.to) {
335
+ const targetKey = makePeerKey(network, message.to);
336
+ const live = livePeers.get(targetKey);
337
+ if (live && live.socket.readyState === live.socket.OPEN && sendSafe(live.socket, message)) {
338
+ return;
339
+ }
340
+ queueRelayMessage(message);
341
+ }
342
+ });
343
+
344
+ socket.on('close', () => cleanupSocketState(socket));
345
+ socket.on('error', () => cleanupSocketState(socket));
346
+ }
347
+
348
+ function contentType(filePath) {
349
+ const ext = path.extname(filePath).toLowerCase();
350
+ if (ext === '.html') return 'text/html; charset=utf-8';
351
+ if (ext === '.js') return 'application/javascript; charset=utf-8';
352
+ if (ext === '.css') return 'text/css; charset=utf-8';
353
+ if (ext === '.json') return 'application/json; charset=utf-8';
354
+ if (ext === '.svg') return 'image/svg+xml';
355
+ if (ext === '.png') return 'image/png';
356
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
357
+ return 'application/octet-stream';
358
+ }
359
+
360
+ function serveStatic(req, res) {
361
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
362
+ let pathname = decodeURIComponent(reqUrl.pathname);
363
+ if (pathname === '/') {
364
+ pathname = '/index.html';
365
+ }
366
+
367
+ const requested = path.normalize(path.join(PUBLIC_DIR, pathname));
368
+ if (!requested.startsWith(PUBLIC_DIR)) {
369
+ res.writeHead(403);
370
+ res.end('Forbidden');
371
+ return;
372
+ }
373
+
374
+ if (!fs.existsSync(requested) || fs.statSync(requested).isDirectory()) {
375
+ res.writeHead(404);
376
+ res.end('Not Found');
377
+ return;
378
+ }
379
+
380
+ const body = fs.readFileSync(requested);
381
+ res.writeHead(200, { 'Content-Type': contentType(requested), 'Content-Length': body.length });
382
+ res.end(body);
383
+ }
384
+
385
+ const server = createServer((req, res) => {
386
+ cleanExpired();
387
+
388
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
389
+ if (reqUrl.pathname === '/health') {
390
+ json(res, { ok: true, version: PSP_VERSION, peers: livePeers.size }, 200);
391
+ return;
392
+ }
393
+
394
+ if (reqUrl.pathname === '/ws') {
395
+ json(res, { ok: false, error: 'Expected WebSocket upgrade on /ws' }, 426);
396
+ return;
397
+ }
398
+
399
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
400
+ json(res, { ok: false, error: 'Method not allowed' }, 405);
401
+ return;
402
+ }
403
+
404
+ serveStatic(req, res);
405
+ });
406
+
407
+ const wss = new WebSocketServer({ noServer: true });
408
+ wss.on('connection', (socket) => attachSocketHandlers(socket));
409
+
410
+ server.on('upgrade', (req, socket, head) => {
411
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
412
+ if (reqUrl.pathname !== '/ws') {
413
+ socket.destroy();
414
+ return;
415
+ }
416
+
417
+ wss.handleUpgrade(req, socket, head, (ws) => {
418
+ wss.emit('connection', ws, req);
419
+ });
420
+ });
421
+
422
+ setInterval(cleanExpired, 5000).unref();
423
+
424
+ server.listen(PORT, HOST, () => {
425
+ console.log(`[node-relay] listening on http://${HOST}:${PORT}`);
426
+ console.log(`[node-relay] ws endpoint ws://${HOST}:${PORT}/ws`);
427
+ });
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+
3
+ const isGlobalInstall = process.env.npm_config_global === 'true';
4
+
5
+ const lines = [
6
+ '',
7
+ 'freertc installed.',
8
+ 'Run commands from the project directory where you want the worker files created.',
9
+ '',
10
+ 'Quick start:',
11
+ isGlobalInstall ? ' 1) freertc' : ' 1) npx freertc',
12
+ isGlobalInstall ? ' 2) freertc deploy' : ' 2) npx freertc deploy',
13
+ '',
14
+ 'Need full control? Use:',
15
+ isGlobalInstall ? ' freertc wizard' : ' npx freertc wizard',
16
+ ''
17
+ ];
18
+
19
+ console.log(lines.join('\n'));