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/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
+ }