diff --git a/src/classes/relay.ts b/src/classes/relay.ts index ae76e18bd..421066e69 100644 --- a/src/classes/relay.ts +++ b/src/classes/relay.ts @@ -1,3 +1,4 @@ +import relayScoreboardService from "../services/relay-scoreboard"; import { RawIncomingNostrEvent, NostrEvent } from "../types/nostr-event"; import { NostrOutgoingMessage } from "../types/nostr-query"; import { Subject } from "./subject"; @@ -46,7 +47,9 @@ export class Relay { ws?: WebSocket; mode: RelayMode = RelayMode.ALL; + private intentionalClose = false; private queue: NostrOutgoingMessage[] = []; + private subscriptionStartTime = new Map(); constructor(url: string, mode: RelayMode = RelayMode.ALL) { this.url = url; @@ -55,6 +58,7 @@ export class Relay { open() { if (this.okay) return; + this.intentionalClose = false; this.ws = new WebSocket(this.url); this.ws.onopen = () => { @@ -69,6 +73,8 @@ export class Relay { this.ws.onclose = () => { this.onClose.next(this); + if (!this.intentionalClose) relayScoreboardService.submitDisconnect(this.url); + if (import.meta.env.DEV) { console.info(`Relay: ${this.url} disconnected`); } @@ -79,11 +85,29 @@ export class Relay { if (this.mode & RelayMode.WRITE) { if (this.connected) { this.ws?.send(JSON.stringify(json)); + + // record start time + if (json[0] === "REQ") { + this.subStartMeasure(json[1]); + } } else this.queue.push(json); } } close() { this.ws?.close(); + this.intentionalClose = true; + this.subscriptionStartTime.clear(); + } + + private subStartMeasure(sub: string) { + this.subscriptionStartTime.set(sub, new Date()); + } + private subEndMeasure(sub: string) { + const date = this.subscriptionStartTime.get(sub); + if (date) { + relayScoreboardService.submitResponseTime(this.url, new Date().valueOf() - date.valueOf()); + this.subscriptionStartTime.delete(sub); + } } private sendQueued() { @@ -130,12 +154,14 @@ export class Relay { switch (type) { case "EVENT": this.onEvent.next({ relay: this, type, subId: data[1], body: data[2] }); + this.subEndMeasure(data[1]); break; case "NOTICE": this.onNotice.next({ relay: this, type, message: data[1] }); break; case "EOSE": this.onEOSE.next({ relay: this, type, subId: data[1] }); + this.subEndMeasure(data[1]); break; case "OK": this.onCommandResult.next({ relay: this, type, eventId: data[1], status: data[2], message: data[3] }); diff --git a/src/helpers/url.ts b/src/helpers/url.ts new file mode 100644 index 000000000..3d23e1a7c --- /dev/null +++ b/src/helpers/url.ts @@ -0,0 +1,17 @@ +import { utils } from "nostr-tools"; + +export function validateRelayUrl(relayUrl: string) { + const normalized = utils.normalizeURL(relayUrl); + const url = new URL(normalized); + + if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol"); + + return url.toString(); +} + +export function safeRelayUrl(relayUrl: string) { + try { + return validateRelayUrl(relayUrl); + } catch (e) {} + return null; +} diff --git a/src/services/db/index.ts b/src/services/db/index.ts index 93726af1e..486c39a54 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -42,6 +42,7 @@ const MIGRATIONS: MigrationFunction[] = [ db.createObjectStore("settings"); db.createObjectStore("relayInfo"); + db.createObjectStore("relayScoreboardStats", { keyPath: "relay" }); db.createObjectStore("accounts", { keyPath: "pubkey" }); }, ]; diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 4b593ed87..41885530c 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -35,6 +35,10 @@ export interface CustomSchema extends DBSchema { value: { pubkey: string; relays: Record; updated: number }; indexes: { pubkey: string }; }; + relayScoreboardStats: { + key: string; + value: { relay: string; responseTimes: [number, Date][]; disconnects: Date[] }; + }; settings: { key: string; value: any; diff --git a/src/services/relay-scoreboard.ts b/src/services/relay-scoreboard.ts new file mode 100644 index 000000000..be4089852 --- /dev/null +++ b/src/services/relay-scoreboard.ts @@ -0,0 +1,115 @@ +import moment from "moment"; +import { SuperMap } from "../classes/super-map"; +import db from "./db"; + +class RelayScoreboardService { + private relays = new Set(); + private relayResponseTimes = new SuperMap(() => []); + private relayDisconnects = new SuperMap(() => []); + + submitResponseTime(relay: string, time: number) { + this.relays.add(relay); + const arr = this.relayResponseTimes.get(relay); + arr.unshift([time, new Date()]); + } + submitDisconnect(relay: string) { + this.relays.add(relay); + const arr = this.relayDisconnects.get(relay); + arr.unshift(new Date()); + } + + pruneResponseTimes() { + const cutOff = moment().subtract(1, "week").toDate(); + for (const [relay, arr] of this.relayResponseTimes) { + while (true) { + const lastResponse = arr.pop(); + if (!lastResponse) break; + if (lastResponse[1] >= cutOff) { + arr.push(lastResponse); + break; + } + } + } + } + pruneResponseDisconnects() { + const cutOff = moment().subtract(1, "week").toDate(); + for (const [relay, arr] of this.relayDisconnects) { + while (true) { + const lastDisconnect = arr.pop(); + if (!lastDisconnect) break; + if (lastDisconnect >= cutOff) { + arr.push(lastDisconnect); + break; + } + } + } + } + + getAverageResponseTime(relay: string) { + const times = this.relayResponseTimes.get(relay); + // TODO: not sure if this is a good fix for keeping unconnected relays from the top of the list + if (times.length === 0) return 200; + const total = times.reduce((total, [time]) => total + time, 0); + return total / times.length; + } + getDisconnects(relay: string) { + return this.relayDisconnects.get(relay).length; + } + + getRankedRelays(customRelays?: string[]) { + const relays = customRelays ?? Array.from(this.relays); + const relayAverageResponseTimes = new SuperMap(() => 0); + const relayDisconnects = new SuperMap(() => 0); + + for (const relay of relays) { + const averageResponseTime = this.getAverageResponseTime(relay); + const disconnectTimes = this.relayDisconnects.get(relay).length; + relayAverageResponseTimes.set(relay, averageResponseTime); + relayDisconnects.set(relay, disconnectTimes); + } + + return relays.sort((a, b) => { + let diff = 0; + diff += Math.sign(relayAverageResponseTimes.get(a) - relayAverageResponseTimes.get(b)); + diff += Math.sign(relayDisconnects.get(a) - relayDisconnects.get(b)); + return diff; + }); + } + + async loadStats() { + const stats = await db.getAll("relayScoreboardStats"); + + for (const relayStats of stats) { + this.relays.add(relayStats.relay); + this.relayResponseTimes.set(relayStats.relay, relayStats.responseTimes); + this.relayDisconnects.set(relayStats.relay, relayStats.disconnects); + } + } + + async saveStats() { + const transaction = db.transaction("relayScoreboardStats", "readwrite"); + for (const relay of this.relays) { + const responseTimes = this.relayResponseTimes.get(relay); + const disconnects = this.relayDisconnects.get(relay); + transaction.store.put({ relay, responseTimes, disconnects }); + } + await transaction.done; + } +} + +const relayScoreboardService = new RelayScoreboardService(); + +relayScoreboardService.loadStats().then(() => { + console.log("Loaded relay scoreboard stats"); +}); + +setInterval(() => { + relayScoreboardService.saveStats(); +}, 1000 * 5); + +if (import.meta.env.DEV) { + // @ts-ignore + window.relayScoreboardService = relayScoreboardService; +} + +export default relayScoreboardService; diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 946ab4b03..24b7b1a25 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -13,6 +13,7 @@ import { IconButton, Text, Badge, + useToast, } from "@chakra-ui/react"; import { SyntheticEvent, useEffect, useState } from "react"; import { TrashIcon, UndoIcon } from "../../components/icons"; @@ -23,9 +24,12 @@ import { useList } from "react-use"; import { RelayUrlInput } from "../../components/relay-url-input"; import useSubject from "../../hooks/use-subject"; import { RelayStatus } from "../../components/relay-status"; +import relayScoreboardService from "../../services/relay-scoreboard"; +import { validateRelayUrl } from "../../helpers/url"; export const RelaysView = () => { const relays = useSubject(clientRelaysService.relays); + const toast = useToast(); const [pendingAdd, addActions] = useList([]); const [pendingRemove, removeActions] = useList([]); @@ -49,11 +53,16 @@ export const RelaysView = () => { }; const handleAddRelay = (event: SyntheticEvent) => { event.preventDefault(); - setRelayInputValue(""); - - const url = relayInputValue; - if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) { - addActions.push({ url, mode: RelayMode.ALL }); + try { + const url = validateRelayUrl(relayInputValue); + if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) { + addActions.push({ url, mode: RelayMode.ALL }); + } + setRelayInputValue(""); + } catch (e) { + if (e instanceof Error) { + toast({ status: "error", description: e.message }); + } } }; const savePending = async () => { @@ -72,6 +81,8 @@ export const RelaysView = () => { Url + Avg Response + Disconnects Status @@ -85,6 +96,8 @@ export const RelaysView = () => { {relay.url} + {relayScoreboardService.getAverageResponseTime(relay.url).toFixed(2)}ms + {relayScoreboardService.getDisconnects(relay.url)} diff --git a/src/views/user/relays.tsx b/src/views/user/relays.tsx index f2ce2a3ef..db9a173c9 100644 --- a/src/views/user/relays.tsx +++ b/src/views/user/relays.tsx @@ -1,8 +1,11 @@ -import { Text, Grid, Box, IconButton, Flex, Heading } from "@chakra-ui/react"; +import { Text, Grid, Box, IconButton, Flex, Heading, Badge, Alert, AlertIcon } from "@chakra-ui/react"; import { useUserContacts } from "../../hooks/use-user-contacts"; import { useNavigate, useOutletContext } from "react-router-dom"; import { GlobalIcon } from "../../components/icons"; import { useUserRelays } from "../../hooks/use-user-relays"; +import { useMemo } from "react"; +import relayScoreboardService from "../../services/relay-scoreboard"; +import { RelayConfig, RelayMode } from "../../classes/relay"; const UserRelaysTab = () => { const { pubkey } = useOutletContext() as { pubkey: string }; @@ -10,41 +13,39 @@ const UserRelaysTab = () => { const userRelays = useUserRelays(pubkey); const navigate = useNavigate(); + const relays = useMemo(() => { + let arr: RelayConfig[] = userRelays?.relays ?? []; + if (arr.length === 0 && contacts) + arr = Array.from(Object.entries(contacts.relays)).map(([url, opts]) => ({ + url, + mode: (opts.write ? RelayMode.WRITE : 0) | (opts.read ? RelayMode.READ : 0), + })); + const rankedUrls = relayScoreboardService.getRankedRelays(arr.map((r) => r.url)); + return rankedUrls.map((u) => arr.find((r) => r.url === u) as RelayConfig); + }, [userRelays, contacts]); + return ( - - {userRelays && ( - - {userRelays.relays.map((relayConfig) => ( - - {relayConfig.url} - } - onClick={() => navigate("/global?relay=" + relayConfig.url)} - size="sm" - aria-label="Global Feed" - /> - - ))} - - )} - {contacts && ( - <> - Relays from contact list (old) - - {Object.entries(contacts.relays).map(([url, opts]) => ( - - {url} - } - onClick={() => navigate("/global?relay=" + url)} - size="sm" - aria-label="Global Feed" - /> - - ))} - - + + {!userRelays && contacts?.relays && ( + + + Cant find new relay list + )} + {relays.map((relayConfig) => ( + + {relayConfig.url} + {relayScoreboardService.getAverageResponseTime(relayConfig.url).toFixed(2)}ms + {relayConfig.mode & RelayMode.WRITE ? Write : null} + {relayConfig.mode & RelayMode.READ ? Read : null} + } + onClick={() => navigate("/global?relay=" + relayConfig.url)} + size="sm" + aria-label="Global Feed" + /> + + ))} ); };