Get rid of typed emitter

This commit is contained in:
Jon Staab
2025-04-02 08:54:50 -07:00
parent 1cbcb0ae4a
commit 35f75bb38e
16 changed files with 124 additions and 173 deletions
+19 -8
View File
@@ -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<string, Socket>()
_data = new Map<string, PoolItem>()
_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)
}
+2 -19
View File
@@ -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<SinglePublishEvents>) {
export class SinglePublish extends EventEmitter {
status = PublishStatus.Pending
_unsubscriber: () => void
@@ -107,19 +98,11 @@ export class SinglePublish extends (EventEmitter as new () => TypedEmitter<Singl
// MultiPublish
export type MultiPublishEvents = {
[PublishEvent.Success]: (id: string, detail: string, url: string) => 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<SinglePublishOptions, "relay"> & {
relays: string[]
}
export class MultiPublish extends (EventEmitter as new () => TypedEmitter<MultiPublishEvents>) {
export class MultiPublish extends EventEmitter {
status: Record<string, PublishStatus>
_children: SinglePublish[] = []
+9 -6
View File
@@ -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<SocketEvents>) {
export class Socket extends EventEmitter {
status = SocketStatus.Closed
_ws?: WebSocket
@@ -57,6 +56,9 @@ export class Socket extends (EventEmitter as new () => TypedEmitter<SocketEvents
this.on(SocketEvent.Status, (status: SocketStatus) => {
this.status = status
})
this._sendQueue.stop()
this.setMaxListeners(1000)
}
open = () => {
@@ -74,15 +76,16 @@ export class Socket extends (EventEmitter as new () => TypedEmitter<SocketEvents
}
this._ws.onerror = () => {
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) => {
+2 -2
View File
@@ -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<AdapterEvents>) {
export abstract class AbstractAdapter extends EventEmitter {
_unsubscribers: Unsubscriber[] = []
abstract urls: string[]
+34 -88
View File
@@ -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<AuthStateEvents>) {
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<AuthState
if (isRelayOk(message)) {
const [_, id, ok, details] = message
console.log("ok", message)
if (id === this.request) {
this.details = details
@@ -66,6 +68,8 @@ export class AuthState extends (EventEmitter as new () => TypedEmitter<AuthState
if (isRelayAuth(message)) {
const [_, challenge] = message
console.log("relay auth", message)
this.challenge = challenge
this.request = undefined
this.details = undefined
@@ -74,11 +78,13 @@ export class AuthState extends (EventEmitter as new () => TypedEmitter<AuthState
}),
on(socket, SocketEvent.Enqueue, (message: RelayMessage) => {
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<AuthState
this.emit(AuthStateEvent.Status, status)
}
async authenticate(sign: (event: StampedEvent) => Promise<SignedEvent>) {
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<SignedEvent>
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()
}
}
+1 -3
View File
@@ -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<DifferenceEvents>) {
export class Difference extends EventEmitter {
have = new Set<string>()
need = new Set<string>()
@@ -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) => {
+31 -7
View File
@@ -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<SignedEvent>
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,
+7 -9
View File
@@ -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<SingleRequestEvents> {}
export class SingleRequest extends (EventEmitter as new () => TypedEmitter<SingleRequestEvents>) {
export class SingleRequest extends EventEmitter {
_ids = new Set<string>()
_eose = new Set<string>()
_unsubscribers: Unsubscriber[] = []
@@ -128,6 +126,9 @@ export class SingleRequest extends (EventEmitter as new () => TypedEmitter<Singl
setTimeout(() => 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<SingleRequestOptions, "relay"> & {
relays: string[]
}
// Needed for typescript to infer emitter methods
export interface MultiRequest extends TypedEmitter<MultiRequestEvents> {}
export class MultiRequest extends (EventEmitter as new () => TypedEmitter<MultiRequestEvents>) {
export class MultiRequest extends EventEmitter {
_children: SingleRequest[] = []
_closed = new Set<string>()
-4
View File
@@ -1,5 +1 @@
import TypedEventEmitter, {EventMap} from "typed-emitter"
export type TypedEmitter<T extends EventMap> = TypedEventEmitter.default<T>
export type Unsubscriber = () => void