Add Relay, Pool

This commit is contained in:
Jonathan Staab
2023-03-25 10:57:23 -05:00
parent 39d001280c
commit daca5adf11
8 changed files with 252 additions and 2171 deletions
+14
View File
@@ -0,0 +1,14 @@
export type Deferred<T> = Promise<T> & {
resolve: (arg: T) => void
reject: (arg: T) => void
}
export const defer = (): Deferred<any> => {
let resolve, reject
const p = new Promise((resolve_, reject_) => {
resolve = resolve_
reject = reject_
})
return Object.assign(p, {resolve, reject})
}
+46
View File
@@ -0,0 +1,46 @@
import {Relay} from "./Relay"
const normalizeUrl = url => url.replace(/\/+$/, "").toLowerCase().trim()
export class Pool {
constructor() {
this.relays = new Map()
}
add(url) {
url = normalizeUrl(url)
if (!this.relays.has(url)) {
this.relays.set(url, new Relay(url))
}
return this.relays.get(url)
}
remove(url) {
url = normalizeUrl(url)
this.relays.get(url)?.disconnect()
this.relays.delete(url)
}
async waitFor(url) {
const relay = this.add(url)
await relay.connect()
return relay.status === Relay.STATUS.READY ? relay : null
}
async execute(urls, callback) {
const results = await Promise.all([
urls.map(async url => {
const relay = await this.waitFor(url)
if (!relay) {
return null
}
return [relay, callback(relay)]
}),
])
return results.filter(Boolean)
}
}
+160
View File
@@ -0,0 +1,160 @@
import {WebSocket} from "ws"
import {Deferred, defer} from "./Deferred"
export class Relay {
ws?: WebSocket
url: string
ready?: Deferred<void>
queue: string[]
error: string
status: string
timeout?: number
bus: EventBus
static STATUS = {
NEW: "new",
PENDING: "pending",
CLOSED: "closed",
ERROR: "error",
READY: "ready",
}
static ERROR = {
CONNECTION: "connection",
UNAUTHORIZED: "unauthorized",
FORBIDDEN: "forbidden",
}
constructor(url) {
if (connections[url]) {
error(`Connection to ${url} already exists`)
}
this.ws = null
this.url = url
this.ready = null
this.queue = []
this.timeout = null
this.bus = new EventBus()
this.error = null
this.status = Relay.STATUS.NEW
}
async connect() {
if (this.status === Relay.STATUS.NEW) {
if (this.ws) {
error("Attempted to connect when already connected", this)
}
this.ready = defer()
this.ws = new WebSocket(this.url)
this.status = Relay.STATUS.PENDING
this.ws.addEventListener("open", () => {
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 = global.setTimeout(() => this.handleMessages(), 10)
}
})
this.ws.addEventListener("error", e => {
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", () => {
log(`Closed connection to ${this.url}`)
this.disconnect()
this.ready.reject()
this.status = Relay.STATUS.CLOSED
})
}
await this.ready.catch(() => null)
}
disconnect() {
if (this.ws) {
log(`Disconnecting from ${this.url}`)
this.ws.close()
this.ws = null
}
}
handleMessages() {
for (const json of this.queue.splice(0, 10)) {
let message
try {
message = JSON.parse(json)
} catch (e) {
continue
}
this.bus.handle(...message)
}
this.timeout = this.queue.length > 0 ? global.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))
}
subscribe(filters, id, {onEvent, onEose}) {
const [eventChannel, eoseChannel] = [
this.bus.on("EVENT", (subid, e) => subid === id && onEvent(e)),
this.bus.on("EOSE", subid => subid === id && onEose()),
]
this.send("REQ", id, ...filters)
return {
conn: this,
unsub: () => {
if (this.status === Relay.STATUS.READY) {
this.send("CLOSE", id)
}
this.bus.off("EVENT", eventChannel)
this.bus.off("EOSE", eoseChannel)
},
}
}
publish(event, {onOk, onError}) {
const withCleanup = cb => k => {
if (k === event.id) {
cb()
this.bus.off("OK", okChannel)
this.bus.off("ERROR", errorChannel)
}
}
const [okChannel, errorChannel] = [
this.bus.on("OK", withCleanup(onOk)),
this.bus.on("ERROR", withCleanup(onError)),
]
this.send("EVENT", event)
}
count(filter, id, {onCount}) {
const channel = this.bus.on("COUNT", (subid, ...payload) => {
if (subid === id) {
onCount(...payload)
this.bus.off("COUNT", channel)
}
})
this.send("COUNT", id, ...filter)
}
}
+4 -1
View File
@@ -1 +1,4 @@
export * from './EventBus' export * from "./EventBus"
export * from "./Deferred"
export * from "./Relay"
export * from "./Pool"
+24 -2151
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -17,7 +17,6 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check:ts": "tsc --lib esnext lib/*.ts --noEmit",
"check:fmt": "prettier --check lib/*", "check:fmt": "prettier --check lib/*",
"check": "run-p check:*", "check": "run-p check:*",
"format": "prettier --write lib/*" "format": "prettier --write lib/*"
@@ -28,12 +27,12 @@
"devDependencies": { "devDependencies": {
"@tsconfig/recommended": "^1.0.2", "@tsconfig/recommended": "^1.0.2",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"vite": "^4.2.1", "vite": "^4.2.1"
"vite-plugin-eslint": "^1.8.1"
}, },
"dependencies": { "dependencies": {
"husky": "^8.0.3", "husky": "^8.0.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "^5.0.2" "typescript": "^5.0.2",
"ws": "^8.13.0"
} }
} }
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "@tsconfig/recommended/tsconfig.json"
"compilerOptions": {
"useDefineForClassFields": true,
"allowSyntheticDefaultImports": true,
"module": "esnext",
"resolveJsonModule": true,
"isolatedModules": false,
"importsNotUsedAsValues": "preserve"
},
"include": ["lib/**/*.d.ts", "lib/**/*.ts"]
}
+1 -3
View File
@@ -1,13 +1,11 @@
import path from 'path' import path from 'path'
import {defineConfig} from 'vite' import {defineConfig} from 'vite'
import eslint from 'vite-plugin-eslint'
export default defineConfig({ export default defineConfig({
plugins: [eslint()],
build: { build: {
lib: { lib: {
entry: path.resolve(__dirname, 'lib/main.ts'),
name: 'paravel', name: 'paravel',
entry: path.resolve(__dirname, 'lib/main.ts'),
fileName: (format) => `paravel.${format}.js` fileName: (format) => `paravel.${format}.js`
} }
} }