Re-organize connection management
This commit is contained in:
@@ -4,22 +4,24 @@ Another nostr toolkit, focused on creating highly a configurable client system.
|
|||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
|
|
||||||
- [Deferred](./src/Deferred.ts') is just a promise with `resolve` and `reject` methods.
|
- [Deferred](./src/utils/Deferred.ts') is just a promise with `resolve` and `reject` methods.
|
||||||
- [EventBus](./src/EventBus.ts') is an implementation of an event bus.
|
- [Socket](./src/utils/Socket.ts') is a wrapper around isomorphic-ws that handles json parsing/serialization.
|
||||||
- [Socket](./src/Socket.ts') is a wrapper around isomorphic-ws that handles connection status and json parsing/serialization.
|
- [Queue](./src/utils/Queue.ts') is an implementation of an asynchronous queue.
|
||||||
|
|
||||||
# Components
|
# Components
|
||||||
|
|
||||||
- [Pool](./src/Pool.ts') is a thin wrapper around `Map` for use with `Relay`s.
|
- [Connection](./src/Connection.ts') is a wrapper for `Socket` with send and receive queues, and a `ConnectionMeta` instance.
|
||||||
|
- [ConnectionMeta](./src/ConnectionMeta.ts') tracks stats for a given `Connection`.
|
||||||
- [Executor](./src/Executor.ts') implements common nostr flows on `target`
|
- [Executor](./src/Executor.ts') implements common nostr flows on `target`
|
||||||
|
- [Pool](./src/Pool.ts') is a thin wrapper around `Map` for use with `Relay`s.
|
||||||
|
|
||||||
# Executor targets
|
# Executor targets
|
||||||
|
|
||||||
Executor targets have an event `bus`, a `send` method, a `cleanup` method, and are passed to an `Executor` for use.
|
Executor targets have an event `bus`, a `send` method, a `cleanup` method, and are passed to an `Executor` for use.
|
||||||
|
|
||||||
- [Relay](./src/Relay.ts') takes a `Socket` and provides listeners for different verbs.
|
- [Relay](./src/Relay.ts') takes a `Connection` and provides listeners for different verbs.
|
||||||
- [Relays](./src/Relays.ts') takes an array of `Socket`s and provides listeners for different verbs, merging all events into a single stream.
|
- [Relays](./src/Relays.ts') takes an array of `Connection`s and provides listeners for different verbs, merging all events into a single stream.
|
||||||
- [Plex](./src/Plex.ts') takes an array of urls and a `Socket` and sends and receives wrapped nostr messages over that connection.
|
- [Plex](./src/Plex.ts') takes an array of urls and a `Connection` and sends and receives wrapped nostr messages over that connection.
|
||||||
|
|
||||||
# Example
|
# Example
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paravel",
|
"name": "paravel",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"description": "Yet another toolkit for nostr",
|
"description": "Yet another toolkit for nostr",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"pub": "npm i && npm run lint && node build.js && npm publish",
|
"pub": "npm i && npm run lint && node build.js && npm publish",
|
||||||
"lint:ts": "tsc --noEmit --esModuleInterop --strict src/**/*",
|
"lint:ts": "tsc",
|
||||||
"lint:es": "eslint src/* --fix",
|
"lint:es": "eslint src/* --fix",
|
||||||
"lint": "run-p lint:*"
|
"lint": "run-p lint:*"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {EventEmitter} from 'events'
|
||||||
|
import {Socket, isMessage, asMessage} from './util/Socket'
|
||||||
|
import type {SocketMessage} from './util/Socket'
|
||||||
|
import {Queue} from './util/Queue'
|
||||||
|
import {AuthStatus, ConnectionMeta} from './ConnectionMeta'
|
||||||
|
|
||||||
|
class SendQueue extends Queue {
|
||||||
|
constructor(readonly cxn: Connection) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSend(message: SocketMessage) {
|
||||||
|
if (!this.cxn.socket.isReady()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [verb] = asMessage(message)
|
||||||
|
|
||||||
|
if (['AUTH', 'CLOSE'].includes(verb)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only defer for auth if we're not multiplexing
|
||||||
|
if (isMessage(message) && ![AuthStatus.Ok, AuthStatus.Pending].includes(this.cxn.meta.authStatus)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verb === 'REQ') {
|
||||||
|
return this.cxn.meta.pendingRequests.size < 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(message: SocketMessage) {
|
||||||
|
this.cxn.onSend(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReceiveQueue extends Queue {
|
||||||
|
constructor(readonly cxn: Connection) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(message: SocketMessage) {
|
||||||
|
this.cxn.onReceive(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Connection extends EventEmitter {
|
||||||
|
url: string
|
||||||
|
socket: Socket
|
||||||
|
sendQueue: SendQueue
|
||||||
|
receiveQueue: ReceiveQueue
|
||||||
|
meta: ConnectionMeta
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.url = url
|
||||||
|
this.socket = new Socket(url, this)
|
||||||
|
this.sendQueue = new SendQueue(this)
|
||||||
|
this.receiveQueue = new ReceiveQueue(this)
|
||||||
|
this.meta = new ConnectionMeta(this)
|
||||||
|
this.setMaxListeners(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
send = (m: SocketMessage) => this.sendQueue.push(m)
|
||||||
|
|
||||||
|
onOpen = () => this.emit('open', this)
|
||||||
|
|
||||||
|
onClose = () => this.emit('close', this)
|
||||||
|
|
||||||
|
onError = () => this.emit('fault', this)
|
||||||
|
|
||||||
|
onMessage = (m: SocketMessage) => this.receiveQueue.push(m)
|
||||||
|
|
||||||
|
onSend = (message: SocketMessage) => {
|
||||||
|
this.emit('send', this, message)
|
||||||
|
this.socket.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceive = (message: SocketMessage) => {
|
||||||
|
this.emit('receive', this, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureConnected = ({shouldReconnect = true}) => {
|
||||||
|
if (shouldReconnect && !this.socket.isHealthy()) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket.isPending()) {
|
||||||
|
this.socket.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.socket.reset()
|
||||||
|
this.sendQueue.clear()
|
||||||
|
this.meta.clearPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.socket.disconnect()
|
||||||
|
this.removeAllListeners()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type {Event, Filter} from './types'
|
||||||
|
import type {Connection} from './Connection'
|
||||||
|
|
||||||
|
export type PublishMeta = {
|
||||||
|
sent: number
|
||||||
|
event: Event
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestMeta = {
|
||||||
|
sent: number
|
||||||
|
filters: Filter[]
|
||||||
|
eoseReceived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuthStatus {
|
||||||
|
Ok = 'ok',
|
||||||
|
Pending = 'pending',
|
||||||
|
Unauthorized = 'unauthorized',
|
||||||
|
Forbidden = 'forbidden',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ConnectionStatus {
|
||||||
|
Unauthorized = 'unauthorized',
|
||||||
|
Forbidden = 'forbidden',
|
||||||
|
Error = 'error',
|
||||||
|
Closed = 'closed',
|
||||||
|
Slow = 'slow',
|
||||||
|
Ok = 'ok',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConnectionMeta {
|
||||||
|
authStatus = AuthStatus.Pending
|
||||||
|
pendingPublishes = new Map<string, PublishMeta>()
|
||||||
|
pendingRequests = new Map<string, RequestMeta>()
|
||||||
|
publishCount = 0
|
||||||
|
requestCount = 0
|
||||||
|
eventCount = 0
|
||||||
|
lastOpen = 0
|
||||||
|
lastClose = 0
|
||||||
|
lastFault = 0
|
||||||
|
lastPublish = 0
|
||||||
|
lastRequest = 0
|
||||||
|
lastEvent = 0
|
||||||
|
responseCount = 0
|
||||||
|
responseTimer = 0
|
||||||
|
|
||||||
|
constructor(cxn: Connection) {
|
||||||
|
cxn.on('open', () => {
|
||||||
|
this.lastOpen = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
cxn.on('close', () => {
|
||||||
|
this.lastClose = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
cxn.on('fault', () => {
|
||||||
|
this.lastFault = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
cxn.on('send', (cxn, [verb, ...payload]) => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'REQ') this.onSendRequest(...payload)
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'CLOSE') this.onSendClose(...payload)
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'EVENT') this.onSendEvent(...payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
cxn.on('receive', (cxn, [verb, ...payload]) => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'OK') this.onReceiveOk(...payload)
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'AUTH') this.onReceiveAuth(...payload)
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'EVENT') this.onReceiveEvent(...payload)
|
||||||
|
// @ts-ignore
|
||||||
|
if (verb === 'EOSE') this.onReceiveEose(...payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSendRequest(subId: string, ...filters: Filter[]) {
|
||||||
|
this.requestCount++
|
||||||
|
this.lastRequest = Date.now()
|
||||||
|
this.pendingRequests.set(subId, {
|
||||||
|
filters,
|
||||||
|
sent: Date.now(),
|
||||||
|
eoseReceived: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSendClose(subId: string) {
|
||||||
|
this.pendingRequests.delete(subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSendEvent(event: Event) {
|
||||||
|
this.publishCount++
|
||||||
|
this.lastPublish = Date.now()
|
||||||
|
this.pendingPublishes.set(event.id, {sent: Date.now(), event})
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceiveOk(eventId: string) {
|
||||||
|
const publish = this.pendingPublishes.get(eventId)
|
||||||
|
|
||||||
|
this.authStatus = AuthStatus.Ok
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
this.responseCount++
|
||||||
|
this.responseTimer += Date.now() - publish.sent
|
||||||
|
this.pendingPublishes.delete(eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceiveAuth(eventId: string) {
|
||||||
|
this.authStatus = AuthStatus.Unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceiveEvent(event: Event) {
|
||||||
|
this.eventCount++
|
||||||
|
this.lastEvent = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceiveEose(subId: string) {
|
||||||
|
const request = this.pendingRequests.get(subId)
|
||||||
|
|
||||||
|
// Only count the first eose
|
||||||
|
if (request && !request.eoseReceived) {
|
||||||
|
request.eoseReceived = true
|
||||||
|
|
||||||
|
this.responseCount++
|
||||||
|
this.responseTimer += Date.now() - request.sent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPending = () => {
|
||||||
|
this.pendingPublishes.clear()
|
||||||
|
this.pendingRequests.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpeed = () => this.responseCount ? this.responseTimer / this.responseCount : 0
|
||||||
|
|
||||||
|
getStatus = () => {
|
||||||
|
if (this.authStatus === AuthStatus.Unauthorized) return ConnectionStatus.Unauthorized
|
||||||
|
if (this.authStatus === AuthStatus.Forbidden) return ConnectionStatus.Forbidden
|
||||||
|
if (this.lastFault > this.lastOpen) return ConnectionStatus.Error
|
||||||
|
if (this.lastClose > this.lastOpen) return ConnectionStatus.Closed
|
||||||
|
if (this.getSpeed() > 500) return ConnectionStatus.Slow
|
||||||
|
|
||||||
|
return ConnectionStatus.Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription = () => {
|
||||||
|
switch (this.getStatus()) {
|
||||||
|
case ConnectionStatus.Unauthorized: return 'Logging in'
|
||||||
|
case ConnectionStatus.Forbidden: return 'Failed to log in'
|
||||||
|
case ConnectionStatus.Error: return 'Failed to connect'
|
||||||
|
case ConnectionStatus.Closed: return 'Waiting to reconnect'
|
||||||
|
case ConnectionStatus.Slow: return 'Slow Connection'
|
||||||
|
case ConnectionStatus.Ok: return 'Connected'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-14
@@ -1,18 +1,34 @@
|
|||||||
import {EventEmitter} from 'events'
|
import {EventEmitter} from 'events'
|
||||||
|
import type {Event, Filter} from './types'
|
||||||
|
import type {Message} from './util/Socket'
|
||||||
|
|
||||||
const createSubId = prefix => [prefix, Math.random().toString().slice(2, 10)].join('-')
|
type Target = EventEmitter & {
|
||||||
|
send: (...args: Message) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventCallback = (url: string, event: Event) => void
|
||||||
|
type EoseCallback = (url: string) => void
|
||||||
|
type AuthCallback = (url: string, challenge: string) => void
|
||||||
|
type OkCallback = (url: string, id: string, ...extra: any[]) => void
|
||||||
|
type ErrorCallback = (url: string, id: string, ...extra: any[]) => void
|
||||||
|
type CountCallback = (url: string, ...extra: any[]) => void
|
||||||
|
type SubscribeOpts = {onEvent: EventCallback, onEose: EoseCallback}
|
||||||
|
type PublishOpts = {verb: string, onOk: OkCallback, onError: ErrorCallback}
|
||||||
|
type CountOpts = {onCount: CountCallback}
|
||||||
|
type AuthOpts = {onAuth: AuthCallback, onOk: OkCallback}
|
||||||
|
|
||||||
|
const createSubId = (prefix: string) => [prefix, Math.random().toString().slice(2, 10)].join('-')
|
||||||
|
|
||||||
export class Executor {
|
export class Executor {
|
||||||
target: EventEmitter
|
|
||||||
constructor(target) {
|
constructor(readonly target: Target) {}
|
||||||
this.target = target
|
|
||||||
}
|
subscribe(filters: Filter[], {onEvent, onEose}: SubscribeOpts) {
|
||||||
subscribe(filters, {onEvent, onEose}) {
|
|
||||||
let closed = false
|
let closed = false
|
||||||
|
|
||||||
const id = createSubId('REQ')
|
const id = createSubId('REQ')
|
||||||
const eventListener = (url, subid, e) => subid === id && onEvent?.(url, e)
|
const eventListener = (url: string, subid: string, e: Event) => subid === id && onEvent?.(url, e)
|
||||||
const eoseListener = (url, subid) => subid === id && onEose?.(url)
|
const eoseListener = (url: string, subid: string) => subid === id && onEose?.(url)
|
||||||
|
|
||||||
this.target.on('EVENT', eventListener)
|
this.target.on('EVENT', eventListener)
|
||||||
this.target.on('EOSE', eoseListener)
|
this.target.on('EOSE', eoseListener)
|
||||||
@@ -30,9 +46,9 @@ export class Executor {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
publish(event, {verb = 'EVENT', onOk, onError} = {}) {
|
publish(event: Event, {verb = 'EVENT', onOk, onError}: PublishOpts) {
|
||||||
const okListener = (url, id, ...payload) => id === event.id && onOk(url, id, ...payload)
|
const okListener = (url: string, id: string, ...payload: any[]) => id === event.id && onOk(url, id, ...payload)
|
||||||
const errorListener = (url, id, ...payload) => id === event.id && onError(url, id, ...payload)
|
const errorListener = (url: string, id: string, ...payload: any[]) => id === event.id && onError(url, id, ...payload)
|
||||||
|
|
||||||
this.target.on('OK', okListener)
|
this.target.on('OK', okListener)
|
||||||
this.target.on('ERROR', errorListener)
|
this.target.on('ERROR', errorListener)
|
||||||
@@ -45,9 +61,9 @@ export class Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
count(filters, {onCount}) {
|
count(filters: Filter[], {onCount}: CountOpts) {
|
||||||
const id = createSubId('COUNT')
|
const id = createSubId('COUNT')
|
||||||
const countListener = (url, subid, ...payload) => {
|
const countListener = (url: string, subid: string, ...payload: any[]) => {
|
||||||
if (subid === id) {
|
if (subid === id) {
|
||||||
onCount(url, ...payload)
|
onCount(url, ...payload)
|
||||||
this.target.off('COUNT', countListener)
|
this.target.off('COUNT', countListener)
|
||||||
@@ -61,7 +77,7 @@ export class Executor {
|
|||||||
unsubscribe: () => this.target.off('COUNT', countListener)
|
unsubscribe: () => this.target.off('COUNT', countListener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleAuth({onAuth, onOk}) {
|
handleAuth({onAuth, onOk}: AuthOpts) {
|
||||||
this.target.on('AUTH', onAuth)
|
this.target.on('AUTH', onAuth)
|
||||||
this.target.on('OK', onOk)
|
this.target.on('OK', onOk)
|
||||||
|
|
||||||
|
|||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
import {EventEmitter} from 'events'
|
|
||||||
|
|
||||||
export class Plex extends EventEmitter {
|
|
||||||
constructor(urls, socket) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.urls = urls
|
|
||||||
this.socket = socket
|
|
||||||
this.socket.on('receive', this.onMessage)
|
|
||||||
}
|
|
||||||
get sockets() {
|
|
||||||
return [this.socket]
|
|
||||||
}
|
|
||||||
send = (...payload) => {
|
|
||||||
this.socket.send([{relays: this.urls}, payload])
|
|
||||||
}
|
|
||||||
onMessage = (socket, [{relays}, [verb, ...payload]]) => {
|
|
||||||
this.emit(verb, relays[0], ...payload)
|
|
||||||
}
|
|
||||||
cleanup = () => {
|
|
||||||
this.removeAllListeners()
|
|
||||||
this.socket.off('receive', this.onMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+23
-16
@@ -1,35 +1,42 @@
|
|||||||
import {Socket} from "./util/Socket"
|
import {Connection} from "./Connection"
|
||||||
import {EventEmitter} from 'events'
|
import {EventEmitter} from 'events'
|
||||||
|
|
||||||
export class Pool extends EventEmitter {
|
export class Pool extends EventEmitter {
|
||||||
data: Map<string, Socket>
|
data: Map<string, Connection>
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.data = new Map()
|
this.data = new Map()
|
||||||
}
|
}
|
||||||
has(url) {
|
has(url: string) {
|
||||||
return this.data.has(url)
|
return this.data.has(url)
|
||||||
}
|
}
|
||||||
get(url, {autoConnect = true} = {}) {
|
get(url: string, {autoConnect = true} = {}) {
|
||||||
if (!this.data.has(url) && autoConnect) {
|
let connection = this.data.get(url)
|
||||||
const socket = new Socket(url)
|
|
||||||
|
|
||||||
this.data.set(url, socket)
|
if (autoConnect) {
|
||||||
this.emit('init', socket)
|
if (!connection) {
|
||||||
|
connection = new Connection(url)
|
||||||
|
|
||||||
socket.on('open', () => this.emit('open', socket))
|
this.data.set(url, connection)
|
||||||
socket.on('close', () => this.emit('close', socket))
|
this.emit('init', connection)
|
||||||
|
|
||||||
|
connection.on('open', () => this.emit('open', connection))
|
||||||
|
connection.on('close', () => this.emit('close', connection))
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.ensureConnected({
|
||||||
|
shouldReconnect: connection.meta.lastClose < Date.now() - 30_000,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.data.get(url)
|
return connection
|
||||||
}
|
}
|
||||||
remove(url) {
|
remove(url: string) {
|
||||||
const socket = this.data.get(url)
|
const connection = this.data.get(url)
|
||||||
|
|
||||||
if (socket) {
|
if (connection) {
|
||||||
socket.disconnect()
|
connection.destroy()
|
||||||
socket.removeAllListeners()
|
|
||||||
|
|
||||||
this.data.delete(url)
|
this.data.delete(url)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import {EventEmitter} from 'events'
|
|
||||||
|
|
||||||
export class Relay extends EventEmitter {
|
|
||||||
constructor(socket) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.socket = socket
|
|
||||||
this.socket.on('receive', this.onMessage)
|
|
||||||
}
|
|
||||||
get sockets() {
|
|
||||||
return [this.socket]
|
|
||||||
}
|
|
||||||
send(...payload) {
|
|
||||||
this.socket.send(payload)
|
|
||||||
}
|
|
||||||
onMessage = (socket, [verb, ...payload]) => {
|
|
||||||
this.emit(verb, socket.url, ...payload)
|
|
||||||
}
|
|
||||||
cleanup = () => {
|
|
||||||
this.removeAllListeners()
|
|
||||||
this.socket.off('receive', this.onMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import {EventEmitter} from 'events'
|
|
||||||
|
|
||||||
export class Relays extends EventEmitter {
|
|
||||||
constructor(sockets) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.sockets = sockets
|
|
||||||
this.sockets.forEach(socket => {
|
|
||||||
socket.on('receive', this.onMessage)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
send = (...payload) => {
|
|
||||||
this.sockets.forEach(socket => {
|
|
||||||
socket.send(payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onMessage = (socket, [verb, ...payload]) => {
|
|
||||||
this.emit(verb, socket.url, ...payload)
|
|
||||||
}
|
|
||||||
cleanup = () => {
|
|
||||||
this.removeAllListeners()
|
|
||||||
this.sockets.forEach(socket => {
|
|
||||||
socket.off('receive', this.onMessage)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-5
@@ -1,7 +1,10 @@
|
|||||||
export * from "./util/Deferred"
|
export * from "./Connection"
|
||||||
export * from "./util/Socket"
|
export * from "./ConnectionMeta"
|
||||||
export * from "./Executor"
|
export * from "./Executor"
|
||||||
export * from "./Plex"
|
|
||||||
export * from "./Pool"
|
export * from "./Pool"
|
||||||
export * from "./Relay"
|
export * from "./util/Deferred"
|
||||||
export * from "./Relays"
|
export * from "./util/Queue"
|
||||||
|
export * from "./util/Socket"
|
||||||
|
export * from "./target/Plex"
|
||||||
|
export * from "./target/Relay"
|
||||||
|
export * from "./target/Relays"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {EventEmitter} from 'events'
|
||||||
|
import {Connection} from '../Connection'
|
||||||
|
import type {PlexMessage, Message} from '../util/Socket'
|
||||||
|
|
||||||
|
export class Plex extends EventEmitter {
|
||||||
|
constructor(readonly urls: string[], readonly connection: Connection) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.connection.on('receive', this.onMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
get connections() {
|
||||||
|
return [this.connection]
|
||||||
|
}
|
||||||
|
|
||||||
|
send = (...payload: Message) => {
|
||||||
|
this.connection.send([{relays: this.urls}, payload])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage = (connection: Connection, [{relays}, [verb, ...payload]]: PlexMessage) => {
|
||||||
|
this.emit(verb, relays[0], ...payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
this.removeAllListeners()
|
||||||
|
this.connection.off('receive', this.onMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {EventEmitter} from 'events'
|
||||||
|
import type {Connection} from '../Connection'
|
||||||
|
import type {Message} from '../util/Socket'
|
||||||
|
|
||||||
|
export class Relay extends EventEmitter {
|
||||||
|
constructor(readonly connection: Connection) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.connection.on('receive', this.onMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
get connections() {
|
||||||
|
return [this.connection]
|
||||||
|
}
|
||||||
|
|
||||||
|
send(...payload: Message) {
|
||||||
|
this.connection.send(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage = (connection: Connection, [verb, ...payload]: Message) => {
|
||||||
|
this.emit(verb, connection.url, ...payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
this.removeAllListeners()
|
||||||
|
this.connection.off('receive', this.onMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {EventEmitter} from 'events'
|
||||||
|
import type {Connection} from '../Connection'
|
||||||
|
import type {Message} from '../util/Socket'
|
||||||
|
|
||||||
|
export class Relays extends EventEmitter {
|
||||||
|
constructor(readonly connections: Connection[]) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
connections.forEach(connection => {
|
||||||
|
connection.on('receive', this.onMessage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
send = (...payload: Message) => {
|
||||||
|
this.connections.forEach(connection => {
|
||||||
|
connection.send(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage = (connection: Connection, [verb, ...payload]: Message) => {
|
||||||
|
this.emit(verb, connection.url, ...payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
this.removeAllListeners()
|
||||||
|
this.connections.forEach(connection => {
|
||||||
|
connection.off('receive', this.onMessage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type Event = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Filter = Record<string, any>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export class Queue {
|
||||||
|
timeout?: NodeJS.Timeout
|
||||||
|
messages: any[] = []
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.messages = []
|
||||||
|
}
|
||||||
|
|
||||||
|
push(message: any) {
|
||||||
|
this.messages.push(message)
|
||||||
|
this.enqueueWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(message: any) {
|
||||||
|
throw new Error("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSend(message: any) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
doWork() {
|
||||||
|
for (const message of this.messages.splice(0, 10)) {
|
||||||
|
if (this.shouldSend(message)) {
|
||||||
|
this.handle(message)
|
||||||
|
} else {
|
||||||
|
this.messages.push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeout = undefined
|
||||||
|
this.enqueueWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueWork() {
|
||||||
|
if (this.timeout) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.messages.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeout = setTimeout(() => this.doWork(), 100) as NodeJS.Timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-110
@@ -1,144 +1,122 @@
|
|||||||
|
import type {MessageEvent} from 'isomorphic-ws'
|
||||||
import WebSocket from "isomorphic-ws"
|
import WebSocket from "isomorphic-ws"
|
||||||
import {EventEmitter} from 'events'
|
|
||||||
import {Deferred, defer} from "./Deferred"
|
import {Deferred, defer} from "./Deferred"
|
||||||
|
|
||||||
export class Socket extends EventEmitter {
|
export type Message = [string, ...any[]]
|
||||||
ws?: WebSocket
|
|
||||||
url: string
|
|
||||||
ready: Deferred<void>
|
|
||||||
timeout?: NodeJS.Timeout
|
|
||||||
receiveQueue: any[] = []
|
|
||||||
sendQueue: any[] = []
|
|
||||||
status: string
|
|
||||||
static STATUS = {
|
|
||||||
NEW: "new",
|
|
||||||
UNAUTHORIZED: "unauthorized",
|
|
||||||
PENDING: "pending",
|
|
||||||
CLOSED: "closed",
|
|
||||||
ERROR: "error",
|
|
||||||
READY: "ready",
|
|
||||||
}
|
|
||||||
constructor(url: string) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
|
export type PlexMessage = [{relays: string[]}, Message]
|
||||||
|
|
||||||
|
export type SocketMessage = Message | PlexMessage
|
||||||
|
|
||||||
|
export const isMessage = (m: SocketMessage): boolean => typeof m[0] === 'string'
|
||||||
|
|
||||||
|
export const asMessage = (m: SocketMessage): Message => isMessage(m) ? m : m[1]
|
||||||
|
|
||||||
|
export type SocketOpts = {
|
||||||
|
onOpen: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onError: () => void
|
||||||
|
onMessage: (message: SocketMessage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Socket {
|
||||||
|
url: string
|
||||||
|
ws?: WebSocket
|
||||||
|
ready: Deferred<void>
|
||||||
|
|
||||||
|
constructor(url: string, readonly opts: SocketOpts) {
|
||||||
this.url = url
|
this.url = url
|
||||||
this.ready = defer()
|
this.ready = defer()
|
||||||
this.status = Socket.STATUS.NEW
|
}
|
||||||
|
|
||||||
this.setMaxListeners(100)
|
_close() {
|
||||||
|
if (!this.ws) {
|
||||||
|
throw new Error('Socket was closed before it was opened')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid "WebSocket was closed before the connection was established"
|
||||||
|
this.ready.then(() => this.ws?.close()).catch(() => null)
|
||||||
}
|
}
|
||||||
send = (message: any) => {
|
|
||||||
this.connect()
|
isPending() {
|
||||||
this.sendQueue.push(message)
|
return !this.ws
|
||||||
this.enqueueWork()
|
|
||||||
}
|
}
|
||||||
onMessage = (event: {data: string}) => {
|
|
||||||
this.receiveQueue.push(event.data)
|
isConnecting() {
|
||||||
this.enqueueWork()
|
return this.ws?.readyState === WebSocket.CONNECTING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isReady() {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosing() {
|
||||||
|
return this.ws?.readyState === WebSocket.CLOSING
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosed() {
|
||||||
|
return this.ws?.readyState === WebSocket.CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy() {
|
||||||
|
return this.isPending() || this.isConnecting() || this.isReady()
|
||||||
|
}
|
||||||
|
|
||||||
onOpen = () => {
|
onOpen = () => {
|
||||||
this.status = Socket.STATUS.READY
|
|
||||||
this.ready.resolve()
|
this.ready.resolve()
|
||||||
this.emit('open', this)
|
this.opts.onOpen()
|
||||||
}
|
|
||||||
onError = () => {
|
|
||||||
this.disconnect()
|
|
||||||
this.ready.reject()
|
|
||||||
this.status = Socket.STATUS.ERROR
|
|
||||||
this.emit('fault', this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose = () => {
|
onClose = () => {
|
||||||
if (this.ws) {
|
|
||||||
const ws = this.ws
|
|
||||||
|
|
||||||
// Avoid "WebSocket was closed before the connection was established"
|
|
||||||
this.ready.then(() => ws.close(), () => null)
|
|
||||||
this.ws.removeEventListener("open", this.onOpen)
|
|
||||||
this.ws.removeEventListener("close", this.onClose)
|
|
||||||
this.ws.removeEventListener("error", this.onError)
|
|
||||||
// @ts-ignore
|
|
||||||
this.ws.removeEventListener("message", this.onMessage)
|
|
||||||
this.ws = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.status !== Socket.STATUS.ERROR) {
|
|
||||||
this.status = Socket.STATUS.CLOSED
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ready.reject()
|
this.ready.reject()
|
||||||
this.emit('close', this)
|
this.opts.onClose()
|
||||||
|
this._close()
|
||||||
}
|
}
|
||||||
connect = () => {
|
|
||||||
const {NEW, CLOSED, PENDING} = Socket.STATUS
|
|
||||||
|
|
||||||
if ([NEW, CLOSED].includes(this.status)) {
|
onError = () => {
|
||||||
this.ready = defer()
|
this.ready.reject()
|
||||||
this.status = PENDING
|
this.opts.onError()
|
||||||
this.ws = new WebSocket(this.url)
|
this._close()
|
||||||
this.ws.addEventListener("open", this.onOpen)
|
|
||||||
this.ws.addEventListener("close", this.onClose)
|
|
||||||
this.ws.addEventListener("error", this.onError)
|
|
||||||
// @ts-ignore
|
|
||||||
this.ws.addEventListener("message", this.onMessage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
disconnect = () => {
|
|
||||||
this.onClose()
|
onMessage = (event: MessageEvent) => {
|
||||||
}
|
|
||||||
receiveMessage = (json: string) => {
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(json)
|
this.opts.onMessage(JSON.parse(event.data as string))
|
||||||
|
|
||||||
if (message?.[0] == 'AUTH') {
|
|
||||||
this.status = Socket.STATUS.UNAUTHORIZED
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message?.[0] == 'OK' && this.status === Socket.STATUS.UNAUTHORIZED) {
|
|
||||||
this.status = Socket.STATUS.READY
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('receive', this, message)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// pass
|
// pass
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendMessage = (message: any) => {
|
|
||||||
this.emit('send', this, message)
|
|
||||||
|
|
||||||
// @ts-ignore
|
send = (message: any) => {
|
||||||
|
if (!this.ws) {
|
||||||
|
throw new Error('Send attempted before socket was opened')
|
||||||
|
}
|
||||||
|
|
||||||
this.ws.send(JSON.stringify(message))
|
this.ws.send(JSON.stringify(message))
|
||||||
}
|
}
|
||||||
shouldDefer = (payload: any[]) => {
|
|
||||||
if (this.ws?.readyState !== 1) {
|
connect = () => {
|
||||||
return true
|
if (this.ws) {
|
||||||
|
throw new Error(`Already attempted connection for ${this.url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.status === Socket.STATUS.UNAUTHORIZED) {
|
this.ws = new WebSocket(this.url)
|
||||||
return payload?.[0] !== 'AUTH'
|
this.ws.onopen = this.onOpen
|
||||||
}
|
this.ws.onclose = this.onClose
|
||||||
|
this.ws.onerror = this.onError
|
||||||
return this.status !== Socket.STATUS.READY
|
this.ws.onmessage = this.onMessage
|
||||||
}
|
}
|
||||||
doWork = () => {
|
|
||||||
this.timeout = undefined
|
|
||||||
|
|
||||||
for (const payload of this.receiveQueue.splice(0, 10)) {
|
disconnect() {
|
||||||
this.receiveMessage(payload)
|
this.onClose()
|
||||||
}
|
|
||||||
|
|
||||||
for (const payload of this.sendQueue.splice(0, 10)) {
|
|
||||||
if (this.shouldDefer(payload)) {
|
|
||||||
this.sendQueue.push(payload)
|
|
||||||
} else {
|
|
||||||
this.sendMessage(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.enqueueWork()
|
|
||||||
}
|
}
|
||||||
enqueueWork = () => {
|
|
||||||
if (!this.timeout && (this.receiveQueue.length > 0 || this.sendQueue.length > 0)) {
|
reset() {
|
||||||
this.timeout = setTimeout(() => this.doWork(), 100) as NodeJS.Timeout
|
if (this.ws) {
|
||||||
|
this._close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.ws = undefined
|
||||||
|
this.ready = defer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"target": "esnext",
|
"target": "es2015",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": false,
|
"isolatedModules": false,
|
||||||
"importsNotUsedAsValues": "preserve"
|
"importsNotUsedAsValues": "preserve"
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user