add NIP-66 relay stats service

This commit is contained in:
hzrd149 2024-01-15 10:57:35 +00:00
parent aaa62088a1
commit a39e6adf8c
18 changed files with 226 additions and 54 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add NIP-66 relay stats service

View File

@ -16,7 +16,7 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays";
import TimelineLoader from "../../classes/timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { getSharableEventAddress } from "../../helpers/nip19";
import { safeRelayUrls } from "../../helpers/url";
import { safeRelayUrls } from "../../helpers/relay";
const kindColors: Record<number, FlexProps["bg"]> = {
[kinds.ShortTextNote]: "blue.500",

View File

@ -18,9 +18,9 @@ import {
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { RelayFavicon } from "../relay-favicon";
import { RelayUrlInput } from "../relay-url-input";
import { normalizeRelayUrl } from "../../helpers/url";
import { unique } from "../../helpers/array";
import relayScoreboardService from "../../services/relay-scoreboard";
import { normalizeRelayURL } from "../../helpers/relay";
function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
const [url, setUrl] = useState("");
@ -32,7 +32,7 @@ function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
onSubmit={(e) => {
try {
e.preventDefault();
onSubmit(normalizeRelayUrl(url));
onSubmit(normalizeRelayURL(url));
setUrl("");
} catch (err) {
if (err instanceof Error) {

View File

@ -5,8 +5,8 @@ import { NostrEvent } from "../../../types/nostr-event";
import UserAvatar from "../../user-avatar";
import UserLink from "../../user-link";
import RelayCard from "../../../views/relays/components/relay-card";
import { safeRelayUrl } from "../../../helpers/url";
import { useRegisterIntersectionEntity } from "../../../providers/local/intersection-observer";
import { safeRelayUrl } from "../../../helpers/relay";
export default function RelayRecommendation({ event }: { event: NostrEvent }) {
const safeUrl = safeRelayUrl(event.content);

View File

@ -0,0 +1,44 @@
import { NostrEvent, isDTag } from "../../types/nostr-event";
export const SELF_REPORTED_KIND = 10066;
export const MONITOR_METADATA_KIND = 10166;
export const MONITOR_STATS_KIND = 30066;
export function getRelayURL(stats: NostrEvent) {
if (stats.kind === SELF_REPORTED_KIND) return stats.tags.find((t) => t[0] === "r")?.[1];
return stats.tags.find(isDTag)?.[1];
}
export function getNetwork(stats: NostrEvent) {
return stats.tags.find((t) => t[0] === "n")?.[1];
}
export function getSupportedNIPs(stats: NostrEvent) {
return stats.tags.filter((t) => t[0] === "N" && t[1]).map((t) => t[1] && parseInt(t[1]));
}
type RTTValues = {
min: number;
max?: number;
average?: number;
median?: number;
};
function getRTTTag(stats: NostrEvent, name: string): RTTValues | undefined {
const values = stats.tags
.find((t) => t[0] === "rtt" && t[1] === "open")
?.slice(2)
.map((v) => parseInt(v));
if (!values || values.length === 0) return undefined;
return {
min: values[0],
max: values[1],
average: values[2],
median: values[3],
};
}
export function getRTT(stats: NostrEvent) {
const open = getRTTTag(stats, "open");
const read = getRTTTag(stats, "read");
const write = getRTTTag(stats, "write");
return { open, read, write };
}

View File

@ -1,10 +1,49 @@
import { SimpleRelay, SimpleSubscription, SimpleSubscriptionOptions } from "nostr-idb";
import { RelayConfig } from "../classes/relay";
import { NostrQuery, NostrRequestFilter } from "../types/nostr-query";
import { safeRelayUrl } from "./url";
import { Filter } from "nostr-tools";
import { NostrEvent } from "../types/nostr-event";
export function validateRelayURL(relay: string) {
if (relay.includes(",ws")) throw new Error("Can not have multiple relays in one string");
const url = new URL(relay);
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
return url;
}
export function isValidRelayURL(relay: string) {
try {
validateRelayURL(relay);
return true;
} catch (e) {
return false;
}
}
export function normalizeRelayURL(relayUrl: string) {
const url = validateRelayURL(relayUrl);
url.pathname = url.pathname.replace(/\/+/g, "/");
if ((url.port === "80" && url.protocol === "ws:") || (url.port === "443" && url.protocol === "wss:")) url.port = "";
url.searchParams.sort();
url.hash = "";
return url.toString();
}
export function safeNormalizeRelayURL(relayUrl: string) {
try {
return normalizeRelayURL(relayUrl);
} catch (e) {
return null;
}
}
// TODO: move these to helpers/relay
export function safeRelayUrl(relayUrl: string) {
if (isValidRelayURL(relayUrl)) return new URL(relayUrl).toString();
return null;
}
export function safeRelayUrls(urls: string[]): string[] {
return urls.map(safeRelayUrl).filter(Boolean) as string[];
}
export function normalizeRelayConfigs(relays: RelayConfig[]) {
const seen: string[] = [];
return relays.reduce((newArr, r) => {

View File

@ -1,4 +1,4 @@
import type { URLSearchParamsInit } from "react-router-dom";
import { isValidRelayURL, validateRelayURL } from "./relay";
export const convertToUrl = (url: string | URL) => (url instanceof URL ? url : new URL(url));
@ -22,33 +22,6 @@ export function isAudioURL(url: string | URL) {
return AUDIO_EXT.some((ext) => u.pathname.endsWith(ext));
}
export function normalizeRelayUrl(relayUrl: string) {
const url = new URL(relayUrl);
if (relayUrl.includes(",ws")) throw new Error("Can not have multiple relays in one string");
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
url.pathname = url.pathname.replace(/\/+/g, "/");
if (url.pathname.endsWith("/")) url.pathname = url.pathname.slice(0, -1);
if ((url.port === "80" && url.protocol === "ws:") || (url.port === "443" && url.protocol === "wss:")) url.port = "";
url.searchParams.sort();
url.hash = "";
return url.origin + (url.pathname === "/" ? "" : url.pathname) + url.search;
}
export function safeRelayUrl(relayUrl: string) {
try {
return normalizeRelayUrl(relayUrl);
} catch (e) {}
return null;
}
export function safeRelayUrls(urls: string[]): string[] {
return urls.map(safeRelayUrl).filter(Boolean) as string[];
}
export function replaceDomain(url: string | URL, replacementUrl: string | URL) {
const newUrl = new URL(url);
replacementUrl = convertToUrl(replacementUrl);

View File

@ -0,0 +1,17 @@
import relayStatsService from "../services/relay-stats";
import useSubject from "./use-subject";
export default function useRelayStats(relay: string) {
const monitorSub = relayStatsService.requestMonitorStats(relay);
const selfReportedSub = relayStatsService.requestSelfReported(relay);
const monitor = useSubject(monitorSub);
const selfReported = useSubject(selfReportedSub);
const stats = monitor || selfReported || undefined;
return {
monitor,
selfReported,
stats,
};
}

View File

@ -1,6 +1,6 @@
import React, { useContext } from "react";
import { unique } from "../../helpers/array";
import { safeRelayUrl } from "../../helpers/url";
import { safeRelayUrls } from "../../helpers/relay";
export const RelayContext = React.createContext<string[]>([]);
@ -18,7 +18,7 @@ export function AdditionalRelayProvider({
extend?: boolean;
}) {
const parentRelays = useAdditionalRelayContext();
const safeUrls = (extend ? [...parentRelays, ...relays] : relays).map(safeRelayUrl).filter(Boolean) as string[];
const safeUrls = safeRelayUrls(extend ? [...parentRelays, ...relays] : relays);
return <RelayContext.Provider value={unique(safeUrls)}>{children}</RelayContext.Provider>;
}

View File

@ -1,7 +1,7 @@
import Relay from "../classes/relay";
import Subject from "../classes/subject";
import { logger } from "../helpers/debug";
import { normalizeRelayUrl } from "../helpers/url";
import { normalizeRelayURL } from "../helpers/relay";
export class RelayPoolService {
relays = new Map<string, Relay>();
@ -14,7 +14,7 @@ export class RelayPoolService {
return Array.from(this.relays.values());
}
getRelayClaims(url: string) {
const normalized = normalizeRelayUrl(url);
const normalized = normalizeRelayURL(url);
if (!this.relayClaims.has(normalized)) {
this.relayClaims.set(normalized, new Set());
}
@ -22,7 +22,7 @@ export class RelayPoolService {
}
requestRelay(url: string, connect = true) {
const normalized = normalizeRelayUrl(url);
const normalized = normalizeRelayURL(url);
if (!this.relays.has(normalized)) {
const newRelay = new Relay(normalized);
this.relays.set(normalized, newRelay);
@ -65,11 +65,11 @@ export class RelayPoolService {
// id can be anything
addClaim(url: string, id: any) {
const normalized = normalizeRelayUrl(url);
const normalized = normalizeRelayURL(url);
this.getRelayClaims(normalized).add(id);
}
removeClaim(url: string, id: any) {
const normalized = normalizeRelayUrl(url);
const normalized = normalizeRelayURL(url);
this.getRelayClaims(normalized).delete(id);
}

View File

@ -0,0 +1,95 @@
import _throttle from "lodash/throttle";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { NostrEvent } from "../types/nostr-event";
import relayInfoService from "./relay-info";
import { normalizeRelayURL } from "../helpers/relay";
import { localCacheRelay } from "./local-cache-relay";
import { MONITOR_STATS_KIND, SELF_REPORTED_KIND, getRelayURL } from "../helpers/nostr/relay-stats";
const MONITOR_PUBKEY = "151c17c9d234320cf0f189af7b761f63419fd6c38c6041587a008b7682e4640f";
const MONITOR_RELAY = "wss://history.nostr.watch";
class RelayStatsService {
private selfReported = new SuperMap<string, Subject<NostrEvent | null>>(() => new Subject());
private monitorStats = new SuperMap<string, Subject<NostrEvent>>(() => new Subject());
constructor() {
// load all stats from cache and subscribe to future ones
localCacheRelay.subscribe([{ kinds: [SELF_REPORTED_KIND, MONITOR_STATS_KIND] }], {
onevent: (e) => this.handleEvent(e, false),
});
}
handleEvent(event: NostrEvent, cache = true) {
// ignore all events before NIP-66 start date
if (event.created_at < 1704196800) return;
const relay = getRelayURL(event);
if (!relay) return;
const sub = this.monitorStats.get(relay);
if (event.kind === SELF_REPORTED_KIND) {
if (!sub.value || event.created_at > sub.value.created_at) {
sub.next(event);
if (cache) localCacheRelay.publish(event);
}
} else if (event.kind === MONITOR_STATS_KIND) {
if (!sub.value || event.created_at > sub.value.created_at) {
sub.next(event);
if (cache) localCacheRelay.publish(event);
}
}
}
requestSelfReported(relay: string) {
relay = normalizeRelayURL(relay);
const sub = this.selfReported.get(relay);
if (sub.value === undefined) {
relayInfoService.getInfo(relay).then((info) => {
if (!info.pubkey) return sub.next(null);
const request = new NostrRequest([relay, MONITOR_RELAY]);
request.onEvent.subscribe(this.handleEvent, this);
request.start({ kinds: [SELF_REPORTED_KIND], authors: [info.pubkey] });
});
}
return sub;
}
requestMonitorStats(relay: string) {
relay = normalizeRelayURL(relay);
const sub = this.monitorStats.get(relay);
if (sub.value === undefined) {
this.pendingMonitorStats.add(relay);
this.throttleBatchRequestMonitorStats();
}
return sub;
}
throttleBatchRequestMonitorStats = _throttle(this.batchRequestMonitorStats, 200);
pendingMonitorStats = new Set<string>();
private batchRequestMonitorStats() {
const relays = Array.from(this.pendingMonitorStats);
const request = new NostrRequest([MONITOR_RELAY]);
request.onEvent.subscribe(this.handleEvent, this);
request.start({ since: 1704196800, kinds: [MONITOR_STATS_KIND], "#d": relays, authors: [MONITOR_PUBKEY] });
this.pendingMonitorStats.clear();
}
}
const relayStatsService = new RelayStatsService();
if (import.meta.env.DEV) {
//@ts-ignore
window.relayStatsService = relayStatsService;
}
export default relayStatsService;

View File

@ -3,10 +3,9 @@ import _throttle from "lodash.throttle";
import NostrRequest from "../classes/nostr-request";
import Subject from "../classes/subject";
import SuperMap from "../classes/super-map";
import { safeRelayUrls } from "../helpers/url";
import { NostrEvent } from "../types/nostr-event";
import { localCacheRelay } from "./local-cache-relay";
import { relayRequest } from "../helpers/relay";
import { relayRequest, safeRelayUrls } from "../helpers/relay";
import { logger } from "../helpers/debug";
const RELAY_REQUEST_BATCH_TIME = 500;
@ -20,8 +19,8 @@ class SingleEventService {
const subject = this.cache.get(id);
if (subject.value) return subject;
const newUrls = safeRelayUrls(relays);
this.pending.set(id, this.pending.get(id)?.concat(newUrls) ?? newUrls);
relays = safeRelayUrls(relays);
this.pending.set(id, this.pending.get(id)?.concat(relays) ?? relays);
this.batchRequestsThrottle();
return subject;

View File

@ -19,9 +19,6 @@ import {
ModalHeader,
ModalOverlay,
ModalProps,
Radio,
RadioGroup,
Stack,
Text,
Textarea,
useToast,
@ -36,11 +33,11 @@ import Upload01 from "../../../components/icons/upload-01";
import { nostrBuildUploadImage } from "../../../helpers/nostr-build";
import { useSigningContext } from "../../../providers/global/signing-provider";
import { RelayUrlInput } from "../../../components/relay-url-input";
import { safeRelayUrl } from "../../../helpers/url";
import { RelayFavicon } from "../../../components/relay-favicon";
import NpubAutocomplete from "../../../components/npub-autocomplete";
import { normalizeToHexPubkey } from "../../../helpers/nip19";
import { safeUrl } from "../../../helpers/parse";
import { safeRelayUrl } from "../../../helpers/relay";
function RemoveButton({ ...props }: IconButtonProps) {
return <IconButton icon={<TrashIcon />} size="sm" colorScheme="red" variant="ghost" ml="auto" {...props} />;

View File

@ -23,11 +23,11 @@ import { useState } from "react";
import { useRelayInfo } from "../../../hooks/use-relay-info";
import UserAvatar from "../../../components/user-avatar";
import UserLink from "../../../components/user-link";
import { safeRelayUrl } from "../../../helpers/url";
import { useDebounce } from "react-use";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { CodeIcon } from "../../../components/icons";
import { Metadata } from "./relay-card";
import { safeRelayUrl } from "../../../helpers/relay";
function RelayDetails({ url, debug }: { url: string; debug?: boolean }) {
const { info } = useRelayInfo(url);

View File

@ -38,6 +38,8 @@ import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"
import useCurrentAccount from "../../../hooks/use-current-account";
import RawJson from "../../../components/debug-modals/raw-json";
import { RelayShareButton } from "./relay-share-button";
import useRelayStats from "../../../hooks/use-relay-stats";
import { getNetwork } from "../../../helpers/nostr/relay-stats";
const B = styled.span`
font-weight: bold;
@ -52,6 +54,7 @@ export const Metadata = ({ name, children }: { name: string } & PropsWithChildre
export function RelayMetadata({ url, extended }: { url: string; extended?: boolean }) {
const { info } = useRelayInfo(url);
const { stats } = useRelayStats(url);
return (
<Box>
@ -66,6 +69,7 @@ export function RelayMetadata({ url, extended }: { url: string; extended?: boole
)}
{extended && (
<>
{stats && <Metadata name="Network">{getNetwork(stats)}</Metadata>}
<Metadata name="Software">{info?.software}</Metadata>
<Metadata name="Version">{info?.version}</Metadata>
</>

View File

@ -5,13 +5,13 @@ import { Button, Divider, Flex, Heading, Input, SimpleGrid, Spacer, Switch, useD
import { useClientRelays } from "../../hooks/use-client-relays";
import relayPoolService from "../../services/relay-pool";
import { safeRelayUrl } from "../../helpers/url";
import AddCustomRelayModal from "./components/add-custom-modal";
import RelayCard from "./components/relay-card";
import clientRelaysService from "../../services/client-relays";
import { RelayMode } from "../../classes/relay";
import { ErrorBoundary } from "../../components/error-boundary";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { isValidRelayURL } from "../../helpers/relay";
export default function RelaysView() {
const [search, setSearch] = useState("");
@ -24,7 +24,7 @@ export default function RelaysView() {
.getRelays()
.filter((r) => !clientRelays.includes(r.url))
.map((r) => r.url)
.filter(safeRelayUrl);
.filter(isValidRelayURL);
const { value: onlineRelays = [] } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>),

View File

@ -14,7 +14,6 @@ import {
useDisclosure,
} from "@chakra-ui/react";
import { safeRelayUrl } from "../../../helpers/url";
import { useRelayInfo } from "../../../hooks/use-relay-info";
import { RelayDebugButton, RelayJoinAction, RelayMetadata } from "../components/relay-card";
import SupportedNIPs from "../components/supported-nips";
@ -26,6 +25,7 @@ import PeopleListProvider from "../../../providers/local/people-list-provider";
import PeopleListSelection from "../../../components/people-list-selection/people-list-selection";
import { RelayFavicon } from "../../../components/relay-favicon";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import { safeRelayUrl } from "../../../helpers/relay";
const RelayDetailsTab = lazy(() => import("./relay-details"));
function RelayPage({ relay }: { relay: string }) {
@ -104,7 +104,6 @@ export default function RelayView() {
if (!relay) return <>No relay url</>;
const safeUrl = safeRelayUrl(relay);
if (!safeUrl) return <>Bad relay url</>;
return (

View File

@ -28,7 +28,7 @@ import {
LinearScale,
CategoryScale,
} from "chart.js";
import { Line, Pie } from "react-chartjs-2";
import { Pie } from "react-chartjs-2";
import _throttle from "lodash.throttle";
import { useAppTitle } from "../../../hooks/use-app-title";