- NIP-46 remote signer in the page (bunker:// and nostrconnect:// pairing) - Chrome extension (NIP-07) with per-site permission prompts, unlocked by the site via externally_connectable; downloadable from the page - Passkey (WebAuthn PRF) encryption of the Drive key file - NIP-49 backups: download or save to Drive; reveal nsec - Fix: oldest key file wins when two devices sign up at once - Fix: never overwrite an existing kind-0 because a relay was slow - Fix: EOSE tracking; relays reconnect with backoff; verify event signatures - One-click re-sign-in (consent only the first time) - Vendored, pinned nostr-tools; CSP; no third-party scripts but Google's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
7.6 KiB
JavaScript
241 lines
7.6 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.
|
|
// * Dropped relays reconnect with backoff (2s → 60s), and immediately when
|
|
// the browser comes back online. The NIP-46 signer relies on this.
|
|
// * Every incoming event's signature is verified before handlers see it.
|
|
// * Per-relay status is exposed via onStatus() so the UI can show progress.
|
|
|
|
import { verifyEvent } from "./vendor/nostr-tools.js";
|
|
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
|
|
const EOSE_TIMEOUT_MS = 5000; // onEose fires by then even if a relay is silent
|
|
const MAX_RETRY_DELAY_MS = 60_000;
|
|
|
|
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.retries = new Map(); // url -> consecutive failures
|
|
this.retryTimers = new Map();
|
|
this.subs = new Map(); // subId -> { req, handler }
|
|
this.recentPublishes = []; // { msg, t }
|
|
this.subId = 0;
|
|
this._listeners = new Set(); // status change listeners
|
|
this._started = false;
|
|
|
|
addEventListener("online", () => this._reconnectNow());
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") this._reconnectNow();
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
_scheduleRetry(url) {
|
|
if (!this._started || this.retryTimers.has(url)) return;
|
|
const n = (this.retries.get(url) || 0) + 1;
|
|
this.retries.set(url, n);
|
|
const delay = Math.min(MAX_RETRY_DELAY_MS, 2000 * 2 ** (n - 1));
|
|
this.retryTimers.set(url, setTimeout(() => {
|
|
this.retryTimers.delete(url);
|
|
this._open(url);
|
|
}, delay));
|
|
}
|
|
|
|
_reconnectNow() {
|
|
if (!this._started) return;
|
|
for (const url of this.urls) {
|
|
if (this.status.get(url) === "open" || this.status.get(url) === "connecting") continue;
|
|
clearTimeout(this.retryTimers.get(url));
|
|
this.retryTimers.delete(url);
|
|
this.retries.set(url, 0);
|
|
this._open(url);
|
|
}
|
|
}
|
|
|
|
_open(url) {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (ok) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(ok);
|
|
};
|
|
const failed = (state) => {
|
|
this.sockets.delete(url);
|
|
this.status.set(url, state);
|
|
this._notify();
|
|
this._scheduleRetry(url);
|
|
finish(false);
|
|
};
|
|
|
|
let ws;
|
|
try {
|
|
ws = new WebSocket(url);
|
|
} catch {
|
|
return failed("closed");
|
|
}
|
|
|
|
this.status.set(url, "connecting");
|
|
this._notify();
|
|
|
|
const timer = setTimeout(() => {
|
|
if (ws.readyState === WebSocket.CONNECTING) {
|
|
try { ws.close(); } catch {}
|
|
failed("timeout");
|
|
}
|
|
}, PER_RELAY_CONNECT_TIMEOUT_MS);
|
|
|
|
ws.addEventListener("open", () => {
|
|
clearTimeout(timer);
|
|
this.sockets.set(url, ws);
|
|
this.status.set(url, "open");
|
|
this.retries.set(url, 0);
|
|
this._notify();
|
|
|
|
// 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("close", () => {
|
|
clearTimeout(timer);
|
|
if (this.sockets.get(url) === ws || !settled) {
|
|
failed(this.status.get(url) === "timeout" ? "timeout" : "closed");
|
|
}
|
|
});
|
|
|
|
// Wire messages to active subscription handlers.
|
|
ws.addEventListener("message", (ev) => {
|
|
let msg;
|
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
const sub = this.subs.get(msg[1]);
|
|
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 } = {}) {
|
|
this._started = true;
|
|
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;
|
|
clearTimeout(ceiling);
|
|
resolve(this.sockets.size);
|
|
};
|
|
const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS);
|
|
attempts.forEach((p) => p.then((ok) => { if (ok && ++opened >= minRelays) finish(); }));
|
|
Promise.allSettled(attempts).then(finish);
|
|
});
|
|
}
|
|
|
|
// Add a relay at runtime (e.g. one named in a nostrconnect:// link).
|
|
addRelay(url) {
|
|
if (!/^wss?:\/\//.test(url) || this.urls.includes(url)) return;
|
|
this.urls.push(url);
|
|
this._open(url);
|
|
}
|
|
|
|
publish(event) {
|
|
const msg = JSON.stringify(["EVENT", event]);
|
|
this.recentPublishes.push({ msg, t: Date.now() });
|
|
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 verified match. onEose(n)
|
|
// fires once: when every relay open at that moment has sent EOSE, or after
|
|
// EOSE_TIMEOUT_MS, whichever is first.
|
|
subscribe(filters, onEvent, onEose) {
|
|
const id = `s${++this.subId}`;
|
|
const req = JSON.stringify(["REQ", id, ...filters]);
|
|
const seen = new Set();
|
|
const eoseFrom = new Set();
|
|
let eoseFired = false;
|
|
const fireEose = () => {
|
|
if (eoseFired) return;
|
|
eoseFired = true;
|
|
clearTimeout(eoseTimer);
|
|
onEose?.(eoseFrom.size); // how many relays actually answered
|
|
};
|
|
const eoseTimer = setTimeout(fireEose, EOSE_TIMEOUT_MS);
|
|
|
|
const handler = (msg, url) => {
|
|
if (msg[0] === "EVENT") {
|
|
const e = msg[2];
|
|
if (!e || seen.has(e.id) || !verifyEvent(e)) return;
|
|
seen.add(e.id);
|
|
onEvent(e);
|
|
} else if (msg[0] === "EOSE") {
|
|
eoseFrom.add(url);
|
|
if ([...this.sockets.keys()].every((u) => eoseFrom.has(u))) fireEose();
|
|
}
|
|
};
|
|
|
|
this.subs.set(id, { req, handler });
|
|
|
|
for (const ws of this.sockets.values()) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
try { ws.send(req); } catch {}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
clearTimeout(eoseTimer);
|
|
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();
|