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
package/src/index.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
const PSP_VERSION = "1.0";
|
|
2
|
+
|
|
3
|
+
const DISCOVERY_TYPES = new Set(["announce", "withdraw", "discover", "peer_list", "redirect"]);
|
|
4
|
+
const NEGOTIATION_TYPES = new Set(["connect_request", "connect_accept", "connect_reject", "offer", "answer", "ice_candidate", "ice_end", "renegotiate"]);
|
|
5
|
+
const CONTROL_TYPES = new Set(["ping", "pong", "bye", "error", "ack"]);
|
|
6
|
+
const EXTENSION_TYPES = new Set(["ext"]);
|
|
7
|
+
|
|
8
|
+
const MESSAGE_TYPES = new Set([
|
|
9
|
+
...DISCOVERY_TYPES, ...NEGOTIATION_TYPES, ...CONTROL_TYPES, ...EXTENSION_TYPES
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const RELAY_TYPES = new Set([
|
|
13
|
+
"connect_request", "connect_accept", "connect_reject",
|
|
14
|
+
"offer", "answer", "ice_candidate", "ice_end", "renegotiate",
|
|
15
|
+
"bye", "error", "ack", "ext", "peer_list", "redirect"
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
19
|
+
const MAX_TTL_MS = 120_000;
|
|
20
|
+
const MAX_MESSAGE_SIZE = 64 * 1024;
|
|
21
|
+
const MAX_BATCH = 50;
|
|
22
|
+
const RELAY_EXPIRY_MS = 5 * 60_000; // relay entry expires after 5 min without heartbeat
|
|
23
|
+
const FEDERATION_INTERVAL_MS = 2 * 60_000; // re-heartbeat every 2 min per isolate
|
|
24
|
+
const DEFAULT_HUB_URL = "wss://peer.ooo/ws"; // default bootstrap hub
|
|
25
|
+
|
|
26
|
+
const livePeers = new Map(); // key: "network:peerId" -> { peerId, network, socket, lastSeen }
|
|
27
|
+
const networkSubscribers = new Map(); // key: network -> Set of sockets
|
|
28
|
+
|
|
29
|
+
let lastFederationMs = 0; // tracks last heartbeat time within this isolate
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
async fetch(request, env, ctx) {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
const upgrade = request.headers.get("Upgrade");
|
|
35
|
+
|
|
36
|
+
// Heartbeat: self-register and sync with hub every FEDERATION_INTERVAL_MS
|
|
37
|
+
if (env.RELAY_URL && env.DB) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (now - lastFederationMs > FEDERATION_INTERVAL_MS) {
|
|
40
|
+
lastFederationMs = now;
|
|
41
|
+
ctx.waitUntil((async () => {
|
|
42
|
+
const selfUrl = normalizeRelayUrl(env.RELAY_URL);
|
|
43
|
+
if (!selfUrl) return;
|
|
44
|
+
await upsertRelay(env.DB, selfUrl, env.RELAY_NAME || null).catch(() => {});
|
|
45
|
+
const hubUrl = env.GLOBAL_RELAY_URL || DEFAULT_HUB_URL;
|
|
46
|
+
// Skip registering with hub if we ARE the hub
|
|
47
|
+
if (normalizeRelayUrl(hubUrl) !== selfUrl) {
|
|
48
|
+
await registerWithHub({ ...env, GLOBAL_RELAY_URL: hubUrl }, selfUrl).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
})());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (upgrade && upgrade.toLowerCase() === "websocket") {
|
|
55
|
+
if (url.pathname !== "/ws") {
|
|
56
|
+
return jsonResponse({ ok: false, error: "WebSocket endpoint is /ws" }, 404);
|
|
57
|
+
}
|
|
58
|
+
return handleWebSocket(request, env, ctx);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (url.pathname === "/ws") {
|
|
62
|
+
return jsonResponse({ ok: false, error: "Expected WebSocket upgrade on /ws" }, 426);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (url.pathname === "/health") {
|
|
66
|
+
return jsonResponse({ ok: true, version: PSP_VERSION, peers: livePeers.size }, 200);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Federation: relay registry endpoints (any worker can serve these from its own D1)
|
|
70
|
+
if (url.pathname === "/api/v1/relays") {
|
|
71
|
+
if (request.method === "GET") {
|
|
72
|
+
return handleListRelays(env);
|
|
73
|
+
}
|
|
74
|
+
if (request.method === "POST") {
|
|
75
|
+
return handleRegisterRelay(request, env);
|
|
76
|
+
}
|
|
77
|
+
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return env.ASSETS?.fetch(request) ?? new Response("Not Found", { status: 404 });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function jsonResponse(body, status = 200) {
|
|
85
|
+
return new Response(JSON.stringify(body), {
|
|
86
|
+
status,
|
|
87
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ===================== Federation =====================
|
|
92
|
+
|
|
93
|
+
async function handleListRelays(env) {
|
|
94
|
+
if (!env.DB) return jsonResponse({ ok: false, error: "No database" }, 503);
|
|
95
|
+
const relays = await listRelays(env.DB);
|
|
96
|
+
return jsonResponse({ ok: true, relays });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleRegisterRelay(request, env) {
|
|
100
|
+
if (!env.DB) return jsonResponse({ ok: false, error: "No database" }, 503);
|
|
101
|
+
let body;
|
|
102
|
+
try { body = await request.json(); } catch { return jsonResponse({ ok: false, error: "Invalid JSON" }, 400); }
|
|
103
|
+
if (!body?.url || typeof body.url !== "string") {
|
|
104
|
+
return jsonResponse({ ok: false, error: "Missing url" }, 400);
|
|
105
|
+
}
|
|
106
|
+
const normalizedUrl = normalizeRelayUrl(body.url);
|
|
107
|
+
if (!normalizedUrl) {
|
|
108
|
+
return jsonResponse({ ok: false, error: "Invalid relay url" }, 400);
|
|
109
|
+
}
|
|
110
|
+
await upsertRelay(env.DB, normalizedUrl, body.name || null);
|
|
111
|
+
const relays = await listRelays(env.DB);
|
|
112
|
+
return jsonResponse({ ok: true, relays });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// POST to the global hub; cache returned relay list into own D1 so both sides know each other
|
|
116
|
+
async function registerWithHub(env, selfUrl) {
|
|
117
|
+
const resp = await fetch(`${relayHttpBase(normalizeRelayUrl(env.GLOBAL_RELAY_URL))}/api/v1/relays`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ url: selfUrl, name: env.RELAY_NAME || null })
|
|
121
|
+
});
|
|
122
|
+
if (!resp.ok) return [];
|
|
123
|
+
const data = await resp.json();
|
|
124
|
+
const relays = data.relays || [];
|
|
125
|
+
// Cache peer relays locally so discover/forward works without hitting hub each time
|
|
126
|
+
if (env.DB) {
|
|
127
|
+
await Promise.all(
|
|
128
|
+
relays
|
|
129
|
+
.filter(r => r.url && r.url !== selfUrl)
|
|
130
|
+
.map(r => upsertRelay(env.DB, r.url, r.name || null).catch(() => {}))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return relays;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get peer relay URLs from own D1 (excludes self); works for both hub and contributors
|
|
137
|
+
async function getPeerRelayUrls(db, selfUrl) {
|
|
138
|
+
if (!db) return [];
|
|
139
|
+
const relays = await listRelays(db);
|
|
140
|
+
return relays.map(r => r.url).filter(u => u !== selfUrl);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Normalize any relay URL to a canonical wss:// WebSocket URL
|
|
144
|
+
function normalizeRelayUrl(url) {
|
|
145
|
+
if (!url) return null;
|
|
146
|
+
let u = url.trim();
|
|
147
|
+
// Convert http(s):// to ws(s)://
|
|
148
|
+
u = u.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
149
|
+
// Ensure it ends with /ws
|
|
150
|
+
if (!u.endsWith("/ws")) u = u.replace(/\/$/, "") + "/ws";
|
|
151
|
+
return u;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Derive HTTP base URL from a wss:// relay URL (wss://peer.ooo/ws → https://peer.ooo)
|
|
155
|
+
function relayHttpBase(wsUrl) {
|
|
156
|
+
return wsUrl.replace(/^wss?:\/\//, (m) => m === "wss://" ? "https://" : "http://").replace(/\/ws$/, "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Open a short-lived WebSocket to a remote relay: get its peer list and exchange relay lists
|
|
160
|
+
async function queryRelayForPeers(relayUrl, network, selfRelayId, db, selfKnownRelays) {
|
|
161
|
+
try {
|
|
162
|
+
const resp = await fetch(relayUrl, { headers: { Upgrade: "websocket" } });
|
|
163
|
+
if (resp.status !== 101) return [];
|
|
164
|
+
const ws = resp.webSocket;
|
|
165
|
+
ws.accept();
|
|
166
|
+
|
|
167
|
+
return await new Promise((resolve) => {
|
|
168
|
+
const timer = setTimeout(() => { try { ws.close(); } catch {} resolve([]); }, 4000);
|
|
169
|
+
let gotPeerList = false;
|
|
170
|
+
|
|
171
|
+
ws.addEventListener("message", async (ev) => {
|
|
172
|
+
try {
|
|
173
|
+
const msg = JSON.parse(ev.data);
|
|
174
|
+
if (msg.type === "peer_list" && msg.network === network && !gotPeerList) {
|
|
175
|
+
gotPeerList = true;
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
ws.close();
|
|
178
|
+
resolve((msg.body?.peers || []).map(p => ({ ...p, relay_url: relayUrl })));
|
|
179
|
+
}
|
|
180
|
+
// Cache any relay list the remote sends us via ext
|
|
181
|
+
if (msg.type === "ext" && msg.body?.action === "relay_list" && db) {
|
|
182
|
+
const remoteRelays = msg.body.relays || [];
|
|
183
|
+
await Promise.all(
|
|
184
|
+
remoteRelays.map(r => r.url ? upsertRelay(db, r.url, r.name || null).catch(() => {}) : null)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
} catch {}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
ws.addEventListener("error", () => { clearTimeout(timer); resolve([]); });
|
|
191
|
+
ws.addEventListener("close", () => { if (!gotPeerList) { clearTimeout(timer); resolve([]); } });
|
|
192
|
+
|
|
193
|
+
const relayPeerId = selfRelayId || "relay-bridge";
|
|
194
|
+
// Announce as relay bridge
|
|
195
|
+
ws.send(JSON.stringify({
|
|
196
|
+
psp_version: PSP_VERSION, type: "announce", network,
|
|
197
|
+
from: relayPeerId, message_id: crypto.randomUUID(),
|
|
198
|
+
timestamp: Date.now(), ttl_ms: 10_000, body: { capabilities: { relay: true } }
|
|
199
|
+
}));
|
|
200
|
+
// Share our known relay list so the remote can cache us
|
|
201
|
+
if (selfKnownRelays?.length) {
|
|
202
|
+
ws.send(JSON.stringify({
|
|
203
|
+
psp_version: PSP_VERSION, type: "ext", network,
|
|
204
|
+
from: relayPeerId, message_id: crypto.randomUUID(),
|
|
205
|
+
timestamp: Date.now(), ttl_ms: 10_000,
|
|
206
|
+
body: { action: "relay_list", relays: selfKnownRelays }
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
// Request their peers
|
|
210
|
+
ws.send(JSON.stringify({
|
|
211
|
+
psp_version: PSP_VERSION, type: "discover", network,
|
|
212
|
+
from: relayPeerId, message_id: crypto.randomUUID(),
|
|
213
|
+
timestamp: Date.now(), ttl_ms: 10_000, body: {}
|
|
214
|
+
}));
|
|
215
|
+
});
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Open a short-lived WebSocket to a remote relay and forward a PSP message through it
|
|
222
|
+
async function forwardToRelay(relayUrl, message, selfRelayId) {
|
|
223
|
+
try {
|
|
224
|
+
const wsUrl = relayUrl;
|
|
225
|
+
const resp = await fetch(wsUrl, { headers: { Upgrade: "websocket" } });
|
|
226
|
+
if (resp.status !== 101) return;
|
|
227
|
+
const ws = resp.webSocket;
|
|
228
|
+
ws.accept();
|
|
229
|
+
|
|
230
|
+
// Outbound Worker WebSocket: send immediately after accept(), no open event needed
|
|
231
|
+
const relayPeerId = selfRelayId || "relay-bridge";
|
|
232
|
+
ws.send(JSON.stringify({
|
|
233
|
+
psp_version: PSP_VERSION, type: "announce", network: message.network,
|
|
234
|
+
from: relayPeerId, message_id: crypto.randomUUID(),
|
|
235
|
+
timestamp: Date.now(), ttl_ms: 10_000, body: { capabilities: { relay: true } }
|
|
236
|
+
}));
|
|
237
|
+
ws.send(JSON.stringify(message));
|
|
238
|
+
ws.close();
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ===================== D1 Relay Registry =====================
|
|
243
|
+
|
|
244
|
+
async function upsertRelay(db, url, name) {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
await db.prepare(`
|
|
247
|
+
INSERT INTO psp_relays (url, name, registered_at_ms, last_seen_ms)
|
|
248
|
+
VALUES (?1, ?2, ?3, ?3)
|
|
249
|
+
ON CONFLICT(url) DO UPDATE SET name = excluded.name, last_seen_ms = excluded.last_seen_ms
|
|
250
|
+
`).bind(url, name, now).run();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function listRelays(db) {
|
|
254
|
+
const cutoff = Date.now() - RELAY_EXPIRY_MS;
|
|
255
|
+
const result = await db.prepare(`
|
|
256
|
+
SELECT url, name, last_seen_ms FROM psp_relays
|
|
257
|
+
WHERE last_seen_ms > ?1
|
|
258
|
+
ORDER BY last_seen_ms DESC
|
|
259
|
+
`).bind(cutoff).all();
|
|
260
|
+
return (result.results || []).map(r => ({ url: r.url, name: r.name }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Broadcast peer list to all connected peers in a network
|
|
264
|
+
async function broadcastPeerList(db, network) {
|
|
265
|
+
const sockets = networkSubscribers.get(network);
|
|
266
|
+
if (!sockets || sockets.size === 0) return;
|
|
267
|
+
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const result = await db.prepare(`
|
|
270
|
+
SELECT peer_id, session_id, updated_at_ms
|
|
271
|
+
FROM psp_announcements
|
|
272
|
+
WHERE network = ?1 AND expires_at_ms > ?2
|
|
273
|
+
ORDER BY peer_id ASC
|
|
274
|
+
LIMIT ?3
|
|
275
|
+
`).bind(network, now, MAX_BATCH).all();
|
|
276
|
+
|
|
277
|
+
const peers = (result.results || []).map(row => ({
|
|
278
|
+
peer_id: row.peer_id,
|
|
279
|
+
session_id: row.session_id,
|
|
280
|
+
timestamp: row.updated_at_ms
|
|
281
|
+
}));
|
|
282
|
+
|
|
283
|
+
const message = {
|
|
284
|
+
psp_version: PSP_VERSION,
|
|
285
|
+
type: "peer_list",
|
|
286
|
+
network,
|
|
287
|
+
from: "bootstrap-relay",
|
|
288
|
+
to: null,
|
|
289
|
+
message_id: crypto.randomUUID(),
|
|
290
|
+
timestamp: Date.now(),
|
|
291
|
+
ttl_ms: DEFAULT_TTL_MS,
|
|
292
|
+
body: { peers }
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const payload = JSON.stringify(message);
|
|
296
|
+
for (const socket of sockets) {
|
|
297
|
+
try {
|
|
298
|
+
socket.send(payload);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
sockets.delete(socket);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ===================== D1 Database Functions =====================
|
|
306
|
+
|
|
307
|
+
async function upsertAnnouncement(db, message) {
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
const ttl = Math.min(message.ttl_ms || DEFAULT_TTL_MS, MAX_TTL_MS);
|
|
310
|
+
const expiresAt = now + ttl;
|
|
311
|
+
|
|
312
|
+
await db.prepare(`
|
|
313
|
+
INSERT INTO psp_announcements (network, peer_id, session_id, expires_at_ms, updated_at_ms)
|
|
314
|
+
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
315
|
+
ON CONFLICT(network, peer_id) DO UPDATE SET
|
|
316
|
+
session_id = excluded.session_id,
|
|
317
|
+
expires_at_ms = excluded.expires_at_ms,
|
|
318
|
+
updated_at_ms = excluded.updated_at_ms
|
|
319
|
+
`).bind(message.network, message.from, message.session_id || null, expiresAt, now).run();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function deleteAnnouncement(db, network, peerId) {
|
|
323
|
+
await db.prepare(`DELETE FROM psp_announcements WHERE network = ?1 AND peer_id = ?2`)
|
|
324
|
+
.bind(network, peerId).run();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function findPeers(db, network, requesterPeerId) {
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
const result = await db.prepare(`
|
|
330
|
+
SELECT peer_id, session_id, updated_at_ms
|
|
331
|
+
FROM psp_announcements
|
|
332
|
+
WHERE network = ?1 AND peer_id != ?2 AND expires_at_ms > ?3
|
|
333
|
+
ORDER BY peer_id ASC
|
|
334
|
+
LIMIT ?4
|
|
335
|
+
`).bind(network, requesterPeerId, now, MAX_BATCH).all();
|
|
336
|
+
|
|
337
|
+
return (result.results || []).map(row => ({
|
|
338
|
+
peer_id: row.peer_id,
|
|
339
|
+
session_id: row.session_id,
|
|
340
|
+
timestamp: row.updated_at_ms
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function insertRelayMessage(db, message) {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
const ttl = Math.min(message.ttl_ms || DEFAULT_TTL_MS, MAX_TTL_MS);
|
|
347
|
+
const expiresAt = now + ttl;
|
|
348
|
+
|
|
349
|
+
await db.prepare(`
|
|
350
|
+
INSERT INTO psp_relay (network, to_peer_id, type, session_id, message_json, expires_at_ms, created_at_ms)
|
|
351
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
352
|
+
`).bind(
|
|
353
|
+
message.network,
|
|
354
|
+
message.to,
|
|
355
|
+
message.type,
|
|
356
|
+
message.session_id || null,
|
|
357
|
+
JSON.stringify(message),
|
|
358
|
+
expiresAt,
|
|
359
|
+
now
|
|
360
|
+
).run();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function fetchRelayMessages(db, network, toPeerId) {
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
const result = await db.prepare(`
|
|
366
|
+
SELECT id, message_json
|
|
367
|
+
FROM psp_relay
|
|
368
|
+
WHERE network = ?1 AND to_peer_id = ?2 AND expires_at_ms > ?3
|
|
369
|
+
ORDER BY created_at_ms ASC
|
|
370
|
+
LIMIT ?4
|
|
371
|
+
`).bind(network, toPeerId, now, MAX_BATCH).all();
|
|
372
|
+
|
|
373
|
+
return (result.results || []).map(row => ({
|
|
374
|
+
id: row.id,
|
|
375
|
+
message: JSON.parse(row.message_json)
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function deliverQueuedRelayMessages(db, socket, network, peerId) {
|
|
380
|
+
if (!db) return 0;
|
|
381
|
+
|
|
382
|
+
const queued = await fetchRelayMessages(db, network, peerId);
|
|
383
|
+
if (queued.length === 0) return 0;
|
|
384
|
+
|
|
385
|
+
console.log(`[OUT] Delivering ${queued.length} queued messages to ${peerId}`);
|
|
386
|
+
const deliveredIds = [];
|
|
387
|
+
for (const { id, message: queuedMsg } of queued) {
|
|
388
|
+
try {
|
|
389
|
+
socket.send(JSON.stringify(queuedMsg));
|
|
390
|
+
deliveredIds.push(id);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error(`[OUT] Failed to deliver queued message:`, err?.message);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (deliveredIds.length > 0) {
|
|
397
|
+
await deleteRelayMessagesById(db, deliveredIds);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return deliveredIds.length;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function deleteRelayMessagesById(db, ids) {
|
|
404
|
+
if (!ids.length) return;
|
|
405
|
+
const placeholders = ids.map((_, i) => `?${i + 1}`).join(", ");
|
|
406
|
+
await db.prepare(`DELETE FROM psp_relay WHERE id IN (${placeholders})`)
|
|
407
|
+
.bind(...ids).run();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function cleanupExpired(db) {
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
await db.prepare(`DELETE FROM psp_announcements WHERE expires_at_ms <= ?1`).bind(now).run();
|
|
413
|
+
await db.prepare(`DELETE FROM psp_relay WHERE expires_at_ms <= ?1`).bind(now).run();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ===================== WebSocket Handler =====================
|
|
417
|
+
|
|
418
|
+
function handleWebSocket(request, env, ctx) {
|
|
419
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
420
|
+
|
|
421
|
+
let peerKey = null;
|
|
422
|
+
let network = null;
|
|
423
|
+
let peerId = null;
|
|
424
|
+
|
|
425
|
+
function cleanupPeerState() {
|
|
426
|
+
const currentNetwork = network;
|
|
427
|
+
|
|
428
|
+
if (!network || !peerId) {
|
|
429
|
+
return currentNetwork;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const currentPeerId = peerId;
|
|
433
|
+
const key = `${currentNetwork}:${currentPeerId}`;
|
|
434
|
+
|
|
435
|
+
livePeers.delete(key);
|
|
436
|
+
peerKey = null;
|
|
437
|
+
peerId = null;
|
|
438
|
+
network = null;
|
|
439
|
+
|
|
440
|
+
if (env.DB) {
|
|
441
|
+
ctx.waitUntil(
|
|
442
|
+
deleteAnnouncement(env.DB, currentNetwork, currentPeerId)
|
|
443
|
+
.then(() => broadcastPeerList(env.DB, currentNetwork))
|
|
444
|
+
.catch(() => {})
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return currentNetwork;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
server.addEventListener("message", async (event) => {
|
|
452
|
+
try {
|
|
453
|
+
const result = await handleClientMessage(server, event.data, env, ctx, peerKey, network);
|
|
454
|
+
if (result) {
|
|
455
|
+
peerKey = result.peerKey;
|
|
456
|
+
network = result.network;
|
|
457
|
+
peerId = result.peerId;
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error("[WS] Error:", err?.message || String(err));
|
|
461
|
+
try {
|
|
462
|
+
server.send(JSON.stringify({
|
|
463
|
+
psp_version: PSP_VERSION, type: "error",
|
|
464
|
+
from: env.RELAY_PEER_ID || "relay", to: "client",
|
|
465
|
+
body: { error: err?.message || "Unknown error" }
|
|
466
|
+
}));
|
|
467
|
+
} catch {}
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
server.addEventListener("close", () => {
|
|
472
|
+
const subscriberNetwork = cleanupPeerState();
|
|
473
|
+
if (subscriberNetwork) {
|
|
474
|
+
const sockets = networkSubscribers.get(subscriberNetwork);
|
|
475
|
+
if (sockets) {
|
|
476
|
+
sockets.delete(server);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
server.addEventListener("error", () => {
|
|
482
|
+
const subscriberNetwork = cleanupPeerState();
|
|
483
|
+
if (subscriberNetwork) {
|
|
484
|
+
const sockets = networkSubscribers.get(subscriberNetwork);
|
|
485
|
+
if (sockets) {
|
|
486
|
+
sockets.delete(server);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
server.accept();
|
|
492
|
+
|
|
493
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function handleClientMessage(socket, rawData, env, ctx, prevPeerKey = null, prevNetwork = null) {
|
|
497
|
+
try {
|
|
498
|
+
if (!rawData) return null;
|
|
499
|
+
if (rawData.length > MAX_MESSAGE_SIZE) return null;
|
|
500
|
+
|
|
501
|
+
let message;
|
|
502
|
+
try {
|
|
503
|
+
message = JSON.parse(rawData);
|
|
504
|
+
} catch (e) {
|
|
505
|
+
socket.send(JSON.stringify({
|
|
506
|
+
psp_version: PSP_VERSION, type: "error",
|
|
507
|
+
from: env.RELAY_PEER_ID || "relay", to: "client",
|
|
508
|
+
body: { error: "Invalid JSON" }
|
|
509
|
+
}));
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!validEnvelope(message)) {
|
|
514
|
+
socket.send(JSON.stringify({
|
|
515
|
+
psp_version: PSP_VERSION, type: "error",
|
|
516
|
+
from: env.RELAY_PEER_ID || "relay", to: message?.from || "unknown",
|
|
517
|
+
body: { error: "Invalid PSP envelope" }
|
|
518
|
+
}));
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const { network, from: peerId, type } = message;
|
|
523
|
+
const db = env.DB;
|
|
524
|
+
const peerKey = `${network}:${peerId}`;
|
|
525
|
+
|
|
526
|
+
// Subscribe to network on first message and whenever network changes on the same socket.
|
|
527
|
+
if (!prevPeerKey || prevNetwork !== network) {
|
|
528
|
+
if (prevNetwork && prevNetwork !== network) {
|
|
529
|
+
const oldSockets = networkSubscribers.get(prevNetwork);
|
|
530
|
+
if (oldSockets) {
|
|
531
|
+
oldSockets.delete(socket);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (!networkSubscribers.has(network)) {
|
|
535
|
+
networkSubscribers.set(network, new Set());
|
|
536
|
+
}
|
|
537
|
+
networkSubscribers.get(network).add(socket);
|
|
538
|
+
console.log(`[NET] Peer ${peerId} subscribed to ${network}`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Track live peer
|
|
542
|
+
livePeers.set(peerKey, { peerId, network, socket, lastSeen: Date.now() });
|
|
543
|
+
|
|
544
|
+
if (type === "announce") {
|
|
545
|
+
if (db) {
|
|
546
|
+
await upsertAnnouncement(db, message);
|
|
547
|
+
await deliverQueuedRelayMessages(db, socket, network, peerId);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Only broadcast peer_list when the peer is newly joining, not on heartbeat re-announces.
|
|
551
|
+
// prevPeerKey === peerKey means same peer on the same socket sending a periodic keep-alive;
|
|
552
|
+
// no topology change occurred, so no need to push a new list to everyone.
|
|
553
|
+
const isHeartbeat = prevPeerKey === peerKey;
|
|
554
|
+
if (!isHeartbeat && db) {
|
|
555
|
+
console.log(`[NET] Broadcasting peer_list for ${network} after new announce from ${peerId}`);
|
|
556
|
+
broadcastPeerList(db, network).catch((err) => console.error(`[Broadcast error]`, err?.message));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
} else if (type === "withdraw") {
|
|
560
|
+
if (db) {
|
|
561
|
+
await deleteAnnouncement(db, network, peerId);
|
|
562
|
+
}
|
|
563
|
+
livePeers.delete(peerKey);
|
|
564
|
+
if (db) {
|
|
565
|
+
broadcastPeerList(db, network).catch(() => {});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
} else if (type === "discover") {
|
|
569
|
+
// Local peers first
|
|
570
|
+
if (db) {
|
|
571
|
+
broadcastPeerList(db, network).catch(() => {});
|
|
572
|
+
}
|
|
573
|
+
// Fan out to all known peer relays, exchanging relay lists bidirectionally
|
|
574
|
+
if (env.RELAY_URL && env.DB) {
|
|
575
|
+
ctx.waitUntil((async () => {
|
|
576
|
+
const selfRelayId = env.RELAY_PEER_ID || "relay-bridge";
|
|
577
|
+
const selfUrl = normalizeRelayUrl(env.RELAY_URL);
|
|
578
|
+
const allRelays = await listRelays(env.DB);
|
|
579
|
+
const remoteUrls = allRelays.map(r => r.url).filter(u => u !== selfUrl);
|
|
580
|
+
if (!remoteUrls.length) return;
|
|
581
|
+
|
|
582
|
+
const results = await Promise.all(
|
|
583
|
+
remoteUrls.map(u => queryRelayForPeers(u, network, selfRelayId, env.DB, allRelays))
|
|
584
|
+
);
|
|
585
|
+
const remotePeers = results.flat();
|
|
586
|
+
if (!remotePeers.length) return;
|
|
587
|
+
|
|
588
|
+
const message = {
|
|
589
|
+
psp_version: PSP_VERSION, type: "peer_list", network,
|
|
590
|
+
from: selfRelayId, to: peerId,
|
|
591
|
+
message_id: crypto.randomUUID(), timestamp: Date.now(),
|
|
592
|
+
ttl_ms: DEFAULT_TTL_MS,
|
|
593
|
+
body: { peers: remotePeers }
|
|
594
|
+
};
|
|
595
|
+
try { socket.send(JSON.stringify(message)); } catch {}
|
|
596
|
+
})());
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
} else if (type === "ext" && message.body?.action === "relay_list") {
|
|
600
|
+
// Remote relay is sharing its known relay list — cache any new entries
|
|
601
|
+
if (db) {
|
|
602
|
+
const remoteRelays = message.body.relays || [];
|
|
603
|
+
await Promise.all(
|
|
604
|
+
remoteRelays
|
|
605
|
+
.filter(r => r.url)
|
|
606
|
+
.map(r => upsertRelay(db, r.url, r.name || null).catch(() => {}))
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
} else if (type === "ping") {
|
|
611
|
+
socket.send(JSON.stringify({
|
|
612
|
+
psp_version: PSP_VERSION, type: "pong", network,
|
|
613
|
+
from: env.RELAY_PEER_ID || "relay", to: peerId,
|
|
614
|
+
message_id: crypto.randomUUID(), timestamp: Date.now(),
|
|
615
|
+
ttl_ms: DEFAULT_TTL_MS, body: {}
|
|
616
|
+
}));
|
|
617
|
+
if (db) {
|
|
618
|
+
await deliverQueuedRelayMessages(db, socket, network, peerId);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
} else if (type === "bye") {
|
|
622
|
+
if (db) {
|
|
623
|
+
await deleteAnnouncement(db, network, peerId);
|
|
624
|
+
}
|
|
625
|
+
livePeers.delete(peerKey);
|
|
626
|
+
if (db) {
|
|
627
|
+
broadcastPeerList(db, network).catch(() => {});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
} else if (RELAY_TYPES.has(type)) {
|
|
631
|
+
// RTC negotiation messages - relay immediately if online, queue if offline
|
|
632
|
+
if (!message.to) return { peerKey, network, peerId };
|
|
633
|
+
|
|
634
|
+
// Try immediate delivery to live peer
|
|
635
|
+
const liveKey = `${network}:${message.to}`;
|
|
636
|
+
const live = livePeers.get(liveKey);
|
|
637
|
+
let deliveredLive = false;
|
|
638
|
+
if (live) {
|
|
639
|
+
try {
|
|
640
|
+
live.socket.send(rawData);
|
|
641
|
+
deliveredLive = true;
|
|
642
|
+
console.log(`[RELAY] Delivered ${type} from ${peerId} to ${message.to} immediately`);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
console.error(`[RELAY] Failed to deliver to ${message.to}:`, err?.message);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!deliveredLive && db) {
|
|
649
|
+
await insertRelayMessage(db, message);
|
|
650
|
+
if (!live) {
|
|
651
|
+
console.log(`[RELAY] Peer ${message.to} offline, queued ${type} in DB`);
|
|
652
|
+
} else {
|
|
653
|
+
console.log(`[RELAY] Queued ${type} for ${message.to} after live delivery failure`);
|
|
654
|
+
}
|
|
655
|
+
} else if (!deliveredLive) {
|
|
656
|
+
console.warn(`[RELAY] Could not deliver ${type} to ${message.to}; persistence unavailable`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// If still not delivered locally and federation is enabled, fan out to peer relays via WebSocket
|
|
660
|
+
if (!deliveredLive && env.RELAY_URL && env.DB) {
|
|
661
|
+
ctx.waitUntil((async () => {
|
|
662
|
+
const selfRelayId = env.RELAY_PEER_ID || "relay-bridge";
|
|
663
|
+
const selfUrl = normalizeRelayUrl(env.RELAY_URL);
|
|
664
|
+
const remoteUrls = await getPeerRelayUrls(env.DB, selfUrl);
|
|
665
|
+
if (!remoteUrls.length) return;
|
|
666
|
+
console.log(`[FED] Forwarding ${type} to ${remoteUrls.length} peer relay(s) for ${message.to}`);
|
|
667
|
+
await Promise.all(remoteUrls.map(u => forwardToRelay(u, message, selfRelayId)));
|
|
668
|
+
})());
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
ctx.waitUntil(cleanupExpired(db).catch(() => {}));
|
|
673
|
+
return { peerKey, network, peerId };
|
|
674
|
+
} catch (err) {
|
|
675
|
+
console.error("[Handler] Error:", err?.message || String(err));
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function validEnvelope(msg) {
|
|
681
|
+
return (
|
|
682
|
+
typeof msg === "object" && msg !== null &&
|
|
683
|
+
msg.psp_version === PSP_VERSION &&
|
|
684
|
+
typeof msg.type === "string" && MESSAGE_TYPES.has(msg.type) &&
|
|
685
|
+
typeof msg.from === "string" && msg.from.trim() &&
|
|
686
|
+
typeof msg.network === "string" && msg.network.trim() &&
|
|
687
|
+
typeof msg.message_id === "string" &&
|
|
688
|
+
typeof msg.timestamp === "number"
|
|
689
|
+
);
|
|
690
|
+
}
|