Improve consistency of interface

This commit is contained in:
Jonathan Staab
2023-03-27 15:05:34 -05:00
parent 853b42c1c9
commit 9b6a779397
9 changed files with 223 additions and 165 deletions
+46
View File
@@ -0,0 +1,46 @@
# Paravel
Another nostr toolkit, focused on creating highly a configurable client system.
# Utilities
- [Deferred](./lib/Deferred.ts') is just a promise with `resolve` and `reject` methods.
- [EventBus](./lib/EventBus.ts') is an implementation of an event bus.
- [Socket](./lib/Socket.ts') is a wrapper around isomorphic-ws that handles connection status and json parsing/serialization.
# Components
- [Pool](./lib/Pool.ts') is a thin wrapper around `Map` for use with `Relay`s.
- [Executor](./lib/Executor.ts') implements common nostr flows on `target`
# Executables
Executables have an event `bus` and a `send` method and are passed to an `Executor` for use.
- [Relay](./lib/Relay.ts') takes a `Socket` and provides listeners for different verbs.
- [Relays](./lib/Relays.ts') takes an array of `Socket`s and provides listeners for different verbs, merging all events into a single stream.
- [Plex](./lib/Plex.ts') takes an array of urls and a `Socket` and sends and receives wrapped nostr messages over that connection.
# Example
Functionality is split into small chunks to allow for changing out implementations as needed. This is useful when attempting to support novel use cases. Here's a simple implementation of an agent that can use a multiplexer if enabled, or can fall back to communicating directly with all relays.
```javascript
class Agent {
constructor(multiplexerUrl) {
this.multiplexerUrl = multiplexerUrl
this.pool = new Pool()
}
getTarget(urls) {
return this.multiplexerUrl
? new Plex(urls, this.pool.add(this.multiplexerUrl))
: new Relays(urls.map(url => this.pool.add(url)))
}
}
subscribe(urls, filters, id, {onEvent, onEose}) {
const executor = new Executor(this.getTarget(urls))
return executor.subscribe(filters, id, {onEvent, onEose})
}
}
```
+2 -2
View File
@@ -1,12 +1,12 @@
import type {EventBus} from './util/EventBus.ts' import type {EventBus} from './util/EventBus.ts'
type ExecutorTarget = { type Executable = {
bus: EventBus bus: EventBus
send: (verb: string, ...args) => void send: (verb: string, ...args) => void
} }
export class Executor { export class Executor {
target: ExecutorTarget target: Executable
constructor(target) { constructor(target) {
this.target = target this.target = target
} }
+22
View File
@@ -0,0 +1,22 @@
import {EventBus} from "./util/EventBus"
export class Plex {
constructor(urls, socket) {
this.urls = urls
this.socket = socket
this.bus = new EventBus()
this.onMessage = this.onMessage.bind(this)
this.socket.bus.on('message', this.onMessage)
}
async send(...payload) {
await this.socket.connect()
this.socket.send([{relays: this.urls}, payload])
}
onMessage(message) {
const [verb, ...payload] = message[1]
this.bus.handle(verb, ...payload)
}
}
+3 -16
View File
@@ -1,9 +1,7 @@
import {Relay} from "./Relay" import {Socket} from "./util/Socket"
const normalizeUrl = url => url.replace(/\/+$/, "").toLowerCase().trim()
export class Pool { export class Pool {
relays: Map<string, Relay> relays: Map<string, Socket>
constructor() { constructor() {
this.relays = new Map() this.relays = new Map()
this.interval = setInterval(() => { this.interval = setInterval(() => {
@@ -13,17 +11,13 @@ export class Pool {
}, 30_000) }, 30_000)
} }
add(url) { add(url) {
url = normalizeUrl(url)
if (!this.relays.has(url)) { if (!this.relays.has(url)) {
this.relays.set(url, new Relay(url)) this.relays.set(url, new Socket(url))
} }
return this.relays.get(url) return this.relays.get(url)
} }
remove(url) { remove(url) {
url = normalizeUrl(url)
this.relays.get(url)?.disconnect() this.relays.get(url)?.disconnect()
this.relays.delete(url) this.relays.delete(url)
} }
@@ -34,11 +28,4 @@ export class Pool {
this.remove(url) this.remove(url)
} }
} }
async waitFor(url) {
const relay = this.add(url)
await relay.connect()
return relay.status === Relay.STATUS.READY ? relay : null
}
} }
+11 -108
View File
@@ -1,118 +1,21 @@
import WebSocket from "isomorphic-ws"
import {EventBus} from "./util/EventBus" import {EventBus} from "./util/EventBus"
import {Deferred, defer} from "./util/Deferred"
export class Relay { export class Relay {
ws?: WebSocket constructor(socket) {
url: string this.socket = socket
ready?: Deferred<void>
queue: string[]
error: string
status: string
timeout?: NodeJS.Timeout
bus: EventBus
static STATUS = {
NEW: "new",
PENDING: "pending",
CLOSED: "closed",
ERROR: "error",
READY: "ready",
}
static ERROR = {
CONNECTION: "connection",
UNAUTHORIZED: "unauthorized",
FORBIDDEN: "forbidden",
}
constructor(url) {
this.ws = null
this.url = url
this.ready = null
this.queue = []
this.timeout = null
this.bus = new EventBus() this.bus = new EventBus()
this.error = null this.onMessage = this.onMessage.bind(this)
this.status = Relay.STATUS.NEW
this.socket.bus.on('message', this.onMessage)
} }
async connect() { async send(...payload) {
if (this.status === Relay.STATUS.NEW) { await this.socket.connect()
if (this.ws) {
console.error("Attempted to connect when already connected", this)
}
this.ready = defer() this.socket.send(payload)
this.ws = new WebSocket(this.url)
this.status = Relay.STATUS.PENDING
this.ws.addEventListener("open", () => {
console.log(`Opened connection to ${this.url}`)
this.status = Relay.STATUS.READY
this.ready.resolve()
})
this.ws.addEventListener("message", e => {
this.queue.push(e.data)
if (!this.timeout) {
this.timeout = setTimeout(() => this.handleMessages(), 10)
}
})
this.ws.addEventListener("error", e => {
console.log(`Error on connection to ${this.url}`)
this.disconnect()
this.ready.reject()
this.error = Relay.ERROR.CONNECTION
this.status = Relay.STATUS.CLOSED
})
this.ws.addEventListener("close", () => {
console.log(`Closed connection to ${this.url}`)
this.disconnect()
this.ready.reject()
this.status = Relay.STATUS.CLOSED
})
}
await this.ready.catch(() => null)
} }
reconnect() { onMessage(message) {
if (this.status === Relay.STATUS.ERROR) { const [verb, ...payload] = message
this.status = Relay.STATUS.NEW
this.connect()
}
}
disconnect() {
if (this.ws) {
console.log(`Disconnecting from ${this.url}`)
this.ws.close() this.bus.handle(verb, ...payload)
this.ws = null
}
}
handleMessages() {
for (const json of this.queue.splice(0, 10)) {
let message
try {
message = JSON.parse(json)
} catch (e) {
continue
}
const [verb, ...args] = message
this.bus.handle(verb, ...args)
}
this.timeout = this.queue.length > 0 ? setTimeout(() => this.handleMessages(), 10) : null
}
send(...payload) {
if (this.ws?.readyState !== 1) {
console.warn("Send attempted before socket was ready", this)
}
this.ws.send(JSON.stringify(payload))
} }
} }
-24
View File
@@ -1,24 +0,0 @@
import type {Relay} from './Relay'
import {EventBus} from './util/EventBus'
export class RelaySet {
relays: Relay[]
bus: EventBus
constructor(relays) {
this.relays = relays
this.bus = new EventBus()
relays.forEach(relay => {
relay.bus.pipe(EventBus.ANY, this.bus)
})
}
send(...payload) {
this.relays.forEach(async relay => {
await relay.connect()
if (relay.status === Relay.STATUS.READY) {
relay.send(...payload)
}
})
}
}
+26
View File
@@ -0,0 +1,26 @@
import {Socket} from './util/Socket'
import {EventBus} from './util/EventBus'
export class Relays {
sockets: Socket[]
bus: EventBus
constructor(sockets) {
this.sockets = sockets
this.bus = new EventBus()
this.onMessage = this.onMessage.bind(this)
sockets.forEach(socket => socket.bus.on('message', this.onMessage))
}
send(...payload) {
this.sockets.forEach(socket => {
await socket.connect()
socket.send(...payload)
})
}
onMessage(message) {
const [verb, ...payload] = message
this.bus.handle(verb, ...payload)
}
}
+7 -15
View File
@@ -1,32 +1,24 @@
export type EventBusHandler = (...args: any[]) => void export type EventBusHandler = (...args: any[]) => void
export type EventBusListener = {
id: string
handler: EventBusHandler
}
export class EventBus { export class EventBus {
static ANY = Math.random().toString().slice(2) static ANY = Math.random().toString().slice(2)
listeners: Record<string, Array<EventBusListener>> = {} listeners: Record<string, Array<EventBusHandler>> = {}
on(name: string, handler: EventBusHandler) { on(name: string, handler: EventBusHandler) {
const id = Math.random().toString().slice(2) this.listeners[name] = this.listeners[name] || ([] as Array<EventBusHandler>)
this.listeners[name].push(handler)
this.listeners[name] = this.listeners[name] || ([] as Array<EventBusListener>)
this.listeners[name].push({id, handler})
return id
} }
off(name: string, id: string) { off(name: string, handler: EventBusHandler) {
this.listeners[name] = this.listeners[name].filter(l => l.id !== id) this.listeners[name] = this.listeners[name].filter(h => h !== handler)
} }
clear() { clear() {
this.listeners = {} this.listeners = {}
} }
handle(k: string, ...payload: any) { handle(k: string, ...payload: any) {
for (const {handler} of this.listeners[k] || []) { for (const handler of this.listeners[k] || []) {
handler(...payload) handler(...payload)
} }
for (const {handler} of this.listeners[EventBus.ANY] || []) { for (const handler of this.listeners[EventBus.ANY] || []) {
handler(k, ...payload) handler(k, ...payload)
} }
} }
+106
View File
@@ -0,0 +1,106 @@
import WebSocket from "isomorphic-ws"
import {EventBus} from "./EventBus"
import {Deferred, defer} from "./Deferred"
export class Socket {
ws?: WebSocket
url: string
ready?: Deferred<void>
timeout?: NodeJS.Timeout
queue: string[]
bus: EventBus
status: string
static STATUS = {
NEW: "new",
PENDING: "pending",
CLOSED: "closed",
READY: "ready",
}
constructor(url: string) {
this.ws = undefined
this.url = url
this.ready = undefined
this.timeout = undefined
this.queue = []
this.bus = new EventBus()
this.status = Socket.STATUS.NEW
}
async connect() {
if ([Socket.STATUS.NEW, Socket.STATUS.CLOSED].includes(this.status)) {
if (this.ws) {
console.error("Attempted to connect when already connected", this)
}
this.ready = defer()
this.ws = new WebSocket(this.url)
this.status = Socket.STATUS.PENDING
this.ws.addEventListener("open", () => {
console.log(`Opened connection to ${this.url}`)
this.status = Socket.STATUS.READY
this.ready?.resolve()
})
this.ws.addEventListener("message", e => {
this.queue.push(e.data as string)
if (!this.timeout) {
this.timeout = this.handleMessagesAsync()
}
})
this.ws.addEventListener("error", e => {
console.log(`Error on connection to ${this.url}`)
this.disconnect()
this.ready?.reject()
this.status = Socket.STATUS.CLOSED
})
this.ws.addEventListener("close", () => {
console.log(`Closed connection to ${this.url}`)
this.disconnect()
this.ready?.reject()
this.status = Socket.STATUS.CLOSED
})
}
await this.ready?.catch(() => null)
}
disconnect() {
if (this.ws) {
console.log(`Disconnecting from ${this.url}`)
this.ws.close()
this.ws = undefined
}
}
handleMessages() {
for (const json of this.queue.splice(0, 10)) {
let message
try {
message = JSON.parse(json)
} catch (e) {
continue
}
this.bus.handle('message', message)
}
this.timeout = this.queue.length > 0 ? this.handleMessagesAsync() : undefined
}
handleMessagesAsync() {
return setTimeout(() => this.handleMessages(), 10) as NodeJS.Timeout
}
send(message: any) {
if (this.status === Socket.STATUS.READY) {
if (this.ws?.readyState !== 1) {
console.warn("Send attempted before socket was ready", this)
}
this.ws?.send(JSON.stringify(message))
}
}
}