From e0e9ad58345e96a9b39eb00334c87105a9e42c98 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 28 Apr 2026 16:08:41 -0700 Subject: [PATCH] Add client package --- packages/client/.eslintignore | 3 + packages/client/package.json | 51 ++++ packages/client/src/index.ts | 2 + packages/client/src/thunk.ts | 405 ++++++++++++++++++++++++++++ packages/client/tsconfig.build.json | 19 ++ packages/client/tsconfig.json | 3 + packages/net/src/adapter.ts | 8 + packages/net/src/context.ts | 12 +- packages/net/src/pool.ts | 10 - packages/net/src/repository.ts | 10 - 10 files changed, 497 insertions(+), 26 deletions(-) create mode 100644 packages/client/.eslintignore create mode 100644 packages/client/package.json create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/thunk.ts create mode 100644 packages/client/tsconfig.build.json create mode 100644 packages/client/tsconfig.json diff --git a/packages/client/.eslintignore b/packages/client/.eslintignore new file mode 100644 index 0000000..4c72c12 --- /dev/null +++ b/packages/client/.eslintignore @@ -0,0 +1,3 @@ +build +normalize-url +__tests__ \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..bf1b6b3 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,51 @@ +{ + "name": "@welshman/app", + "version": "0.8.13", + "author": "hodlbod", + "license": "MIT", + "description": "A collection of svelte stores for use in building nostr client applications.", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "dist/app/src/index.js", + "types": "dist/app/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" + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..32426f7 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,2 @@ +export * from "./thunk.ts" +export * from "./client.ts" diff --git a/packages/client/src/thunk.ts b/packages/client/src/thunk.ts new file mode 100644 index 0000000..56a4b58 --- /dev/null +++ b/packages/client/src/thunk.ts @@ -0,0 +1,405 @@ +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" + +export type ThunkOptions = Override< + PublishOptions, + { + user: User + event: EventTemplate + recipient?: string + delay?: number + pow?: number + } +> + +export class Thunk { + _subs: Subscriber[] = [] + + event: HashedEvent + results: PublishResultsByRelay = {} + complete = defer() + 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.user.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.user.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.user.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.user.tracker.removeRelay(this.event.id, url) + this.options.user.tracker.track(signedEvent.id, url) + } + } + + this.options.user.repository.removeEvent(this.event.id) + this.options.user.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.user.tracker.track(this.event.id, url) + } + + this.options.user.repository.publish(this.event) + thunks.update($thunks => append(this, $thunks)) + + this.controller.signal.addEventListener("abort", () => { + if (this.wrap) { + this.user.wrapManager.remove(this.wrap.id) + } else { + this.options.user.repository.removeEvent(this.event.id) + } + + thunks.update($thunks => remove(this, $thunks)) + }) + } + + subscribe(subscriber: Subscriber) { + this._subs.push(subscriber) + + subscriber(this) + + return () => { + this._subs = remove(subscriber, this._subs) + } + } +} + +export class MergedThunk { + _subs: Subscriber[] = [] + + 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) { + 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(resolve => { + thunk.subscribe($thunk => { + const error = getThunkError($thunk) + + if (error !== undefined) { + resolve(error) + } + }) + }) + +export const waitForThunkCompletion = (thunk: Thunk) => + new Promise(resolve => { + thunk.subscribe($thunk => { + if (thunkIsComplete($thunk)) { + resolve() + } + }) + }) + +// Thunk state + +export const thunks = writable([]) + +export const thunkQueue = new TaskQueue({ + 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 { + 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) diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json new file mode 100644 index 0000000..524ea02 --- /dev/null +++ b/packages/client/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "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/signer": ["../signer/src/index.js"], + "@welshman/store": ["../store/src/index.js"], + "@welshman/util": ["../util/src/index.js"] + } + }, + + "include": [ + "src/**/*" + ] +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/net/src/adapter.ts b/packages/net/src/adapter.ts index 8d71706..0529d62 100644 --- a/packages/net/src/adapter.ts +++ b/packages/net/src/adapter.ts @@ -162,10 +162,18 @@ export const getAdapter = (url: string, adapterContext: AdapterContext = {}) => } 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) } if (isRelayUrl(url)) { + if (!context.pool) { + throw new Error("Unable to connect to relays without context.pool") + } + return new SocketAdapter(context.pool.get(url)) } diff --git a/packages/net/src/context.ts b/packages/net/src/context.ts index c69f954..97b984f 100644 --- a/packages/net/src/context.ts +++ b/packages/net/src/context.ts @@ -3,17 +3,17 @@ import {AbstractAdapter} from "./adapter.js" import {Repository} from "./repository.js" import {Pool} from "./pool.js" +export type AdapterFactory = (url: string, context: NetContext) => AbstractAdapter + export type NetContext = { - pool: Pool - repository: Repository isEventValid: (event: TrustedEvent, url: string) => boolean isEventDeleted: (event: TrustedEvent, url: string) => boolean - getAdapter?: (url: string, context: NetContext) => AbstractAdapter + pool?: Pool + repository?: Repository + getAdapter?: AdapterFactory } export const netContext: NetContext = { - pool: Pool.get(), - repository: Repository.get(), isEventValid: (event, url) => verifyEvent(event), - isEventDeleted: (event, url) => netContext.repository.isDeleted(event), + isEventDeleted: (event, url) => netContext.repository?.isDeleted(event) ?? false, } diff --git a/packages/net/src/pool.ts b/packages/net/src/pool.ts index 0382bf6..04337a6 100644 --- a/packages/net/src/pool.ts +++ b/packages/net/src/pool.ts @@ -9,20 +9,10 @@ export type PoolOptions = { makeSocket?: (url: string) => Socket } -export let poolSingleton: Pool - export class Pool { _data = new Map() _subs: PoolSubscription[] = [] - static get() { - if (!poolSingleton) { - poolSingleton = new Pool() - } - - return poolSingleton - } - constructor(readonly options: PoolOptions = {}) {} has(url: string) { diff --git a/packages/net/src/repository.ts b/packages/net/src/repository.ts index 28410e4..d30361d 100644 --- a/packages/net/src/repository.ts +++ b/packages/net/src/repository.ts @@ -26,8 +26,6 @@ export const LOCAL_RELAY_URL = "local://welshman.relay/" const getDay = (ts: number) => Math.floor(ts / DAY) -export let repositorySingleton: Repository - export type RepositoryUpdate = { added: TrustedEvent[] removed: Set @@ -61,14 +59,6 @@ export class Repository extends Emitter { deletes = new Map() expired = new Map() - static get() { - if (!repositorySingleton) { - repositorySingleton = new Repository() - } - - return repositorySingleton - } - constructor() { super()