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.
- package/README.md +246 -0
- package/bin/freertc.mjs +106 -0
- package/package.json +68 -0
- package/public/app.js +2851 -0
- package/public/index.html +821 -0
- package/scripts/d1-schema.sql +44 -0
- package/scripts/dev-server.mjs +129 -0
- package/scripts/non-cloudflare-server.mjs +427 -0
- package/scripts/postinstall-message.mjs +19 -0
- package/scripts/project-bootstrap.mjs +113 -0
- package/scripts/wrangler-install-wizard.mjs +697 -0
- package/src/index.js +690 -0
- package/wrangler.template.jsonc +71 -0
- package/wrangler.workers-dev.jsonc +19 -0
|
@@ -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'));
|