Compare commits

...

6 Commits

Author SHA1 Message Date
Jon Staab 87d8a0832d remove net global state 2026-06-16 12:31:46 -07:00
Jon Staab 34065a18cf Remove router singleton 2026-06-16 11:08:46 -07:00
Jon Staab 96b0116c9b Auto register client plugins 2026-06-16 10:32:59 -07:00
Jon Staab ea9cc0bf26 AI refactor 2026-06-16 09:22:26 -07:00
Jon Staab 28339976b9 Add more stuff to client 2026-06-15 18:59:29 -07:00
Jon Staab e0e9ad5834 Add client package 2026-06-15 18:59:28 -07:00
50 changed files with 3291 additions and 99 deletions
+1
View File
@@ -9,3 +9,4 @@ results
*.tsbuildinfo *.tsbuildinfo
.vscode .vscode
docs/**/*.html docs/**/*.html
.local
+3
View File
@@ -0,0 +1,3 @@
build
normalize-url
__tests__
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@welshman/client",
"version": "0.8.13",
"author": "hodlbod",
"license": "MIT",
"description": "An instance-based, composable client for building nostr applications",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "dist/client/src/index.js",
"types": "dist/client/src/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": {
"fuse.js": "^7.0.0",
"throttle-debounce": "^5.0.2"
},
"peerDependencies": {
"@pomade/core": "^0.2.1",
"@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*",
"@welshman/router": "workspace:*",
"@welshman/signer": "workspace:*",
"@welshman/store": "workspace:*",
"@welshman/util": "workspace:*",
"svelte": "^4.0.0 || ^5.0.0"
},
"devDependencies": {
"rimraf": "~6.0.0",
"typescript": "~5.8.0",
"@pomade/core": "^0.2.1",
"@types/throttle-debounce": "^5.0.2",
"@welshman/feeds": "workspace:*",
"@welshman/lib": "workspace:*",
"@welshman/net": "workspace:*",
"@welshman/router": "workspace:*",
"@welshman/signer": "workspace:*",
"@welshman/store": "workspace:*",
"@welshman/util": "workspace:*",
"svelte": "^5.39.12"
}
}
+26
View File
@@ -0,0 +1,26 @@
import {BLOCKED_RELAYS, asDecryptedEvent, readList, getRelaysFromList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Kind-10006 blocked-relay lists, keyed by pubkey. Loaded via the outbox model,
* so it depends on the relay-list collection. Feeds `RelayStats.getQuality` so
* blocked relays are never selected.
*/
export class BlockedRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [BLOCKED_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: list => list.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(BLOCKED_RELAYS)(pubkey, relayHints)
}
getBlockedRelays = (pubkey: string) => getRelaysFromList(this.get(pubkey))
}
+23
View File
@@ -0,0 +1,23 @@
import {BLOSSOM_SERVERS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Blossom server lists (kind 10063), keyed by pubkey. Loaded via the outbox
* model (the author's write relays), so it depends on the relay-list collection.
*/
export class BlossomServerLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [BLOSSOM_SERVERS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: list => list.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(BLOSSOM_SERVERS)(pubkey, relayHints)
}
}
+106
View File
@@ -0,0 +1,106 @@
import type {Unsubscriber} from "svelte/store"
import {call} from "@welshman/lib"
import {Pool, Socket, Tracker, Repository, WrapManager, defaultSocketPolicies} from "@welshman/net"
import type {NetContext, AdapterFactory, SocketPolicy} from "@welshman/net"
import type {User} from "./user.js"
import type {ClientPolicy} from "./policies.js"
export type ClientConfig = {
dufflepudUrl?: string
getDefaultRelays?: () => string[]
getIndexerRelays?: () => string[]
getSearchRelays?: () => string[]
}
export type ClientOptions = {
user?: User
config?: ClientConfig
getAdapter?: AdapterFactory
socketPolicies?: SocketPolicy[]
policies?: ClientPolicy[]
}
export interface IClient {
user?: User
config: ClientConfig
use: <T>(Ctor: new (ctx: IClient) => T) => T
netContext: NetContext
pool: Pool
tracker: Tracker
repository: Repository
wrapManager: WrapManager
}
/**
* The core of an application instance. Owns the primitives a single identity
* needs (so data never bleeds across sessions) — a private repository, a socket
* pool, a tracker, a wrap manager — and a `use` registry that resolves data
* modules (including net/store helpers) on demand.
*/
export class Client implements IClient {
user?: User
config: ClientConfig
netContext: NetContext
pool: Pool
tracker: Tracker
repository: Repository
wrapManager: WrapManager
// Per-client singletons of data modules, keyed by constructor. Owned by the
// client (so it's GC'd with the client — no WeakMap needed), this is what
// `use` memoizes against.
private singletons = new Map<Function, unknown>()
private policyCleanups: Unsubscriber[] = []
constructor(options: ClientOptions = {}) {
this.user = options.user
this.config = options.config ?? {}
this.pool = new Pool({
makeSocket: (url: string) => {
let socketPolicies = options.socketPolicies ?? defaultSocketPolicies
if (this.user) {
socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()]
}
return new Socket(url, socketPolicies)
},
})
this.tracker = new Tracker()
this.repository = new Repository()
this.wrapManager = new WrapManager({
tracker: this.tracker,
repository: this.repository,
})
this.netContext = {
pool: this.pool,
repository: this.repository,
getAdapter: options.getAdapter,
}
// Apply policies last, once the primitives and `use` registry exist. They
// own all side effects; their cleanups run on `cleanup()`.
this.policyCleanups = (options.policies ?? []).map(policy => policy(this))
}
// Resolve the per-client singleton of a data module, constructing it on first
// use. This is how modules reach their dependencies (e.g. ctx.use(RelayLists)),
// replacing constructor injection and letting cycles resolve lazily.
use = <T>(Ctor: new (ctx: IClient) => T): T => {
let instance = this.singletons.get(Ctor) as T | undefined
if (!instance) {
this.singletons.set(Ctor, (instance = new Ctor(this)))
}
return instance
}
cleanup() {
this.policyCleanups.forEach(call)
this.pool.clear()
this.tracker.clear()
this.repository.clear()
this.wrapManager.clear()
}
}
+107
View File
@@ -0,0 +1,107 @@
import {writable} from "svelte/store"
import type {Readable, Unsubscriber} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import {getter, makeDeriveItem, makeLoadItem, makeForceLoadItem} from "@welshman/store"
import type {MakeLoadItemOptions} from "@welshman/store"
import type {IClient} from "./client.js"
/**
* Base class for a reactive, keyed collection of "local" (non-event) data —
* things like relay stats or NIP-11 profiles that aren't backed by the
* repository. The collection owns its own map and is its own Svelte store: its
* `subscribe` emits the underlying `Map`.
*
* Subclasses reach the client through the `IClient` seam, never the
* concrete `Client`, so they never create a dependency cycle.
*/
export class ClientData<T> {
protected index = writable(new Map<string, T>())
protected getIndex = getter(this.index)
protected itemSubscribers: ((key: string, value: Maybe<T>) => void)[] = []
public derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
constructor(protected readonly ctx: IClient) {
this.derive = makeDeriveItem(this.index)
}
subscribe = this.index.subscribe
get = (key: string): Maybe<T> => this.getIndex().get(key)
getAll = (): T[] => Array.from(this.getIndex().values())
keys = () => this.getIndex().keys()
values = () => this.getIndex().values()
set = (key: string, value: T) => {
this.index.update($items => {
$items.set(key, value)
return $items
})
this.emitItem(key, value)
}
delete = (key: string) => {
this.index.update($items => {
$items.delete(key)
return $items
})
this.emitItem(key, undefined)
}
clear = () => {
const keys = Array.from(this.getIndex().keys())
this.index.set(new Map())
for (const key of keys) {
this.emitItem(key, undefined)
}
}
onItem = (subscriber: (key: string, value: Maybe<T>) => void): Unsubscriber => {
this.itemSubscribers.push(subscriber)
return () => {
const i = this.itemSubscribers.indexOf(subscriber)
if (i !== -1) this.itemSubscribers.splice(i, 1)
}
}
protected emitItem = (key: string, value: Maybe<T>) => {
for (const subscriber of this.itemSubscribers) {
subscriber(key, value)
}
}
}
/**
* A `ClientData` collection that knows how to lazily load items by key from the
* network. Subclasses implement `fetch`; `load`/`forceLoad`/`derive` are derived
* from it (with per-key caching and backoff via `makeLoadItem`).
*/
export abstract class LoadableData<T> extends ClientData<T> {
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(ctx: IClient, options: MakeLoadItemOptions = {}) {
super(ctx)
// Subclasses implement `fetch` as an arrow field, whose initializer runs
// *after* super() — so `this.fetch` is undefined here. makeLoadItem captures
// its loadItem eagerly, so we defer the lookup to call time via this wrapper.
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
this.load = makeLoadItem(fetch, this.get, options)
this.forceLoad = makeForceLoadItem(fetch, this.get)
this.derive = makeDeriveItem(this.index, this.load)
}
}
+382
View File
@@ -0,0 +1,382 @@
import {uniq, reject, nth, now, nthNe, removeUndefined, nthEq} from "@welshman/lib"
import {
sendManagementRequest,
addToListPublicly,
addToListPrivately,
updateList,
removeFromList,
makeHttpAuth,
getListTags,
getRelayTags,
getRelayTagValues,
getRelaysFromList,
makeList,
makeRoomCreateEvent,
makeRoomDeleteEvent,
makeRoomEditEvent,
makeRoomJoinEvent,
makeRoomLeaveEvent,
makeRoomAddMemberEvent,
makeRoomRemoveMemberEvent,
isPublishedProfile,
createProfile,
editProfile,
RelayMode,
makeEvent,
MESSAGING_RELAYS,
BLOCKED_RELAYS,
SEARCH_RELAYS,
FOLLOWS,
RELAYS,
MUTES,
PINS,
prep,
} from "@welshman/util"
import type {ManagementRequest, EventTemplate, RoomMeta, Profile} from "@welshman/util"
import {addMaximalFallbacks, Router} from "./router.js"
import {MergedThunk, publishThunk} from "./thunk.js"
import type {ThunkOptions} from "./thunk.js"
import type {IClient} from "./client.js"
import type {User} from "./user.js"
import {RelayLists} from "./relayLists.js"
import {MessagingRelayLists} from "./messagingRelayLists.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
import {SearchRelayLists} from "./searchRelayLists.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
import {PinLists} from "./pins.js"
export type SendWrappedOptions = Omit<
ThunkOptions,
"event" | "relays" | "recipient" | "client" | "user"
> & {
event: EventTemplate
recipients: string[]
}
/**
* The high-level "do an action" API: each method builds an event for the
* client's user and publishes it via a thunk. Siblings (the user's lists, the
* router) are resolved lazily through `ctx.use`; the acting user is `ctx.user`.
*/
export class Commands {
constructor(readonly ctx: IClient) {}
private get user(): User {
if (!this.ctx.user) {
throw new Error("Commands require a signed-in user")
}
return this.ctx.user
}
private get router() {
return this.ctx.use(Router)
}
private get relayLists() {
return this.ctx.use(RelayLists)
}
private get messagingRelayLists() {
return this.ctx.use(MessagingRelayLists)
}
private get blockedRelayLists() {
return this.ctx.use(BlockedRelayLists)
}
private get searchRelayLists() {
return this.ctx.use(SearchRelayLists)
}
private get followLists() {
return this.ctx.use(FollowLists)
}
private get muteLists() {
return this.ctx.use(MuteLists)
}
private get pinLists() {
return this.ctx.use(PinLists)
}
private publish = (options: Omit<ThunkOptions, "client" | "user">) =>
publishThunk({...options, client: this.ctx, user: this.user})
private fromUser = () => this.router.FromUser().policy(addMaximalFallbacks).getUrls()
private encryptToSelf = (payload: string) => this.user.nip44EncryptToSelf(payload)
// NIP 65
removeRelay = async (url: string, mode: RelayMode) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const alt = mode === RelayMode.Read ? RelayMode.Write : RelayMode.Read
const tags = list.publicTags.filter(nthNe(1, url))
// If we had a duplicate that was used as the alt mode, keep the alt
if (dup && (!dup[2] || dup[2] === alt)) {
tags.push(["r", url, alt])
}
const event = {kind: list.kind, content: list.event?.content || "", tags}
const relays = this.fromUser()
// Make sure to notify the old relay too
relays.push(url)
return this.publish({event, relays})
}
addRelay = async (url: string, mode: RelayMode) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const dup = getRelayTags(getListTags(list)).find(nthEq(1, url))
const tag = removeUndefined(["r", url, dup && dup[2] !== mode ? undefined : mode])
const tags = [...list.publicTags.filter(nthNe(1, url)), tag]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
setRelays = async (tags: string[][]) => {
const event = makeEvent(RELAYS, {tags})
const relays = this.router
.merge([this.router.Index(), this.router.FromRelays(getRelayTagValues(tags))])
.getUrls()
return this.publish({event, relays})
}
setReadRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const writeRelays = reject(nthEq(2, RelayMode.Read), getRelayTags(getListTags(list))).map(
nth(1),
)
const writeTags = writeRelays.map(url => ["r", url, RelayMode.Write])
const readTags = urls.map(url => ["r", url, RelayMode.Read])
const tags = [...writeTags, ...readTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
setWriteRelays = async (urls: string[]) => {
const list = (await this.relayLists.forceLoad(this.user.pubkey, [])) || makeList({kind: RELAYS})
const readRelays = reject(nthEq(2, RelayMode.Write), getRelayTags(getListTags(list))).map(
nth(1),
)
const readTags = readRelays.map(url => ["r", url, RelayMode.Read])
const writeTags = urls.map(url => ["r", url, RelayMode.Write])
const tags = [...readTags, ...writeTags]
const event = {kind: list.kind, content: list.event?.content || "", tags}
return this.publish({event, relays: this.fromUser()})
}
// NIP 17
removeMessagingRelay = async (url: string) => {
const list =
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: MESSAGING_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addMessagingRelay = async (url: string) => {
const list =
(await this.messagingRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: MESSAGING_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setMessagingRelays = async (urls: string[]) => {
const event = makeEvent(MESSAGING_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// Blocked Relays
removeBlockedRelay = async (url: string) => {
const list =
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: BLOCKED_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addBlockedRelay = async (url: string) => {
const list =
(await this.blockedRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: BLOCKED_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setBlockedRelays = async (urls: string[]) => {
const event = makeEvent(BLOCKED_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// Search Relays
removeSearchRelay = async (url: string) => {
const list =
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: SEARCH_RELAYS})
const event = await removeFromList(list, url).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
addSearchRelay = async (url: string) => {
const list =
(await this.searchRelayLists.forceLoad(this.user.pubkey, [])) ||
makeList({kind: SEARCH_RELAYS})
const event = await addToListPublicly(list, ["relay", url]).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setSearchRelays = async (urls: string[]) => {
const event = makeEvent(SEARCH_RELAYS, {tags: urls.map(url => ["relay", url])})
return this.publish({event, relays: this.router.FromUser().getUrls()})
}
// NIP 01
setProfile = (profile: Profile) => {
const relays = this.router.merge([this.router.Index(), this.router.FromUser()]).getUrls()
const event = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
return this.publish({event, relays})
}
// NIP 02
unfollow = async (value: string) => {
const list =
(await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
follow = async (tag: string[]) => {
const list =
(await this.followLists.forceLoad(this.user.pubkey, [])) || makeList({kind: FOLLOWS})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
unmute = async (value: string) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
mutePublicly = async (tag: string[]) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
mutePrivately = async (tag: string[]) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await addToListPrivately(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
setMutes = async ({
publicTags,
privateTags,
}: {
publicTags?: string[][]
privateTags?: string[][]
}) => {
const list = (await this.muteLists.forceLoad(this.user.pubkey, [])) || makeList({kind: MUTES})
const event = await updateList(list, {publicTags, privateTags}).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
unpin = async (value: string) => {
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
const event = await removeFromList(list, value).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
pin = async (tag: string[]) => {
const list = (await this.pinLists.forceLoad(this.user.pubkey, [])) || makeList({kind: PINS})
const event = await addToListPublicly(list, tag).reconcile(this.encryptToSelf)
return this.publish({event, relays: this.fromUser()})
}
// NIP 59
sendWrapped = async ({event, recipients, ...options}: SendWrappedOptions) => {
// Stabilize the event id across the different wraps
const stableEvent = prep(event, this.user.pubkey, now())
return new MergedThunk(
await Promise.all(
uniq(recipients).map(async recipient => {
const relays = getRelaysFromList(await this.messagingRelayLists.load(recipient))
return this.publish({event: stableEvent, relays, recipient, ...options})
}),
),
)
}
// NIP 86
manageRelay = async (url: string, request: ManagementRequest) => {
url = url.replace(/^ws/, "http")
const authTemplate = await makeHttpAuth(url, "POST", JSON.stringify(request))
const authEvent = await this.user.sign(authTemplate)
return sendManagementRequest(url, request, authEvent)
}
// NIP 29
createRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomCreateEvent(room), relays: [url]})
deleteRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomDeleteEvent(room), relays: [url]})
editRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomEditEvent(room), relays: [url]})
joinRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomJoinEvent(room), relays: [url]})
leaveRoom = (url: string, room: RoomMeta) =>
this.publish({event: makeRoomLeaveEvent(room), relays: [url]})
addRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish({event: makeRoomAddMemberEvent(room, pubkey), relays: [url]})
removeRoomMember = (url: string, room: RoomMeta, pubkey: string) =>
this.publish({event: makeRoomRemoveMemberEvent(room, pubkey), relays: [url]})
}
+14
View File
@@ -0,0 +1,14 @@
import {Client} from "./client.js"
import type {ClientOptions} from "./client.js"
import {defaultClientPolicies} from "./policies.js"
/**
* Creates a batteries-included client: a `Client` wired with the default client
* policies (event ingestion, relay-stats collection, gift-wrap unwrapping).
* Reach data modules via `client.use(Profiles)`, `client.use(Commands)`, etc.
*
* For a bare client (no default side effects) construct `new Client(...)`
* directly, or pass your own `policies`.
*/
export const createApp = (options: ClientOptions = {}) =>
new Client({...options, policies: options.policies ?? defaultClientPolicies})
+69
View File
@@ -0,0 +1,69 @@
import {Scope, FeedController} from "@welshman/feeds"
import type {FeedControllerOptions, Feed} from "@welshman/feeds"
import type {AdapterContext} from "@welshman/net"
import type {IClient} from "./client.js"
import {Router} from "./router.js"
import {Wot} from "./wot.js"
export type MakeFeedControllerOptions = Partial<Omit<FeedControllerOptions, "feed">> & {feed: Feed}
/**
* Builds `FeedController`s wired to this client. Scope/WOT pubkey resolution is
* delegated to `Wot`, and feeds fetch through THIS client's net context (pool +
* repository) rather than the global one.
*/
export class Feeds {
constructor(readonly ctx: IClient) {}
getPubkeysForScope = (scope: Scope): string[] => {
const $pubkey = this.ctx.user?.pubkey
if (!$pubkey) {
return []
}
switch (scope) {
case Scope.Self:
return [$pubkey]
case Scope.Follows:
return this.ctx.use(Wot).getFollows($pubkey)
case Scope.Network:
return this.ctx.use(Wot).getNetwork($pubkey)
case Scope.Followers:
return this.ctx.use(Wot).getFollowers($pubkey)
default:
return []
}
}
getPubkeysForWOTRange = (min: number, max: number): string[] => {
const pubkeys = []
const $maxWot = this.ctx.use(Wot).getMaxWot() ?? 0
const thresholdMin = $maxWot * min
const thresholdMax = $maxWot * max
for (const [tpk, score] of this.ctx.use(Wot).getWotGraph().entries()) {
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(tpk)
}
}
return pubkeys
}
// The net seam: route feed requests through this client's pool/repository so
// feeds fetch through THIS client rather than the global net context.
get netContext(): AdapterContext {
return {pool: this.ctx.pool, repository: this.ctx.repository}
}
makeFeedController = (options: MakeFeedControllerOptions) =>
new FeedController({
router: this.ctx.use(Router),
getPubkeysForScope: this.getPubkeysForScope,
getPubkeysForWOTRange: this.getPubkeysForWOTRange,
signer: this.ctx.user?.signer,
context: this.netContext,
...options,
})
}
+23
View File
@@ -0,0 +1,23 @@
import {FOLLOWS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Kind-3 follow lists, keyed by pubkey. Loaded via the outbox model (the
* author's write relays), so it depends on the relay-list collection.
*/
export class FollowLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [FOLLOWS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: followList => followList.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(FOLLOWS)(pubkey, relayHints)
}
}
+49
View File
@@ -0,0 +1,49 @@
import {get, writable} from "svelte/store"
import {TaskQueue} from "@welshman/lib"
import {getPubkeyTagValues} from "@welshman/util"
import type {TrustedEvent, SignedEvent} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import type {IClient} from "./client.js"
/**
* Per-client gift-wrap (NIP-59) state: the unwrap queue plus failure/dedup
* tracking. Scoped to `ctx.user`, so a client only ever unwraps its own user's
* messages into its own repository — which is what keeps DM history from being
* merged across identities. The repository subscription that feeds it lives in
* `clientPolicyGiftWraps`.
*/
export class GiftWraps {
failedUnwraps = new Set<string>()
queue: TaskQueue<TrustedEvent>
constructor(readonly ctx: IClient) {
this.queue = new TaskQueue<TrustedEvent>({
batchSize: 50,
batchDelay: 30,
processItem: async (wrap: TrustedEvent) => {
const signer = this.ctx.user?.signer
const recipient = this.ctx.user?.pubkey
// Only unwrap messages addressed to our user
if (!signer || !recipient || !getPubkeyTagValues(wrap.tags).includes(recipient)) {
return
}
try {
const rumor = await Nip59.fromSigner(signer).unwrap(wrap as SignedEvent)
this.ctx.wrapManager.add({wrap: wrap as SignedEvent, rumor, recipient})
} catch (e) {
this.failedUnwraps.add(wrap.id)
}
},
})
}
enqueue = (wrap: TrustedEvent) => {
if (this.failedUnwraps.has(wrap.id)) return
if (this.ctx.wrapManager.getRumor(wrap.id)) return
this.queue.push(wrap)
}
}
+122
View File
@@ -0,0 +1,122 @@
import {tryCatch, fetchJson, batcher, postJson, last} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {deriveDeduplicated} from "@welshman/store"
import {LoadableData} from "./clientData.js"
import type {IClient} from "./client.js"
import {Profiles} from "./profiles.js"
export type Handle = {
nip05: string
pubkey?: string
nip46?: string[]
relays?: string[]
}
export async function queryProfile(nip05: string): Promise<Maybe<Handle>> {
const parts = nip05.split("@")
const name = parts.length > 1 ? parts[0] : "_"
const domain = last(parts)
try {
const {
names,
relays = {},
nip46 = {},
} = await fetchJson(`https://${domain}/.well-known/nostr.json?name=${name}`)
const pubkey = names[name]
if (!pubkey) {
return undefined
}
return {
nip05,
pubkey,
nip46: nip46[pubkey],
relays: relays[pubkey],
}
} catch (_e) {
return undefined
}
}
export const displayNip05 = (nip05: string) =>
nip05?.startsWith("_@") ? last(nip05.split("@")) : nip05
export const displayHandle = (handle: Handle) => displayNip05(handle.nip05)
/**
* NIP-05 handles, keyed by nip05 identifier. A "local" loadable collection:
* items aren't nostr events, they're fetched over HTTP (either directly from
* each domain's `.well-known/nostr.json`, or via a dufflepud proxy to protect
* user privacy). Depends on the profiles collection to resolve a pubkey's
* handle.
*/
export class Handles extends LoadableData<Handle> {
constructor(ctx: IClient) {
super(ctx)
}
fetch = batcher(800, async (nip05s: string[]) => {
const result = new Map<string, Handle>()
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
if (this.ctx.config.dufflepudUrl) {
const res: any = await tryCatch(
async () =>
await postJson(`${this.ctx.config.dufflepudUrl}/handle/info`, {handles: nip05s}),
)
for (const {handle: nip05, info} of res?.data || []) {
if (info) {
result.set(nip05, {...info, nip05})
}
}
} else {
const results = await Promise.all(
nip05s.map(async nip05 => ({
nip05,
info: await tryCatch(async () => await queryProfile(nip05)),
})),
)
for (const {nip05, info} of results) {
if (info) {
result.set(nip05, {...info, nip05})
}
}
}
for (const [nip05, info] of result) {
this.set(nip05, info)
}
return nip05s.map(nip05 => result.get(nip05))
})
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await this.ctx.use(Profiles).load(pubkey, relays)
return $profile?.nip05 ? this.load($profile.nip05) : undefined
}
deriveForPubkey = (pubkey: string, relays: string[] = []) => {
this.loadForPubkey(pubkey, relays)
return deriveDeduplicated(
[this.index, this.ctx.use(Profiles).derive(pubkey, relays)],
([$handlesByNip05, $profile]) => {
if (!$profile?.nip05) return undefined
const handle = $handlesByNip05.get($profile.nip05)
if (handle?.pubkey !== pubkey) return undefined
return handle
},
)
}
display = (nip05: string) => displayNip05(nip05)
}
+33
View File
@@ -0,0 +1,33 @@
export * from "./client.js"
export * from "./policies.js"
export * from "./networking.js"
export * from "./stores.js"
export * from "./clientData.js"
export * from "./repositoryCollection.js"
export * from "./user.js"
export * from "./router.js"
export * from "./relays.js"
export * from "./relayStats.js"
export * from "./relayLists.js"
export * from "./blockedRelayLists.js"
export * from "./plaintext.js"
export * from "./profiles.js"
export * from "./follows.js"
export * from "./mutes.js"
export * from "./pins.js"
export * from "./blossom.js"
export * from "./messagingRelayLists.js"
export * from "./searchRelayLists.js"
export * from "./handles.js"
export * from "./zappers.js"
export * from "./topics.js"
export * from "./tags.js"
export * from "./session.js"
export * from "./wot.js"
export * from "./feeds.js"
export * from "./search.js"
export * from "./sync.js"
export * from "./giftWraps.js"
export * from "./commands.js"
export * from "./thunk.js"
export * from "./createApp.js"
@@ -0,0 +1,24 @@
import {MESSAGING_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Kind-10050 messaging relay lists (NIP-17), keyed by pubkey. Loaded via the
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class MessagingRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [MESSAGING_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: list => list.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(MESSAGING_RELAYS)(pubkey, relayHints)
}
}
+38
View File
@@ -0,0 +1,38 @@
import {MUTES, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import type {IClient} from "./client.js"
import {RelayLists} from "./relayLists.js"
import {Plaintext} from "./plaintext.js"
/**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
* encrypted content, so decoding goes through the plaintext cache.
*/
export class MuteLists extends RepositoryCollection<PublishedList> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [MUTES]}],
eventToItem: async (event: TrustedEvent) => {
const content = await ctx.use(Plaintext).ensure(event)
// If this is our own mute list but it couldn't be decrypted yet because
// no signer is available, don't cache a result with empty private tags —
// that would get stuck permanently since the repository view won't
// re-process an already-seen event id. Returning undefined leaves it
// uncached so it's retried once a signer is available. For other
// pubkeys' lists we fall through and read just the public tags.
if (event.content && content === undefined && event.pubkey === ctx.user?.pubkey) {
return undefined
}
return readList(asDecryptedEvent(event, {content}))
},
getKey: mute => mute.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(MUTES)(pubkey, relayHints)
}
}
+38
View File
@@ -0,0 +1,38 @@
import {request, publish, diff, pull, push, makeLoader} from "@welshman/net"
import type {
Loader,
LoaderOptions,
RequestOptions,
PublishOptions,
DiffOptions,
PullOptions,
PushOptions,
} from "@welshman/net"
import type {IClient} from "./client.js"
/**
* Net utilities bound to the client's net context (its pool + repository). Reach
* it via `client.use(Networking)`; `load` is a shared, batched loader.
*/
export class Networking {
load: Loader
constructor(readonly ctx: IClient) {
this.load = this.makeLoader({delay: 200, timeout: 3000, threshold: 0.5})
}
request = (options: Omit<RequestOptions, "context">) =>
request({...options, context: this.ctx.netContext})
publish = (options: Omit<PublishOptions, "context">) =>
publish({...options, context: this.ctx.netContext})
diff = (options: Omit<DiffOptions, "context">) => diff({...options, context: this.ctx.netContext})
pull = (options: Omit<PullOptions, "context">) => pull({...options, context: this.ctx.netContext})
push = (options: Omit<PushOptions, "context">) => push({...options, context: this.ctx.netContext})
makeLoader = (options: Omit<LoaderOptions, "context">): Loader =>
makeLoader({...options, context: this.ctx.netContext})
}
+23
View File
@@ -0,0 +1,23 @@
import {PINS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* NIP-51 pin lists (kind 10001), keyed by pubkey. Loaded via the outbox model
* (the author's write relays), so it depends on the relay-list collection.
*/
export class PinLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [PINS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: pins => pins.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(PINS)(pubkey, relayHints)
}
}
+42
View File
@@ -0,0 +1,42 @@
import {decrypt} from "@welshman/signer"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {ClientData} from "./clientData.js"
/**
* A cache of decrypted event content, keyed by event id.
*
* In the old global model decryption used `getSigner(getSession(event.pubkey))`
* — whichever logged-in account authored the event. In the per-client model
* there is exactly one identity, so this reduces to "is this our user?". That
* scoping is also what keeps decrypted content (including DM rumors) from
* bleeding across identities — each client decrypts only its own.
*/
export class Plaintext extends ClientData<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
// Check for key presence rather than truthiness so a legitimately empty
// decrypted result ("") is treated as cached and we don't re-hit the signer
// on every call.
if (event.content && this.get(event.id) === undefined) {
const signer = event.pubkey === this.ctx.user?.pubkey ? this.ctx.user?.signer : undefined
if (!signer) return
let result
try {
result = await decrypt(signer, event.pubkey, event.content)
} catch (e: any) {
if (!String(e).match(/invalid base64/)) {
throw e
}
}
if (result !== undefined) {
this.set(event.id, result)
}
}
return this.get(event.id)
}
}
+87
View File
@@ -0,0 +1,87 @@
import type {Unsubscriber} from "svelte/store"
import {on} from "@welshman/lib"
import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {SocketEvent, isRelayEvent} from "@welshman/net"
import type {RelayMessage} from "@welshman/net"
import type {IClient} from "./client.js"
import {RelayStats} from "./relayStats.js"
import {GiftWraps} from "./giftWraps.js"
/**
* A client policy is a side effect applied once per client at construction,
* returning a cleanup function — directly analogous to a socket policy. Policies
* own everything that subscribes or links components together (event ingestion,
* stats collection, gift-wrap unwrapping), so the data classes themselves stay
* pure and free of subscriptions, and teardown is centralized in `cleanup()`.
*/
export type ClientPolicy = (client: IClient) => Unsubscriber
/**
* Ingests every event received on any socket into the client's repository. The
* net layer doesn't do this for us, and it's how all the repository-backed
* collections (and gift-wrap unwrapping) get populated.
*/
export const clientPolicyIngest: ClientPolicy = client =>
client.pool.subscribe(socket => {
const onReceive = (message: RelayMessage) => {
if (!isRelayEvent(message)) return
const event = message[2]
if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) return
if (!verifyEvent(event)) return
client.tracker.track(event.id, socket.url)
client.repository.publish(event)
}
socket.on(SocketEvent.Receive, onReceive)
return () => socket.off(SocketEvent.Receive, onReceive)
})
/**
* Wires socket activity on the client's pool into the RelayStats store.
*/
export const clientPolicyRelayStats: ClientPolicy = client => {
const stats = client.use(RelayStats)
return client.pool.subscribe(socket => {
socket.on(SocketEvent.Send, stats.onSocketSend)
socket.on(SocketEvent.Receive, stats.onSocketReceive)
socket.on(SocketEvent.Status, stats.onSocketStatus)
return () => {
socket.off(SocketEvent.Send, stats.onSocketSend)
socket.off(SocketEvent.Receive, stats.onSocketReceive)
socket.off(SocketEvent.Status, stats.onSocketStatus)
}
})
}
/**
* Watches the client's repository for gift wraps (existing and incoming) and
* feeds them to the unwrap queue.
*/
export const clientPolicyGiftWraps: ClientPolicy = client => {
const giftWraps = client.use(GiftWraps)
for (const wrap of client.repository.query([{kinds: [WRAP]}])) {
giftWraps.enqueue(wrap)
}
return on(client.repository, "update", ({added}: {added: TrustedEvent[]}) => {
for (const event of added) {
if (event.kind === WRAP) {
giftWraps.enqueue(event)
}
}
})
}
export const defaultClientPolicies: ClientPolicy[] = [
clientPolicyIngest,
clientPolicyRelayStats,
clientPolicyGiftWraps,
]
+33
View File
@@ -0,0 +1,33 @@
import {derived, readable} from "svelte/store"
import {readProfile, displayProfile, displayPubkey, PROFILE} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* Kind-0 profiles, keyed by pubkey. Loaded via the outbox model (the author's
* write relays), resolved through the relay-list collection at fetch time.
*/
export class Profiles extends RepositoryCollection<ReturnType<typeof readProfile>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [PROFILE]}],
eventToItem: readProfile,
getKey: profile => profile.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(PROFILE)(pubkey, relayHints)
}
display = (pubkey: string | undefined) =>
pubkey ? displayProfile(this.get(pubkey), displayPubkey(pubkey)) : ""
deriveDisplay = (pubkey: string | undefined, ...args: any[]) =>
pubkey
? derived(this.derive(pubkey, ...args), $profile =>
displayProfile($profile, displayPubkey(pubkey)),
)
: readable("")
}
+82
View File
@@ -0,0 +1,82 @@
import {chunk, first} from "@welshman/lib"
import {
RELAYS,
RelayMode,
asDecryptedEvent,
readList,
getRelaysFromList,
isPlainReplaceableKind,
sortEventsDesc,
} from "@welshman/util"
import type {Filter, TrustedEvent, PublishedList} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {Router, addMinimalFallbacks} from "./router.js"
import {Networking} from "./networking.js"
import type {IClient} from "./client.js"
/**
* NIP-65 relay lists, keyed by pubkey. This is the routing substrate every other
* outbox-model load depends on, so it also exposes `loadUsingOutbox` /
* `makeOutboxLoader` for other collections to build their fetchers on. It and the
* Router reference each other lazily via `ctx.use`, so the cycle never bites.
*/
export class RelayLists extends RepositoryCollection<PublishedList> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: (list: PublishedList) => list.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
const filters = [{kinds: [RELAYS], authors: [pubkey], limit: 1}]
const router = this.ctx.use(Router)
return Promise.all([
this.ctx.use(Networking).load({filters, relays: router.FromRelays(relayHints).getUrls()}),
this.ctx.use(Networking).load({filters, relays: router.FromPubkey(pubkey).getUrls()}),
this.ctx.use(Networking).load({filters, relays: router.Index().getUrls()}),
])
}
getRelaysForPubkey = (pubkey: string, mode?: RelayMode) =>
getRelaysFromList(this.get(pubkey), mode)
// Load a pubkey's events using their advertised write relays (outbox model)
loadUsingOutbox = async (kind: number, pubkey: string, filter: Filter = {}) => {
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
const writeRelays = getRelaysFromList(await this.load(pubkey), RelayMode.Write)
const allRelays = this.ctx
.use(Router)
.FromRelays(writeRelays)
.policy(addMinimalFallbacks)
.limit(8)
.getUrls()
if (isPlainReplaceableKind(kind)) {
filters[0].limit = 1
}
for (const relays of chunk(2, allRelays)) {
const events = await this.ctx.use(Networking).load({filters, relays})
if (events.length > 0) {
return first(sortEventsDesc(events))
}
}
}
makeOutboxLoader =
(kind: number, filter: Filter = {}) =>
async (pubkey: string, relayHints: string[] = []) => {
const filters: Filter[] = [{...filter, kinds: [kind], authors: [pubkey]}]
const relays = this.ctx.use(Router).FromRelays(relayHints).getUrls()
await Promise.all([
this.ctx.use(Networking).load({filters, relays}),
this.loadUsingOutbox(kind, pubkey, filter),
])
}
}
+195
View File
@@ -0,0 +1,195 @@
import {groupBy, batch, now, uniq, ago, DAY, HOUR, MINUTE} from "@welshman/lib"
import {isOnionUrl, isLocalUrl, isIPAddress, isRelayUrl} from "@welshman/util"
import {SocketStatus} from "@welshman/net"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import {ClientData} from "./clientData.js"
import {BlockedRelayLists} from "./blockedRelayLists.js"
export type RelayStatsUpdate = [string, (stats: RelayStatsItem) => void]
export type RelayStatsItem = {
url: string
first_seen: number
recent_errors: number[]
open_count: number
close_count: number
publish_count: number
request_count: number
event_count: number
last_open: number
last_close: number
last_error: number
last_publish: number
last_request: number
last_event: number
last_auth: number
publish_success_count: number
publish_failure_count: number
eose_count: number
notice_count: number
}
export const makeRelayStatsItem = (url: string): RelayStatsItem => ({
url,
first_seen: now(),
recent_errors: [],
open_count: 0,
close_count: 0,
publish_count: 0,
request_count: 0,
event_count: 0,
last_open: 0,
last_close: 0,
last_error: 0,
last_publish: 0,
last_request: 0,
last_event: 0,
last_auth: 0,
publish_success_count: 0,
publish_failure_count: 0,
eose_count: 0,
notice_count: 0,
})
/**
* Per-relay connection statistics, keyed by url, plus the `getQuality` heuristic
* the router uses to rank relays. A pure store — the socket wiring that fills it
* lives in `clientPolicyRelayStats`.
*/
export class RelayStats extends ClientData<RelayStatsItem> {
getQuality = (url: string) => {
// Skip non-relays entirely
if (!isRelayUrl(url)) return 0
// Skip relays the user has blocked
const pubkey = this.ctx.user?.pubkey
if (pubkey && this.ctx.use(BlockedRelayLists).getBlockedRelays(pubkey).includes(url)) {
return 0
}
const stats = this.get(url)
// If we have recent errors, skip it
if (stats) {
if (stats.recent_errors.filter(n => n > ago(MINUTE)).length > 0) return 0
if (stats.recent_errors.filter(n => n > ago(HOUR)).length > 3) return 0
if (stats.recent_errors.filter(n => n > ago(DAY)).length > 10) return 0
}
// Prefer stuff we're connected to
if (this.ctx.pool.has(url)) return 1
// Prefer stuff we've connected to in the past
if (stats) return 0.9
// If it's not a 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
}
private update = batch(150, (batched: RelayStatsUpdate[]) => {
for (const [url, updates] of groupBy(([url]) => url, batched)) {
if (!url || !isRelayUrl(url)) {
console.warn(`Attempted to update stats for an invalid relay url: ${url}`)
continue
}
const prev = this.get(url)
const next = prev ? {...prev} : makeRelayStatsItem(url)
for (const [, update] of updates) {
update(next)
}
this.set(url, next)
}
})
onSocketSend = ([verb]: ClientMessage, url: string) => {
if (verb === "REQ") {
this.update([
url,
stats => {
stats.request_count++
stats.last_request = now()
},
])
} else if (verb === "EVENT") {
this.update([
url,
stats => {
stats.publish_count++
stats.last_publish = now()
},
])
}
}
onSocketReceive = ([verb, ...extra]: RelayMessage, url: string) => {
if (verb === "OK") {
const [, ok] = extra
this.update([
url,
stats => {
if (ok) {
stats.publish_success_count++
} else {
stats.publish_failure_count++
}
},
])
} else if (verb === "AUTH") {
this.update([url, stats => (stats.last_auth = now())])
} else if (verb === "EVENT") {
this.update([
url,
stats => {
stats.event_count++
stats.last_event = now()
},
])
} else if (verb === "EOSE") {
this.update([url, stats => stats.eose_count++])
} else if (verb === "NOTICE") {
this.update([url, stats => stats.notice_count++])
}
}
onSocketStatus = (status: string, url: string) => {
if (status === SocketStatus.Open) {
this.update([
url,
stats => {
stats.last_open = now()
stats.open_count++
},
])
}
if (status === SocketStatus.Closed) {
this.update([
url,
stats => {
stats.last_close = now()
stats.close_count++
},
])
}
if (status === SocketStatus.Error) {
this.update([
url,
stats => {
stats.last_error = now()
stats.recent_errors = uniq(stats.recent_errors.concat(now())).slice(-10)
},
])
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import {derived} from "svelte/store"
import {fetchJson} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {displayRelayUrl, displayRelayProfile} from "@welshman/util"
import type {RelayProfile} from "@welshman/util"
import {LoadableData} from "./clientData.js"
/**
* NIP-11 relay profiles, keyed by url. A "local" loadable collection: items
* aren't nostr events, they're fetched over HTTP from each relay.
*/
export class Relays extends LoadableData<RelayProfile> {
fetch = async (url: string): Promise<Maybe<RelayProfile>> => {
try {
const json = await fetchJson(url.replace(/^ws/, "http"), {
headers: {
Accept: "application/nostr+json",
},
})
if (json) {
const info = {...json, url} as RelayProfile
if (!Array.isArray(info.supported_nips)) {
info.supported_nips = []
}
info.supported_nips = info.supported_nips.map(String)
this.set(url, info)
return info
}
} catch (e) {
// pass
}
}
display = (url: string) => displayRelayProfile(this.get(url), displayRelayUrl(url))
deriveDisplay = (url: string) =>
derived(this.derive(url), $relay => displayRelayProfile($relay, displayRelayUrl(url)))
}
@@ -0,0 +1,89 @@
import type {Readable} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {deriveItems, getter, makeLoadItem, makeForceLoadItem, makeDeriveItem} from "@welshman/store"
import type {EventToItem, ItemsByKey, MakeLoadItemOptions} from "@welshman/store"
import type {IClient} from "./client.js"
import {Stores} from "./stores.js"
export type RepositoryCollectionOptions<T> = {
filters: Filter[]
eventToItem: EventToItem<T>
getKey: (item: T) => string
loadOptions?: MakeLoadItemOptions
}
/**
* Base class for a reactive, keyed collection of data derived from nostr events.
* The repository is the single source of truth — the collection is a live view
* over `ctx.deriveItemsByKey`, never a duplicated map. Subclasses implement
* `fetch` (how to load an item by key from the network) and pass the
* filters/decoder via `super`.
*
* Like `ClientData`, subclasses depend only on the `IClient` seam.
*/
export abstract class RepositoryCollection<T> {
byKey: Readable<ItemsByKey<T>>
all: Readable<T[]>
subscribe: Readable<ItemsByKey<T>>["subscribe"]
get: (key: string) => Maybe<T>
getAll: () => T[]
keys: () => IterableIterator<string>
values: () => IterableIterator<T>
load: (key: string, ...args: any[]) => Promise<Maybe<T>>
forceLoad: (key: string, ...args: any[]) => Promise<Maybe<T>>
// Reactive view of a single key that also triggers a load
derive: (key?: string, ...args: any[]) => Readable<Maybe<T>>
// Reactive view of a single key that does not trigger a load
derived: (key?: string, ...args: any[]) => Readable<Maybe<T>>
private getByKey: () => ItemsByKey<T>
abstract fetch(key: string, ...args: any[]): Promise<unknown>
constructor(
protected readonly ctx: IClient,
options: RepositoryCollectionOptions<T>,
) {
const fetch = (key: string, ...args: any[]) => this.fetch(key, ...args)
this.byKey = ctx.use(Stores).deriveItemsByKey<T>({
filters: options.filters,
eventToItem: options.eventToItem,
getKey: options.getKey,
})
this.all = deriveItems(this.byKey)
this.subscribe = this.byKey.subscribe
this.getByKey = getter(this.byKey)
this.getAll = getter(this.all)
this.get = (key: string) => this.getByKey().get(key)
this.keys = () => this.getByKey().keys()
this.values = () => this.getByKey().values()
this.load = makeLoadItem(fetch, this.get, options.loadOptions)
this.forceLoad = makeForceLoadItem(fetch, this.get)
this.derive = makeDeriveItem(this.byKey, this.load)
this.derived = makeDeriveItem(this.byKey)
}
// Convenience views of the current user's own item (replaces the old
// user.ts userProfile/userFollowList/etc. derived stores)
getForUser = () => {
const pubkey = this.ctx.user?.pubkey
return pubkey ? this.get(pubkey) : undefined
}
deriveForUser = (...args: any[]) => this.derive(this.ctx.user?.pubkey, ...args)
loadForUser = (...args: any[]) => {
const pubkey = this.ctx.user?.pubkey
return pubkey ? this.load(pubkey, ...args) : Promise.resolve(undefined)
}
forceLoadForUser = (...args: any[]) => {
const pubkey = this.ctx.user?.pubkey
return pubkey ? this.forceLoad(pubkey, ...args) : Promise.resolve(undefined)
}
}
+29
View File
@@ -0,0 +1,29 @@
import {Router as BaseRouter} from "@welshman/router"
import {RelayLists} from "./relayLists.js"
import {RelayStats} from "./relayStats.js"
import type {IClient} from "./client.js"
// Re-export the upstream router surface (scenarios, fallback policies,
// makeSelection, getFilterSelections, types). The local `Router` below shadows
// the upstream `Router` in this re-export.
export * from "@welshman/router"
/**
* The upstream `@welshman/router` Router, wired to this client: relay lists come
* from the `RelayLists` collection, quality from `RelayStats`, and the user
* pubkey + relay-getters from the client (via `ctx.config`). Reach it via
* `client.use(Router)`. This replaces the old forked copy — one source of truth,
* no global `routerContext`/`Router.get()`.
*/
export class Router extends BaseRouter {
constructor(ctx: IClient) {
super({
getUserPubkey: () => ctx.user?.pubkey,
getPubkeyRelays: (pubkey, mode) => ctx.use(RelayLists).getRelaysForPubkey(pubkey, mode),
getRelayQuality: url => ctx.use(RelayStats).getQuality(url),
getDefaultRelays: ctx.config.getDefaultRelays,
getIndexerRelays: ctx.config.getIndexerRelays,
getSearchRelays: ctx.config.getSearchRelays,
})
}
}
+128
View File
@@ -0,0 +1,128 @@
import Fuse from "fuse.js"
import type {IFuseOptions, FuseResult} from "fuse.js"
import {debounce} from "throttle-debounce"
import {derived} from "svelte/store"
import type {Readable} from "svelte/store"
import {dec, inc, sortBy} from "@welshman/lib"
import {PROFILE} from "@welshman/util"
import type {PublishedProfile, RelayProfile} from "@welshman/util"
import {throttled, deriveItems} from "@welshman/store"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import {Topics} from "./topics.js"
import type {Topic} from "./topics.js"
import {Relays} from "./relays.js"
import {Handles} from "./handles.js"
import {Wot} from "./wot.js"
export type SearchOptions<V, T> = {
getValue: (item: T) => V
fuseOptions?: IFuseOptions<T>
onSearch?: (term: string) => void
sortFn?: (items: FuseResult<T>) => any
}
export type Search<V, T> = {
options: T[]
getValue: (item: T) => V
getOption: (value: V) => T | undefined
searchOptions: (term: string) => T[]
searchValues: (term: string) => V[]
}
export const createSearch = <V, T>(options: T[], opts: SearchOptions<V, T>): Search<V, T> => {
const fuse = new Fuse(options, {...opts.fuseOptions, includeScore: true})
const map = new Map<V, T>(options.map(item => [opts.getValue(item), item]))
const search = (term: string) => {
opts.onSearch?.(term)
let results = term ? fuse.search(term) : options.map(item => ({item}) as FuseResult<T>)
if (opts.sortFn) {
results = sortBy(opts.sortFn, results)
}
return results.map(result => result.item)
}
return {
options,
getValue: opts.getValue,
getOption: (value: V) => map.get(value),
searchOptions: (term: string) => search(term),
searchValues: (term: string) => search(term).map(opts.getValue),
}
}
/**
* Reactive fuzzy searches over the client's profiles, topics, and relays.
* `profileSearch` blends fuse scores with web-of-trust weight (via `Wot`) and
* fires a debounced NIP-50 network search through the client's loader.
*/
export class Searches {
profileSearch: Readable<Search<string, PublishedProfile>>
topicSearch: Readable<Search<string, Topic>>
relaySearch: Readable<Search<string, RelayProfile>>
constructor(readonly ctx: IClient) {
this.profileSearch = derived(
[throttled(800, this.ctx.use(Profiles).all), throttled(800, this.ctx.use(Handles))],
([$profiles, $handlesByNip05]) => {
// Remove invalid nip05's from profiles
const options = $profiles.map(p => {
const isNip05Valid = !p.nip05 || $handlesByNip05.get(p.nip05)?.pubkey === p.event.pubkey
return isNip05Valid ? p : {...p, nip05: ""}
})
return createSearch(options, {
onSearch: this.searchProfiles,
getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => {
const wotScore = this.ctx.use(Wot).getWotGraph().get(item.event.pubkey) || 0
return dec(score) * inc(wotScore / (this.ctx.use(Wot).getMaxWot() || 1))
},
fuseOptions: {
keys: [
"nip05",
{name: "name", weight: 0.8},
{name: "display_name", weight: 0.5},
{name: "about", weight: 0.3},
],
threshold: 0.3,
shouldSort: false,
},
})
},
)
this.topicSearch = derived(this.ctx.use(Topics).all, $topics =>
createSearch($topics, {
getValue: (topic: Topic) => topic.name,
fuseOptions: {keys: ["name"]},
}),
)
this.relaySearch = derived(deriveItems(this.ctx.use(Relays)), $relays =>
createSearch($relays, {
getValue: (relay: RelayProfile) => relay.url,
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
},
}),
)
}
searchProfiles = debounce(500, (search: string) => {
if (search.length > 2) {
this.ctx.use(Networking).load({
filters: [{kinds: [PROFILE], search}],
relays: this.ctx.use(Router).Search().getUrls(),
})
}
})
}
+24
View File
@@ -0,0 +1,24 @@
import {SEARCH_RELAYS, asDecryptedEvent, readList} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {RepositoryCollection} from "./repositoryCollection.js"
import {RelayLists} from "./relayLists.js"
import type {IClient} from "./client.js"
/**
* NIP-51 search relay lists (kind 10007), keyed by pubkey. Loaded via the
* outbox model (the author's write relays), so it depends on the relay-list
* collection.
*/
export class SearchRelayLists extends RepositoryCollection<ReturnType<typeof readList>> {
constructor(ctx: IClient) {
super(ctx, {
filters: [{kinds: [SEARCH_RELAYS]}],
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
getKey: searchRelayList => searchRelayList.event.pubkey,
})
}
fetch(pubkey: string, relayHints: string[] = []) {
return this.ctx.use(RelayLists).makeOutboxLoader(SEARCH_RELAYS)(pubkey, relayHints)
}
}
+249
View File
@@ -0,0 +1,249 @@
import {Client as PomadeClient, PomadeSigner} from "@pomade/core"
import type {ClientOptions as PomadeClientOptions} from "@pomade/core"
import {writable} from "svelte/store"
import {randomId, append} from "@welshman/lib"
import {getPubkey} from "@welshman/util"
import {
WrappedSigner,
Nip46Broker,
Nip46Signer,
Nip07Signer,
Nip01Signer,
Nip55Signer,
} from "@welshman/signer"
import type {ISigner} from "@welshman/signer"
import {User} from "./user.js"
import type {UserOptions} from "./user.js"
/**
* Session descriptors and the signer construction that turns them into a
* `User`. In the old global package these fed a multi-account registry (a single
* `sessions` map + `pubkey` pointer over one shared repository — the root of the
* merged-DM bug). In the per-client model each session becomes its own `User`
* (and thus its own `Client` with its own repository), so "multi-account" is
* just "multiple clients" and lives above this module.
*/
export enum SessionMethod {
Nip01 = "nip01",
Nip07 = "nip07",
Nip46 = "nip46",
Nip55 = "nip55",
Pomade = "pomade",
Pubkey = "pubkey",
Anonymous = "anonymous",
}
export type SessionNip01 = {
method: SessionMethod.Nip01
pubkey: string
secret: string
}
export type SessionNip07 = {
method: SessionMethod.Nip07
pubkey: string
}
export type SessionNip46 = {
method: SessionMethod.Nip46
pubkey: string
secret: string
handler: {
pubkey: string
relays: string[]
}
}
export type SessionNip55 = {
method: SessionMethod.Nip55
pubkey: string
signer: string
}
export type SessionPomade = {
method: SessionMethod.Pomade
pubkey: string
clientOptions: PomadeClientOptions
email: string
}
export type SessionPubkey = {
method: SessionMethod.Pubkey
pubkey: string
}
export type SessionAnonymous = {
method: SessionMethod.Anonymous
}
export type SessionAnyMethod =
| SessionNip01
| SessionNip07
| SessionNip46
| SessionNip55
| SessionPomade
| SessionPubkey
| SessionAnonymous
export type Session = SessionAnyMethod & Record<string, any>
// Session factories
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 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 makePomadeSession = (
pubkey: string,
email: string,
clientOptions: PomadeClientOptions,
): SessionPomade => ({
method: SessionMethod.Pomade,
pubkey,
clientOptions,
email,
})
export const makePubkeySession = (pubkey: string): SessionPubkey => ({
method: SessionMethod.Pubkey,
pubkey,
})
// Type guards
export const isNip01Session = (session?: Session): session is SessionNip01 =>
session?.method === SessionMethod.Nip01
export const isNip07Session = (session?: Session): session is SessionNip07 =>
session?.method === SessionMethod.Nip07
export const isNip46Session = (session?: Session): session is SessionNip46 =>
session?.method === SessionMethod.Nip46
export const isNip55Session = (session?: Session): session is SessionNip55 =>
session?.method === SessionMethod.Nip55
export const isPomadeSession = (session?: Session): session is SessionPomade =>
session?.method === SessionMethod.Pomade
export const isPubkeySession = (session?: Session): session is SessionPubkey =>
session?.method === SessionMethod.Pubkey
// Signer construction
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
export type SignerLogEntry = {
id: string
method: string
started_at: number
finished_at?: number
ok?: boolean
}
export const signerLog = writable<SignerLogEntry[]>([])
export const wrapSigner = (signer: ISigner) =>
new WrappedSigner(signer, async <T>(method: string, thunk: () => Promise<T>) => {
const id = randomId()
signerLog.update(log => append({id, method, started_at: Date.now()}, log))
try {
const result = await thunk()
signerLog.update(log =>
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)),
)
return result
} catch (error: any) {
signerLog.update(log =>
log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)),
)
throw error
}
})
export const getSigner = (session?: Session): ISigner | undefined => {
if (isNip07Session(session)) return wrapSigner(new Nip07Signer())
if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret))
if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey))
if (isPomadeSession(session))
return wrapSigner(new PomadeSigner(new PomadeClient(session.clientOptions)))
if (isNip46Session(session)) {
const {
secret: clientSecret,
handler: {relays, pubkey: signerPubkey},
} = session
const broker = new Nip46Broker({clientSecret, signerPubkey, relays})
return wrapSigner(new Nip46Signer(broker))
}
}
/**
* Build a `User` (pubkey + signer) from a session descriptor. Returns undefined
* for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the
* result to `new Client({user})` / `createApp({user})`.
*/
export const userFromSession = (session: Session, options: UserOptions = {}): User | undefined => {
const signer = getSigner(session)
return signer && typeof session.pubkey === "string"
? new User(session.pubkey, signer, options)
: undefined
}
// Login helpers — each returns a User to build a client/app with
export const loginWithNip01 = (secret: string, options?: UserOptions) =>
userFromSession(makeNip01Session(secret), options)
export const loginWithNip07 = (pubkey: string, options?: UserOptions) =>
userFromSession(makeNip07Session(pubkey), options)
export const loginWithNip46 = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
options?: UserOptions,
) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays), options)
export const loginWithNip55 = (pubkey: string, signer: string, options?: UserOptions) =>
userFromSession(makeNip55Session(pubkey, signer), options)
export const loginWithPomade = (
pubkey: string,
email: string,
clientOptions: PomadeClientOptions,
options?: UserOptions,
) => userFromSession(makePomadeSession(pubkey, email, clientOptions), options)
+58
View File
@@ -0,0 +1,58 @@
import {
getEventsById,
deriveEventsById,
deriveEvents,
makeDeriveEvent,
getEventsByIdByUrl,
deriveEventsByIdByUrl,
getEventsByIdForUrl,
deriveEventsByIdForUrl,
deriveItemsByKey,
deriveIsDeleted,
} from "@welshman/store"
import type {
EventsByIdOptions,
EventOptions,
EventsByIdByUrlOptions,
EventsByIdForUrlOptions,
ItemsByKeyOptions,
} from "@welshman/store"
import type {TrustedEvent} from "@welshman/util"
import type {IClient} from "./client.js"
/**
* Store/derivation utilities bound to the client's repository and tracker. Reach
* it via `client.use(Stores)`.
*/
export class Stores {
constructor(readonly ctx: IClient) {}
getEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
getEventsById({...options, repository: this.ctx.repository})
deriveEventsById = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEventsById({...options, repository: this.ctx.repository})
deriveEvents = (options: Omit<EventsByIdOptions, "repository">) =>
deriveEvents({...options, repository: this.ctx.repository})
makeDeriveEvent = (options: Omit<EventOptions, "repository">) =>
makeDeriveEvent({...options, repository: this.ctx.repository})
getEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
getEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveEventsByIdByUrl = (options: Omit<EventsByIdByUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdByUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
getEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
getEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveEventsByIdForUrl = (options: Omit<EventsByIdForUrlOptions, "tracker" | "repository">) =>
deriveEventsByIdForUrl({...options, tracker: this.ctx.tracker, repository: this.ctx.repository})
deriveItemsByKey = <T>(options: Omit<ItemsByKeyOptions<T>, "repository">) =>
deriveItemsByKey<T>({...options, repository: this.ctx.repository})
deriveIsDeleted = (event: TrustedEvent) => deriveIsDeleted(this.ctx.repository, event)
}
+59
View File
@@ -0,0 +1,59 @@
import {isSignedEvent} from "@welshman/util"
import type {Filter, SignedEvent} from "@welshman/util"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import {Relays} from "./relays.js"
export type AppSyncOpts = {
relays: string[]
filters: Filter[]
}
/**
* Negentropy-aware sync. Pulls/pushes events between the local repository and a
* set of relays, using NIP-77 reconciliation where the relay supports it and
* falling back to plain request/publish otherwise. Reads NIP-11 relay profiles
* from the `Relays` collection to detect negentropy support.
*/
export class Sync {
constructor(readonly ctx: IClient) {}
query = (filters: Filter[]) =>
this.ctx.repository.query(filters, {shouldSort: filters.every(f => f.limit === undefined)})
hasNegentropy = (url: string) => {
const relay = this.ctx.use(Relays).get(url)
if (relay?.negentropy) return true
if (relay?.supported_nips?.includes?.("77")) return true
if (relay?.software?.includes?.("strfry") && !relay?.version?.match(/^0\./)) return true
return false
}
pull = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
await (this.hasNegentropy(relay)
? net.pull({filters, events, relays: [relay]})
: net.request({filters, relays: [relay], autoClose: true}))
}),
)
}
push = async ({relays, filters}: AppSyncOpts) => {
const net = this.ctx.use(Networking)
const events = this.query(filters).filter(isSignedEvent)
await Promise.all(
relays.map(async relay => {
await (this.hasNegentropy(relay)
? net.push({filters, events, relays: [relay]})
: Promise.all(events.map((event: SignedEvent) => net.publish({event, relays: [relay]}))))
}),
)
}
}
+142
View File
@@ -0,0 +1,142 @@
import {uniq, remove} from "@welshman/lib"
import {
getAddress,
isReplaceable,
getReplyTags,
getPubkeyTagValues,
isReplaceableKind,
isShareableRelayUrl,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {Router} from "./router.js"
import {Profiles} from "./profiles.js"
import type {IClient} from "./client.js"
/**
* Builders for nostr tags (p/e/a/q/zap/reply/comment/reaction). Needs the router
* for relay hints, the profiles collection for display names, and the client's
* user to avoid self-tagging.
*/
export class Tags {
constructor(readonly ctx: IClient) {}
tagZapSplit = (pubkey: string, split = 1) => [
"zap",
pubkey,
this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
String(split),
]
tagPubkey = (pubkey: string) => [
"p",
pubkey,
this.ctx.use(Router).FromPubkey(pubkey).getUrl() || "",
this.ctx.use(Profiles).display(pubkey),
]
tagEvent = (event: TrustedEvent, url = "", mark = "") => {
if (!url) {
url = this.ctx.use(Router).Event(event).getUrl() || ""
}
const tags = [["e", event.id, url, mark, event.pubkey]]
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), url, mark, event.pubkey])
}
return tags
}
tagEventPubkeys = (event: TrustedEvent) =>
uniq(
remove(this.ctx.user?.pubkey ?? "", [event.pubkey, ...getPubkeyTagValues(event.tags)]),
).map(pubkey => this.tagPubkey(pubkey))
tagEventForQuote = (event: TrustedEvent, relay?: string) => {
const hint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
return ["q", event.id, hint, event.pubkey]
}
tagEventForReply = (event: TrustedEvent, relay?: string) => {
const tags = this.tagEventPubkeys(event)
const {roots, replies} = getReplyTags(event.tags)
const parents = roots.length > 0 ? roots : replies
const mark = parents.length > 0 ? "reply" : "root"
const hint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
// If the parent included roots use them, otherwise use replies as a fallback
for (const [k, id, originalHint = "", _, pubkey = ""] of parents) {
const hint = isShareableRelayUrl(originalHint)
? originalHint
: this.ctx.use(Router).EventRoots(event).getUrl()
tags.push([k, id, hint || "", "root", pubkey])
}
// e-tag the event
tags.push(["e", event.id, hint, mark, event.pubkey])
// a-tag the event
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), hint, mark, event.pubkey])
}
return tags
}
tagEventForComment = (event: TrustedEvent, relay?: string) => {
const pubkeyHint = this.ctx.use(Router).FromPubkey(event.pubkey).getUrl() || ""
const eventHint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
const address = getAddress(event)
const seenRoots = new Set<string>()
const tags: string[][] = []
for (const [t, ...tag] of event.tags) {
if (["K", "E", "A", "I", "P"].includes(t)) {
tags.push([t, ...tag])
seenRoots.add(t)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["P", event.pubkey, pubkeyHint])
tags.push(["E", event.id, eventHint, event.pubkey])
if (isReplaceableKind(event.kind)) {
tags.push(["A", address, eventHint, event.pubkey])
}
}
tags.push(["k", String(event.kind)])
tags.push(["p", event.pubkey, pubkeyHint])
tags.push(["e", event.id, eventHint, event.pubkey])
if (isReplaceableKind(event.kind)) {
tags.push(["a", address, eventHint, event.pubkey])
}
return tags
}
tagEventForReaction = (event: TrustedEvent, relay?: string) => {
const hint = relay || this.ctx.use(Router).Event(event).getUrl() || ""
const tags: string[][] = []
// Mention the event's author
if (event.pubkey !== this.ctx.user?.pubkey) {
tags.push(this.tagPubkey(event.pubkey))
}
tags.push(["k", String(event.kind)])
tags.push(["e", event.id, hint])
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), hint])
}
return tags
}
}
+409
View File
@@ -0,0 +1,409 @@
import type {Subscriber} from "svelte/store"
import {writable} from "svelte/store"
import type {Override} from "@welshman/lib"
import {append, TaskQueue, ensurePlural, remove, defer, sleep, nth, without} from "@welshman/lib"
import {
HashedEvent,
EventTemplate,
SignedEvent,
isSignedEvent,
WRAPPED_KINDS,
prep,
makePow,
} from "@welshman/util"
import {PublishStatus, PublishResult, PublishOptions, PublishResultsByRelay} from "@welshman/net"
import {Nip01Signer, Nip59} from "@welshman/signer"
import type {IClient} from "./client.js"
import {Networking} from "./networking.js"
import type {User} from "./user.js"
export type ThunkOptions = Override<
PublishOptions,
{
user: User
client: IClient
event: EventTemplate
recipient?: string
delay?: number
pow?: number
}
>
export class Thunk {
_subs: Subscriber<Thunk>[] = []
event: HashedEvent
results: PublishResultsByRelay = {}
complete = defer<void>()
controller = new AbortController()
wrap?: SignedEvent
constructor(readonly options: ThunkOptions) {
if (!options.recipient && WRAPPED_KINDS.includes(options.event.kind)) {
throw new Error(`Attempted to publish a kind ${options.event.kind} without wrapping it`)
}
this.event = prep(options.event, this.options.user.pubkey)
for (const relay of options.relays) {
this.results[relay] = {
relay,
status: PublishStatus.Sending,
detail: "sending...",
}
}
this.controller.signal.addEventListener("abort", () => {
for (const relay of options.relays) {
this._setAborted({
relay,
status: PublishStatus.Aborted,
detail: "aborted",
})
}
})
}
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
_fail(detail: string) {
for (const relay of this.options.relays) {
this.results[relay] = {
relay,
status: PublishStatus.Failure,
detail: detail,
}
}
this._notify()
}
_setPending = (result: PublishResult) => {
this.options.onPending?.(result)
this.results[result.relay] = result
this._notify()
}
_setTimeout = (result: PublishResult) => {
this.options.onTimeout?.(result)
this.results[result.relay] = result
this._notify()
}
_setAborted = (result: PublishResult) => {
this.options.onAborted?.(result)
this.results[result.relay] = result
this._notify()
}
async _publish(event: SignedEvent) {
// Wait if the thunk is to be delayed
if (this.options.delay) {
await sleep(this.options.delay)
}
// Skip publishing if aborted
if (this.controller.signal.aborted) {
return
}
// Send it off
await this.options.client.use(Networking).publish({
...this.options,
event,
onSuccess: (result: PublishResult) => {
this.options.onSuccess?.(result)
this.results[result.relay] = result
this._notify()
},
onFailure: (result: PublishResult) => {
this.options.onFailure?.(result)
this.results[result.relay] = result
this._notify()
},
onPending: this._setPending,
onTimeout: this._setTimeout,
onAborted: this._setAborted,
onComplete: (result: PublishResult) => {
if (result.status !== PublishStatus.Success) {
this.options.client.tracker.removeRelay(event.id, result.relay)
}
this.options.onComplete?.(result)
this._subs = []
},
})
// Notify the caller that we're done
this.complete.resolve()
}
async publish() {
// Handle abort immediately if possible
if (this.controller.signal.aborted) return
const {recipient} = this.options
// If we're sending it privately, wrap the event using nip 59
if (recipient) {
const wrapper = Nip01Signer.ephemeral()
const nip59 = new Nip59(this.options.user.signer, wrapper)
this.wrap = await nip59.wrap(recipient, this.event)
// If we're calculating pow, update the hash and re-sign
if (this.options.pow) {
this.wrap = await wrapper.sign(await makePow(this.wrap, this.options.pow).result, {
signal: AbortSignal.timeout(30_000),
})
}
this.options.client.wrapManager.add({recipient, wrap: this.wrap, rumor: this.event})
return this._publish(this.wrap)
}
// If the event has been signed, we're good to go
if (isSignedEvent(this.event)) {
if (this.options.pow) {
console.warn("Event is already signed, skipping proof of work calculation")
}
return this._publish(this.event)
}
// Allow for lazily signing/powing events in order to decrease apparent latency in the UI
// that results from waiting for remote signers
try {
if (this.options.pow) {
this.event = await makePow(this.event, this.options.pow).result
}
const signedEvent = await this.options.user.signer.sign(this.event, {
signal: AbortSignal.timeout(30_000),
})
// Update tracker and repository with the signed event since the id will have changed
if (this.options.pow) {
for (const url of this.options.relays) {
this.options.client.tracker.removeRelay(this.event.id, url)
this.options.client.tracker.track(signedEvent.id, url)
}
}
this.options.client.repository.removeEvent(this.event.id)
this.options.client.repository.publish(signedEvent)
return this._publish(signedEvent)
} catch (e: any) {
console.error("Failed to sign event", e)
return this._fail(String(e || "Failed to sign event"))
}
}
enqueue() {
thunkQueue.push(this)
for (const url of this.options.relays) {
this.options.client.tracker.track(this.event.id, url)
}
this.options.client.repository.publish(this.event)
thunks.update($thunks => append(this, $thunks))
this.controller.signal.addEventListener("abort", () => {
if (this.wrap) {
this.options.client.wrapManager.remove(this.wrap.id)
} else {
this.options.client.repository.removeEvent(this.event.id)
}
thunks.update($thunks => remove(this, $thunks))
})
}
subscribe(subscriber: Subscriber<Thunk>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
}
}
export class MergedThunk {
_subs: Subscriber<MergedThunk>[] = []
results: PublishResultsByRelay = {}
constructor(readonly thunks: Thunk[]) {
const {Aborted, Failure, Timeout, Pending, Sending, Success} = PublishStatus
const relays = new Set(thunks.flatMap(thunk => thunk.options.relays))
for (const thunk of thunks) {
thunk.subscribe($thunk => {
this.results = {}
for (const relay of relays) {
for (const status of [Aborted, Failure, Timeout, Pending, Sending, Success]) {
const thunk = thunks.find(t => t.results[relay]?.status === status)
if (thunk) {
this.results[relay] = thunk.results[relay]!
}
}
}
this._notify()
if (thunks.every(thunkIsComplete)) {
this._subs = []
}
})
}
}
_notify() {
for (const subscriber of this._subs) {
subscriber(this)
}
}
subscribe(subscriber: Subscriber<MergedThunk>) {
this._subs.push(subscriber)
subscriber(this)
return () => {
this._subs = remove(subscriber, this._subs)
}
}
}
export type AbstractThunk = Thunk | MergedThunk
export const isThunk = (thunk: AbstractThunk): thunk is Thunk => thunk instanceof Thunk
export const isMergedThunk = (thunk: AbstractThunk): thunk is MergedThunk =>
thunk instanceof MergedThunk
// Thunk status urls
export const getThunkUrlsWithStatus = (
statuses: PublishStatus | PublishStatus[],
thunk: AbstractThunk,
) => {
statuses = ensurePlural(statuses)
return Object.entries(thunk.results)
.filter(([_, {status}]) => statuses.includes(status))
.map(nth(0)) as string[]
}
export const getCompleteThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus(
without([PublishStatus.Sending, PublishStatus.Pending], Object.values(PublishStatus)),
thunk,
)
export const getIncompleteThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
export const getFailedThunkUrls = (thunk: AbstractThunk) =>
getThunkUrlsWithStatus([PublishStatus.Failure, PublishStatus.Timeout], thunk)
// Thunk status checks
export const thunkHasStatus = (statuses: PublishStatus | PublishStatus[], thunk: AbstractThunk) =>
getThunkUrlsWithStatus(statuses, thunk).length > 0
export const thunkIsComplete = (thunk: AbstractThunk) =>
!thunkHasStatus([PublishStatus.Sending, PublishStatus.Pending], thunk)
// Thunk errors
export const getThunkError = (thunk: Thunk) => {
for (const [_, {status, detail}] of Object.entries(thunk.results)) {
if (status === PublishStatus.Failure) {
return detail
}
}
if (thunkIsComplete(thunk)) {
return ""
}
}
// Thunk utilities that return promises
export const waitForThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
const error = getThunkError($thunk)
if (error !== undefined) {
resolve(error)
}
})
})
export const waitForThunkCompletion = (thunk: Thunk) =>
new Promise<void>(resolve => {
thunk.subscribe($thunk => {
if (thunkIsComplete($thunk)) {
resolve()
}
})
})
// Thunk state
export const thunks = writable<Thunk[]>([])
export const thunkQueue = new TaskQueue<Thunk>({
batchSize: 10,
batchDelay: 100,
processItem: (thunk: Thunk) => {
thunk.publish()
},
})
// Other thunk utilities
export const mergeThunks = (thunks: AbstractThunk[]) =>
new MergedThunk(Array.from(flattenThunks(thunks)))
export function* flattenThunks(thunks: AbstractThunk[]): Iterable<Thunk> {
for (const thunk of thunks) {
if (isMergedThunk(thunk)) {
yield* flattenThunks(thunk.thunks)
} else {
yield thunk
}
}
}
export const publishThunk = (options: ThunkOptions) => {
const thunk = new Thunk(options)
thunk.enqueue()
return thunk
}
export const abortThunk = (thunk: AbstractThunk) => {
for (const child of flattenThunks([thunk])) {
child.controller.abort()
}
}
export const retryThunk = (thunk: AbstractThunk) =>
isMergedThunk(thunk)
? mergeThunks(thunk.thunks.map(t => publishThunk(t.options)))
: publishThunk(thunk.options)
+59
View File
@@ -0,0 +1,59 @@
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {on} from "@welshman/lib"
import {getTopicTagValues} from "@welshman/util"
import {deriveItems} from "@welshman/store"
import type {IClient} from "./client.js"
export type Topic = {
name: string
count: number
}
/**
* Hashtag topics with occurrence counts, derived live from the client's
* repository tag index.
*/
export class Topics {
byName: Readable<Map<string, Topic>>
all: Readable<Topic[]>
constructor(readonly ctx: IClient) {
const topicsByName = new Map<string, Topic>()
const addTopic = (name: string) => {
const topic = topicsByName.get(name)
if (topic) {
topic.count++
} else {
topicsByName.set(name, {name, count: 1})
}
}
for (const tagString of ctx.repository.eventsByTag.keys()) {
if (tagString.startsWith("t:")) {
addTopic(tagString.slice(2).toLowerCase())
}
}
this.byName = readable(topicsByName, set =>
on(ctx.repository, "update", ({added}: {added: {tags: string[][]}[]}) => {
let dirty = false
for (const event of added) {
for (const name of getTopicTagValues(event.tags)) {
addTopic(name)
dirty = true
}
}
if (dirty) {
set(topicsByName)
}
}),
)
this.all = deriveItems(this.byName)
}
}
+37
View File
@@ -0,0 +1,37 @@
import {makeSocketPolicyAuth} from "@welshman/net"
import type {Socket} from "@welshman/net"
import type {StampedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
export type UserOptions = {
shouldAuth?: (socket: Socket) => boolean
}
/**
* A single identity: a pubkey plus the signer that proves it. A `Client` is
* centered on (at most) one `User`, since the data a user can access depends
* entirely on who they are.
*/
export class User {
constructor(
readonly pubkey: string,
readonly signer: ISigner,
readonly options: UserOptions = {},
) {}
static async fromSigner(signer: ISigner, options: UserOptions = {}) {
const pubkey = await signer.getPubkey()
return new User(pubkey, signer, options)
}
makeSocketPolicyAuth = () =>
makeSocketPolicyAuth({
sign: this.signer.sign,
shouldAuth: this.options.shouldAuth,
})
sign = (event: StampedEvent) => this.signer.sign(event)
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
}
+132
View File
@@ -0,0 +1,132 @@
import {derived, writable} from "svelte/store"
import type {Readable, Writable} from "svelte/store"
import {max, throttle, addToMapKey, inc, dec} from "@welshman/lib"
import {getListTags, getPubkeyTagValues} from "@welshman/util"
import {throttled, getter} from "@welshman/store"
import type {IClient} from "./client.js"
import {FollowLists} from "./follows.js"
import {MuteLists} from "./mutes.js"
/**
* Web-of-trust scoring derived from follow and mute lists. The trust graph is
* built from the perspective of the client's user (or, with no user, the union
* of every known follow list) and updated reactively as lists change.
*/
export class Wot {
followersByPubkey: Readable<Map<string, Set<string>>>
mutersByPubkey: Readable<Map<string, Set<string>>>
wotGraph: Writable<Map<string, number>>
maxWot: Readable<number | undefined>
private getFollowersByPubkeyStore: () => Map<string, Set<string>>
private getMutersByPubkeyStore: () => Map<string, Set<string>>
private getWotGraphStore: () => Map<string, number>
private getMaxWotStore: () => number | undefined
constructor(readonly ctx: IClient) {
const followLists = this.ctx.use(FollowLists)
const muteLists = this.ctx.use(MuteLists)
this.followersByPubkey = derived(throttled(1000, followLists.all), lists => {
const $followersByPubkey = new Map<string, Set<string>>()
for (const list of lists) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($followersByPubkey, pubkey, list.event.pubkey)
}
}
return $followersByPubkey
})
this.mutersByPubkey = derived(throttled(1000, muteLists.all), lists => {
const $mutersByPubkey = new Map<string, Set<string>>()
for (const list of lists) {
for (const pubkey of getPubkeyTagValues(getListTags(list))) {
addToMapKey($mutersByPubkey, pubkey, list.event.pubkey)
}
}
return $mutersByPubkey
})
this.wotGraph = writable(new Map<string, number>())
this.maxWot = derived(this.wotGraph, $g => max(Array.from($g.values())))
this.getFollowersByPubkeyStore = getter(this.followersByPubkey)
this.getMutersByPubkeyStore = getter(this.mutersByPubkey)
this.getWotGraphStore = getter(this.wotGraph)
this.getMaxWotStore = getter(this.maxWot)
followLists.subscribe(this.buildGraph)
muteLists.subscribe(this.buildGraph)
}
getFollows = (pubkey: string) =>
getPubkeyTagValues(getListTags(this.ctx.use(FollowLists).get(pubkey)))
getMutes = (pubkey: string) =>
getPubkeyTagValues(getListTags(this.ctx.use(MuteLists).get(pubkey)))
getNetwork = (pubkey: string) => {
const pubkeys = new Set(this.getFollows(pubkey))
const network = new Set<string>()
for (const follow of pubkeys) {
for (const tpk of this.getFollows(follow)) {
if (!pubkeys.has(tpk)) {
network.add(tpk)
}
}
}
return Array.from(network)
}
getFollowersByPubkey = () => this.getFollowersByPubkeyStore()
getMutersByPubkey = () => this.getMutersByPubkeyStore()
getFollowers = (pubkey: string) => Array.from(this.getFollowersByPubkey().get(pubkey) || [])
getMuters = (pubkey: string) => Array.from(this.getMutersByPubkey().get(pubkey) || [])
getFollowsWhoFollow = (pubkey: string, target: string) =>
this.getFollows(pubkey).filter(other => this.getFollows(other).includes(target))
getFollowsWhoMute = (pubkey: string, target: string) =>
this.getFollows(pubkey).filter(other => this.getMutes(other).includes(target))
getWotGraph = () => this.getWotGraphStore()
getMaxWot = () => this.getMaxWotStore()
buildGraph = throttle(1000, () => {
const $pubkey = this.ctx.user?.pubkey
const $graph = new Map<string, number>()
const $follows = $pubkey
? this.getFollows($pubkey)
: Array.from(this.ctx.use(FollowLists).keys())
for (const follow of $follows) {
for (const pubkey of this.getFollows(follow)) {
$graph.set(pubkey, inc($graph.get(pubkey)))
}
for (const pubkey of this.getMutes(follow)) {
$graph.set(pubkey, dec($graph.get(pubkey)))
}
}
this.wotGraph.set($graph)
})
getWotScore = (pubkey: string, target: string) => {
const follows = pubkey ? this.getFollowsWhoFollow(pubkey, target) : this.getFollowers(target)
const mutes = pubkey ? this.getFollowsWhoMute(pubkey, target) : this.getMuters(target)
return follows.length - mutes.length
}
}
+126
View File
@@ -0,0 +1,126 @@
import {writable} from "svelte/store"
import {
removeUndefined,
fetchJson,
bech32ToHex,
hexToBech32,
tryCatch,
batcher,
postJson,
} from "@welshman/lib"
import {getTagValues, zapFromEvent} from "@welshman/util"
import type {Zapper, Zap, TrustedEvent} from "@welshman/util"
import {deriveDeduplicated} from "@welshman/store"
import {LoadableData} from "./clientData.js"
import type {IClient} from "./client.js"
import {Profiles} from "./profiles.js"
/**
* Lightning zapper info, keyed by lnurl. A "local" loadable collection: items
* aren't nostr events, they're fetched over HTTP (either directly from each
* lnurl, or via a dufflepud proxy to protect user privacy). Depends on the
* profiles collection to resolve a pubkey's lnurl.
*/
export class Zappers extends LoadableData<Zapper> {
constructor(ctx: IClient) {
super(ctx)
}
fetch = batcher(800, async (lnurls: string[]) => {
const result = new Map<string, Zapper>()
const valid = lnurls.filter(lnurl => lnurl.startsWith("lnurl1"))
const addZapper = (lnurl: string, info: any) => {
if (info) {
try {
result.set(lnurl, {...info, lnurl})
} catch (_e) {
// pass
}
}
}
// Use dufflepud if it's set up to protect user privacy, otherwise fetch directly
if (this.ctx.config.dufflepudUrl) {
const hexUrls = valid.map(bech32ToHex)
const res: any = await tryCatch(
async () =>
await postJson(`${this.ctx.config.dufflepudUrl}/zapper/info`, {lnurls: hexUrls}),
)
for (const {lnurl, info} of res?.data || []) {
addZapper(hexToBech32("lnurl", lnurl), info)
}
} else {
await Promise.all(
valid.map(async lnurl => {
addZapper(lnurl, await tryCatch(async () => await fetchJson(bech32ToHex(lnurl))))
}),
)
}
for (const [lnurl, zapper] of result) {
this.set(lnurl, zapper)
}
return lnurls.map(lnurl => result.get(lnurl))
})
loadForPubkey = async (pubkey: string, relays: string[] = []) => {
const $profile = await this.ctx.use(Profiles).load(pubkey, relays)
return $profile?.lnurl ? this.load($profile.lnurl) : undefined
}
deriveForPubkey = (pubkey: string, relays: string[] = []) => {
this.loadForPubkey(pubkey, relays)
return deriveDeduplicated(
[this.index, this.ctx.use(Profiles).derive(pubkey, relays)],
([$zappersByLnurl, $profile]) =>
$profile?.lnurl ? $zappersByLnurl.get($profile.lnurl) : undefined,
)
}
getLnUrlsForEvent = async (event: TrustedEvent) => {
const pubkeys = getTagValues("zap", event.tags)
if (pubkeys.length > 0) {
const profiles = await Promise.all(pubkeys.map(pubkey => this.ctx.use(Profiles).load(pubkey)))
const lnurls = removeUndefined(profiles.map(profile => profile?.lnurl))
if (lnurls.length > 0) {
return lnurls
}
}
const profile = await this.ctx.use(Profiles).load(event.pubkey)
return removeUndefined([profile?.lnurl])
}
getZapperForZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
const lnurls = await this.getLnUrlsForEvent(parent)
return lnurls.length > 0 ? this.load(lnurls[0]) : undefined
}
getValidZap = async (zap: TrustedEvent, parent: TrustedEvent) => {
const zapper = await this.getZapperForZap(zap, parent)
return zapper ? zapFromEvent(zap, zapper) : undefined
}
getValidZaps = async (zaps: TrustedEvent[], parent: TrustedEvent) =>
removeUndefined(await Promise.all(zaps.map(zap => this.getValidZap(zap, parent))))
deriveValidZaps = (zaps: TrustedEvent[], parent: TrustedEvent) => {
const store = writable<Zap[]>([])
this.getValidZaps(zaps, parent).then(validZaps => {
store.set(validZaps)
})
return store
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@welshman/feeds": ["../feeds/src/index.js"],
"@welshman/lib": ["../lib/src/index.js"],
"@welshman/net": ["../net/src/index.js"],
"@welshman/router": ["../router/src/index.js"],
"@welshman/signer": ["../signer/src/index.js"],
"@welshman/store": ["../store/src/index.js"],
"@welshman/util": ["../util/src/index.js"]
}
},
"include": [
"src/**/*"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+5
View File
@@ -10,6 +10,7 @@ import {
} from "@welshman/util" } from "@welshman/util"
import {ISigner} from "@welshman/signer" import {ISigner} from "@welshman/signer"
import {AdapterContext} from "@welshman/net" import {AdapterContext} from "@welshman/net"
import type {Router} from "@welshman/router"
import { import {
CreatedAtItem, CreatedAtItem,
RequestItem, RequestItem,
@@ -25,6 +26,7 @@ import {getFeedArgs, feedsFromTags} from "./utils.js"
import {requestPage, requestDVM} from "./request.js" import {requestPage, requestDVM} from "./request.js"
export type FeedCompilerOptions = { export type FeedCompilerOptions = {
router: Router
signer?: ISigner signer?: ISigner
signal?: AbortSignal signal?: AbortSignal
context?: AdapterContext context?: AdapterContext
@@ -157,6 +159,7 @@ export class FeedCompiler {
items.map(({mappings, ...request}) => items.map(({mappings, ...request}) =>
requestDVM({ requestDVM({
...request, ...request,
router: this.options.router,
signer: this.options.signer, signer: this.options.signer,
context: this.options.context, context: this.options.context,
onResult: async (e: TrustedEvent) => { onResult: async (e: TrustedEvent) => {
@@ -267,6 +270,7 @@ export class FeedCompiler {
const eventsByAddress = new Map<string, TrustedEvent>() const eventsByAddress = new Map<string, TrustedEvent>()
await requestPage({ await requestPage({
router: this.options.router,
autoClose: true, autoClose: true,
signal: this.options.signal, signal: this.options.signal,
context: this.options.context, context: this.options.context,
@@ -304,6 +308,7 @@ export class FeedCompiler {
labelItems.map(({mappings, relays, ...filter}) => labelItems.map(({mappings, relays, ...filter}) =>
requestPage({ requestPage({
relays, relays,
router: this.options.router,
autoClose: true, autoClose: true,
signal: this.options.signal, signal: this.options.signal,
context: this.options.context, context: this.options.context,
+2
View File
@@ -159,6 +159,7 @@ export class FeedController {
await requestPage( await requestPage(
omitVals([undefined], { omitVals([undefined], {
relays, relays,
router: this.options.router,
autoClose: true, autoClose: true,
filters: trimFilters(requestFilters), filters: trimFilters(requestFilters),
signal: this.options.signal, signal: this.options.signal,
@@ -360,6 +361,7 @@ export class FeedController {
requestPage( requestPage(
omitVals([undefined], { omitVals([undefined], {
relays, relays,
router: this.options.router,
signal, signal,
onEvent: (event: TrustedEvent) => onEvent?.(event), onEvent: (event: TrustedEvent) => onEvent?.(event),
filters: trimFilters(requestFilters), filters: trimFilters(requestFilters),
+8 -4
View File
@@ -15,6 +15,7 @@ import {LOCAL_RELAY_URL, Tracker, AdapterContext, request, publish} from "@welsh
export type RequestPageOptions = { export type RequestPageOptions = {
filters: Filter[] filters: Filter[]
router: Router
onEvent: (event: TrustedEvent) => void onEvent: (event: TrustedEvent) => void
relays?: string[] relays?: string[]
tracker?: Tracker tracker?: Tracker
@@ -25,6 +26,7 @@ export type RequestPageOptions = {
export const requestPage = async ({ export const requestPage = async ({
filters, filters,
router,
onEvent, onEvent,
relays = [], relays = [],
tracker = new Tracker(), tracker = new Tracker(),
@@ -49,14 +51,14 @@ export const requestPage = async ({
threshold: 0.1, threshold: 0.1,
autoClose, autoClose,
filters: withSearch, filters: withSearch,
relays: Router.get().Search().getUrls(), relays: router.Search().getUrls(),
}), }),
) )
} }
if (withoutSearch.length > 0) { if (withoutSearch.length > 0) {
promises.push( promises.push(
...getFilterSelections(filters).flatMap(({relays, filters}) => ...getFilterSelections(filters, router).flatMap(({relays, filters}) =>
request({ request({
tracker, tracker,
signal, signal,
@@ -91,6 +93,7 @@ export const requestPage = async ({
export type RequestDVMOptions = { export type RequestDVMOptions = {
kind: number kind: number
router: Router
tags?: string[][] tags?: string[][]
relays?: string[] relays?: string[]
signer?: ISigner signer?: ISigner
@@ -100,6 +103,7 @@ export type RequestDVMOptions = {
export const requestDVM = async ({ export const requestDVM = async ({
kind, kind,
router,
onResult, onResult,
tags = [], tags = [],
relays = [], relays = [],
@@ -110,10 +114,10 @@ export const requestDVM = async ({
const events = await request({ const events = await request({
autoClose: true, autoClose: true,
filters: [{kinds: [RELAYS], authors: getPubkeyTagValues(tags)}], filters: [{kinds: [RELAYS], authors: getPubkeyTagValues(tags)}],
relays: Router.get().Index().policy(addMinimalFallbacks).getUrls(), relays: router.Index().policy(addMinimalFallbacks).getUrls(),
}) })
relays = Router.get() relays = router
.FromRelays(events.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e))))) .FromRelays(events.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)))))
.policy(addMinimalFallbacks) .policy(addMinimalFallbacks)
.getUrls() .getUrls()
+11 -5
View File
@@ -1,5 +1,5 @@
import EventEmitter from "events" import EventEmitter from "events"
import {call, sleep, mergeRight, on} from "@welshman/lib" import {call, sleep, on} from "@welshman/lib"
import {isRelayUrl, matchFilters, Filter} from "@welshman/util" import {isRelayUrl, matchFilters, Filter} from "@welshman/util"
import {LOCAL_RELAY_URL, Repository} from "./repository.js" import {LOCAL_RELAY_URL, Repository} from "./repository.js"
import { import {
@@ -13,7 +13,7 @@ import {
} from "./message.js" } from "./message.js"
import {Socket, SocketEvent} from "./socket.js" import {Socket, SocketEvent} from "./socket.js"
import {Unsubscriber} from "./util.js" import {Unsubscriber} from "./util.js"
import {netContext, NetContext} from "./context.js" import type {NetContext} from "./context.js"
export enum AdapterEvent { export enum AdapterEvent {
Receive = "receive", Receive = "receive",
@@ -150,9 +150,7 @@ export class MockAdapter extends AbstractAdapter {
export type AdapterContext = Partial<NetContext> export type AdapterContext = Partial<NetContext>
export const getAdapter = (url: string, adapterContext: AdapterContext = {}) => { export const getAdapter = (url: string, context: AdapterContext = {}) => {
const context = mergeRight(netContext, adapterContext as any)
if (context.getAdapter) { if (context.getAdapter) {
const adapter = context.getAdapter(url, context) const adapter = context.getAdapter(url, context)
@@ -162,10 +160,18 @@ export const getAdapter = (url: string, adapterContext: AdapterContext = {}) =>
} }
if (url === LOCAL_RELAY_URL) { if (url === LOCAL_RELAY_URL) {
if (!context.repository) {
throw new Error("LOCAL_RELAY_URL cannot be used without context.repository")
}
return new LocalAdapter(context.repository) return new LocalAdapter(context.repository)
} }
if (isRelayUrl(url)) { if (isRelayUrl(url)) {
if (!context.pool) {
throw new Error("Unable to connect to relays without context.pool")
}
return new SocketAdapter(context.pool.get(url)) return new SocketAdapter(context.pool.get(url))
} }
+5 -13
View File
@@ -1,19 +1,11 @@
import {verifyEvent, TrustedEvent} from "@welshman/util"
import {AbstractAdapter} from "./adapter.js" import {AbstractAdapter} from "./adapter.js"
import {Repository} from "./repository.js" import {Repository} from "./repository.js"
import {Pool} from "./pool.js" import {Pool} from "./pool.js"
export type NetContext = { export type AdapterFactory = (url: string, context: NetContext) => AbstractAdapter
pool: Pool
repository: Repository
isEventValid: (event: TrustedEvent, url: string) => boolean
isEventDeleted: (event: TrustedEvent, url: string) => boolean
getAdapter?: (url: string, context: NetContext) => AbstractAdapter
}
export const netContext: NetContext = { export type NetContext = {
pool: Pool.get(), pool?: Pool
repository: Repository.get(), repository?: Repository
isEventValid: (event, url) => verifyEvent(event), getAdapter?: AdapterFactory
isEventDeleted: (event, url) => netContext.repository.isDeleted(event),
} }
-10
View File
@@ -9,20 +9,10 @@ export type PoolOptions = {
makeSocket?: (url: string) => Socket makeSocket?: (url: string) => Socket
} }
export let poolSingleton: Pool
export class Pool { export class Pool {
_data = new Map<string, Socket>() _data = new Map<string, Socket>()
_subs: PoolSubscription[] = [] _subs: PoolSubscription[] = []
static get() {
if (!poolSingleton) {
poolSingleton = new Pool()
}
return poolSingleton
}
constructor(readonly options: PoolOptions = {}) {} constructor(readonly options: PoolOptions = {}) {}
has(url: string) { has(url: string) {
+8 -13
View File
@@ -26,8 +26,6 @@ export const LOCAL_RELAY_URL = "local://welshman.relay/"
const getDay = (ts: number) => Math.floor(ts / DAY) const getDay = (ts: number) => Math.floor(ts / DAY)
export let repositorySingleton: Repository
export type RepositoryUpdate = { export type RepositoryUpdate = {
added: TrustedEvent[] added: TrustedEvent[]
removed: Set<string> removed: Set<string>
@@ -61,14 +59,6 @@ export class Repository extends Emitter {
deletes = new Map<string, {created_at: number; pubkey: string}[]>() deletes = new Map<string, {created_at: number; pubkey: string}[]>()
expired = new Map<string, number>() expired = new Map<string, number>()
static get() {
if (!repositorySingleton) {
repositorySingleton = new Repository()
}
return repositorySingleton
}
constructor() { constructor() {
super() super()
@@ -81,9 +71,7 @@ export class Repository extends Emitter {
return Array.from(this.eventsById.values()) return Array.from(this.eventsById.values())
} }
load = (events: TrustedEvent[]) => { clear = () => {
const stale = new Set(this.eventsById.keys())
this.eventsById.clear() this.eventsById.clear()
this.eventsByAddress.clear() this.eventsByAddress.clear()
this.eventsByTag.clear() this.eventsByTag.clear()
@@ -92,6 +80,13 @@ export class Repository extends Emitter {
this.eventsByKind.clear() this.eventsByKind.clear()
this.deletes.clear() this.deletes.clear()
this.expired.clear() this.expired.clear()
this.emit("clear")
}
load = (events: TrustedEvent[]) => {
const stale = new Set(this.eventsById.keys())
this.clear()
const added = [] const added = []
+4 -7
View File
@@ -15,6 +15,7 @@ import {
unionFilters, unionFilters,
matchFilters, matchFilters,
TrustedEvent, TrustedEvent,
verifyEvent,
deduplicateEvents, deduplicateEvents,
getFilterResultCardinality, getFilterResultCardinality,
} from "@welshman/util" } from "@welshman/util"
@@ -27,7 +28,6 @@ import {
} from "./message.js" } from "./message.js"
import {getAdapter, AdapterContext, AdapterEvent} from "./adapter.js" import {getAdapter, AdapterContext, AdapterEvent} from "./adapter.js"
import {SocketEvent, SocketStatus} from "./socket.js" import {SocketEvent, SocketStatus} from "./socket.js"
import {netContext} from "./context.js"
import {Tracker} from "./tracker.js" import {Tracker} from "./tracker.js"
export type BaseRequestOptions = { export type BaseRequestOptions = {
@@ -36,7 +36,6 @@ export type BaseRequestOptions = {
context?: AdapterContext context?: AdapterContext
autoClose?: boolean autoClose?: boolean
isEventValid?: (event: TrustedEvent, url: string) => boolean isEventValid?: (event: TrustedEvent, url: string) => boolean
isEventDeleted?: (event: TrustedEvent, url: string) => boolean
onEvent?: (event: TrustedEvent, url: string) => void onEvent?: (event: TrustedEvent, url: string) => void
onDeleted?: (event: unknown, url: string) => void onDeleted?: (event: unknown, url: string) => void
onInvalid?: (event: unknown, url: string) => void onInvalid?: (event: unknown, url: string) => void
@@ -60,8 +59,8 @@ export const requestOne = (options: RequestOneOptions) => {
const deferred = defer<TrustedEvent[]>() const deferred = defer<TrustedEvent[]>()
const tracker = options.tracker || new Tracker() const tracker = options.tracker || new Tracker()
const adapter = getAdapter(options.relay, options.context) const adapter = getAdapter(options.relay, options.context)
const isEventValid = options.isEventValid || netContext.isEventValid const isEventValid: (event: TrustedEvent, url: string) => boolean =
const isEventDeleted = options.isEventDeleted || netContext.isEventDeleted options.isEventValid || (event => verifyEvent(event))
let closed = false let closed = false
@@ -88,7 +87,7 @@ export const requestOne = (options: RequestOneOptions) => {
if (ids.has(id)) { if (ids.has(id)) {
if (tracker.track(event.id, url)) { if (tracker.track(event.id, url)) {
options.onDuplicate?.(event, url) options.onDuplicate?.(event, url)
} else if (isEventDeleted(event, url)) { } else if (options.context?.repository?.isDeleted(event)) {
options.onDeleted?.(event, url) options.onDeleted?.(event, url)
} else if (!isEventValid(event, url)) { } else if (!isEventValid(event, url)) {
options.onInvalid?.(event, url) options.onInvalid?.(event, url)
@@ -216,7 +215,6 @@ export type LoaderOptions = {
threshold?: number threshold?: number
context?: AdapterContext context?: AdapterContext
isEventValid?: (event: TrustedEvent, url: string) => boolean isEventValid?: (event: TrustedEvent, url: string) => boolean
isEventDeleted?: (event: TrustedEvent, url: string) => boolean
} }
export type LoadOptions = { export type LoadOptions = {
@@ -320,7 +318,6 @@ export const makeLoader = (options: LoaderOptions) =>
signal: signalsByRelay.get(relay), signal: signalsByRelay.get(relay),
context: options.context, context: options.context,
isEventValid: options.isEventValid, isEventValid: options.isEventValid,
isEventDeleted: options.isEventDeleted,
onEvent: (event: TrustedEvent, url: string) => { onEvent: (event: TrustedEvent, url: string) => {
for (const request of getOpenRequests()) { for (const request of getOpenRequests()) {
if (matchFilters(request.filters, event)) { if (matchFilters(request.filters, event)) {
+6 -1
View File
@@ -41,10 +41,15 @@ export class WrapManager extends Emitter {
// Adding/importing // Adding/importing
load = (wrapItems: WrapItem[]) => { clear = () => {
this._wrapIndex.clear() this._wrapIndex.clear()
this._rumorIndex.clear() this._rumorIndex.clear()
this._recipientIndex.clear() this._recipientIndex.clear()
this.emit("load")
}
load = (wrapItems: WrapItem[]) => {
this.clear()
for (const wrapItem of wrapItems) { for (const wrapItem of wrapItems) {
this._add(wrapItem) this._add(wrapItem)
+18 -46
View File
@@ -2,7 +2,6 @@ import {
nth, nth,
uniq, uniq,
intersection, intersection,
mergeLeft,
first, first,
clamp, clamp,
sortBy, sortBy,
@@ -28,14 +27,10 @@ import {
normalizeRelayUrl, normalizeRelayUrl,
TrustedEvent, TrustedEvent,
Filter, Filter,
readList,
getAncestorTags, getAncestorTags,
asDecryptedEvent,
getRelaysFromList,
getPubkeyTags, getPubkeyTags,
RelayMode, RelayMode,
} from "@welshman/util" } from "@welshman/util"
import {Repository} from "@welshman/net"
export const INDEXED_KINDS = [PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS] export const INDEXED_KINDS = [PROFILE, RELAYS, MESSAGING_RELAYS, FOLLOWS]
@@ -114,30 +109,8 @@ export const addMaximalFallbacks = (count: number, limit: number) => limit - cou
// Router class // Router class
export const routerContext: RouterOptions = {
getPubkeyRelays: (pubkey: string, mode?: RelayMode) => {
return uniq(
Repository.get()
.query([{kinds: [RELAYS], authors: [pubkey]}])
.flatMap(event => getRelaysFromList(readList(asDecryptedEvent(event)), mode)),
)
},
}
export class Router { export class Router {
readonly options: RouterOptions constructor(readonly options: RouterOptions) {}
static configure(options: RouterOptions) {
Object.assign(routerContext, options)
}
static get() {
return new Router(routerContext)
}
constructor(options: RouterOptions) {
this.options = mergeLeft(options, routerContext)
}
// Utilities derived from options // Utilities derived from options
@@ -357,58 +330,58 @@ export class RouterScenario {
type FilterScenario = {filter: Filter; scenario: RouterScenario} type FilterScenario = {filter: Filter; scenario: RouterScenario}
type FilterSelectionRule = (filter: Filter) => FilterScenario[] type FilterSelectionRule = (filter: Filter, router: Router) => FilterScenario[]
export const getFilterSelectionsForSearch = (filter: Filter) => { export const getFilterSelectionsForSearch = (filter: Filter, router: Router) => {
if (!filter.search) return [] if (!filter.search) return []
const relays = routerContext.getSearchRelays?.() || [] const relays = router.options.getSearchRelays?.() || []
return [{filter, scenario: Router.get().FromRelays(relays).weight(10)}] return [{filter, scenario: router.FromRelays(relays).weight(10)}]
} }
export const getFilterSelectionsForWraps = (filter: Filter) => { export const getFilterSelectionsForWraps = (filter: Filter, router: Router) => {
if (!filter.kinds?.includes(WRAP) || filter.authors) return [] if (!filter.kinds?.includes(WRAP) || filter.authors) return []
return [ return [
{ {
filter: {...filter, kinds: [WRAP]}, filter: {...filter, kinds: [WRAP]},
scenario: Router.get().MessagesForUser(), scenario: router.MessagesForUser(),
}, },
] ]
} }
export const getFilterSelectionsForIndexedKinds = (filter: Filter) => { export const getFilterSelectionsForIndexedKinds = (filter: Filter, router: Router) => {
const kinds = intersection(INDEXED_KINDS, filter.kinds || []) const kinds = intersection(INDEXED_KINDS, filter.kinds || [])
if (kinds.length === 0) return [] if (kinds.length === 0) return []
const relays = routerContext.getIndexerRelays?.() || [] const relays = router.options.getIndexerRelays?.() || []
return [ return [
{ {
filter: {...filter, kinds}, filter: {...filter, kinds},
scenario: Router.get().FromRelays(relays), scenario: router.FromRelays(relays),
}, },
] ]
} }
export const getFilterSelectionsForAuthors = (filter: Filter) => { export const getFilterSelectionsForAuthors = (filter: Filter, router: Router) => {
if (!filter.authors) return [] if (!filter.authors) return []
const chunkCount = clamp([1, 30], Math.round(filter.authors.length / 30)) const chunkCount = clamp([1, 30], Math.round(filter.authors.length / 30))
return chunks(chunkCount, filter.authors).map(authors => ({ return chunks(chunkCount, filter.authors).map(authors => ({
filter: {...filter, authors}, filter: {...filter, authors},
scenario: Router.get().FromPubkeys(authors), scenario: router.FromPubkeys(authors),
})) }))
} }
export const getFilterSelectionsForUser = (filter: Filter) => [ export const getFilterSelectionsForUser = (filter: Filter, router: Router) => [
{filter, scenario: Router.get().ForUser().weight(0.2)}, {filter, scenario: router.ForUser().weight(0.2)},
] ]
export const defaultFilterSelectionRules = [ export const defaultFilterSelectionRules: FilterSelectionRule[] = [
getFilterSelectionsForSearch, getFilterSelectionsForSearch,
getFilterSelectionsForWraps, getFilterSelectionsForWraps,
getFilterSelectionsForIndexedKinds, getFilterSelectionsForIndexedKinds,
@@ -418,13 +391,14 @@ export const defaultFilterSelectionRules = [
export const getFilterSelections = ( export const getFilterSelections = (
filters: Filter[], filters: Filter[],
router: Router,
rules: FilterSelectionRule[] = defaultFilterSelectionRules, rules: FilterSelectionRule[] = defaultFilterSelectionRules,
): RelaysAndFilters[] => { ): RelaysAndFilters[] => {
const filtersById = new Map<string, Filter>() const filtersById = new Map<string, Filter>()
const scenariosById = new Map<string, RouterScenario[]>() const scenariosById = new Map<string, RouterScenario[]>()
for (const filter of filters) { for (const filter of filters) {
for (const filterScenario of rules.flatMap(rule => rule(filter))) { for (const filterScenario of rules.flatMap(rule => rule(filter, router))) {
const id = getFilterId(filterScenario.filter) const id = getFilterId(filterScenario.filter)
filtersById.set(id, filterScenario.filter) filtersById.set(id, filterScenario.filter)
@@ -435,9 +409,7 @@ export const getFilterSelections = (
const result = [] const result = []
for (const [id, filter] of filtersById.entries()) { for (const [id, filter] of filtersById.entries()) {
const scenario = Router.get() const scenario = router.merge(scenariosById.get(id) || []).policy(addMinimalFallbacks)
.merge(scenariosById.get(id) || [])
.policy(addMinimalFallbacks)
result.push({filters: [filter], relays: scenario.getUrls()}) result.push({filters: [filter], relays: scenario.getUrls()})
} }
+46
View File
@@ -110,6 +110,52 @@ importers:
specifier: ~5.8.0 specifier: ~5.8.0
version: 5.8.2 version: 5.8.2
packages/client:
dependencies:
fuse.js:
specifier: ^7.0.0
version: 7.1.0
throttle-debounce:
specifier: ^5.0.2
version: 5.0.2
devDependencies:
'@pomade/core':
specifier: ^0.2.1
version: 0.2.1(@frostr/bifrost@1.0.7(typescript@5.8.2))(@noble/hashes@2.0.1)(@welshman/lib@packages+lib)(@welshman/net@packages+net)(@welshman/signer@packages+signer)(@welshman/util@packages+util)(nostr-tools@2.19.4(typescript@5.8.2))
'@types/throttle-debounce':
specifier: ^5.0.2
version: 5.0.2
'@welshman/feeds':
specifier: workspace:*
version: link:../feeds
'@welshman/lib':
specifier: workspace:*
version: link:../lib
'@welshman/net':
specifier: workspace:*
version: link:../net
'@welshman/router':
specifier: workspace:*
version: link:../router
'@welshman/signer':
specifier: workspace:*
version: link:../signer
'@welshman/store':
specifier: workspace:*
version: link:../store
'@welshman/util':
specifier: workspace:*
version: link:../util
rimraf:
specifier: ~6.0.0
version: 6.0.1
svelte:
specifier: ^5.39.12
version: 5.46.3
typescript:
specifier: ~5.8.0
version: 5.8.2
packages/content: packages/content:
dependencies: dependencies:
'@braintree/sanitize-url': '@braintree/sanitize-url':