211 lines
6.3 KiB
JavaScript
211 lines
6.3 KiB
JavaScript
// Minimal nostr relay pool — fast-start, fault-tolerant.
|
|
//
|
|
// Design:
|
|
// * Open all relays in parallel, each with a 4s connect timeout.
|
|
// * connectAll() resolves as soon as the FIRST relay opens (or after the
|
|
// overall timeout). The rest keep opening in the background.
|
|
// * Late-arriving relays automatically replay anything we've already sent
|
|
// (active subscriptions + recent publishes), so the user never waits.
|
|
// * Per-relay status is exposed via onStatus() so the UI can show progress.
|
|
|
|
import { RELAYS } from "./config.js";
|
|
|
|
const PER_RELAY_CONNECT_TIMEOUT_MS = 4000;
|
|
const OVERALL_CONNECT_TIMEOUT_MS = 4500; // hard ceiling for connectAll()
|
|
const PUBLISH_REPLAY_WINDOW_MS = 60_000; // re-send recent publishes to late relays
|
|
|
|
class RelayPool {
|
|
constructor(urls = RELAYS) {
|
|
this.urls = urls;
|
|
this.sockets = new Map(); // url -> WebSocket (only OPEN ones)
|
|
this.status = new Map(); // url -> "connecting"|"open"|"closed"|"timeout"
|
|
this.subs = new Map(); // subId -> { req, handler }
|
|
this.recentPublishes = []; // { msg, t }
|
|
this.subId = 0;
|
|
this._listeners = new Set(); // status change listeners
|
|
}
|
|
|
|
onStatus(fn) {
|
|
this._listeners.add(fn);
|
|
fn(this.statusSnapshot());
|
|
return () => this._listeners.delete(fn);
|
|
}
|
|
|
|
statusSnapshot() {
|
|
const out = {};
|
|
for (const u of this.urls) out[u] = this.status.get(u) || "idle";
|
|
return out;
|
|
}
|
|
|
|
_notify() {
|
|
const snap = this.statusSnapshot();
|
|
for (const fn of this._listeners) fn(snap);
|
|
}
|
|
|
|
_open(url) {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (ok) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(ok);
|
|
};
|
|
|
|
let ws;
|
|
try {
|
|
ws = new WebSocket(url);
|
|
} catch {
|
|
this.status.set(url, "closed");
|
|
this._notify();
|
|
return finish(false);
|
|
}
|
|
|
|
this.status.set(url, "connecting");
|
|
this._notify();
|
|
|
|
const timer = setTimeout(() => {
|
|
if (ws.readyState === WebSocket.CONNECTING) {
|
|
this.status.set(url, "timeout");
|
|
this._notify();
|
|
try { ws.close(); } catch {}
|
|
finish(false);
|
|
}
|
|
}, PER_RELAY_CONNECT_TIMEOUT_MS);
|
|
|
|
ws.addEventListener("open", () => {
|
|
clearTimeout(timer);
|
|
const t0 = performance.now();
|
|
this.sockets.set(url, ws);
|
|
this.status.set(url, "open");
|
|
this._notify();
|
|
console.log(`[relay] open ${url} (${Math.round(t0)}ms since page)`);
|
|
|
|
// Replay active subscriptions and recent publishes to this new relay.
|
|
for (const { req } of this.subs.values()) {
|
|
try { ws.send(req); } catch {}
|
|
}
|
|
const cutoff = Date.now() - PUBLISH_REPLAY_WINDOW_MS;
|
|
for (const { msg, t } of this.recentPublishes) {
|
|
if (t >= cutoff) {
|
|
try { ws.send(msg); } catch {}
|
|
}
|
|
}
|
|
finish(true);
|
|
});
|
|
|
|
ws.addEventListener("error", () => {
|
|
clearTimeout(timer);
|
|
if (this.status.get(url) !== "open") this.status.set(url, "closed");
|
|
this._notify();
|
|
finish(false);
|
|
});
|
|
|
|
ws.addEventListener("close", () => {
|
|
clearTimeout(timer);
|
|
this.sockets.delete(url);
|
|
if (this.status.get(url) !== "timeout") this.status.set(url, "closed");
|
|
this._notify();
|
|
finish(false);
|
|
});
|
|
|
|
// Wire messages to active subscription handlers.
|
|
ws.addEventListener("message", (ev) => {
|
|
let msg;
|
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
const subId = msg[1];
|
|
const sub = this.subs.get(subId);
|
|
if (sub) sub.handler(msg, url);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Resolves as soon as we have at least `minRelays` open, or after the
|
|
// overall timeout. Always returns the number of relays that opened.
|
|
async connectAll({ minRelays = 1 } = {}) {
|
|
const attempts = this.urls.map((u) => this._open(u));
|
|
let opened = 0;
|
|
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
resolve(this.sockets.size);
|
|
};
|
|
|
|
const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS);
|
|
|
|
attempts.forEach((p) =>
|
|
p.then((ok) => {
|
|
if (ok) opened++;
|
|
if (opened >= minRelays) {
|
|
clearTimeout(ceiling);
|
|
// Resolve quickly once we hit the threshold; other relays keep
|
|
// opening in the background and self-register via _open().
|
|
finish();
|
|
}
|
|
if (opened === 0 && attempts.every((_) => false)) {
|
|
/* unreachable; here for clarity */
|
|
}
|
|
})
|
|
);
|
|
|
|
// If every attempt fully settled with zero opens, resolve too.
|
|
Promise.allSettled(attempts).then(() => finish());
|
|
});
|
|
}
|
|
|
|
publish(event) {
|
|
const msg = JSON.stringify(["EVENT", event]);
|
|
this.recentPublishes.push({ msg, t: Date.now() });
|
|
// trim
|
|
if (this.recentPublishes.length > 200) this.recentPublishes.shift();
|
|
for (const ws of this.sockets.values()) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
try { ws.send(msg); } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// filters: array of REQ filters. onEvent(event) per match. onEose() once
|
|
// every currently-open relay has signaled EOSE.
|
|
subscribe(filters, onEvent, onEose) {
|
|
const id = `s${++this.subId}`;
|
|
const req = JSON.stringify(["REQ", id, ...filters]);
|
|
const seen = new Set();
|
|
const eoseSet = new Set();
|
|
|
|
const handler = (msg, url) => {
|
|
if (msg[0] === "EVENT" && msg[1] === id) {
|
|
const e = msg[2];
|
|
if (seen.has(e.id)) return;
|
|
seen.add(e.id);
|
|
onEvent(e);
|
|
} else if (msg[0] === "EOSE" && msg[1] === id) {
|
|
eoseSet.add(url);
|
|
if (eoseSet.size >= this.sockets.size && onEose) onEose();
|
|
}
|
|
};
|
|
|
|
this.subs.set(id, { req, handler });
|
|
|
|
for (const ws of this.sockets.values()) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
try { ws.send(req); } catch {}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
const close = JSON.stringify(["CLOSE", id]);
|
|
for (const ws of this.sockets.values()) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
try { ws.send(close); } catch {}
|
|
}
|
|
}
|
|
this.subs.delete(id);
|
|
};
|
|
}
|
|
}
|
|
|
|
export const pool = new RelayPool();
|