Googstr/public/relay.js
Jason Tudisco 21093eb800 Initial commit: Goostr static site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:37:10 -06:00

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();