diff --git a/docs/lib/tools.md b/docs/lib/tools.md index 9048c1c..319968b 100644 --- a/docs/lib/tools.md +++ b/docs/lib/tools.md @@ -5,6 +5,18 @@ The `Tools` module provides a comprehensive collection of utility functions for ## Basic functional programming utilities ```typescript +// Null or undefined +export type Nil = null | undefined; + +// Check whether something is null or undefined +export declare const isNil: (x: T, ...args: unknown[]) => boolean; + +// Check whether something is not null or undefined +export declare const isNotNil: (x: T, ...args: unknown[]) => x is (T & null) | (T & {}) | (T & undefined); + +// Assert that a nullable type is not null or undefined +export declare const assertNotNil: (x: T, ...args: unknown[]) => NonNullable; + // Function that does nothing and returns undefined export declare const noop: (...args: unknown[]) => undefined; @@ -120,6 +132,33 @@ export declare const ago: (unit: number, count?: number) => number; // Converts seconds to milliseconds export declare const ms: (seconds: number) => number; + +// Converts seconds to date +export declare const secondsToDate: (seconds: number) => Date; + +// Converts date object to seconds +export declare const dateToSeconds: (date: Date) => number; + +// Creates a local date from a date string +export declare const createLocalDate: (dateString: any, timezone?: string) => Date; +/** Formatter for date+time */ +export declare const dateTimeFormatter: Intl.DateTimeFormat; + +// Formats seconds as a datetime +export declare const formatTimestamp: (seconds: number) => string; +/** Formatter for date */ +export declare const dateFormatter: Intl.DateTimeFormat; + +// Formats seconds as a date +export declare const formatTimestampAsDate: (ts: number) => string; +/** Formatter for time */ +export declare const timeFormatter: Intl.DateTimeFormat; + +// Formats seconds as a time +export declare const formatTimestampAsTime: (ts: number) => string; + +// Formats seconds as a relative date (x minutes ago) +export declare const formatTimestampRelative: (ts: number) => string; ``` ## Sequences @@ -232,6 +271,9 @@ export declare const toIterable: (x: any) => any; // Ensures value is array by wrapping if needed export declare const ensurePlural: (x: T | T[]) => T[]; + +// Ensures values are not undefined +export declare const removeNil: (xs: T[]) => (T & {})[]; ``` ## Objects diff --git a/packages/app/package.json b/packages/app/package.json index 2469f49..7407de3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -25,6 +25,7 @@ "@welshman/feeds": "workspace:*", "@welshman/lib": "workspace:*", "@welshman/relay": "workspace:*", + "@welshman/router": "workspace:*", "@welshman/net": "workspace:*", "@welshman/signer": "workspace:*", "@welshman/store": "workspace:*", diff --git a/packages/app/src/collection.ts b/packages/app/src/collection.ts index cf5b393..b9968c8 100644 --- a/packages/app/src/collection.ts +++ b/packages/app/src/collection.ts @@ -10,7 +10,12 @@ export type CachedLoaderOptions = { subscribers?: Subscriber[] } -export const makeCachedLoader = ({name, load, indexStore, subscribers = []}: CachedLoaderOptions) => { +export const makeCachedLoader = ({ + name, + load, + indexStore, + subscribers = [], +}: CachedLoaderOptions) => { const pending = new Map>() const loadAttempts = new Map() diff --git a/packages/app/src/commands.ts b/packages/app/src/commands.ts index aa4eb5a..3390119 100644 --- a/packages/app/src/commands.ts +++ b/packages/app/src/commands.ts @@ -1,9 +1,9 @@ import {get} from "svelte/store" import {addToListPublicly, removeFromList, makeList, FOLLOWS, MUTES, PINS} from "@welshman/util" +import {Router, addMaximalFallbacks} from "@welshman/router" import {userFollows, userMutes, userPins} from "./user.js" import {nip44EncryptToSelf} from "./session.js" import {publishThunk} from "./thunk.js" -import {Router, addMaximalFallbacks} from "./router.js" export const unfollow = async (value: string) => { const list = get(userFollows) || makeList({kind: FOLLOWS}) diff --git a/packages/app/src/core.ts b/packages/app/src/core.ts index 32f8766..87d9791 100644 --- a/packages/app/src/core.ts +++ b/packages/app/src/core.ts @@ -1,32 +1,14 @@ import {throttle} from "@welshman/lib" -import {verifyEvent, isEphemeralKind, isDVMKind} from "@welshman/util" import {Repository, LocalRelay} from "@welshman/relay" -import {Pool, Tracker, SocketEvent, isRelayEvent} from "@welshman/net" import {custom} from "@welshman/store" -import {loadRelay, trackRelayStats} from "./relays.js" +import {Tracker} from "@welshman/net" -export const repository = Repository.getSingleton() +export const repository = Repository.get() export const relay = new LocalRelay(repository) export const tracker = new Tracker() -Pool.getSingleton().subscribe(socket => { - loadRelay(socket.url) - trackRelayStats(socket) - - socket.on(SocketEvent.Receive, message => { - if (isRelayEvent(message)) { - const event = message[2] - - if (!isEphemeralKind(event.kind) && !isDVMKind(event.kind) && verifyEvent(event)) { - tracker.track(event.id, socket.url) - repository.publish(event) - } - } - }) -}) - // Adapt above objects to stores export const makeRepositoryStore = ({throttle: t = 300}: {throttle?: number} = {}) => diff --git a/packages/app/src/feeds.ts b/packages/app/src/feeds.ts index 15715c8..f69c062 100644 --- a/packages/app/src/feeds.ts +++ b/packages/app/src/feeds.ts @@ -2,10 +2,10 @@ import {nthEq, partition, race, now} from "@welshman/lib" import {createEvent, getPubkeyTagValues, TrustedEvent} from "@welshman/util" import {request, Tracker} from "@welshman/net" import {Scope, FeedController, RequestOpts, FeedOptions, DVMOpts, Feed} from "@welshman/feeds" +import {Router, addMinimalFallbacks, getFilterSelections} from "@welshman/router" import {makeDvmRequest} from "@welshman/dvm" import {makeSecret, Nip01Signer} from "@welshman/signer" import {pubkey, signer} from "./session.js" -import {Router, addMinimalFallbacks, getFilterSelections} from "./router.js" import {loadRelaySelections} from "./relaySelections.js" import {wotGraph, maxWot, getFollows, getNetwork, getFollowers} from "./wot.js" import {repository} from "./core.js" @@ -14,10 +14,10 @@ export type FeedRequestHandlerOptions = { signal?: AbortSignal } -export const makeFeedRequestHandler = ({signal}: FeedRequestHandlerOptions) => +export const makeFeedRequestHandler = + ({signal}: FeedRequestHandlerOptions) => async ({filters = [{}], relays = [], onEvent}: RequestOpts) => { const tracker = new Tracker() - const requestOptions = {} if (relays.length > 0) { await request({tracker, signal, relays, filters, onEvent, autoClose: true}) diff --git a/packages/app/src/follows.ts b/packages/app/src/follows.ts index fbc8a66..388c80d 100644 --- a/packages/app/src/follows.ts +++ b/packages/app/src/follows.ts @@ -19,5 +19,5 @@ export const { name: "follows", store: follows, getKey: follows => follows.event.pubkey, - load: makeOutboxLoader(FOLLOWS) + load: makeOutboxLoader(FOLLOWS), }) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index f902da1..1b2a2e6 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -12,7 +12,6 @@ export * from "./profiles.js" export * from "./pins.js" export * from "./relays.js" export * from "./relaySelections.js" -export * from "./router.js" export * from "./search.js" export * from "./session.js" export * from "./storage.js" @@ -25,3 +24,54 @@ export * from "./user.js" export * from "./util.js" export * from "./wot.js" export * from "./zappers.js" + +import {sortBy, throttleWithValue, tryCatch} from "@welshman/lib" +import {verifyEvent, isEphemeralKind, isDVMKind} from "@welshman/util" +import {routerContext} from "@welshman/router" +import {Pool, SocketEvent, isRelayEvent} from "@welshman/net" +import {pubkey} from "./session.js" +import {repository, tracker} from "./core.js" +import {getPubkeyRelays} from "./relaySelections.js" +import {Relay, relays, loadRelay, trackRelayStats, getRelayQuality} from "./relays.js" + +// Sync relays with our database + +Pool.get().subscribe(socket => { + loadRelay(socket.url) + trackRelayStats(socket) + + socket.on(SocketEvent.Receive, message => { + if (isRelayEvent(message)) { + const event = message[2] + + if (!isEphemeralKind(event.kind) && !isDVMKind(event.kind) && verifyEvent(event)) { + tracker.track(event.id, socket.url) + repository.publish(event) + } + } + }) +}) + +// Configure the router + +const _relayGetter = (fn?: (relay: Relay) => any) => + throttleWithValue(200, () => { + let _relays = relays.get() + + if (fn) { + _relays = _relays.filter(fn) + } + + return sortBy(r => -getRelayQuality(r.url), _relays) + .slice(0, 5) + .map(r => r.url) + }) + +routerContext.getUserPubkey = () => pubkey.get() +routerContext.getPubkeyRelays = getPubkeyRelays +routerContext.getRelayQuality = getRelayQuality +routerContext.getDefaultRelays = _relayGetter() +routerContext.getIndexerRelays = _relayGetter() +routerContext.getSearchRelays = _relayGetter(r => + tryCatch(() => r.profile?.supported_nips?.includes(50)), +) diff --git a/packages/app/src/mutes.ts b/packages/app/src/mutes.ts index ae94df6..a195f88 100644 --- a/packages/app/src/mutes.ts +++ b/packages/app/src/mutes.ts @@ -25,5 +25,5 @@ export const { name: "mutes", store: mutes, getKey: mute => mute.event.pubkey, - load: makeOutboxLoader(MUTES) + load: makeOutboxLoader(MUTES), }) diff --git a/packages/app/src/pins.ts b/packages/app/src/pins.ts index e0fc5e8..d271d00 100644 --- a/packages/app/src/pins.ts +++ b/packages/app/src/pins.ts @@ -19,5 +19,5 @@ export const { name: "pins", store: pins, getKey: pins => pins.event.pubkey, - load: makeOutboxLoader(PINS) + load: makeOutboxLoader(PINS), }) diff --git a/packages/app/src/profiles.ts b/packages/app/src/profiles.ts index 128c084..aa976c1 100644 --- a/packages/app/src/profiles.ts +++ b/packages/app/src/profiles.ts @@ -30,5 +30,7 @@ export const displayProfileByPubkey = (pubkey: string | undefined) => export const deriveProfileDisplay = (pubkey: string | undefined, relays: string[] = []) => pubkey - ? derived(deriveProfile(pubkey, relays), $profile => displayProfile($profile, displayPubkey(pubkey))) + ? derived(deriveProfile(pubkey, relays), $profile => + displayProfile($profile, displayPubkey(pubkey)), + ) : readable("") diff --git a/packages/app/src/relaySelections.ts b/packages/app/src/relaySelections.ts index 0f5b965..bcc4eeb 100644 --- a/packages/app/src/relaySelections.ts +++ b/packages/app/src/relaySelections.ts @@ -9,11 +9,11 @@ import { getRelayTags, getRelayTagValues, } from "@welshman/util" -import {TrustedEvent, Filter, PublishedList, List} from "@welshman/util" -import {request, load} from "@welshman/net" +import {TrustedEvent, PublishedList, List} from "@welshman/util" +import {request} from "@welshman/net" import {deriveEventsMapped} from "@welshman/store" +import {Router, RelayMode} from "@welshman/router" import {repository} from "./core.js" -import {Router} from "./router.js" import {collection} from "./collection.js" export const getRelayUrls = (list?: List): string[] => @@ -33,7 +33,6 @@ export const getWriteRelayUrls = (list?: List): string[] => .map((t: string[]) => normalizeRelayUrl(t[1])), ) - export type OutboxLoaderRequest = { pubkey: string relays: string[] @@ -59,8 +58,8 @@ export const loadUsingOutbox = batcher(200, (requests: OutboxLoaderRequest[]) => return requests.map(always(promise)) }) -export const makeOutboxLoader = (kind: number) => - (pubkey: string, relays: string[]) => loadUsingOutbox({pubkey, relays, kind}) +export const makeOutboxLoader = (kind: number) => (pubkey: string, relays: string[]) => + loadUsingOutbox({pubkey, relays, kind}) export const relaySelections = deriveEventsMapped(repository, { filters: [{kinds: [RELAYS]}], @@ -79,6 +78,22 @@ export const { load: makeOutboxLoader(RELAYS), }) +export const getPubkeyRelays = (pubkey: string, mode?: string) => { + const $relaySelections = relaySelectionsByPubkey.get() + const $inboxSelections = inboxRelaySelectionsByPubkey.get() + + switch (mode) { + case RelayMode.Read: + return getReadRelayUrls($relaySelections.get(pubkey)) + case RelayMode.Write: + return getWriteRelayUrls($relaySelections.get(pubkey)) + case RelayMode.Inbox: + return getRelayUrls($inboxSelections.get(pubkey)) + default: + return getRelayUrls($relaySelections.get(pubkey)) + } +} + export const inboxRelaySelections = deriveEventsMapped(repository, { filters: [{kinds: [INBOX_RELAYS]}], itemToEvent: item => item.event, diff --git a/packages/app/src/relays.ts b/packages/app/src/relays.ts index d6c9347..5881a29 100644 --- a/packages/app/src/relays.ts +++ b/packages/app/src/relays.ts @@ -1,9 +1,29 @@ import {writable, derived} from "svelte/store" import {withGetter} from "@welshman/store" -import {groupBy, indexBy, batch, now, uniq, batcher, postJson} from "@welshman/lib" +import { + groupBy, + indexBy, + batch, + now, + uniq, + batcher, + postJson, + ago, + DAY, + HOUR, + MINUTE, +} from "@welshman/lib" import {RelayProfile} from "@welshman/util" -import {normalizeRelayUrl, displayRelayUrl, displayRelayProfile} from "@welshman/util" -import {Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net" +import { + normalizeRelayUrl, + displayRelayUrl, + displayRelayProfile, + isOnionUrl, + isLocalUrl, + isIPAddress, + isRelayUrl, +} from "@welshman/util" +import {Pool, Socket, SocketStatus, SocketEvent, ClientMessage, RelayMessage} from "@welshman/net" import {collection} from "./collection.js" import {appContext} from "./context.js" @@ -116,6 +136,34 @@ export const displayRelayByPubkey = (url: string) => export const deriveRelayDisplay = (url: string) => derived(deriveRelay(url), $relay => displayRelayProfile($relay?.profile, displayRelayUrl(url))) +export const getRelayQuality = (url: string) => { + const relay = relaysByUrl.get().get(url) + + // Skip non-relays entirely + if (!isRelayUrl(url)) return 0 + + // If we have recent errors, skip it + if (relay?.stats) { + if (relay.stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0 + if (relay.stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0 + if (relay.stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0 + } + + // Prefer stuff we're connected to + if (Pool.get().has(url)) return 1 + + // Prefer stuff we've connected to in the past + if (relay?.stats) return 0.9 + + // If it's not weird url give it an ok score + if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) { + return 0.8 + } + + // Default to a "meh" score + return 0.7 +} + // Utilities for syncing stats from connections to relays type RelayStatsUpdate = [string, (stats: RelayStats) => void] diff --git a/packages/app/src/search.ts b/packages/app/src/search.ts index 8a38246..82679f2 100644 --- a/packages/app/src/search.ts +++ b/packages/app/src/search.ts @@ -5,11 +5,11 @@ import {dec, sortBy} from "@welshman/lib" import {PROFILE, PublishedProfile} from "@welshman/util" import {load} from "@welshman/net" import {throttled} from "@welshman/store" +import {Router} from "@welshman/router" import {wotGraph} from "./wot.js" import {profiles} from "./profiles.js" import {topics, Topic} from "./topics.js" import {relays, Relay} from "./relays.js" -import {Router} from "./router.js" import {handlesByNip05} from "./handles.js" export type SearchOptions = { diff --git a/packages/app/src/session.ts b/packages/app/src/session.ts index 45b25bb..b61413e 100644 --- a/packages/app/src/session.ts +++ b/packages/app/src/session.ts @@ -1,14 +1,21 @@ import {derived} from "svelte/store" import {cached, hash, omit, equals, assoc} from "@welshman/lib" import {withGetter, synced} from "@welshman/store" -import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer, getPubkey} from "@welshman/signer" +import { + Nip46Broker, + Nip46Signer, + Nip07Signer, + Nip01Signer, + Nip55Signer, + getPubkey, +} from "@welshman/signer" export enum SessionMethod { - Nip01 = 'nip01', - Nip07 = 'nip07', - Nip46 = 'nip46', - Nip55 = 'nip55', - Pubkey = 'pubkey', + Nip01 = "nip01", + Nip07 = "nip07", + Nip46 = "nip46", + Nip55 = "nip55", + Pubkey = "pubkey", } export type SessionNip01 = { @@ -77,7 +84,7 @@ export const updateSession = (pubkey: string, f: (session: Session) => Session) putSession(f(getSession(pubkey))) export const dropSession = (_pubkey: string) => { - pubkey.update($pubkey => $pubkey === _pubkey ? undefined : $pubkey) + pubkey.update($pubkey => ($pubkey === _pubkey ? undefined : $pubkey)) sessions.update($sessions => omit([_pubkey], $sessions)) } @@ -88,37 +95,57 @@ export const clearSessions = () => { // Session factories -export const makeNip01Session = (secret: string): SessionNip01 => - ({method: SessionMethod.Nip01, secret, pubkey: getPubkey(secret)}) +export const makeNip01Session = (secret: string): SessionNip01 => ({ + method: SessionMethod.Nip01, + secret, + pubkey: getPubkey(secret), +}) -export const makeNip07Session = (pubkey: string): SessionNip07 => - ({method: SessionMethod.Nip07, pubkey}) +export const makeNip07Session = (pubkey: string): SessionNip07 => ({ + method: SessionMethod.Nip07, + pubkey, +}) -export const makeNip46Session = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]): SessionNip46 => - ({method: SessionMethod.Nip46, pubkey, secret: clientSecret, handler: {pubkey: signerPubkey, relays}}) +export const makeNip46Session = ( + pubkey: string, + clientSecret: string, + signerPubkey: string, + relays: string[], +): SessionNip46 => ({ + method: SessionMethod.Nip46, + pubkey, + secret: clientSecret, + handler: {pubkey: signerPubkey, relays}, +}) -export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => - ({method: SessionMethod.Nip55, pubkey, signer}) +export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({ + method: SessionMethod.Nip55, + pubkey, + signer, +}) -export const makePubkeySession = (pubkey: string): SessionPubkey => - ({method: SessionMethod.Pubkey, pubkey}) +export const makePubkeySession = (pubkey: string): SessionPubkey => ({ + method: SessionMethod.Pubkey, + pubkey, +}) // Login utilities -export const loginWithNip01 = (secret: string) => - addSession(makeNip01Session(secret)) +export const loginWithNip01 = (secret: string) => addSession(makeNip01Session(secret)) -export const loginWithNip07 = (pubkey: string) => - addSession(makeNip07Session(pubkey)) +export const loginWithNip07 = (pubkey: string) => addSession(makeNip07Session(pubkey)) -export const loginWithNip46 = (pubkey: string, clientSecret: string, signerPubkey: string, relays: string[]) => - addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays)) +export const loginWithNip46 = ( + pubkey: string, + clientSecret: string, + signerPubkey: string, + relays: string[], +) => addSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays)) export const loginWithNip55 = (pubkey: string, signer: string) => addSession(makeNip55Session(pubkey, signer)) -export const loginWithPubkey = (pubkey: string) => - addSession(makePubkeySession(pubkey)) +export const loginWithPubkey = (pubkey: string) => addSession(makePubkeySession(pubkey)) // Other stuff diff --git a/packages/app/src/storage.ts b/packages/app/src/storage.ts index 855f8a8..ba21060 100644 --- a/packages/app/src/storage.ts +++ b/packages/app/src/storage.ts @@ -120,4 +120,3 @@ export const clearStorage = async () => { db = undefined // force initStorage to run again in tests } } - diff --git a/packages/app/src/storageAdapters.ts b/packages/app/src/storageAdapters.ts index 504eda7..8445976 100644 --- a/packages/app/src/storageAdapters.ts +++ b/packages/app/src/storageAdapters.ts @@ -10,7 +10,7 @@ import { getListTags, TrustedEvent, } from "@welshman/util" -import {throttled, withGetter, WritableWithGetter} from "@welshman/store" +import {throttled, withGetter} from "@welshman/store" import {Tracker} from "@welshman/net" import {Repository, RepositoryUpdate} from "@welshman/relay" import {getAll, bulkPut, bulkDelete} from "./storage.js" @@ -123,8 +123,7 @@ export class PlaintextStorageAdapter { const interval = setInterval(() => { bulkPut( this.options.name, - Object.entries(plaintext.get()) - .map(([key, value]) => ({key, value})), + Object.entries(plaintext.get()).map(([key, value]) => ({key, value})), ) }, 10_000) @@ -156,14 +155,16 @@ export class TrackerStorageAdapter { const onUpdate = throttle(3000, async () => { await bulkPut( this.options.name, - Array.from(this.options.tracker.relaysById.entries()) - .map(([id, relays]) => ({id, relays: Array.from(relays)})) + Array.from(this.options.tracker.relaysById.entries()).map(([id, relays]) => ({ + id, + relays: Array.from(relays), + })), ) }) - this.options.tracker.on('update', onUpdate) + this.options.tracker.on("update", onUpdate) - return () => this.options.tracker.off('update', onUpdate) + return () => this.options.tracker.off("update", onUpdate) } } @@ -224,30 +225,32 @@ export class EventsStorageAdapter { } export const defaultStorageAdapters = { - relays: new RelaysStorageAdapter({name: 'relays'}), - handles: new HandlesStorageAdapter({name: 'handles'}), - zappers: new ZappersStorageAdapter({name: 'zappers'}), - freshness: new FreshnessStorageAdapter({name: 'freshness'}), - plaintext: new PlaintextStorageAdapter({name: 'plaintext'}), - tracker: new TrackerStorageAdapter({name: 'tracker', tracker}), - events: new EventsStorageAdapter(call(() => { - const userFollowPubkeys = withGetter( - derived(userFollows, l => new Set(getPubkeyTagValues(getListTags(l)))), - ) + relays: new RelaysStorageAdapter({name: "relays"}), + handles: new HandlesStorageAdapter({name: "handles"}), + zappers: new ZappersStorageAdapter({name: "zappers"}), + freshness: new FreshnessStorageAdapter({name: "freshness"}), + plaintext: new PlaintextStorageAdapter({name: "plaintext"}), + tracker: new TrackerStorageAdapter({name: "tracker", tracker}), + events: new EventsStorageAdapter( + call(() => { + const userFollowPubkeys = withGetter( + derived(userFollows, l => new Set(getPubkeyTagValues(getListTags(l)))), + ) - return { - repository, - name: 'events', - limit: 10_000, - rankEvent: (e: TrustedEvent) => { - const $sessions = sessions.get() - const metaKinds = [PROFILE, FOLLOWS, MUTES, RELAYS, INBOX_RELAYS] + return { + repository, + name: "events", + limit: 10_000, + rankEvent: (e: TrustedEvent) => { + const $sessions = sessions.get() + const metaKinds = [PROFILE, FOLLOWS, MUTES, RELAYS, INBOX_RELAYS] - if ($sessions[e.pubkey] || e.tags.some(t => $sessions[t[1]])) return 1 - if (metaKinds.includes(e.kind) && userFollowPubkeys.get()?.has(e.pubkey)) return 1 + if ($sessions[e.pubkey] || e.tags.some(t => $sessions[t[1]])) return 1 + if (metaKinds.includes(e.kind) && userFollowPubkeys.get()?.has(e.pubkey)) return 1 - return 0 - }, - } - })), + return 0 + }, + } + }), + ), } diff --git a/packages/app/src/sync.ts b/packages/app/src/sync.ts index 9753694..2bbc34b 100644 --- a/packages/app/src/sync.ts +++ b/packages/app/src/sync.ts @@ -1,11 +1,6 @@ import type {Filter} from "@welshman/util" import {isSignedEvent, SignedEvent} from "@welshman/util" -import { - push as basePush, - pull as basePull, - publishOne, - requestOne, -} from "@welshman/net" +import {push as basePush, pull as basePull, publishOne, requestOne} from "@welshman/net" import {repository} from "./core.js" import {relaysByUrl} from "./relays.js" diff --git a/packages/app/src/tags.ts b/packages/app/src/tags.ts index af89ed3..ab9e7bc 100644 --- a/packages/app/src/tags.ts +++ b/packages/app/src/tags.ts @@ -7,9 +7,9 @@ import { isReplaceableKind, } from "@welshman/util" import type {TrustedEvent} from "@welshman/util" +import {Router} from "@welshman/router" import {displayProfileByPubkey} from "./profiles.js" import {pubkey} from "./session.js" -import {Router} from "./router.js" export const tagZapSplit = (pubkey: string, split = 1) => [ "zap", diff --git a/packages/app/src/thunk.ts b/packages/app/src/thunk.ts index f44ee1d..18c2365 100644 --- a/packages/app/src/thunk.ts +++ b/packages/app/src/thunk.ts @@ -1,14 +1,10 @@ import type {Subscriber} from "svelte/store" -import {Writable, Readable, writable, derived, get} from "svelte/store" +import {writable, get} from "svelte/store" import { - Deferred, - fromPairs, TaskQueue, ifLet, dissoc, remove, - identity, - uniq, defer, sleep, assoc, @@ -30,7 +26,7 @@ import { isUnwrappedEvent, isSignedEvent, } from "@welshman/util" -import {publish, AdapterContext, PublishStatus, PublishOptions, PublishStatusByRelay} from "@welshman/net" +import {publish, PublishStatus, PublishOptions, PublishStatusByRelay} from "@welshman/net" import {repository, tracker} from "./core.js" import {pubkey, getSession, getSigner} from "./session.js" @@ -52,7 +48,7 @@ export const prepEvent = (event: ThunkEvent) => { return event as TrustedEvent } -export type ThunkOptions = Omit & { +export type ThunkOptions = Omit & { event: ThunkEvent delay?: number } @@ -173,7 +169,7 @@ export class Thunk { this.options.onComplete?.() this._subs = [] }, - }) + }), ) } @@ -198,7 +194,6 @@ export class MergedThunk { constructor(readonly thunks: Thunk[]) { const {Aborted, Failure, Timeout, Pending, Success} = PublishStatus const relays = new Set(thunks.flatMap(thunk => Object.keys(thunk.options.relays))) - const statusMaps = thunks.map(thunk => thunk.status) for (const thunk of thunks) { this.controller.signal.addEventListener("abort", () => thunk.controller.abort()) @@ -246,8 +241,7 @@ export class MergedThunk { export type AbstractThunk = Thunk | MergedThunk -export const isThunk = (thunk: AbstractThunk): thunk is Thunk => - thunk instanceof Thunk +export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk => thunk instanceof MergedThunk @@ -261,18 +255,22 @@ export const thunkUrlsWithStatus = (thunk: AbstractThunk, status: PublishStatus) export const thunkCompleteUrls = (thunk: AbstractThunk) => { const incompleteStatuses = [PublishStatus.Sending, PublishStatus.Pending] - return Object.entries(thunk.status).filter(([_, s]) => !incompleteStatuses.includes(s)).map(nth(1)) + return Object.entries(thunk.status) + .filter(([_, s]) => !incompleteStatuses.includes(s)) + .map(nth(1)) } export const thunkIncompleteUrls = (thunk: AbstractThunk) => { const incompleteStatuses = [PublishStatus.Sending, PublishStatus.Pending] - return Object.entries(thunk.status).filter(([_, s]) => incompleteStatuses.includes(s)).map(nth(1)) + return Object.entries(thunk.status) + .filter(([_, s]) => incompleteStatuses.includes(s)) + .map(nth(1)) } export const thunkIsComplete = (thunk: AbstractThunk) => thunkCompleteUrls(thunk).length > 0 -export function* walkThunks(thunks: (AbstractThunk)[]): Iterable { +export function* walkThunks(thunks: AbstractThunk[]): Iterable { for (const thunk of thunks) { if (thunk instanceof MergedThunk) { yield* walkThunks(thunk.thunks) diff --git a/packages/content/package.json b/packages/content/package.json index 2b6f987..8e47996 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -7,6 +7,7 @@ "publishConfig": { "access": "public" }, + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/dvm/src/request.ts b/packages/dvm/src/request.ts index 49e5182..ab05464 100644 --- a/packages/dvm/src/request.ts +++ b/packages/dvm/src/request.ts @@ -1,4 +1,4 @@ -import {Emitter, now} from "@welshman/lib" +import {now} from "@welshman/lib" import {TrustedEvent, SignedEvent, Filter} from "@welshman/util" import {request, publish, AdapterContext} from "@welshman/net" @@ -13,15 +13,7 @@ export type DVMRequestOptions = { } export const requestDvmResponse = (options: DVMRequestOptions) => { - const { - event, - relays, - context, - timeout = 30_000, - autoClose = true, - onResult, - onProgress, - } = options + const {event, relays, context, timeout = 30_000, autoClose = true, onResult, onProgress} = options const kind = event.kind + 1000 const kinds = onProgress ? [kind, 7000] : [kind] const filters: Filter[] = [{kinds, since: now() - 60, "#e": [event.id]}] diff --git a/packages/feeds/package.json b/packages/feeds/package.json index 9e7c597..2e20763 100644 --- a/packages/feeds/package.json +++ b/packages/feeds/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@welshman/lib": "workspace:*", + "@welshman/net": "workspace:*", "@welshman/util": "workspace:*" }, "devDependencies": { diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index dceeed6..e86c4de 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -1,6 +1,5 @@ import {bech32, utf8} from "@scure/base" - type Obj = Record // ---------------------------------------------------------------------------- @@ -224,6 +223,12 @@ export const QUARTER = 90 * DAY /** One year in seconds (approximate) */ export const YEAR = 365 * DAY +/** User's default locale */ +export const LOCALE = new Intl.DateTimeFormat().resolvedOptions().locale + +/** User's default timezone */ +export const TIMEZONE = new Date().toString().match(/GMT[^\s]+/)![0] + /** * Multiplies time unit by count * @param unit - Time unit in seconds @@ -250,6 +255,97 @@ export const ago = (unit: number, count = 1) => now() - int(unit, count) */ export const ms = (seconds: number) => seconds * 1000 +/** + * Converts seconds to date + * @param seconds - Time in seconds + * @returns Date object + */ +export const secondsToDate = (seconds: number) => new Date(seconds * 1000) + +/** + * Converts date object to seconds + * @param date - Date object + * @returns timestamp in seconds + */ +export const dateToSeconds = (date: Date) => Math.round(date.valueOf() / 1000) + +/** + * Creates a local date from a date string + * @param dateString - date string + * @param timezone - timezone string + * @returns timezone-aware Date object + */ +export const createLocalDate = (dateString: any, timezone = TIMEZONE) => + new Date(`${dateString} ${timezone}`) + +/** Formatter for date+time */ +export const dateTimeFormatter = new Intl.DateTimeFormat(LOCALE, { + dateStyle: "short", + timeStyle: "short", +}) + +/** + * Formats seconds as a datetime + * @param seconds - timestamp in seconds + * @returns datetime string + */ +export const formatTimestamp = (seconds: number) => dateTimeFormatter.format(secondsToDate(seconds)) + +/** Formatter for date */ +export const dateFormatter = new Intl.DateTimeFormat(LOCALE, { + year: "numeric", + month: "long", + day: "numeric", +}) + +/** + * Formats seconds as a date + * @param seconds - timestamp in seconds + * @returns date string + */ +export const formatTimestampAsDate = (ts: number) => dateFormatter.format(secondsToDate(ts)) + +/** Formatter for time */ +export const timeFormatter = new Intl.DateTimeFormat(LOCALE, { + timeStyle: "short", +}) + +/** + * Formats seconds as a time + * @param seconds - timestamp in seconds + * @returns time string + */ +export const formatTimestampAsTime = (ts: number) => timeFormatter.format(secondsToDate(ts)) + +/** + * Formats seconds as a relative date (x minutes ago) + * @param seconds - timestamp in seconds + * @returns relative date string + */ +export const formatTimestampRelative = (ts: number) => { + let unit + let delta = now() - ts + if (delta < int(MINUTE)) { + unit = "second" + } else if (delta < int(HOUR)) { + unit = "minute" + delta = Math.round(delta / int(MINUTE)) + } else if (delta < int(DAY, 2)) { + unit = "hour" + delta = Math.round(delta / int(HOUR)) + } else { + unit = "day" + delta = Math.round(delta / int(DAY)) + } + + const locale = new Intl.RelativeTimeFormat().resolvedOptions().locale + const formatter = new Intl.RelativeTimeFormat(locale, { + numeric: "auto", + }) + + return formatter.format(-delta, unit as Intl.RelativeTimeFormatUnit) +} + // ---------------------------------------------------------------------------- // Sequences // ---------------------------------------------------------------------------- @@ -360,6 +456,14 @@ export const difference = (a: T[], b: T[]) => { */ export const remove = (a: T, xs: T[]) => xs.filter(x => x !== a) +/** + * Removes element at index + * @param i - Index to remove + * @param xs - Source array + * @returns New array with element removed + */ +export const removeAt = (i: number, xs: T[]) => [...xs.slice(0, i), ...xs.slice(i + 1)] + /** * Returns elements from second array not present in first * @param a - Array of elements to exclude @@ -602,7 +706,10 @@ export const chunks = (n: number, xs: T[]) => { export const splitAt = (n: number, xs: T[]) => [xs.slice(0, n), xs.slice(n)] /** Inserts element into array at index */ -export const insert = (n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n)] +export const insertAt = (n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n)] + +/** Replaces array element at index */ +export const replaceAt = (n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n + 1)] /** Returns random element from array */ export const choice = (xs: T[]): T => xs[Math.floor(xs.length * Math.random())] @@ -926,7 +1033,7 @@ export const poll = ({interval = 300, condition, signal}: PollOptions) => } }, interval) - signal.addEventListener('abort', () => { + signal.addEventListener("abort", () => { resolve() clearInterval(int) }) diff --git a/packages/net/__tests__/request.test.ts b/packages/net/__tests__/request.test.ts index 3bf5a86..3a32a34 100644 --- a/packages/net/__tests__/request.test.ts +++ b/packages/net/__tests__/request.test.ts @@ -17,7 +17,7 @@ describe("requestOne", () => { it("everything basically works", async () => { let id const sendSpy = vi.fn(m => { - if (m[0] === 'REQ') { + if (m[0] === "REQ") { id = m[1] } }) @@ -87,13 +87,13 @@ describe("request", () => { it("everything basically works", async () => { let id1, id2 const send1Spy = vi.fn(m => { - if (m[0] === 'REQ') { + if (m[0] === "REQ") { id1 = m[1] } }) const adapter1 = new MockAdapter("1", send1Spy) const send2Spy = vi.fn(m => { - if (m[0] === 'REQ') { + if (m[0] === "REQ") { id2 = m[1] } }) diff --git a/packages/net/package.json b/packages/net/package.json index 92afea5..310ad00 100644 --- a/packages/net/package.json +++ b/packages/net/package.json @@ -23,6 +23,7 @@ "@welshman/lib": "workspace:*", "@welshman/relay": "workspace:*", "@welshman/util": "workspace:*", + "events": "^3.3.0", "isomorphic-ws": "^5.0.0" }, "devDependencies": { diff --git a/packages/net/src/context.ts b/packages/net/src/context.ts index 2231e95..60b9736 100644 --- a/packages/net/src/context.ts +++ b/packages/net/src/context.ts @@ -1,5 +1,5 @@ import {Repository} from "@welshman/relay" -import {verifyEvent, TrustedEvent, SignedEvent} from "@welshman/util" +import {verifyEvent, TrustedEvent} from "@welshman/util" import {AbstractAdapter} from "./adapter.js" import {Pool} from "./pool.js" @@ -12,8 +12,8 @@ export type NetContext = { } export const netContext: NetContext = { - pool: Pool.getSingleton(), - repository: Repository.getSingleton(), + pool: Pool.get(), + repository: Repository.get(), isEventValid: (event, url) => verifyEvent(event), isEventDeleted: (event, url) => netContext.repository.isDeleted(event), } diff --git a/packages/net/src/diff.ts b/packages/net/src/diff.ts index a9d1523..ae6946c 100644 --- a/packages/net/src/diff.ts +++ b/packages/net/src/diff.ts @@ -200,17 +200,18 @@ export const pull = async ({context, ...options}: PullOptions) => { await Promise.all( Array.from(idsByRelay.entries()).map(([relay, allIds]) => { return Promise.all( - chunk(500, allIds).map(ids => - new Promise(resolve => - requestOne({ - relay, - context, - filters: [{ids}], - autoClose: true, - onClose: resolve, - onEvent: event => result.push(event as SignedEvent), - }) - ) + chunk(500, allIds).map( + ids => + new Promise(resolve => + requestOne({ + relay, + context, + filters: [{ids}], + autoClose: true, + onClose: resolve, + onEvent: event => result.push(event as SignedEvent), + }), + ), ), ) }), diff --git a/packages/net/src/pool.ts b/packages/net/src/pool.ts index 0997aff..4d56448 100644 --- a/packages/net/src/pool.ts +++ b/packages/net/src/pool.ts @@ -25,7 +25,7 @@ export class Pool { _data = new Map() _subs: PoolSubscription[] = [] - static getSingleton() { + static get() { if (!poolSingleton) { poolSingleton = new Pool() } diff --git a/packages/net/src/publish.ts b/packages/net/src/publish.ts index 9377723..45c8eb9 100644 --- a/packages/net/src/publish.ts +++ b/packages/net/src/publish.ts @@ -1,8 +1,6 @@ -import {EventEmitter} from "events" -import {on, fromPairs, sleep, yieldThread} from "@welshman/lib" import {SignedEvent} from "@welshman/util" import {RelayMessage, ClientMessageType, isRelayOk} from "./message.js" -import {AbstractAdapter, AdapterEvent, AdapterContext, getAdapter} from "./adapter.js" +import {AdapterEvent, AdapterContext, getAdapter} from "./adapter.js" export enum PublishStatus { Sending = "publish:status:sending", @@ -46,28 +44,25 @@ export const publishOne = (options: PublishOneOptions) => resolve(status) } - adapter.on( - AdapterEvent.Receive, - (message: RelayMessage, url: string) => { - if (isRelayOk(message)) { - const [_, id, ok, detail] = message + adapter.on(AdapterEvent.Receive, (message: RelayMessage, url: string) => { + if (isRelayOk(message)) { + const [_, id, ok, detail] = message - if (id !== options.event.id) return + if (id !== options.event.id) return - if (ok) { - status = PublishStatus.Success - options.onSuccess?.(detail) - } else { - status = PublishStatus.Failure - options.onFailure?.(detail) - } - - cleanup() + if (ok) { + status = PublishStatus.Success + options.onSuccess?.(detail) + } else { + status = PublishStatus.Failure + options.onFailure?.(detail) } - }, - ) - options.signal?.addEventListener('abort', () => { + cleanup() + } + }) + + options.signal?.addEventListener("abort", () => { if (status === PublishStatus.Pending) { status = PublishStatus.Aborted options.onAborted?.() @@ -149,8 +144,8 @@ export const publish = async (options: PublishOptions) => { options.onComplete?.() } }, - }) - ) + }), + ), ) return status diff --git a/packages/net/src/request.ts b/packages/net/src/request.ts index 2d089cf..74046b4 100644 --- a/packages/net/src/request.ts +++ b/packages/net/src/request.ts @@ -1,5 +1,16 @@ -import {EventEmitter} from "events" -import {on, uniq, lt, flatten, addToMapKey, defer, Deferred, call, randomId, yieldThread, pushToMapKey, batcher} from "@welshman/lib" +import { + on, + uniq, + lt, + flatten, + addToMapKey, + defer, + Deferred, + call, + randomId, + pushToMapKey, + batcher, +} from "@welshman/lib" import { Filter, getAddress, @@ -9,9 +20,8 @@ import { getFilterResultCardinality, } from "@welshman/util" import {RelayMessage, ClientMessageType, isRelayEvent, isRelayEose} from "./message.js" -import {getAdapter, AdapterContext, AbstractAdapter, AdapterEvent} from "./adapter.js" +import {getAdapter, AdapterContext, AdapterEvent} from "./adapter.js" import {SocketEvent, SocketStatus} from "./socket.js" -import {Unsubscriber} from "./util.js" import {netContext} from "./context.js" import {Tracker} from "./tracker.js" @@ -160,7 +170,6 @@ export const request = async (options: RequestOptions) => { const ctrl = new AbortController() const signal = options.signal ? AbortSignal.any([options.signal, ctrl.signal]) : ctrl.signal const threshold = options.threshold || 1 - const promises: Promise[] = [] if (relays.size !== options.relays.length) { console.warn("Non-unique relays passed to request") @@ -181,14 +190,13 @@ export const request = async (options: RequestOptions) => { options.onClose?.() ctrl.abort() } - } - }) - ) - ) + }, + }), + ), + ), ) } - export type LoaderOptions = { delay: number timeout?: number @@ -251,7 +259,7 @@ export const makeLoader = (options: LoaderOptions) => resultsByRequest.set(request, defer()) // Propagate abort when all requests have been closed for a given relay - request.signal?.addEventListener('abort', () => close(relay, request)) + request.signal?.addEventListener("abort", () => close(relay, request)) } } @@ -268,47 +276,46 @@ export const makeLoader = (options: LoaderOptions) => signalsByRelay.set(relay, AbortSignal.any(signals)) } - Array.from(requestsByRelay).forEach( - async ([relay, requests]) => { - // Union all filters for a given request and send them together - const filters = unionFilters(requests.flatMap(r => r.filters)) + Array.from(requestsByRelay).forEach(async ([relay, requests]) => { + // Union all filters for a given request and send them together + const filters = unionFilters(requests.flatMap(r => r.filters)) - // Propagate events to caller, but only for requests that have not been aborted - const getOpenRequests = () => - requests.filter(request => !closedRequestsByRelay.get(relay)?.has(request)) + // Propagate events to caller, but only for requests that have not been aborted + const getOpenRequests = () => + requests.filter(request => !closedRequestsByRelay.get(relay)?.has(request)) - requestOne({ - relay, - filters, - tracker, - autoClose: true, - signal: signalsByRelay.get(relay), - context: options.context, - isEventValid: options.isEventValid, - isEventDeleted: options.isEventDeleted, - onEvent: (event: TrustedEvent, url: string) => { - for (const request of getOpenRequests()) { - if (matchFilters(request.filters, event)) { - pushToMapKey(eventsByRequest, request, event) - request.onEvent?.(event, url) + requestOne({ + relay, + filters, + tracker, + autoClose: true, + signal: signalsByRelay.get(relay), + context: options.context, + isEventValid: options.isEventValid, + isEventDeleted: options.isEventDeleted, + onEvent: (event: TrustedEvent, url: string) => { + for (const request of getOpenRequests()) { + if (matchFilters(request.filters, event)) { + pushToMapKey(eventsByRequest, request, event) + request.onEvent?.(event, url) - // Calculate cardinality for unioned filters so that we can return early - if (request.filters.length === 1) { - const cardinality = getFilterResultCardinality(request.filters[0]) + // Calculate cardinality for unioned filters so that we can return early + if (request.filters.length === 1) { + const cardinality = getFilterResultCardinality(request.filters[0]) - if (eventsByRequest.get(request)?.length === cardinality) { - close(relay, request) - } + if (eventsByRequest.get(request)?.length === cardinality) { + close(relay, request) } } } - }, - onDisconnect: (url: string) => getOpenRequests().forEach(request => request.onDisconnect?.(url)), - onEose: (url: string) => getOpenRequests().forEach(request => request.onEose?.(url)), - onClose: () => requests.forEach(request => close(relay, request)), - }) - } - ) + } + }, + onDisconnect: (url: string) => + getOpenRequests().forEach(request => request.onDisconnect?.(url)), + onEose: (url: string) => getOpenRequests().forEach(request => request.onEose?.(url)), + onClose: () => requests.forEach(request => close(relay, request)), + }) + }) return allRequests.map(r => resultsByRequest.get(r)!) }) diff --git a/packages/relay/src/repository.ts b/packages/relay/src/repository.ts index dc478d1..dfbf5e9 100644 --- a/packages/relay/src/repository.ts +++ b/packages/relay/src/repository.ts @@ -44,7 +44,7 @@ export class Repository extends Emitter { eventsByKind = new Map() deletes = new Map() - static getSingleton() { + static get() { if (!repositorySingleton) { repositorySingleton = new Repository() } diff --git a/packages/router/.eslintignore b/packages/router/.eslintignore new file mode 100644 index 0000000..e3a7fb4 --- /dev/null +++ b/packages/router/.eslintignore @@ -0,0 +1,2 @@ +build +__tests__ diff --git a/packages/router/README.md b/packages/router/README.md new file mode 100644 index 0000000..9b1ec98 --- /dev/null +++ b/packages/router/README.md @@ -0,0 +1,29 @@ +# @welshman/content [![version](https://badgen.net/npm/v/@welshman/content)](https://npmjs.com/package/@welshman/content) + +Utilities for parsing and rendering note content. Customizable via RenderOptions. + +```typescript +import {parse, render} from '@welshman/content' + +const content = "Hello
from https://coracle.tools! " +const parsed = parse({content, tags: []}) +// [ +// { type: 'text', value: 'Hello
from ', raw: 'Hello
from ' }, +// { +// type: 'link', +// value: { url: URL, isMedia: false }, +// raw: 'https://coracle.tools' +// }, +// { +// type: 'text', +// value: "! ", +// raw: "! " +// } +// ] + +const result = renderAsText(parsed) +// => Hello<br>from https://coracle.tools/! <script>alert('evil')</script> + +const result = renderAsHtml(parsed) +// => Hello<br>from coracle.tools/! <script>alert('evil')</script> +``` diff --git a/packages/router/package.json b/packages/router/package.json new file mode 100644 index 0000000..8f3306d --- /dev/null +++ b/packages/router/package.json @@ -0,0 +1,30 @@ +{ + "name": "@welshman/router", + "version": "0.1.0", + "author": "hodlbod", + "license": "MIT", + "description": "A collection of utilities for nostr relay selection.", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run clean && pnpm run compile --force", + "clean": "rimraf ./dist", + "compile": "tsc -b tsconfig.build.json", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@welshman/lib": "workspace:*", + "@welshman/util": "workspace:*" + }, + "devDependencies": { + "rimraf": "~6.0.0", + "typescript": "~5.8.0" + } +} diff --git a/packages/app/src/router.ts b/packages/router/src/index.ts similarity index 85% rename from packages/app/src/router.ts rename to packages/router/src/index.ts index f941e0a..06a8e9d 100644 --- a/packages/app/src/router.ts +++ b/packages/router/src/index.ts @@ -8,19 +8,14 @@ import { pushToMapKey, inc, add, - ago, take, chunks, - MINUTE, - HOUR, - DAY, } from "@welshman/lib" import { getFilterId, isRelayUrl, isOnionUrl, isLocalUrl, - isIPAddress, isShareableRelayUrl, COMMENT, PROFILE, @@ -35,16 +30,6 @@ import { TrustedEvent, Filter, } from "@welshman/util" -import {Pool} from "@welshman/net" -import {pubkey} from "./session.js" -import { - relaySelectionsByPubkey, - inboxRelaySelectionsByPubkey, - getReadRelayUrls, - getWriteRelayUrls, - getRelayUrls, -} from "./relaySelections.js" -import {relaysByUrl} from "./relays.js" export const INDEXED_KINDS = [PROFILE, RELAYS, INBOX_RELAYS, FOLLOWS] @@ -127,64 +112,10 @@ export const addMinimalFallbacks = (count: number, limit: number) => (count > 0 export const addMaximalFallbacks = (count: number, limit: number) => limit - count -// Default router options - -export const getRelayQuality = (url: string) => { - const relay = relaysByUrl.get().get(url) - - // Skip non-relays entirely - if (!isRelayUrl(url)) return 0 - - // If we have recent errors, skip it - if (relay?.stats) { - if (relay.stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0 - if (relay.stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0 - if (relay.stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0 - } - - // Prefer stuff we're connected to - if (Pool.getSingleton().has(url)) return 1 - - // Prefer stuff we've connected to in the past - if (relay?.stats) return 0.9 - - // If it's not weird url give it an ok score - if (!isIPAddress(url) && !isLocalUrl(url) && !isOnionUrl(url) && !url.startsWith("ws://")) { - return 0.8 - } - - // Default to a "meh" score - return 0.7 -} - -export const getPubkeyRelays = (pubkey: string, mode?: string) => { - const $relaySelections = relaySelectionsByPubkey.get() - const $inboxSelections = inboxRelaySelectionsByPubkey.get() - - switch (mode) { - case RelayMode.Read: - return getReadRelayUrls($relaySelections.get(pubkey)) - case RelayMode.Write: - return getWriteRelayUrls($relaySelections.get(pubkey)) - case RelayMode.Inbox: - return getRelayUrls($inboxSelections.get(pubkey)) - default: - return getRelayUrls($relaySelections.get(pubkey)) - } -} - -export const routerContext: RouterOptions = { - getRelayQuality, - getPubkeyRelays, - getDefaultRelays: () => ["wss://relay.damus.io/", "wss://nos.lol/"], - getIndexerRelays: () => ["wss://purplepag.es/", "wss://relay.nostr.band/"], - getSearchRelays: () => ["wss://relay.nostr.band/", "wss://nostr.wine/"], - getUserPubkey: () => pubkey.get(), - getLimit: () => 3, -} - // Router class +export const routerContext: RouterOptions = {} + export class Router { readonly options: RouterOptions diff --git a/packages/router/tsconfig.build.json b/packages/router/tsconfig.build.json new file mode 100644 index 0000000..dcd3fe9 --- /dev/null +++ b/packages/router/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "outDir": "./dist" + }, + + "include": [ + "src/**/*" + ] +} diff --git a/packages/router/tsconfig.json b/packages/router/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/router/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/router/typedoc.json b/packages/router/typedoc.json new file mode 100644 index 0000000..35fed2c --- /dev/null +++ b/packages/router/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} diff --git a/packages/util/src/Events.ts b/packages/util/src/Events.ts index 68eb064..3f59108 100644 --- a/packages/util/src/Events.ts +++ b/packages/util/src/Events.ts @@ -1,7 +1,7 @@ import {verifiedSymbol, verifyEvent as verifyEventPure} from "nostr-tools/pure" import {setNostrWasm, verifyEvent as verifyEventWasm} from "nostr-tools/wasm" import {initNostrWasm} from "nostr-wasm" -import {mapVals, noop, first, pick, now} from "@welshman/lib" +import {mapVals, first, pick, now} from "@welshman/lib" import {getReplyTagValues, getCommentTagValues} from "./Tags.js" import {getAddress, Address} from "./Address.js" import { @@ -65,16 +65,19 @@ export const verifyEvent = (() => { let verify = verifyEventPure if (typeof WebAssembly === "object") { - initNostrWasm() - .then(nostrWasm => { + initNostrWasm().then( + nostrWasm => { setNostrWasm(nostrWasm) verify = verifyEventWasm - }, e => { + }, + e => { console.warn(e) - }) + }, + ) } - return (event: TrustedEvent) => Boolean(event.sig && (event[verifiedSymbol] || verify(event as SignedEvent))) + return (event: TrustedEvent) => + Boolean(event.sig && (event[verifiedSymbol] || verify(event as SignedEvent))) })() export const isEventTemplate = (e: EventTemplate): e is EventTemplate => diff --git a/packages/util/src/Relay.ts b/packages/util/src/Relay.ts index 671bf4a..5ad4772 100644 --- a/packages/util/src/Relay.ts +++ b/packages/util/src/Relay.ts @@ -34,7 +34,7 @@ export const isRelayUrl = (url: string) => { if (url.match(/\\.*\./)) return false // Skip non-localhost urls without a dot - if (!url.match(/\./) && !url.includes('localhost')) return false + if (!url.match(/\./) && !url.includes("localhost")) return false try { new URL(url) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a8b667..4bcb8a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@welshman/relay': specifier: workspace:* version: link:../relay + '@welshman/router': + specifier: workspace:* + version: link:../router '@welshman/signer': specifier: workspace:* version: link:../signer @@ -213,6 +216,9 @@ importers: '@welshman/lib': specifier: workspace:* version: link:../lib + '@welshman/net': + specifier: workspace:* + version: link:../net '@welshman/util': specifier: workspace:* version: link:../util @@ -254,6 +260,9 @@ importers: '@welshman/util': specifier: workspace:* version: link:../util + events: + specifier: ^3.3.0 + version: 3.3.0 isomorphic-ws: specifier: ^5.0.0 version: 5.0.0(ws@8.18.1) @@ -281,6 +290,22 @@ importers: specifier: ~5.8.0 version: 5.8.2 + packages/router: + dependencies: + '@welshman/lib': + specifier: workspace:* + version: link:../lib + '@welshman/util': + specifier: workspace:* + version: link:../util + devDependencies: + rimraf: + specifier: ~6.0.0 + version: 6.0.1 + typescript: + specifier: ~5.8.0 + version: 5.8.2 + packages/signer: dependencies: '@noble/curves':