feat: negentropy

This commit is contained in:
Kieran 2024-01-25 15:21:42 +00:00
parent 9a0bbb8b74
commit d7460651c8
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
31 changed files with 924 additions and 266 deletions

View File

@ -106,11 +106,6 @@ declare const CONFIG: {
}>; }>;
}; };
/**
* Single relay (Debug)
*/
declare const SINGLE_RELAY: string | undefined;
/** /**
* Build git hash * Build git hash
*/ */

View File

@ -24,9 +24,7 @@ export default function Relay(props: RelayProps) {
const system = useContext(SnortContext); const system = useContext(SnortContext);
const login = useLogin(); const login = useLogin();
const relaySettings = unwrap( const relaySettings = unwrap(login.relays.item[props.addr] ?? system.pool.getConnection(props.addr)?.Settings ?? {});
login.relays.item[props.addr] ?? system.Sockets.find(a => a.address === props.addr)?.settings ?? {},
);
const state = useRelayState(props.addr); const state = useRelayState(props.addr);
const name = useMemo(() => getRelayName(props.addr), [props.addr]); const name = useMemo(() => getRelayName(props.addr), [props.addr]);
@ -44,14 +42,14 @@ export default function Relay(props: RelayProps) {
return ( return (
<> <>
<div className="relay bg-dark"> <div className="relay bg-dark">
<div className={classNames("flex items-center", state?.connected ? "bg-success" : "bg-error")}> <div className={classNames("flex items-center", state?.IsClosed === false ? "bg-success" : "bg-error")}>
<RelayFavicon url={props.addr} /> <RelayFavicon url={props.addr} />
</div> </div>
<div className="flex flex-col g8"> <div className="flex flex-col g8">
<div> <div>
<b>{name}</b> <b>{name}</b>
</div> </div>
{!state?.ephemeral && ( {!state?.Ephemeral && (
<div className="flex g8"> <div className="flex g8">
<AsyncIcon <AsyncIcon
iconName="write" iconName="write"
@ -85,7 +83,7 @@ export default function Relay(props: RelayProps) {
iconName="gear" iconName="gear"
iconSize={16} iconSize={16}
className="button-icon-sm transparent" className="button-icon-sm transparent"
onClick={() => navigate(state?.id ?? "")} onClick={() => navigate(state?.Id ?? "")}
/> />
</div> </div>
)} )}

View File

@ -3,6 +3,6 @@ import { useContext } from "react";
export default function useRelayState(addr: string) { export default function useRelayState(addr: string) {
const system = useContext(SnortContext); const system = useContext(SnortContext);
const c = system.Sockets.find(a => a.address === addr); const c = system.pool.getConnection(addr);
return c; return c;
} }

View File

@ -5,27 +5,27 @@ import useEventPublisher from "./useEventPublisher";
import useLogin from "./useLogin"; import useLogin from "./useLogin";
export function useLoginRelays() { export function useLoginRelays() {
const { relays } = useLogin(); const relays = useLogin(s => s.relays.item);
const { system } = useEventPublisher(); const { system } = useEventPublisher();
useEffect(() => { useEffect(() => {
if (relays) { if (relays) {
updateRelayConnections(system, relays.item).catch(console.error); updateRelayConnections(system, relays).catch(console.error);
} }
}, [relays]); }, [relays]);
} }
export async function updateRelayConnections(system: SystemInterface, relays: Record<string, RelaySettings>) { export async function updateRelayConnections(system: SystemInterface, relays: Record<string, RelaySettings>) {
if (SINGLE_RELAY) { if (import.meta.env.VITE_SINGLE_RELAY) {
system.ConnectToRelay(SINGLE_RELAY, { read: true, write: true }); system.ConnectToRelay(import.meta.env.VITE_SINGLE_RELAY, { read: true, write: true });
} else { } else {
for (const [k, v] of Object.entries(relays)) { for (const [k, v] of Object.entries(relays)) {
// note: don't awit this, causes race condition with sending requests to relays // note: don't awit this, causes race condition with sending requests to relays
system.ConnectToRelay(k, v); system.ConnectToRelay(k, v);
} }
for (const v of system.Sockets) { for (const [k, v] of system.pool) {
if (!relays[v.address] && !v.ephemeral) { if (!relays[k] && !v.Ephemeral) {
system.DisconnectRelay(v.address); system.DisconnectRelay(k);
} }
} }
} }

View File

@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import Timeline from "@/Components/Feed/Timeline"; import Timeline from "@/Components/Feed/Timeline";
import { TimelineSubject } from "@/Feed/TimelineFeed";
import useHistoryState from "@/Hooks/useHistoryState"; import useHistoryState from "@/Hooks/useHistoryState";
import useLogin from "@/Hooks/useLogin"; import useLogin from "@/Hooks/useLogin";
import { debounce, getRelayName, sha256 } from "@/Utils"; import { debounce, getRelayName, sha256 } from "@/Utils";
@ -15,8 +16,8 @@ interface RelayOption {
export const GlobalTab = () => { export const GlobalTab = () => {
const { relays } = useLogin(); const { relays } = useLogin();
const [relay, setRelay] = useHistoryState<RelayOption>(undefined, "global-relay"); const [relay, setRelay] = useHistoryState(undefined, "global-relay");
const [allRelays, setAllRelays] = useHistoryState<RelayOption[]>(undefined, "global-relay-options"); const [allRelays, setAllRelays] = useHistoryState(undefined, "global-relay-options");
const [now] = useState(unixNow()); const [now] = useState(unixNow());
const system = useContext(SnortContext); const system = useContext(SnortContext);
@ -62,11 +63,11 @@ export const GlobalTab = () => {
useEffect(() => { useEffect(() => {
return debounce(500, () => { return debounce(500, () => {
const ret: RelayOption[] = []; const ret: RelayOption[] = [];
system.Sockets.forEach(v => { [...system.pool].forEach(([, v]) => {
if (v.connected) { if (!v.IsClosed) {
ret.push({ ret.push({
url: v.address, url: v.Address,
paid: v.info?.limitation?.payment_required ?? false, paid: v.Info?.limitation?.payment_required ?? false,
}); });
} }
}); });
@ -80,12 +81,13 @@ export const GlobalTab = () => {
}, [relays, relay]); }, [relays, relay]);
const subject = useMemo( const subject = useMemo(
() => ({ () =>
type: "global", ({
items: [], type: "global",
relay: [relay?.url], items: [],
discriminator: `all-${sha256(relay?.url ?? "")}`, relay: [relay?.url],
}), discriminator: `all-${sha256(relay?.url ?? "")}`,
}) as TimelineSubject,
[relay?.url], [relay?.url],
); );

View File

@ -75,14 +75,15 @@ function ZapPoolPageInner() {
const { wallet } = useWallet(); const { wallet } = useWallet();
const relayConnections = useMemo(() => { const relayConnections = useMemo(() => {
return system.Sockets.map(a => { return [...system.pool]
if (a.info?.pubkey && !a.ephemeral) { .map(([, a]) => {
return { if (a.Info?.pubkey && !a.Ephemeral) {
address: a.address, return {
pubkey: a.info.pubkey, address: a.Address,
}; pubkey: a.Info.pubkey,
} };
}) }
})
.filter(a => a !== undefined) .filter(a => a !== undefined)
.map(unwrap); .map(unwrap);
}, [login.relays]); }, [login.relays]);
@ -131,7 +132,7 @@ function ZapPoolPageInner() {
nOut: ( nOut: (
<b> <b>
<FormattedNumber <FormattedNumber
value={ZapPoolController.calcAllocation(login.appData.item.preferences.defaultZapAmount)} value={ZapPoolController?.calcAllocation(login.appData.item.preferences.defaultZapAmount) ?? 0}
/> />
</b> </b>
), ),

View File

@ -16,66 +16,66 @@ const RelayInfo = () => {
const login = useLogin(); const login = useLogin();
const { system } = useEventPublisher(); const { system } = useEventPublisher();
const conn = system.Sockets.find(a => a.id === params.id); const conn = [...system.pool].find(([, a]) => a.Id === params.id)?.[1];
const stats = useRelayState(conn?.address ?? "");
const stats = useRelayState(conn?.Address ?? "");
return ( return (
<> <>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}> <h3 className="pointer" onClick={() => navigate("/settings/relays")}>
<FormattedMessage {...messages.Relays} /> <FormattedMessage {...messages.Relays} />
</h3> </h3>
<div> <div>
<h3>{stats?.info?.name}</h3> <h3>{stats?.Info?.name}</h3>
<p>{stats?.info?.description}</p> <p>{stats?.Info?.description}</p>
{stats?.info?.pubkey && ( {stats?.Info?.pubkey && (
<> <>
<h4> <h4>
<FormattedMessage {...messages.Owner} /> <FormattedMessage {...messages.Owner} />
</h4> </h4>
<ProfilePreview pubkey={parseId(stats.info.pubkey)} /> <ProfilePreview pubkey={parseId(stats.Info.pubkey)} />
</> </>
)} )}
{stats?.info?.software && ( {stats?.Info?.software && (
<div className="flex"> <div className="flex">
<h4 className="grow"> <h4 className="grow">
<FormattedMessage {...messages.Software} /> <FormattedMessage {...messages.Software} />
</h4> </h4>
<div className="flex flex-col"> <div className="flex flex-col">
{stats.info.software.startsWith("http") ? ( {stats.Info.software.startsWith("http") ? (
<a href={stats.info.software} target="_blank" rel="noreferrer"> <a href={stats.Info.software} target="_blank" rel="noreferrer">
{stats.info.software} {stats.Info.software}
</a> </a>
) : ( ) : (
<>{stats.info.software}</> <>{stats.Info.software}</>
)} )}
<small> <small>
{!stats.info.version?.startsWith("v") && "v"} {!stats.Info.version?.startsWith("v") && "v"}
{stats.info.version} {stats.Info.version}
</small> </small>
</div> </div>
</div> </div>
)} )}
{stats?.info?.contact && ( {stats?.Info?.contact && (
<div className="flex"> <div className="flex">
<h4 className="grow"> <h4 className="grow">
<FormattedMessage {...messages.Contact} /> <FormattedMessage {...messages.Contact} />
</h4> </h4>
<a <a
href={`${stats.info.contact.startsWith("mailto:") ? "" : "mailto:"}${stats.info.contact}`} href={`${stats.Info.contact.startsWith("mailto:") ? "" : "mailto:"}${stats.Info.contact}`}
target="_blank" target="_blank"
rel="noreferrer"> rel="noreferrer">
{stats.info.contact} {stats.Info.contact}
</a> </a>
</div> </div>
)} )}
{stats?.info?.supported_nips && ( {stats?.Info?.supported_nips && (
<> <>
<h4> <h4>
<FormattedMessage {...messages.Supports} /> <FormattedMessage {...messages.Supports} />
</h4> </h4>
<div className="grow"> <div className="grow">
{stats.info.supported_nips.map(a => ( {stats.Info?.supported_nips?.map(a => (
<a key={a} target="_blank" rel="noreferrer" href={`https://nips.be/${a}`} className="pill"> <a key={a} target="_blank" rel="noreferrer" href={`https://nips.be/${a}`} className="pill">
NIP-{a.toString().padStart(2, "0")} NIP-{a.toString().padStart(2, "0")}
</a> </a>
@ -87,7 +87,7 @@ const RelayInfo = () => {
<FormattedMessage defaultMessage="Active Subscriptions" id="p85Uwy" /> <FormattedMessage defaultMessage="Active Subscriptions" id="p85Uwy" />
</h4> </h4>
<div className="grow"> <div className="grow">
{stats?.activeRequests.map(a => ( {[...(stats?.ActiveRequests ?? [])].map(a => (
<span className="pill" key={a}> <span className="pill" key={a}>
{a} {a}
</span> </span>
@ -97,9 +97,9 @@ const RelayInfo = () => {
<FormattedMessage defaultMessage="Pending Subscriptions" id="UDYlxu" /> <FormattedMessage defaultMessage="Pending Subscriptions" id="UDYlxu" />
</h4> </h4>
<div className="grow"> <div className="grow">
{stats?.pendingRequests.map(a => ( {stats?.PendingRequests?.map(a => (
<span className="pill" key={a}> <span className="pill" key={a.obj[1]}>
{a} {a.obj[1]}
</span> </span>
))} ))}
</div> </div>
@ -107,7 +107,7 @@ const RelayInfo = () => {
<div <div
className="btn error" className="btn error"
onClick={() => { onClick={() => {
removeRelay(login, unwrap(conn).address); removeRelay(login, unwrap(conn).Address);
navigate("/settings/relays"); navigate("/settings/relays");
}}> }}>
<FormattedMessage {...messages.Remove} /> <FormattedMessage {...messages.Remove} />

View File

@ -21,7 +21,7 @@ const RelaySettingsPage = () => {
const [newRelay, setNewRelay] = useState<string>(); const [newRelay, setNewRelay] = useState<string>();
const otherConnections = useMemo(() => { const otherConnections = useMemo(() => {
return system.Sockets.filter(a => relays.item[a.address] === undefined); return [...system.pool].filter(([k]) => relays.item[k] === undefined).map(([, v]) => v);
}, [relays]); }, [relays]);
const handleNewRelayChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleNewRelayChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -83,7 +83,7 @@ const RelaySettingsPage = () => {
</h3> </h3>
<div className="flex flex-col g8"> <div className="flex flex-col g8">
{otherConnections.map(a => ( {otherConnections.map(a => (
<Relay addr={a.address} key={a.id} /> <Relay addr={a.Address} key={a.Id} />
))} ))}
</div> </div>
</div> </div>

View File

@ -23,9 +23,9 @@ import { SubscriptionEvent } from "@/Utils/Subscription";
import { Nip7OsSigner } from "./Nip7OsSigner"; import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) { export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (SINGLE_RELAY) { if (import.meta.env.VITE_SINGLE_RELAY) {
state.relays.item = { state.relays.item = {
[SINGLE_RELAY]: { read: true, write: true }, [import.meta.env.VITE_SINGLE_RELAY]: { read: true, write: true },
}; };
state.relays.timestamp = 100; state.relays.timestamp = 100;
LoginStore.updateSession(state); LoginStore.updateSession(state);

View File

@ -183,7 +183,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} }
decideInitRelays(relays: Record<string, RelaySettings> | undefined): Record<string, RelaySettings> { decideInitRelays(relays: Record<string, RelaySettings> | undefined): Record<string, RelaySettings> {
if (SINGLE_RELAY) return { [SINGLE_RELAY]: { read: true, write: true } }; if (import.meta.env.VITE_SINGLE_RELAY) return { [import.meta.env.VITE_SINGLE_RELAY]: { read: true, write: true } };
if (relays && Object.keys(relays).length > 0) { if (relays && Object.keys(relays).length > 0) {
return relays; return relays;
} }

View File

@ -104,7 +104,7 @@ export class Zapper {
if (!svc) { if (!svc) {
throw new Error(`Failed to get invoice from ${t.value}`); throw new Error(`Failed to get invoice from ${t.value}`);
} }
const relays = this.system.Sockets.filter(a => !a.ephemeral).map(v => v.address); const relays = [...this.system.pool].filter(([, v]) => !v.Ephemeral).map(([k]) => k);
const pub = t.zap?.anon ?? false ? EventPublisher.privateKey(generateRandomKey().privateKey) : this.publisher; const pub = t.zap?.anon ?? false ? EventPublisher.privateKey(generateRandomKey().privateKey) : this.publisher;
const zap = const zap =
t.zap && svc.canZap t.zap && svc.canZap
@ -199,7 +199,7 @@ export class Zapper {
await svc.load(); await svc.load();
return svc; return svc;
} else if (t.type === "pubkey") { } else if (t.type === "pubkey") {
const profile = await this.system.ProfileLoader.fetchProfile(t.value); const profile = await this.system.profileLoader.fetch(t.value);
if (profile) { if (profile) {
const svc = new LNURL(profile.lud16 ?? profile.lud06 ?? ""); const svc = new LNURL(profile.lud16 ?? profile.lud06 ?? "");
await svc.load(); await svc.load();

View File

@ -60,7 +60,6 @@ export default defineConfig({
define: { define: {
CONFIG: JSON.stringify(appConfig), CONFIG: JSON.stringify(appConfig),
global: {}, // needed for custom-event lib global: {}, // needed for custom-event lib
SINGLE_RELAY: JSON.stringify(process.env.SINGLE_RELAY),
}, },
test: { test: {
globals: true, globals: true,

View File

@ -20,7 +20,7 @@
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",
"@peculiar/webcrypto": "^1.4.3", "@peculiar/webcrypto": "^1.4.3",
"@types/debug": "^4.1.8", "@types/debug": "^4.1.8",
"@types/jest": "^29.5.1", "@types/jest": "^29.5.11",
"@types/lokijs": "^1.5.14", "@types/lokijs": "^1.5.14",
"@types/node": "^20.5.9", "@types/node": "^20.5.9",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.2",

View File

@ -2,7 +2,7 @@ import { removeUndefined, sanitizeRelayUrl, unwrap } from "@snort/shared";
import debug from "debug"; import debug from "debug";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { Connection, ConnectionStateSnapshot, RelaySettings } from "./connection"; import { Connection, RelaySettings } from "./connection";
import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr"; import { NostrEvent, OkResponse, TaggedNostrEvent } from "./nostr";
import { pickRelaysForReply } from "./outbox-model"; import { pickRelaysForReply } from "./outbox-model";
import { SystemInterface } from "."; import { SystemInterface } from ".";
@ -18,7 +18,6 @@ export interface NostrConnectionPoolEvents {
} }
export type ConnectionPool = { export type ConnectionPool = {
getState(): ConnectionStateSnapshot[];
getConnection(id: string): Connection | undefined; getConnection(id: string): Connection | undefined;
connect(address: string, options: RelaySettings, ephemeral: boolean): Promise<Connection | undefined>; connect(address: string, options: RelaySettings, ephemeral: boolean): Promise<Connection | undefined>;
disconnect(address: string): void; disconnect(address: string): void;
@ -45,13 +44,6 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
this.#system = system; this.#system = system;
} }
/**
* Get basic state information from the pool
*/
getState(): ConnectionStateSnapshot[] {
return [...this.#sockets.values()].map(a => a.takeSnapshot());
}
/** /**
* Get a connection object from the pool * Get a connection object from the pool
*/ */
@ -81,7 +73,7 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
c.on("disconnect", code => this.emit("disconnect", addr, code)); c.on("disconnect", code => this.emit("disconnect", addr, code));
c.on("connected", r => this.emit("connected", addr, r)); c.on("connected", r => this.emit("connected", addr, r));
c.on("auth", (cx, r, cb) => this.emit("auth", addr, cx, r, cb)); c.on("auth", (cx, r, cb) => this.emit("auth", addr, cx, r, cb));
await c.Connect(); await c.connect();
return c; return c;
} else { } else {
// update settings if already connected // update settings if already connected
@ -107,7 +99,7 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
const c = this.#sockets.get(addr); const c = this.#sockets.get(addr);
if (c) { if (c) {
this.#sockets.delete(addr); this.#sockets.delete(addr);
c.Close(); c.close();
} }
} }
@ -121,7 +113,7 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
const oks = await Promise.all([ const oks = await Promise.all([
...writeRelays.map(async s => { ...writeRelays.map(async s => {
try { try {
const rsp = await s.SendAsync(ev); const rsp = await s.sendEventAsync(ev);
cb?.(rsp); cb?.(rsp);
return rsp; return rsp;
} catch (e) { } catch (e) {
@ -145,7 +137,7 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
const existing = this.#sockets.get(addrClean); const existing = this.#sockets.get(addrClean);
if (existing) { if (existing) {
return await existing.SendAsync(ev); return await existing.sendEventAsync(ev);
} else { } else {
return await new Promise<OkResponse>((resolve, reject) => { return await new Promise<OkResponse>((resolve, reject) => {
const c = new Connection(address, { write: true, read: true }, true); const c = new Connection(address, { write: true, read: true }, true);
@ -153,11 +145,11 @@ export class DefaultConnectionPool extends EventEmitter<NostrConnectionPoolEvent
const t = setTimeout(reject, 10_000); const t = setTimeout(reject, 10_000);
c.once("connected", async () => { c.once("connected", async () => {
clearTimeout(t); clearTimeout(t);
const rsp = await c.SendAsync(ev); const rsp = await c.sendEventAsync(ev);
c.Close(); c.close();
resolve(rsp); resolve(rsp);
}); });
c.Connect(); c.connect();
}); });
} }
} }

View File

@ -5,11 +5,11 @@ import { unixNowMs, dedupe } from "@snort/shared";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { DefaultConnectTimeout } from "./const"; import { DefaultConnectTimeout } from "./const";
import { ConnectionStats } from "./connection-stats";
import { NostrEvent, OkResponse, ReqCommand, ReqFilter, TaggedNostrEvent, u256 } from "./nostr"; import { NostrEvent, OkResponse, ReqCommand, ReqFilter, TaggedNostrEvent, u256 } from "./nostr";
import { RelayInfo } from "./relay-info"; import { RelayInfo } from "./relay-info";
import EventKind from "./event-kind"; import EventKind from "./event-kind";
import { EventExt } from "./event-ext"; import { EventExt } from "./event-ext";
import { NegentropyFlow } from "./negentropy/negentropy-flow";
/** /**
* Relay settings * Relay settings
@ -19,28 +19,8 @@ export interface RelaySettings {
write: boolean; write: boolean;
} }
/**
* Snapshot of connection stats
*/
export interface ConnectionStateSnapshot {
connected: boolean;
disconnects: number;
avgLatency: number;
events: {
received: number;
send: number;
};
settings?: RelaySettings;
info?: RelayInfo;
pendingRequests: Array<string>;
activeRequests: Array<string>;
id: string;
ephemeral: boolean;
address: string;
}
interface ConnectionEvents { interface ConnectionEvents {
change: (snapshot: ConnectionStateSnapshot) => void; change: () => void;
connected: (wasReconnect: boolean) => void; connected: (wasReconnect: boolean) => void;
event: (sub: string, e: TaggedNostrEvent) => void; event: (sub: string, e: TaggedNostrEvent) => void;
eose: (sub: string) => void; eose: (sub: string) => void;
@ -48,6 +28,13 @@ interface ConnectionEvents {
disconnect: (code: number) => void; disconnect: (code: number) => void;
auth: (challenge: string, relay: string, cb: (ev: NostrEvent) => void) => void; auth: (challenge: string, relay: string, cb: (ev: NostrEvent) => void) => void;
notice: (msg: string) => void; notice: (msg: string) => void;
unknownMessage: (obj: Array<any>) => void;
}
export type SyncCommand = ["SYNC", id: string, fromSet: Array<TaggedNostrEvent>, ...filters: Array<ReqFilter>];
interface ConnectionQueueItem {
obj: ReqCommand | SyncCommand;
cb: () => void;
} }
export class Connection extends EventEmitter<ConnectionEvents> { export class Connection extends EventEmitter<ConnectionEvents> {
@ -58,20 +45,16 @@ export class Connection extends EventEmitter<ConnectionEvents> {
#ephemeral: boolean; #ephemeral: boolean;
Id: string; Id: string;
Address: string; readonly Address: string;
Socket: WebSocket | null = null; Socket: WebSocket | null = null;
PendingRaw: Array<object> = []; PendingRaw: Array<object> = [];
PendingRequests: Array<{ PendingRequests: Array<ConnectionQueueItem> = [];
cmd: ReqCommand;
cb: () => void;
}> = [];
ActiveRequests = new Set<string>(); ActiveRequests = new Set<string>();
Settings: RelaySettings; Settings: RelaySettings;
Info?: RelayInfo; Info?: RelayInfo;
ConnectTimeout: number = DefaultConnectTimeout; ConnectTimeout: number = DefaultConnectTimeout;
Stats: ConnectionStats = new ConnectionStats();
HasStateChange: boolean = true; HasStateChange: boolean = true;
IsClosed: boolean; IsClosed: boolean;
ReconnectTimer?: ReturnType<typeof setTimeout>; ReconnectTimer?: ReturnType<typeof setTimeout>;
@ -102,7 +85,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#setupEphemeral(); this.#setupEphemeral();
} }
async Connect() { async connect() {
try { try {
if (this.Info === undefined) { if (this.Info === undefined) {
const u = new URL(this.Address); const u = new URL(this.Address);
@ -136,19 +119,18 @@ export class Connection extends EventEmitter<ConnectionEvents> {
} }
this.IsClosed = false; this.IsClosed = false;
this.Socket = new WebSocket(this.Address); this.Socket = new WebSocket(this.Address);
this.Socket.onopen = () => this.OnOpen(wasReconnect); this.Socket.onopen = () => this.#onOpen(wasReconnect);
this.Socket.onmessage = e => this.OnMessage(e); this.Socket.onmessage = e => this.#onMessage(e);
this.Socket.onerror = e => this.OnError(e); this.Socket.onerror = e => this.#onError(e);
this.Socket.onclose = e => this.OnClose(e); this.Socket.onclose = e => this.#onClose(e);
} }
Close() { close() {
this.IsClosed = true; this.IsClosed = true;
this.Socket?.close(); this.Socket?.close();
this.notifyChange();
} }
OnOpen(wasReconnect: boolean) { #onOpen(wasReconnect: boolean) {
this.ConnectTimeout = DefaultConnectTimeout; this.ConnectTimeout = DefaultConnectTimeout;
this.#log(`Open!`); this.#log(`Open!`);
this.Down = false; this.Down = false;
@ -157,7 +139,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#sendPendingRaw(); this.#sendPendingRaw();
} }
OnClose(e: WebSocket.CloseEvent) { #onClose(e: WebSocket.CloseEvent) {
if (this.ReconnectTimer) { if (this.ReconnectTimer) {
clearTimeout(this.ReconnectTimer); clearTimeout(this.ReconnectTimer);
this.ReconnectTimer = undefined; this.ReconnectTimer = undefined;
@ -174,12 +156,12 @@ export class Connection extends EventEmitter<ConnectionEvents> {
); );
this.ReconnectTimer = setTimeout(() => { this.ReconnectTimer = setTimeout(() => {
try { try {
this.Connect(); this.connect();
} catch { } catch {
this.emit("disconnect", -1); this.emit("disconnect", -1);
} }
}, this.ConnectTimeout); }, this.ConnectTimeout);
this.Stats.Disconnects++; // todo: stats disconnect
} else { } else {
this.#log(`Closed!`); this.#log(`Closed!`);
this.ReconnectTimer = undefined; this.ReconnectTimer = undefined;
@ -187,10 +169,9 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.emit("disconnect", e.code); this.emit("disconnect", e.code);
this.#reset(); this.#reset();
this.notifyChange();
} }
OnMessage(e: WebSocket.MessageEvent) { #onMessage(e: WebSocket.MessageEvent) {
this.#activity = unixNowMs(); this.#activity = unixNowMs();
if ((e.data as string).length > 0) { if ((e.data as string).length > 0) {
const msg = JSON.parse(e.data as string) as Array<string | NostrEvent | boolean>; const msg = JSON.parse(e.data as string) as Array<string | NostrEvent | boolean>;
@ -201,8 +182,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.#onAuthAsync(msg[1] as string) this.#onAuthAsync(msg[1] as string)
.then(() => this.#sendPendingRaw()) .then(() => this.#sendPendingRaw())
.catch(this.#log); .catch(this.#log);
this.Stats.EventsReceived++; // todo: stats events received
this.notifyChange();
} else { } else {
this.#log("Ignoring unexpected AUTH request"); this.#log("Ignoring unexpected AUTH request");
} }
@ -219,8 +199,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
return; return;
} }
this.emit("event", msg[1] as string, ev); this.emit("event", msg[1] as string, ev);
this.Stats.EventsReceived++; // todo: stats events received
this.notifyChange();
break; break;
} }
case "EOSE": { case "EOSE": {
@ -250,34 +229,34 @@ export class Connection extends EventEmitter<ConnectionEvents> {
} }
default: { default: {
this.#log(`Unknown tag: ${tag}`); this.#log(`Unknown tag: ${tag}`);
this.emit("unknownMessage", msg);
break; break;
} }
} }
} }
} }
OnError(e: WebSocket.Event) { #onError(e: WebSocket.Event) {
this.#log("Error: %O", e); this.#log("Error: %O", e);
this.notifyChange(); this.emit("change");
} }
/** /**
* Send event on this connection * Send event on this connection
*/ */
SendEvent(e: NostrEvent) { sendEvent(e: NostrEvent) {
if (!this.Settings.write) { if (!this.Settings.write) {
return; return;
} }
const req = ["EVENT", e]; this.send(["EVENT", e]);
this.#sendJson(req); // todo: stats events send
this.Stats.EventsSent++; this.emit("change");
this.notifyChange();
} }
/** /**
* Send event on this connection and wait for OK response * Send event on this connection and wait for OK response
*/ */
async SendAsync(e: NostrEvent, timeout = 5000) { async sendEventAsync(e: NostrEvent, timeout = 5000) {
return await new Promise<OkResponse>((resolve, reject) => { return await new Promise<OkResponse>((resolve, reject) => {
if (!this.Settings.write) { if (!this.Settings.write) {
reject(new Error("Not a write relay")); reject(new Error("Not a write relay"));
@ -317,17 +296,16 @@ export class Connection extends EventEmitter<ConnectionEvents> {
}); });
}); });
const req = ["EVENT", e]; this.send(["EVENT", e]);
this.#sendJson(req); // todo: stats events send
this.Stats.EventsSent++; this.emit("change");
this.notifyChange();
}); });
} }
/** /**
* Using relay document to determine if this relay supports a feature * Using relay document to determine if this relay supports a feature
*/ */
SupportsNip(n: number) { supportsNip(n: number) {
return this.Info?.supported_nips?.some(a => a === n) ?? false; return this.Info?.supported_nips?.some(a => a === n) ?? false;
} }
@ -335,7 +313,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
* Queue or send command to the relay * Queue or send command to the relay
* @param cmd The REQ to send to the server * @param cmd The REQ to send to the server
*/ */
QueueReq(cmd: ReqCommand, cbSent: () => void) { queueReq(cmd: ReqCommand | SyncCommand, cbSent: () => void) {
const requestKinds = dedupe( const requestKinds = dedupe(
cmd cmd
.slice(2) .slice(2)
@ -349,63 +327,64 @@ export class Connection extends EventEmitter<ConnectionEvents> {
} }
if (this.ActiveRequests.size >= this.#maxSubscriptions) { if (this.ActiveRequests.size >= this.#maxSubscriptions) {
this.PendingRequests.push({ this.PendingRequests.push({
cmd, obj: cmd,
cb: cbSent, cb: cbSent,
}); });
this.#log("Queuing: %O", cmd); this.#log("Queuing: %O", cmd);
} else { } else {
this.ActiveRequests.add(cmd[1]); this.ActiveRequests.add(cmd[1]);
this.#sendJson(cmd); this.#sendRequestCommand(cmd);
cbSent(); cbSent();
} }
this.notifyChange(); this.emit("change");
} }
CloseReq(id: string) { closeReq(id: string) {
if (this.ActiveRequests.delete(id)) { if (this.ActiveRequests.delete(id)) {
this.#sendJson(["CLOSE", id]); this.send(["CLOSE", id]);
this.emit("eose", id); this.emit("eose", id);
this.#SendQueuedRequests(); this.#sendQueuedRequests();
this.emit("change");
} }
this.notifyChange();
} }
takeSnapshot(): ConnectionStateSnapshot { #sendQueuedRequests() {
return {
connected: this.Socket?.readyState === WebSocket.OPEN,
events: {
received: this.Stats.EventsReceived,
send: this.Stats.EventsSent,
},
avgLatency:
this.Stats.Latency.length > 0
? this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length
: 0,
disconnects: this.Stats.Disconnects,
info: this.Info,
id: this.Id,
pendingRequests: [...this.PendingRequests.map(a => a.cmd[1])],
activeRequests: [...this.ActiveRequests],
ephemeral: this.Ephemeral,
address: this.Address,
};
}
#SendQueuedRequests() {
const canSend = this.#maxSubscriptions - this.ActiveRequests.size; const canSend = this.#maxSubscriptions - this.ActiveRequests.size;
if (canSend > 0) { if (canSend > 0) {
for (let x = 0; x < canSend; x++) { for (let x = 0; x < canSend; x++) {
const p = this.PendingRequests.shift(); const p = this.PendingRequests.shift();
if (p) { if (p) {
this.ActiveRequests.add(p.cmd[1]); this.#sendRequestCommand(p.obj);
this.#sendJson(p.cmd);
p.cb(); p.cb();
this.#log("Sent pending REQ %O", p.cmd); this.#log("Sent pending REQ %O", p.obj);
} }
} }
} }
} }
#sendRequestCommand(cmd: ReqCommand | SyncCommand) {
try {
if (cmd[0] === "REQ") {
this.ActiveRequests.add(cmd[1]);
this.send(cmd);
} else if (cmd[0] === "SYNC") {
if (this.Info?.software?.includes("strfry")) {
const neg = new NegentropyFlow(cmd[1], this, cmd[2], cmd.slice(3) as Array<ReqFilter>);
neg.once("finish", filters => {
if (filters.length > 0) {
this.queueReq(["REQ", cmd[1], ...filters], () => {});
}
});
neg.start();
} else {
throw new Error("SYNC not supported");
}
}
} catch (e) {
console.error(e);
}
}
#reset() { #reset() {
// reset connection Id on disconnect, for query-tracking // reset connection Id on disconnect, for query-tracking
this.Id = uuid(); this.Id = uuid();
@ -413,15 +392,15 @@ export class Connection extends EventEmitter<ConnectionEvents> {
this.ActiveRequests.clear(); this.ActiveRequests.clear();
this.PendingRequests = []; this.PendingRequests = [];
this.PendingRaw = []; this.PendingRaw = [];
this.notifyChange(); this.emit("change");
} }
#sendJson(obj: object) { send(obj: object) {
const authPending = !this.Authed && (this.AwaitingAuth.size > 0 || this.Info?.limitation?.auth_required === true); const authPending = !this.Authed && (this.AwaitingAuth.size > 0 || this.Info?.limitation?.auth_required === true);
if (!this.Socket || this.Socket?.readyState !== WebSocket.OPEN || authPending) { if (!this.Socket || this.Socket?.readyState !== WebSocket.OPEN || authPending) {
this.PendingRaw.push(obj); this.PendingRaw.push(obj);
if (this.Socket?.readyState === WebSocket.CLOSED && this.Ephemeral && this.IsClosed) { if (this.Socket?.readyState === WebSocket.CLOSED && this.Ephemeral && this.IsClosed) {
this.Connect(); this.connect();
} }
return false; return false;
} }
@ -498,14 +477,10 @@ export class Connection extends EventEmitter<ConnectionEvents> {
if (this.ActiveRequests.size > 0) { if (this.ActiveRequests.size > 0) {
this.#log("Inactive connection has %d active requests! %O", this.ActiveRequests.size, this.ActiveRequests); this.#log("Inactive connection has %d active requests! %O", this.ActiveRequests.size, this.ActiveRequests);
} else { } else {
this.Close(); this.close();
} }
} }
}, 5_000); }, 5_000);
} }
} }
notifyChange() {
this.emit("change", this.takeSnapshot());
}
} }

View File

@ -89,7 +89,7 @@ export class Nip46Signer implements EventSigner {
await this.#onReply(e); await this.#onReply(e);
}); });
this.#conn.on("connected", async () => { this.#conn.on("connected", async () => {
this.#conn!.QueueReq( this.#conn!.queueReq(
[ [
"REQ", "REQ",
"reply", "reply",
@ -111,7 +111,7 @@ export class Nip46Signer implements EventSigner {
}); });
} }
}); });
this.#conn.Connect(); this.#conn.connect();
this.#didInit = true; this.#didInit = true;
}); });
} }
@ -119,8 +119,8 @@ export class Nip46Signer implements EventSigner {
async close() { async close() {
if (this.#conn) { if (this.#conn) {
await this.#disconnect(); await this.#disconnect();
this.#conn.CloseReq("reply"); this.#conn.closeReq("reply");
this.#conn.Close(); this.#conn.close();
this.#conn = undefined; this.#conn = undefined;
this.#didInit = false; this.#didInit = false;
} }
@ -236,6 +236,6 @@ export class Nip46Signer implements EventSigner {
this.#log("Send: %O", payload); this.#log("Send: %O", payload);
const evCommand = await eb.buildAndSign(this.#insideSigner); const evCommand = await eb.buildAndSign(this.#insideSigner);
await this.#conn.SendAsync(evCommand); await this.#conn.sendEventAsync(evCommand);
} }
} }

View File

@ -1,4 +1,4 @@
import { RelaySettings, ConnectionStateSnapshot } from "./connection"; import { RelaySettings } from "./connection";
import { RequestBuilder } from "./request-builder"; import { RequestBuilder } from "./request-builder";
import { NostrEvent, OkResponse, ReqFilter, TaggedNostrEvent } from "./nostr"; import { NostrEvent, OkResponse, ReqFilter, TaggedNostrEvent } from "./nostr";
import { ProfileLoaderService } from "./profile-cache"; import { ProfileLoaderService } from "./profile-cache";
@ -65,11 +65,6 @@ export interface SystemInterface {
*/ */
checkSigs: boolean; checkSigs: boolean;
/**
* Get a snapshot of the relay connections
*/
get Sockets(): Array<ConnectionStateSnapshot>;
/** /**
* Do some initialization * Do some initialization
*/ */

View File

@ -0,0 +1,60 @@
import { sha256 } from "@noble/hashes/sha256";
import { encodeVarInt, FINGERPRINT_SIZE } from "./utils";
export class Accumulator {
#buf!: Uint8Array;
constructor() {
this.setToZero();
}
setToZero() {
this.#buf = new Uint8Array(32);
}
add(otherBuf: Uint8Array) {
let currCarry = 0,
nextCarry = 0;
const p = new DataView(this.#buf.buffer);
const po = new DataView(otherBuf.buffer);
for (let i = 0; i < 8; i++) {
const offset = i * 4;
const orig = p.getUint32(offset, true);
const otherV = po.getUint32(offset, true);
let next = orig;
next += currCarry;
next += otherV;
if (next > 4294967295) nextCarry = 1;
p.setUint32(offset, next & 4294967295, true);
currCarry = nextCarry;
nextCarry = 0;
}
}
negate() {
const p = new DataView(this.#buf.buffer);
for (let i = 0; i < 8; i++) {
let offset = i * 4;
p.setUint32(offset, ~p.getUint32(offset, true));
}
const one = new Uint8Array(32);
one[0] = 1;
this.add(one);
}
getFingerprint(n: number) {
const varInt = encodeVarInt(n);
const copy = new Uint8Array(this.#buf.length + varInt.length);
copy.set(this.#buf);
copy.set(varInt, this.#buf.length);
const hash = sha256(copy);
return hash.subarray(0, FINGERPRINT_SIZE);
}
}

View File

@ -0,0 +1,94 @@
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { Connection } from "../connection";
import { ReqFilter, TaggedNostrEvent } from "../nostr";
import { Negentropy } from "./negentropy";
import { NegentropyStorageVector } from "./vector-storage";
import debug from "debug";
import EventEmitter from "eventemitter3";
export interface NegentropyFlowEvents {
/**
* When sync is finished emit a set of filters which can resolve sync
*/
finish: (req: Array<ReqFilter>) => void;
}
/**
* Negentropy sync flow on connection
*/
export class NegentropyFlow extends EventEmitter<NegentropyFlowEvents> {
readonly idSize: number = 16;
#log = debug("NegentropyFlow");
#id: string;
#connection: Connection;
#filters: Array<ReqFilter>;
#negentropy: Negentropy;
#need: Array<string> = [];
constructor(id: string, conn: Connection, set: Array<TaggedNostrEvent>, filters: Array<ReqFilter>) {
super();
this.#id = id;
this.#connection = conn;
this.#filters = filters;
this.#connection.on("unknownMessage", this.#handleMessage.bind(this));
this.#connection.on("notice", n => this.#handleMessage.bind(this));
const storage = new NegentropyStorageVector();
set.forEach(a => storage.insert(a.created_at, a.id));
storage.seal();
this.#negentropy = new Negentropy(storage, 50_000);
}
/**
* Start sync
*/
start() {
const init = this.#negentropy.initiate();
this.#connection.send(["NEG-OPEN", this.#id, this.#filters, bytesToHex(init)]);
}
#handleMessage(msg: Array<any>) {
try {
switch (msg[0] as string) {
case "NOTICE": {
if ((msg[1] as string).includes("negentropy disabled")) {
this.#log("SYNC ERROR: %s", msg[1]);
this.#cleanup();
}
break;
}
case "NEG-ERROR": {
if (msg[1] !== this.#id) break;
this.#log("SYNC ERROR %s", msg[2]);
this.#cleanup();
break;
}
case "NEG-MSG": {
if (msg[1] !== this.#id) break;
const query = hexToBytes(msg[2] as string);
const [nextMsg, _, need] = this.#negentropy.reconcile(query);
if (need.length > 0) {
this.#need.push(...need.map(bytesToHex));
}
if (nextMsg) {
this.#connection.send(["NEG-MSG", this.#id, bytesToHex(nextMsg)]);
} else {
this.#connection.send(["NEG-CLOSE", this.#id]);
this.#cleanup();
}
break;
}
}
} catch (e) {
debugger;
console.error(e);
}
}
#cleanup() {
this.#connection.off("unknownMessage", this.#handleMessage.bind(this));
this.#connection.off("notice", n => this.#handleMessage.bind(this));
this.emit("finish", this.#need.length > 0 ? [{ ids: this.#need }] : []);
}
}

View File

@ -0,0 +1,303 @@
import { bytesToHex } from "@noble/hashes/utils";
import { WrappedBuffer } from "./wrapped-buffer";
import { NegentropyStorageVector, VectorStorageItem } from "./vector-storage";
import {
PROTOCOL_VERSION,
getByte,
encodeVarInt,
Mode,
decodeVarInt,
getBytes,
FINGERPRINT_SIZE,
compareUint8Array,
} from "./utils";
export class Negentropy {
readonly #storage: NegentropyStorageVector;
readonly #frameSizeLimit: number;
#lastTimestampIn: number;
#lastTimestampOut: number;
#isInitiator: boolean = false;
constructor(storage: NegentropyStorageVector, frameSizeLimit = 0) {
if (frameSizeLimit !== 0 && frameSizeLimit < 4096) throw Error("frameSizeLimit too small");
this.#storage = storage;
this.#frameSizeLimit = frameSizeLimit;
this.#lastTimestampIn = 0;
this.#lastTimestampOut = 0;
}
#bound(timestamp: number, id?: Uint8Array) {
return { timestamp, id: id ? id : new Uint8Array(0) };
}
initiate() {
if (this.#isInitiator) throw Error("already initiated");
this.#isInitiator = true;
const output = new WrappedBuffer();
output.set([PROTOCOL_VERSION]);
this.splitRange(0, this.#storage.size(), this.#bound(Number.MAX_VALUE), output);
return this.#renderOutput(output);
}
setInitiator() {
this.#isInitiator = true;
}
reconcile(query: WrappedBuffer | Uint8Array): [Uint8Array | undefined, Array<Uint8Array>, Array<Uint8Array>] {
let haveIds: Array<Uint8Array> = [],
needIds: Array<Uint8Array> = [];
query = query instanceof WrappedBuffer ? query : new WrappedBuffer(query);
this.#lastTimestampIn = this.#lastTimestampOut = 0; // reset for each message
const fullOutput = new WrappedBuffer();
fullOutput.set([PROTOCOL_VERSION]);
const protocolVersion = getByte(query);
if (protocolVersion < 96 || protocolVersion > 111) throw Error("invalid negentropy protocol version byte");
if (protocolVersion !== PROTOCOL_VERSION) {
if (this.#isInitiator)
throw Error("unsupported negentropy protocol version requested: " + (protocolVersion - 96));
else return [this.#renderOutput(fullOutput), haveIds, needIds];
}
const storageSize = this.#storage.size();
let prevBound = this.#bound(0);
let prevIndex = 0;
let skip = false;
while (query.length !== 0) {
let o = new WrappedBuffer();
let doSkip = () => {
if (skip) {
skip = false;
o.append(this.encodeBound(prevBound));
o.append(encodeVarInt(Mode.Skip));
}
};
let currBound = this.decodeBound(query);
let mode = query.length === 0 ? 0 : decodeVarInt(query);
let lower = prevIndex;
let upper = this.#storage.findLowerBound(prevIndex, storageSize, currBound);
if (mode === Mode.Skip) {
skip = true;
} else if (mode === Mode.Fingerprint) {
let theirFingerprint = getBytes(query, FINGERPRINT_SIZE);
let ourFingerprint = this.#storage.fingerprint(lower, upper);
if (compareUint8Array(theirFingerprint, ourFingerprint) !== 0) {
doSkip();
this.splitRange(lower, upper, currBound, o);
} else {
skip = true;
}
} else if (mode === Mode.IdList) {
let numIds = decodeVarInt(query);
let theirElems = {} as Record<string, Uint8Array>; // stringified Uint8Array -> original Uint8Array (or hex)
for (let i = 0; i < numIds; i++) {
let e = getBytes(query, this.#storage.idSize);
theirElems[bytesToHex(e)] = e;
}
this.#storage.iterate(lower, upper, item => {
let k = bytesToHex(item.id);
if (!theirElems[k]) {
// ID exists on our side, but not their side
if (this.#isInitiator) haveIds.push(item.id);
} else {
// ID exists on both sides
delete theirElems[k];
}
return true;
});
if (this.#isInitiator) {
skip = true;
for (let v of Object.values(theirElems)) {
// ID exists on their side, but not our side
needIds.push(v);
}
} else {
doSkip();
let responseIds = new WrappedBuffer();
let numResponseIds = 0;
let endBound = currBound;
this.#storage.iterate(lower, upper, (item, index) => {
if (this.exceededFrameSizeLimit(fullOutput.length + responseIds.length)) {
endBound = item;
upper = index; // shrink upper so that remaining range gets correct fingerprint
return false;
}
responseIds.append(item.id);
numResponseIds++;
return true;
});
o.append(this.encodeBound(endBound));
o.append(encodeVarInt(Mode.IdList));
o.append(encodeVarInt(numResponseIds));
o.append(responseIds.unwrap());
fullOutput.append(o.unwrap());
o.clear();
}
} else {
throw Error("unexpected mode");
}
if (this.exceededFrameSizeLimit(fullOutput.length + o.length)) {
// frameSizeLimit exceeded: Stop range processing and return a fingerprint for the remaining range
let remainingFingerprint = this.#storage.fingerprint(upper, storageSize);
fullOutput.append(this.encodeBound(this.#bound(Number.MAX_VALUE)));
fullOutput.append(encodeVarInt(Mode.Fingerprint));
fullOutput.append(remainingFingerprint);
break;
} else {
fullOutput.append(o.unwrap());
}
prevIndex = upper;
prevBound = currBound;
}
return [
fullOutput.length === 1 && this.#isInitiator ? undefined : this.#renderOutput(fullOutput),
haveIds,
needIds,
];
}
async splitRange(lower: number, upper: number, upperBound: VectorStorageItem, o: WrappedBuffer) {
const numElems = upper - lower;
const buckets = 16;
if (numElems < buckets * 2) {
o.append(this.encodeBound(upperBound));
o.append(encodeVarInt(Mode.IdList));
o.append(encodeVarInt(numElems));
this.#storage.iterate(lower, upper, item => {
o.append(item.id);
return true;
});
} else {
const itemsPerBucket = Math.floor(numElems / buckets);
const bucketsWithExtra = numElems % buckets;
let curr = lower;
for (let i = 0; i < buckets; i++) {
let bucketSize = itemsPerBucket + (i < bucketsWithExtra ? 1 : 0);
let ourFingerprint = this.#storage.fingerprint(curr, curr + bucketSize);
curr += bucketSize;
let nextBound;
if (curr === upper) {
nextBound = upperBound;
} else {
let prevItem: VectorStorageItem, currItem: VectorStorageItem;
this.#storage.iterate(curr - 1, curr + 1, (item, index) => {
if (index === curr - 1) prevItem = item;
else currItem = item;
return true;
});
nextBound = this.getMinimalBound(prevItem!, currItem!);
}
o.append(this.encodeBound(nextBound));
o.append(encodeVarInt(Mode.Fingerprint));
o.append(ourFingerprint);
}
}
}
#renderOutput(o: WrappedBuffer) {
return o.unwrap();
}
exceededFrameSizeLimit(n: number) {
return this.#frameSizeLimit && n > this.#frameSizeLimit - 200;
}
// Decoding
decodeTimestampIn(encoded: Uint8Array | WrappedBuffer) {
let timestamp = decodeVarInt(encoded);
timestamp = timestamp === 0 ? Number.MAX_VALUE : timestamp - 1;
if (this.#lastTimestampIn === Number.MAX_VALUE || timestamp === Number.MAX_VALUE) {
this.#lastTimestampIn = Number.MAX_VALUE;
return Number.MAX_VALUE;
}
timestamp += this.#lastTimestampIn;
this.#lastTimestampIn = timestamp;
return timestamp;
}
decodeBound(encoded: Uint8Array | WrappedBuffer) {
const timestamp = this.decodeTimestampIn(encoded);
const len = decodeVarInt(encoded);
if (len > this.#storage.idSize) throw Error("bound key too long");
const id = new Uint8Array(this.#storage.idSize);
const encodedId = getBytes(encoded, Math.min(len, encoded.length));
id.set(encodedId);
return { timestamp, id };
}
// Encoding
encodeTimestampOut(timestamp: number) {
if (timestamp === Number.MAX_VALUE) {
this.#lastTimestampOut = Number.MAX_VALUE;
return encodeVarInt(0);
}
let temp = timestamp;
timestamp -= this.#lastTimestampOut;
this.#lastTimestampOut = temp;
return encodeVarInt(timestamp + 1);
}
encodeBound(key: VectorStorageItem) {
const tsBytes = this.encodeTimestampOut(key.timestamp);
const idLenBytes = encodeVarInt(key.id.length);
const output = new Uint8Array(tsBytes.length + idLenBytes.length + key.id.length);
output.set(tsBytes);
output.set(idLenBytes, tsBytes.length);
output.set(key.id, tsBytes.length + idLenBytes.length);
return output;
}
getMinimalBound(prev: VectorStorageItem, curr: VectorStorageItem) {
if (curr.timestamp !== prev.timestamp) {
return this.#bound(curr.timestamp);
} else {
let sharedPrefixBytes = 0;
let currKey = curr.id;
let prevKey = prev.id;
for (let i = 0; i < this.#storage.idSize; i++) {
if (currKey[i] !== prevKey[i]) break;
sharedPrefixBytes++;
}
return this.#bound(curr.timestamp, curr.id.subarray(0, sharedPrefixBytes + 1));
}
}
}

View File

@ -0,0 +1,83 @@
import { VectorStorageItem } from "./vector-storage";
import { WrappedBuffer } from "./wrapped-buffer";
export const PROTOCOL_VERSION = 0x61; // Version 1
export const FINGERPRINT_SIZE = 16;
export const enum Mode {
Skip = 0,
Fingerprint = 1,
IdList = 2,
}
/**
* Decode variable int, also consumes the bytes from buf
*/
export function decodeVarInt(buf: Uint8Array | WrappedBuffer) {
let res = 0;
while (1) {
if (buf.length === 0) throw Error("parse ends prematurely");
let byte = 0;
if (buf instanceof WrappedBuffer) {
byte = buf.shift();
} else {
byte = buf[0];
buf = buf.subarray(1);
}
res = (res << 7) | (byte & 127);
if ((byte & 128) === 0) break;
}
return res;
}
export function encodeVarInt(n: number) {
if (n === 0) return new Uint8Array([0]);
let o = [];
while (n !== 0) {
o.push(n & 127);
n >>>= 7;
}
o.reverse();
for (let i = 0; i < o.length - 1; i++) o[i] |= 128;
return new Uint8Array(o);
}
export function getByte(buf: WrappedBuffer) {
return getBytes(buf, 1)[0];
}
export function getBytes(buf: WrappedBuffer | Uint8Array, n: number) {
if (buf.length < n) throw Error("parse ends prematurely");
if (buf instanceof WrappedBuffer) {
return buf.shiftN(n);
} else {
const ret = buf.subarray(0, n);
buf = buf.subarray(n);
return ret;
}
}
export function compareUint8Array(a: Uint8Array, b: Uint8Array) {
for (let i = 0; i < a.byteLength; i++) {
if (a[i] < b[i]) return -1;
if (a[i] > b[i]) return 1;
}
if (a.byteLength > b.byteLength) return 1;
if (a.byteLength < b.byteLength) return -1;
return 0;
}
export function itemCompare(a: VectorStorageItem, b: VectorStorageItem) {
if (a.timestamp === b.timestamp) {
return compareUint8Array(a.id, b.id);
}
return a.timestamp - b.timestamp;
}

View File

@ -0,0 +1,116 @@
import { hexToBytes } from "@noble/hashes/utils";
import { Accumulator } from "./accumulator";
import { itemCompare } from "./utils";
export interface VectorStorageItem {
timestamp: number;
id: Uint8Array;
}
const IdSize = 32;
export class NegentropyStorageVector {
#items: Array<VectorStorageItem> = [];
#sealed = false;
constructor(other?: Array<VectorStorageItem>) {
if (other) {
this.#items = other;
this.#sealed = true;
}
}
get idSize() {
return IdSize;
}
insert(timestamp: number, id: string) {
if (this.#sealed) throw Error("already sealed");
const idData = hexToBytes(id);
if (idData.byteLength !== IdSize) throw Error("bad id size for added item");
this.#items.push({ timestamp, id: idData });
}
seal() {
if (this.#sealed) throw Error("already sealed");
this.#sealed = true;
this.#items.sort(itemCompare);
for (let i = 1; i < this.#items.length; i++) {
if (itemCompare(this.#items[i - 1], this.#items[i]) === 0) {
debugger;
throw Error("duplicate item inserted");
}
}
}
unseal() {
this.#sealed = false;
}
size() {
this.#checkSealed();
return this.#items.length;
}
getItem(i: number) {
this.#checkSealed();
if (i >= this.#items.length) throw Error("out of range");
return this.#items[i];
}
iterate(begin: number, end: number, cb: (item: VectorStorageItem, index: number) => boolean) {
this.#checkSealed();
this.#checkBounds(begin, end);
for (let i = begin; i < end; ++i) {
if (!cb(this.#items[i], i)) break;
}
}
findLowerBound(begin: number, end: number, bound: VectorStorageItem) {
this.#checkSealed();
this.#checkBounds(begin, end);
return this.#binarySearch(this.#items, begin, end, a => itemCompare(a, bound) < 0);
}
fingerprint(begin: number, end: number) {
const out = new Accumulator();
this.iterate(begin, end, item => {
out.add(item.id);
return true;
});
return out.getFingerprint(end - begin);
}
#checkSealed() {
if (!this.#sealed) throw Error("not sealed");
}
#checkBounds(begin: number, end: number) {
if (begin > end || end > this.#items.length) throw Error("bad range");
}
#binarySearch(arr: Array<VectorStorageItem>, first: number, last: number, cmp: (item: VectorStorageItem) => boolean) {
let count = last - first;
while (count > 0) {
let it = first;
let step = Math.floor(count / 2);
it += step;
if (cmp(arr[it])) {
first = ++it;
count -= step + 1;
} else {
count = step;
}
}
return first;
}
}

View File

@ -0,0 +1,62 @@
export class WrappedBuffer {
#raw: Uint8Array;
#length: number;
constructor(buffer?: Uint8Array) {
this.#raw = buffer ? new Uint8Array(buffer) : new Uint8Array(512);
this.#length = buffer ? buffer.length : 0;
}
unwrap() {
return this.#raw.subarray(0, this.#length);
}
get capacity() {
return this.#raw.byteLength;
}
get length() {
return this.#length;
}
set(val: ArrayLike<number>, offset?: number) {
this.#raw.set(val, offset);
this.#length = (offset ?? 0) + val.length;
}
append(val: ArrayLike<number>) {
const targetSize = val.length + this.#length;
this.resize(targetSize);
this.#raw.set(val, this.#length);
this.#length += val.length;
}
clear() {
this.#length = 0;
this.#raw.fill(0);
}
resize(newSize: number) {
if (this.capacity < newSize) {
const newCapacity = Math.max(this.capacity * 2, newSize);
const newArr = new Uint8Array(newCapacity);
newArr.set(this.#raw);
this.#raw = newArr;
}
}
shift() {
const first = this.#raw[0];
this.#raw = this.#raw.subarray(1);
this.#length--;
return first;
}
shiftN(n = 1) {
const firstSubarray = this.#raw.subarray(0, n);
this.#raw = this.#raw.subarray(n);
this.#length -= n;
return firstSubarray;
}
}

View File

@ -3,7 +3,7 @@ import EventEmitter from "eventemitter3";
import { CachedTable } from "@snort/shared"; import { CachedTable } from "@snort/shared";
import { NostrEvent, TaggedNostrEvent, OkResponse } from "./nostr"; import { NostrEvent, TaggedNostrEvent, OkResponse } from "./nostr";
import { RelaySettings, ConnectionStateSnapshot } from "./connection"; import { Connection, RelaySettings } from "./connection";
import { BuiltRawReqFilter, RequestBuilder } from "./request-builder"; import { BuiltRawReqFilter, RequestBuilder } from "./request-builder";
import { RelayMetricHandler } from "./relay-metric-handler"; import { RelayMetricHandler } from "./relay-metric-handler";
import { import {
@ -155,10 +155,6 @@ export class NostrSystem extends EventEmitter<NostrSystemEvents> implements Syst
this.#queryManager.on("request", (subId: string, f: BuiltRawReqFilter) => this.emit("request", subId, f)); this.#queryManager.on("request", (subId: string, f: BuiltRawReqFilter) => this.emit("request", subId, f));
} }
get Sockets(): ConnectionStateSnapshot[] {
return this.pool.getState();
}
async Init() { async Init() {
const t = [ const t = [
this.relayCache.preload(), this.relayCache.preload(),

View File

@ -105,15 +105,17 @@ export class QueryManager extends EventEmitter<QueryManagerEvents> {
} }
async #send(q: Query, qSend: BuiltRawReqFilter) { async #send(q: Query, qSend: BuiltRawReqFilter) {
if (qSend.strategy === RequestStrategy.CacheRelay && this.#system.cacheRelay) {
const qt = q.insertCompletedTrace(qSend, []);
const res = await this.#system.cacheRelay.query(["REQ", qt.id, ...qSend.filters]);
q.feed.add(res?.map(a => ({ ...a, relays: [] }) as TaggedNostrEvent));
return;
}
for (const qfl of this.#queryCacheLayers) { for (const qfl of this.#queryCacheLayers) {
qSend = await qfl.processFilter(q, qSend); qSend = await qfl.processFilter(q, qSend);
} }
if (this.#system.cacheRelay) {
// fetch results from cache first, flag qSend for sync
const data = await this.#system.cacheRelay.query(["REQ", q.id, ...qSend.filters]);
if (data.length > 0) {
qSend.syncFrom = data as Array<TaggedNostrEvent>;
q.feed.add(data as Array<TaggedNostrEvent>);
}
}
// automated outbox model, load relays for queried authors // automated outbox model, load relays for queried authors
for (const f of qSend.filters) { for (const f of qSend.filters) {

View File

@ -277,7 +277,8 @@ export class Query extends EventEmitter<QueryEvents> {
if (this.isOpen()) { if (this.isOpen()) {
for (const qt of this.#tracing) { for (const qt of this.#tracing) {
if (qt.relay === c.Address) { if (qt.relay === c.Address) {
c.QueueReq(["REQ", qt.id, ...qt.filters], () => qt.sentToRelay()); // todo: queue sync?
c.queueReq(["REQ", qt.id, ...qt.filters], () => qt.sentToRelay());
} }
} }
} }
@ -371,7 +372,7 @@ export class Query extends EventEmitter<QueryEvents> {
this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c); this.#log("Cant send non-specific REQ to ephemeral connection %O %O %O", q, q.relay, c);
return false; return false;
} }
if (q.filters.some(a => a.search) && !c.SupportsNip(Nips.Search)) { if (q.filters.some(a => a.search) && !c.supportsNip(Nips.Search)) {
this.#log("Cant send REQ to non-search relay", c.Address); this.#log("Cant send REQ to non-search relay", c.Address);
return false; return false;
} }
@ -382,7 +383,7 @@ export class Query extends EventEmitter<QueryEvents> {
let filters = q.filters; let filters = q.filters;
const qt = new QueryTrace(c.Address, filters, c.Id); const qt = new QueryTrace(c.Address, filters, c.Id);
qt.on("close", x => c.CloseReq(x)); qt.on("close", x => c.closeReq(x));
qt.on("change", () => this.#onProgress()); qt.on("change", () => this.#onProgress());
qt.on("eose", (id, connId, forced) => qt.on("eose", (id, connId, forced) =>
this.emit("trace", { this.emit("trace", {
@ -401,7 +402,12 @@ export class Query extends EventEmitter<QueryEvents> {
c.on("event", handler); c.on("event", handler);
this.on("end", () => c.off("event", handler)); this.on("end", () => c.off("event", handler));
this.#tracing.push(qt); this.#tracing.push(qt);
c.QueueReq(["REQ", qt.id, ...qt.filters], () => qt.sentToRelay());
if (q.syncFrom !== undefined) {
c.queueReq(["SYNC", qt.id, q.syncFrom, ...qt.filters], () => qt.sentToRelay());
} else {
c.queueReq(["REQ", qt.id, ...qt.filters], () => qt.sentToRelay());
}
return qt; return qt;
} }
} }

View File

@ -4,9 +4,8 @@ import { appendDedupe, dedupe, sanitizeRelayUrl, unixNowMs, unwrap } from "@snor
import EventKind from "./event-kind"; import EventKind from "./event-kind";
import { NostrLink, NostrPrefix, SystemInterface } from "."; import { NostrLink, NostrPrefix, SystemInterface } from ".";
import { ReqFilter, u256, HexKey } from "./nostr"; import { ReqFilter, u256, HexKey, TaggedNostrEvent } from "./nostr";
import { AuthorsRelaysCache, splitByWriteRelays, splitFlatByWriteRelays } from "./outbox-model"; import { AuthorsRelaysCache, splitByWriteRelays, splitFlatByWriteRelays } from "./outbox-model";
import { CacheRelay } from "cache-relay";
/** /**
* Which strategy is used when building REQ filters * Which strategy is used when building REQ filters
@ -27,11 +26,6 @@ export const enum RequestStrategy {
* Use pre-determined relays for query * Use pre-determined relays for query
*/ */
ExplicitRelays = "explicit-relays", ExplicitRelays = "explicit-relays",
/**
* Query the cache relay
*/
CacheRelay = "cache-relay",
} }
/** /**
@ -41,10 +35,17 @@ export interface BuiltRawReqFilter {
filters: Array<ReqFilter>; filters: Array<ReqFilter>;
relay: string; relay: string;
strategy: RequestStrategy; strategy: RequestStrategy;
// Use set sync from an existing set of events
syncFrom?: Array<TaggedNostrEvent>;
} }
export interface RequestBuilderOptions { export interface RequestBuilderOptions {
/**
* Dont send CLOSE directly after EOSE and allow events to stream in
*/
leaveOpen?: boolean; leaveOpen?: boolean;
/** /**
* Do not apply diff logic and always use full filters for query * Do not apply diff logic and always use full filters for query
*/ */
@ -131,10 +132,8 @@ export class RequestBuilder {
return this.#builders.map(f => f.filter); return this.#builders.map(f => f.filter);
} }
async build(system: SystemInterface): Promise<Array<BuiltRawReqFilter>> { build(system: SystemInterface): Array<BuiltRawReqFilter> {
const expanded = ( const expanded = this.#builders.flatMap(a => a.build(system.relayCache, this.#options));
await Promise.all(this.#builders.map(a => a.build(system.relayCache, system.cacheRelay, this.#options)))
).flat();
return this.#groupByRelay(system, expanded); return this.#groupByRelay(system, expanded);
} }
@ -295,39 +294,7 @@ export class RequestFilterBuilder {
/** /**
* Build/expand this filter into a set of relay specific queries * Build/expand this filter into a set of relay specific queries
*/ */
async build( build(relays: AuthorsRelaysCache, options?: RequestBuilderOptions): Array<BuiltRawReqFilter> {
relays: AuthorsRelaysCache,
cacheRelay?: CacheRelay,
options?: RequestBuilderOptions,
): Promise<Array<BuiltRawReqFilter>> {
// if since/until are set ignore sync split, cache relay wont be used
if (cacheRelay && this.#filter.since === undefined && this.#filter.until === undefined) {
const latest = await cacheRelay.query([
"REQ",
uuid(),
{
...this.#filter,
since: undefined,
until: undefined,
limit: 1,
},
]);
if (latest.length === 1) {
return [
...this.#buildFromFilter(relays, {
...this.#filter,
since: latest[0].created_at,
until: undefined,
limit: undefined,
}),
{
filters: [this.#filter],
relay: "==CACHE==",
strategy: RequestStrategy.CacheRelay,
},
];
}
}
return this.#buildFromFilter(relays, this.#filter, options); return this.#buildFromFilter(relays, this.#filter, options);
} }

View File

@ -1,7 +1,6 @@
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { import {
ConnectionStateSnapshot,
NostrEvent, NostrEvent,
OkResponse, OkResponse,
ProfileLoaderService, ProfileLoaderService,
@ -82,7 +81,7 @@ export class SystemWorker extends EventEmitter<NostrSystemEvents> implements Sys
}; };
} }
get Sockets(): ConnectionStateSnapshot[] { get Sockets(): never[] {
return []; return [];
} }

View File

@ -0,0 +1,5 @@
import { NegentropyStorageVector, VectorStorageItem } from "../src/negentropy/vector-storage";
describe("negentropy", () => {
it("should decodeBound", () => {});
});

View File

@ -112,9 +112,17 @@ globalThis.onmessage = async ev => {
await barrierQueue(cmdQueue, async () => { await barrierQueue(cmdQueue, async () => {
const req = msg.args as ReqCommand; const req = msg.args as ReqCommand;
const filters = req.slice(2) as Array<ReqFilter>; const filters = req.slice(2) as Array<ReqFilter>;
const results = []; const results: Array<string | NostrEvent> = [];
const ids = new Set<string>();
for (const r of filters) { for (const r of filters) {
results.push(...relay!.req(req[1], r)); const rx = relay!.req(req[1], r);
for (const x of rx) {
if ((typeof x === "string" && ids.has(x)) || ids.has((x as NostrEvent).id)) {
continue;
}
ids.add(typeof x === "string" ? x : (x as NostrEvent).id);
results.push(x);
}
} }
reply(msg.id, results); reply(msg.id, results);
}); });

View File

@ -3080,7 +3080,7 @@ __metadata:
"@snort/shared": ^1.0.11 "@snort/shared": ^1.0.11
"@stablelib/xchacha20": ^1.0.1 "@stablelib/xchacha20": ^1.0.1
"@types/debug": ^4.1.8 "@types/debug": ^4.1.8
"@types/jest": ^29.5.1 "@types/jest": ^29.5.11
"@types/lokijs": ^1.5.14 "@types/lokijs": ^1.5.14
"@types/node": ^20.5.9 "@types/node": ^20.5.9
"@types/uuid": ^9.0.2 "@types/uuid": ^9.0.2
@ -3586,13 +3586,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/jest@npm:^29.5.1": "@types/jest@npm:^29.5.11":
version: 29.5.8 version: 29.5.11
resolution: "@types/jest@npm:29.5.8" resolution: "@types/jest@npm:29.5.11"
dependencies: dependencies:
expect: ^29.0.0 expect: ^29.0.0
pretty-format: ^29.0.0 pretty-format: ^29.0.0
checksum: ca8438a5b4c098c8c023e9d5b279ea306494a1d0b5291cfb498100fa780377145f068b2a021d545b0398bbe0328dcc37044dd3aaf3c6c0fe9b0bef7b46a63453 checksum: f892a06ec9f0afa9a61cd7fa316ec614e21d4df1ad301b5a837787e046fcb40dfdf7f264a55e813ac6b9b633cb9d366bd5b8d1cea725e84102477b366df23fdd
languageName: node languageName: node
linkType: hard linkType: hard