Add client package

This commit is contained in:
Jon Staab
2026-04-28 16:08:41 -07:00
parent 48bf9d6ebe
commit e0e9ad5834
10 changed files with 497 additions and 26 deletions
+3
View File
@@ -0,0 +1,3 @@
build
normalize-url
__tests__
+51
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./thunk.ts"
export * from "./client.ts"
+405
View File
@@ -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<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.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<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)
+19
View File
@@ -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/**/*"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
+8
View File
@@ -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))
}
+6 -6
View File
@@ -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,
}
-10
View File
@@ -9,20 +9,10 @@ export type PoolOptions = {
makeSocket?: (url: string) => Socket
}
export let poolSingleton: Pool
export class Pool {
_data = new Map<string, Socket>()
_subs: PoolSubscription[] = []
static get() {
if (!poolSingleton) {
poolSingleton = new Pool()
}
return poolSingleton
}
constructor(readonly options: PoolOptions = {}) {}
has(url: string) {
-10
View File
@@ -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<string>
@@ -61,14 +59,6 @@ export class Repository extends Emitter {
deletes = new Map<string, {created_at: number; pubkey: string}[]>()
expired = new Map<string, number>()
static get() {
if (!repositorySingleton) {
repositorySingleton = new Repository()
}
return repositorySingleton
}
constructor() {
super()