diff --git a/package-lock.json b/package-lock.json index 4f6892c..3566164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6333,7 +6333,7 @@ "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" @@ -6982,7 +6982,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/tsutils": { @@ -7027,15 +7027,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", - "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", - "license": "MIT", - "optionalDependencies": { - "rxjs": "*" - } - }, "node_modules/typedoc": { "version": "0.27.9", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.27.9.tgz", @@ -7836,8 +7827,7 @@ "@welshman/lib": "^0.1.1", "@welshman/relay": "^0.1.1", "@welshman/util": "^0.1.2", - "isomorphic-ws": "^5.0.0", - "typed-emitter": "^2.1.0" + "isomorphic-ws": "^5.0.0" } }, "packages/net2": { diff --git a/package.json b/package.json index e71fe42..860dc01 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,13 @@ }, "devDependencies": { "@vitest/coverage-v8": "^3.0.5", + "fake-indexeddb": "^6.0.0", "gts": "^6.0.2", "happy-dom": "^17.1.0", "typedoc": "^0.27.9", "typedoc-plugin-markdown": "^4.4.2", "typedoc-vitepress-theme": "^1.1.2", "typescript": "^5.6.3", - "fake-indexeddb": "^6.0.0", "vitepress": "^1.6.3", "vitest": "^3.0.5" } diff --git a/packages/app/src/core.ts b/packages/app/src/core.ts index 0650ab0..32f8766 100644 --- a/packages/app/src/core.ts +++ b/packages/app/src/core.ts @@ -1,12 +1,14 @@ import {throttle} from "@welshman/lib" import {verifyEvent, isEphemeralKind, isDVMKind} from "@welshman/util" -import {Repository} from "@welshman/relay" +import {Repository, LocalRelay} from "@welshman/relay" import {Pool, Tracker, SocketEvent, isRelayEvent} from "@welshman/net" import {custom} from "@welshman/store" import {loadRelay, trackRelayStats} from "./relays.js" export const repository = Repository.getSingleton() +export const relay = new LocalRelay(repository) + export const tracker = new Tracker() Pool.getSingleton().subscribe(socket => { diff --git a/packages/app/src/thunk.ts b/packages/app/src/thunk.ts index c67746b..8a7feb0 100644 --- a/packages/app/src/thunk.ts +++ b/packages/app/src/thunk.ts @@ -26,6 +26,7 @@ export type ThunkRequest = { event: ThunkEvent relays: string[] delay?: number + timeout?: number context?: AdapterContext } @@ -226,6 +227,7 @@ export const thunkQueue = new TaskQueue({ event: signedEvent, relays: thunk.request.relays, context: thunk.request.context, + timeout: thunk.request.timeout, }) // Copy the signature over since we had deferred it diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index 03bb8f0..ffdd257 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -1118,21 +1118,20 @@ export const pushToMapKey = (m: Map, k: K, v: T) => { } /** - * A generic type-safe event listener function that auto-detects the appropriate methods - * for adding and removing event listeners. + * A generic type-safe event listener function that works with event emitters. * * @param target - The event target object with add/remove listener methods * @param eventName - The name of the event to listen for * @param callback - The callback function to execute when the event occurs * @returns A function that removes the event listener when called */ -export const on = ( +export const on = , E extends keyof EventMap>( target: { - on: (event: EventName, handler: (...args: Args) => any, ...rest: any[]) => any - off: (event: EventName, handler: (...args: Args) => any, ...rest: any[]) => any + on(event: E, listener: (...args: EventMap[E]) => any): any + off(event: E, listener: (...args: EventMap[E]) => any): any }, - eventName: EventName, - callback: (...args: Args) => void, + eventName: E, + callback: (...args: EventMap[E]) => void, ): (() => void) => { target.on(eventName, callback) diff --git a/packages/net/package.json b/packages/net/package.json index efad6f5..0b012ca 100644 --- a/packages/net/package.json +++ b/packages/net/package.json @@ -27,9 +27,8 @@ }, "dependencies": { "@welshman/lib": "^0.1.1", - "@welshman/util": "^0.1.2", "@welshman/relay": "^0.1.1", - "isomorphic-ws": "^5.0.0", - "typed-emitter": "^2.1.0" + "@welshman/util": "^0.1.2", + "isomorphic-ws": "^5.0.0" } } diff --git a/packages/net/src/Pool.ts b/packages/net/src/Pool.ts index eff0ea5..773eeae 100644 --- a/packages/net/src/Pool.ts +++ b/packages/net/src/Pool.ts @@ -1,6 +1,7 @@ import {remove} from "@welshman/lib" import {normalizeRelayUrl} from "@welshman/util" import {Socket} from "./socket.js" +import {AuthState} from "./auth.js" import {defaultSocketPolicies} from "./policy.js" export const makeSocket = (url: string, policies = defaultSocketPolicies) => { @@ -21,8 +22,13 @@ export type PoolOptions = { export let poolSingleton: Pool +export type PoolItem = { + socket: Socket + auth: AuthState +} + export class Pool { - _data = new Map() + _data = new Map() _subs: PoolSubscription[] = [] static getSingleton() { @@ -49,15 +55,15 @@ export class Pool { get(_url: string): Socket { const url = normalizeRelayUrl(_url) - const oldSocket = this._data.get(url) + const item = this._data.get(url) - if (oldSocket) { - return oldSocket + if (item) { + return item.socket } const socket = this.makeSocket(url) - this._data.set(url, socket) + this._data.set(url, {socket, auth: new AuthState(socket)}) for (const cb of this._subs) { cb(socket) @@ -66,6 +72,10 @@ export class Pool { return socket } + getAuth(url: string) { + return this._data.get(normalizeRelayUrl(url))?.auth + } + subscribe(cb: PoolSubscription) { this._subs.push(cb) @@ -75,10 +85,11 @@ export class Pool { } remove(url: string) { - const socket = this._data.get(url) + const item = this._data.get(url) - if (socket) { - socket.cleanup() + if (item) { + item.socket.cleanup() + item.auth.cleanup() this._data.delete(url) } diff --git a/packages/net/src/Publish.ts b/packages/net/src/Publish.ts index f9f97c3..fa839f4 100644 --- a/packages/net/src/Publish.ts +++ b/packages/net/src/Publish.ts @@ -3,7 +3,6 @@ import {on, fromPairs, sleep, yieldThread} from "@welshman/lib" import {SignedEvent} from "@welshman/util" import {RelayMessage, ClientMessageType, isRelayOk} from "./message.js" import {AbstractAdapter, AdapterEvent, AdapterContext, getAdapter} from "./adapter.js" -import {TypedEmitter} from "./util.js" export enum PublishStatus { Pending = "publish:status:pending", @@ -23,14 +22,6 @@ export enum PublishEvent { // SinglePublish -export type SinglePublishEvents = { - [PublishEvent.Success]: (id: string, detail: string) => void - [PublishEvent.Failure]: (id: string, detail: string) => void - [PublishEvent.Timeout]: () => void - [PublishEvent.Aborted]: () => void - [PublishEvent.Complete]: () => void -} - export type SinglePublishOptions = { event: SignedEvent relay: string @@ -38,7 +29,7 @@ export type SinglePublishOptions = { timeout?: number } -export class SinglePublish extends (EventEmitter as new () => TypedEmitter) { +export class SinglePublish extends EventEmitter { status = PublishStatus.Pending _unsubscriber: () => void @@ -107,19 +98,11 @@ export class SinglePublish extends (EventEmitter as new () => TypedEmitter void - [PublishEvent.Failure]: (id: string, detail: string, url: string) => void - [PublishEvent.Timeout]: (url: string) => void - [PublishEvent.Aborted]: (url: string) => void - [PublishEvent.Complete]: () => void -} - export type MultiPublishOptions = Omit & { relays: string[] } -export class MultiPublish extends (EventEmitter as new () => TypedEmitter) { +export class MultiPublish extends EventEmitter { status: Record _children: SinglePublish[] = [] diff --git a/packages/net/src/Socket.ts b/packages/net/src/Socket.ts index d9a0f61..933dc57 100644 --- a/packages/net/src/Socket.ts +++ b/packages/net/src/Socket.ts @@ -2,7 +2,6 @@ import WebSocket from "isomorphic-ws" import EventEmitter from "events" import {TaskQueue} from "@welshman/lib" import {RelayMessage, ClientMessage} from "./message.js" -import {TypedEmitter} from "./util.js" export enum SocketStatus { Open = "socket:status:open", @@ -29,7 +28,7 @@ export type SocketEvents = { [SocketEvent.Receive]: (message: RelayMessage, url: string) => void } -export class Socket extends (EventEmitter as new () => TypedEmitter) { +export class Socket extends EventEmitter { status = SocketStatus.Closed _ws?: WebSocket @@ -57,6 +56,9 @@ export class Socket extends (EventEmitter as new () => TypedEmitter { this.status = status }) + + this._sendQueue.stop() + this.setMaxListeners(1000) } open = () => { @@ -74,15 +76,16 @@ export class Socket extends (EventEmitter as new () => TypedEmitter { - this.emit(SocketEvent.Status, SocketStatus.Error, this.url) - this._sendQueue.stop() this._ws = undefined + this._sendQueue.stop() + this.emit(SocketEvent.Status, SocketStatus.Error, this.url) } this._ws.onclose = () => { - this.emit(SocketEvent.Status, SocketStatus.Closed, this.url) - this._sendQueue.stop() this._ws = undefined + this._sendQueue.stop() + console.log("socket closed", this.url) + this.emit(SocketEvent.Status, SocketStatus.Closed, this.url) } this._ws.onmessage = (event: any) => { diff --git a/packages/net/src/adapter.ts b/packages/net/src/adapter.ts index eb84435..5d83dfd 100644 --- a/packages/net/src/adapter.ts +++ b/packages/net/src/adapter.ts @@ -4,7 +4,7 @@ import {isRelayUrl} from "@welshman/util" import {LocalRelay, LOCAL_RELAY_URL} from "@welshman/relay" import {RelayMessage, ClientMessage} from "./message.js" import {Socket, SocketEvent} from "./socket.js" -import {TypedEmitter, Unsubscriber} from "./util.js" +import {Unsubscriber} from "./util.js" import {netContext, NetContext} from "./context.js" export enum AdapterEvent { @@ -15,7 +15,7 @@ export type AdapterEvents = { [AdapterEvent.Receive]: (message: RelayMessage, url: string) => void } -export abstract class AbstractAdapter extends (EventEmitter as new () => TypedEmitter) { +export abstract class AbstractAdapter extends EventEmitter { _unsubscribers: Unsubscriber[] = [] abstract urls: string[] diff --git a/packages/net/src/auth.ts b/packages/net/src/auth.ts index a91a5a2..40e6272 100644 --- a/packages/net/src/auth.ts +++ b/packages/net/src/auth.ts @@ -1,10 +1,10 @@ import EventEmitter from "events" -import {on, call, sleep} from "@welshman/lib" -import type {SignedEvent, StampedEvent} from "@welshman/util" +import {on, call} from "@welshman/lib" +import {SignedEvent, StampedEvent} from "@welshman/util" import {makeEvent, CLIENT_AUTH} from "@welshman/util" import {isRelayAuth, isClientAuth, isRelayOk, RelayMessage} from "./message.js" import {Socket, SocketStatus, SocketEvent} from "./socket.js" -import {TypedEmitter, Unsubscriber} from "./util.js" +import {Unsubscriber} from "./util.js" export const makeAuthEvent = (url: string, challenge: string) => makeEvent(CLIENT_AUTH, { @@ -37,7 +37,7 @@ export type AuthStateEvents = { [AuthStateEvent.Status]: (status: AuthStatus) => void } -export class AuthState extends (EventEmitter as new () => TypedEmitter) { +export class AuthState extends EventEmitter { challenge: string | undefined request: string | undefined details: string | undefined @@ -52,6 +52,8 @@ export class AuthState extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter { if (isClientAuth(message)) { + console.log("client auth", message) this.setStatus(AuthStatus.PendingResponse) } }), on(socket, SocketEvent.Status, (status: SocketStatus) => { if (status === SocketStatus.Closed) { + console.log("closed") this.challenge = undefined this.request = undefined this.details = undefined @@ -93,92 +99,32 @@ export class AuthState extends (EventEmitter as new () => TypedEmitter Promise) { + if (!this.challenge) { + throw new Error("Attempted to authenticate with no challenge") + } + + if (this.status !== AuthStatus.Requested) { + throw new Error(`Attempted to authenticate when auth is already ${this.status}`) + } + + this.setStatus(AuthStatus.PendingSignature) + + const template = makeAuthEvent(this.socket.url, this.challenge) + const event = await sign(template) + + console.log(event) + + if (event) { + this.request = event.id + this.socket.send(["AUTH", event]) + } else { + this.setStatus(AuthStatus.DeniedSignature) + } + } + cleanup() { this.removeAllListeners() this._unsubscribers.forEach(call) } } - -export type AuthManagerOptions = { - sign: (event: StampedEvent) => Promise - eager?: boolean -} - -export class AuthManager { - state: AuthState - - constructor( - readonly socket: Socket, - readonly options: AuthManagerOptions, - ) { - this.state = new AuthState(socket) - this.state.on(AuthStateEvent.Status, (status: string) => { - if (status === AuthStatus.Requested && options.eager) { - this.respond() - } - }) - } - - async waitFor(condition: () => boolean, timeout = 300) { - const start = Date.now() - - while (Date.now() - timeout <= start) { - if (condition()) { - break - } - - await sleep(Math.min(100, Math.ceil(timeout / 3))) - } - } - - async waitForChallenge(timeout = 300) { - await this.waitFor(() => Boolean(this.state.challenge), timeout) - } - - async waitForResolution(timeout = 300) { - await this.waitFor( - () => - [AuthStatus.None, AuthStatus.DeniedSignature, AuthStatus.Forbidden, AuthStatus.Ok].includes( - this.state.status, - ), - timeout, - ) - } - - async attempt(timeout = 300) { - await this.socket.attemptToOpen() - await this.waitForChallenge(Math.ceil(timeout / 2)) - - if (this.state.status === AuthStatus.Requested) { - await this.respond() - } - - await this.waitForResolution(Math.ceil(timeout / 2)) - } - - async respond() { - if (!this.state.challenge) { - throw new Error("Attempted to authenticate with no challenge") - } - - if (this.state.status !== AuthStatus.Requested) { - throw new Error(`Attempted to authenticate when auth is already ${this.state.status}`) - } - - this.state.setStatus(AuthStatus.PendingSignature) - - const template = makeAuthEvent(this.socket.url, this.state.challenge) - const event = await this.options.sign(template) - - if (event) { - this.state.request = event.id - this.socket.send(["AUTH", event]) - } else { - this.state.setStatus(AuthStatus.DeniedSignature) - } - } - - cleanup() { - this.state.cleanup() - } -} diff --git a/packages/net/src/diff.ts b/packages/net/src/diff.ts index 6673deb..168d1ba 100644 --- a/packages/net/src/diff.ts +++ b/packages/net/src/diff.ts @@ -1,7 +1,6 @@ import {EventEmitter} from "events" import {on, sleep, randomId, groupBy, pushToMapKey, inc, flatten, chunk} from "@welshman/lib" import {SignedEvent, Filter} from "@welshman/util" -import {TypedEmitter} from "./util.js" import { RelayMessage, isRelayNegErr, @@ -33,7 +32,7 @@ export type DifferenceOptions = { context?: AdapterContext } -export class Difference extends (EventEmitter as new () => TypedEmitter) { +export class Difference extends EventEmitter { have = new Set() need = new Set() @@ -139,7 +138,6 @@ export const diff = async ({relays, filters, ...options}: DiffOptions) => { diff.on(DifferenceEvent.Close, () => { resolve({relay, have: diff.have, need: diff.need}) - diff.close() }) diff.on(DifferenceEvent.Error, (url, message) => { diff --git a/packages/net/src/policy.ts b/packages/net/src/policy.ts index 0ee53c4..f410dcf 100644 --- a/packages/net/src/policy.ts +++ b/packages/net/src/policy.ts @@ -1,5 +1,5 @@ -import {on, call, sleep, spec, ago, now} from "@welshman/lib" -import {AUTH_JOIN} from "@welshman/util" +import {on, always, call, sleep, spec, ago, now} from "@welshman/lib" +import {AUTH_JOIN, StampedEvent, SignedEvent} from "@welshman/util" import { ClientMessage, isClientAuth, @@ -134,7 +134,6 @@ export const socketPolicyRetryAuthRequired = (socket: Socket) => { */ export const socketPolicyConnectOnSend = (socket: Socket) => { let lastError = 0 - let currentStatus = SocketStatus.Closed const unsubscribers = [ on(socket, SocketEvent.Status, (newStatus: SocketStatus) => { @@ -142,13 +141,10 @@ export const socketPolicyConnectOnSend = (socket: Socket) => { if (newStatus === SocketStatus.Error) { lastError = now() } - - // Keep track of the current status - currentStatus = newStatus }), on(socket, SocketEvent.Enqueue, (message: ClientMessage) => { // When a new message is sent, make sure the socket is open (unless there was a recent error) - if (currentStatus === SocketStatus.Closed && lastError < ago(30)) { + if (socket.status === SocketStatus.Closed && lastError < ago(30)) { socket.open() } }), @@ -235,6 +231,34 @@ export const socketPolicyReopenActive = (socket: Socket) => { return () => unsubscribers.forEach(call) } +export type SocketPolicyAuthOptions = { + sign: (event: StampedEvent) => Promise + shouldAuth?: (socket: Socket) => boolean +} + +/** + * Factory function for a policy which may authenticate the socket + * @param options - SocketPolicyAuthOptions object + * @return a socket policy + */ +export const makeSocketPolicyAuth = (options: SocketPolicyAuthOptions) => (socket: Socket) => { + const authState = new AuthState(socket) + const shouldAuth = options.shouldAuth || always(true) + + const unsubscribers = [ + on(authState, AuthStateEvent.Status, (status: AuthStatus) => { + if (status === AuthStatus.Requested && shouldAuth(socket)) { + authState.authenticate(options.sign) + } + }), + ] + + return () => { + unsubscribers.forEach(call) + authState.cleanup() + } +} + export const defaultSocketPolicies = [ socketPolicyDeferOnAuth, socketPolicyRetryAuthRequired, diff --git a/packages/net/src/request.ts b/packages/net/src/request.ts index eadefdf..89a9f1c 100644 --- a/packages/net/src/request.ts +++ b/packages/net/src/request.ts @@ -10,7 +10,7 @@ import { import {RelayMessage, ClientMessageType, isRelayEvent, isRelayEose} from "./message.js" import {getAdapter, AdapterContext, AbstractAdapter, AdapterEvent} from "./adapter.js" import {SocketEvent, SocketStatus} from "./socket.js" -import {TypedEmitter, Unsubscriber} from "./util.js" +import {Unsubscriber} from "./util.js" import {netContext} from "./context.js" import {Tracker} from "./tracker.js" @@ -41,6 +41,7 @@ export type SingleRequestEvents = { export type SingleRequestOptions = { relay: string filters: Filter[] + signal?: AbortSignal context?: AdapterContext timeout?: number tracker?: Tracker @@ -49,10 +50,7 @@ export type SingleRequestOptions = { isEventDeleted?: (event: TrustedEvent, url: string) => boolean } -// Needed for typescript to infer emitter methods -export interface SingleRequest extends TypedEmitter {} - -export class SingleRequest extends (EventEmitter as new () => TypedEmitter) { +export class SingleRequest extends EventEmitter { _ids = new Set() _eose = new Set() _unsubscribers: Unsubscriber[] = [] @@ -128,6 +126,9 @@ export class SingleRequest extends (EventEmitter as new () => TypedEmitter this.close(), this.options.timeout || 10000) } + // Handle abort signal + this.options.signal?.addEventListener("abort", () => this.close()) + // Start asynchronously so the caller can set up listeners yieldThread().then(() => { for (const filter of this.options.filters) { @@ -171,10 +172,7 @@ export type MultiRequestOptions = Omit & { relays: string[] } -// Needed for typescript to infer emitter methods -export interface MultiRequest extends TypedEmitter {} - -export class MultiRequest extends (EventEmitter as new () => TypedEmitter) { +export class MultiRequest extends EventEmitter { _children: SingleRequest[] = [] _closed = new Set() diff --git a/packages/net/src/util.ts b/packages/net/src/util.ts index c1abb8a..ec47437 100644 --- a/packages/net/src/util.ts +++ b/packages/net/src/util.ts @@ -1,5 +1 @@ -import TypedEventEmitter, {EventMap} from "typed-emitter" - -export type TypedEmitter = TypedEventEmitter.default - export type Unsubscriber = () => void diff --git a/packages/util/src/Events.ts b/packages/util/src/Events.ts index 57c694c..43e4c10 100644 --- a/packages/util/src/Events.ts +++ b/packages/util/src/Events.ts @@ -1,7 +1,7 @@ import {verifiedSymbol, verifyEvent as verifyEventPure} from "nostr-tools/pure" import {setNostrWasm, verifyEvent as verifyEventWasm} from "nostr-tools/wasm" import {initNostrWasm} from "nostr-wasm" -import {mapVals, first, pick, now} from "@welshman/lib" +import {mapVals, noop, first, pick, now} from "@welshman/lib" import {getReplyTagValues, getCommentTagValues} from "./Tags.js" import {getAddress, Address} from "./Address.js" import { @@ -66,7 +66,7 @@ export const verifyEvent = (() => { if (typeof WebAssembly === "object") { initNostrWasm() - .then(setNostrWasm) + .then(setNostrWasm, noop) .then(() => { verify = verifyEventWasm })