Switch to monorepo setup

This commit is contained in:
Jon Staab
2024-03-25 14:22:33 -07:00
parent 74b926e227
commit 54e0775453
49 changed files with 3677 additions and 2321 deletions
+2
View File
@@ -0,0 +1,2 @@
build
normalize-url
+119
View File
@@ -0,0 +1,119 @@
import {Emitter, Queue} from '@coracle.social/lib'
import {AuthStatus, ConnectionMeta} from './ConnectionMeta'
import {Socket, isMessage, asMessage} from './Socket'
import type {SocketMessage} from './Socket'
class SendQueue extends Queue {
constructor(readonly cxn: Connection) {
super()
}
shouldSend(message: SocketMessage) {
if (!this.cxn.socket.isReady()) {
return false
}
const [verb, ...extra] = asMessage(message)
if (['AUTH', 'CLOSE'].includes(verb)) {
return true
}
// Allow relay requests through
if (verb === 'EVENT' && extra[0].kind === 28934) {
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 < 8
}
return true
}
handle(message: SocketMessage) {
// If we ended up handling a CLOSE before we handled the REQ, don't send the REQ
if (message[0] === 'CLOSE') {
this.messages = this.messages.filter(m => !(m[0] === 'REQ' && m[1] === message[1]))
}
this.cxn.onSend(message)
}
}
class ReceiveQueue extends Queue {
constructor(readonly cxn: Connection) {
super()
}
handle(message: SocketMessage) {
this.cxn.onReceive(message)
}
}
export class Connection extends Emitter {
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.disconnect()
}
if (this.socket.isPending()) {
this.socket.connect()
}
}
disconnect() {
this.socket.disconnect()
this.sendQueue.clear()
this.receiveQueue.clear()
this.meta.clearPending()
}
destroy() {
this.disconnect()
this.removeAllListeners()
this.sendQueue.stop()
this.receiveQueue.stop()
}
}
+181
View File
@@ -0,0 +1,181 @@
import type {Event, Filter} from 'nostr-tools'
import type {Connection} from './Connection'
import type {Message} from './Socket'
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(readonly cxn: Connection) {
cxn.on('open', () => {
this.lastOpen = Date.now()
})
cxn.on('close', () => {
this.lastClose = Date.now()
})
cxn.on('fault', () => {
this.lastFault = Date.now()
})
cxn.on('send', (cxn: Connection, message: Message) => {
if (message[0] === 'REQ') this.onSendRequest(message)
if (message[0] === 'CLOSE') this.onSendClose(message)
if (message[0] === 'EVENT') this.onSendEvent(message)
})
cxn.on('receive', (cxn: Connection, message: Message) => {
if (message[0] === 'OK') this.onReceiveOk(message)
if (message[0] === 'AUTH') this.onReceiveAuth(message)
if (message[0] === 'EVENT') this.onReceiveEvent(message)
if (message[0] === 'EOSE') this.onReceiveEose(message)
if (message[0] === 'CLOSED') this.onReceiveClosed(message)
if (message[0] === 'NOTICE') this.onReceiveNotice(message)
})
}
onSendRequest([verb, subId, ...filters]: Message) {
this.requestCount++
this.lastRequest = Date.now()
this.pendingRequests.set(subId, {
filters,
sent: Date.now(),
eoseReceived: false,
})
}
onSendClose([verb, subId]: Message) {
this.pendingRequests.delete(subId)
}
onSendEvent([verb, event]: Message) {
this.publishCount++
this.lastPublish = Date.now()
this.pendingPublishes.set(event.id, {sent: Date.now(), event})
}
onReceiveOk([verb, eventId, ok, notice]: Message) {
const publish = this.pendingPublishes.get(eventId)
if (ok) {
this.authStatus = AuthStatus.Ok
} else if (notice.startsWith('auth-required:')) {
// Re-enqueue pending reqs when auth challenge is received
const pub = this.pendingPublishes.get(eventId)
if (pub) {
this.cxn.send(['EVENT', pub.event])
}
}
if (publish) {
this.responseCount++
this.responseTimer += Date.now() - publish.sent
this.pendingPublishes.delete(eventId)
}
}
onReceiveAuth([verb, eventId]: Message) {
this.authStatus = AuthStatus.Unauthorized
}
onReceiveEvent([verb, event]: Message) {
this.eventCount++
this.lastEvent = Date.now()
}
onReceiveEose([verb, subId]: Message) {
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
}
}
onReceiveClosed([verb, id, notice]: Message) {
if (notice.startsWith('auth-required:')) {
// Re-enqueue pending reqs when auth challenge is received
const req = this.pendingRequests.get(id)
if (req) {
this.cxn.send(['REQ', id, ...req.filters])
}
}
}
onReceiveNotice([verb, notice]: Message) {
console.warn('NOTICE', this.cxn.url, notice)
}
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() > 1000) 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'
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import type {Event, Filter} from 'nostr-tools'
import type {Emitter} from '@coracle.social/lib'
import type {Connection} from './Connection'
import type {Message} from './Socket'
export type Target = Emitter & {
connections: Connection[]
send: (...args: Message) => void
cleanup: () => 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 SubscribeOpts = {onEvent?: EventCallback, onEose?: EoseCallback}
type PublishOpts = {verb?: string, onOk?: OkCallback, onError?: ErrorCallback}
type AuthOpts = {onAuth: AuthCallback, onOk: OkCallback}
const createSubId = (prefix: string) => [prefix, Math.random().toString().slice(2, 10)].join('-')
export class Executor {
constructor(readonly target: Target) {}
subscribe(filters: Filter[], {onEvent, onEose}: SubscribeOpts = {}) {
let closed = false
const id = createSubId('REQ')
const eventListener = (url: string, subid: string, e: Event) => subid === id && onEvent?.(url, e)
const eoseListener = (url: string, subid: string) => subid === id && onEose?.(url)
this.target.on('EVENT', eventListener)
this.target.on('EOSE', eoseListener)
this.target.send("REQ", id, ...filters)
return {
unsubscribe: () => {
if (!closed) {
this.target.send("CLOSE", id)
this.target.off('EVENT', eventListener)
this.target.off('EOSE', eoseListener)
}
closed = true
},
}
}
publish(event: Event, {verb = 'EVENT', onOk, onError}: PublishOpts = {}) {
const okListener = (url: string, id: string, ...payload: any[]) => id === event.id && onOk?.(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('ERROR', errorListener)
this.target.send(verb, event)
return {
unsubscribe: () => {
this.target.off('OK', okListener)
this.target.off('ERROR', errorListener)
}
}
}
handleAuth({onAuth, onOk}: AuthOpts) {
this.target.on('AUTH', onAuth)
this.target.on('OK', onOk)
return {
unsubscribe: () => {
this.target.off('AUTH', onAuth)
this.target.off('OK', onOk)
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import {Emitter} from '@coracle.social/lib'
import {Connection} from "./Connection"
export class Pool extends Emitter {
data: Map<string, Connection>
constructor() {
super()
this.data = new Map()
}
has(url: string) {
return this.data.has(url)
}
get(url: string, {autoConnect = true, reconnectAfter = 3000} = {}): Connection {
let connection = this.data.get(url)
if (autoConnect) {
if (!connection) {
connection = new Connection(url)
this.data.set(url, connection)
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() - reconnectAfter,
})
}
return connection!
}
remove(url: string) {
const connection = this.data.get(url)
if (connection) {
connection.destroy()
this.data.delete(url)
}
}
clear() {
for (const url of this.data.keys()) {
this.remove(url)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import WebSocket from "isomorphic-ws"
import {Deferred, defer} from '@coracle.social/lib'
export type Message = [string, ...any[]]
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<boolean>
failedToConnect = false
constructor(url: string, readonly opts: SocketOpts) {
this.url = url
this.ready = defer()
}
isPending() {
return !this.ws && !this.failedToConnect
}
isConnecting() {
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 = () => {
this.ready.resolve(true)
this.opts.onOpen()
}
onError = () => {
this.opts.onError()
this.disconnect()
}
onMessage = (event: {data: string}) => {
try {
const message = JSON.parse(event.data as string)
if (Array.isArray(message)) {
this.opts.onMessage(message as Message)
} else {
console.warn("Invalid messages received:", message)
}
} catch (e) {
// pass
}
}
send = (message: any) => {
if (!this.ws) {
throw new Error('Send attempted before socket was opened')
}
this.ws.send(JSON.stringify(message))
}
connect = () => {
if (this.ws) {
throw new Error(`Already attempted connection for ${this.url}`)
}
try {
this.ws = new WebSocket(this.url)
this.ws.onopen = this.onOpen
this.ws.onerror = this.onError
this.ws.onmessage = this.onMessage
this.ws.onclose = this.disconnect
} catch (e) {
this.failedToConnect = true
}
}
disconnect = () => {
if (this.ws) {
const currentWs = this.ws
this.ready.then(() => currentWs.close())
this.ready = defer()
this.opts.onClose()
this.ws = undefined
// Resolve a different instance of the promise
this.ready.resolve(false)
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import {Emitter} from '@coracle.social/lib'
import type {Filter} from '@coracle.social/util'
import {matchFilters, hasValidSignature} from '@coracle.social/util'
import type {Event} from 'nostr-tools'
import type {Executor} from "./Executor"
import type {Connection} from './Connection'
export type SubscriptionOpts = {
executor: Executor
filters: Filter[]
timeout?: number
closeOnEose?: boolean
hasSeen?: (e: Event, url: string) => boolean
shouldValidate?: (e: Event, url: string) => boolean
}
export class Subscription extends Emitter {
unsubscribe: () => void
dead = new Set<string>()
seen = new Set<string>()
eose = new Set<string>()
closeHandlers = new Map()
opened = Date.now()
closed?: number
constructor(readonly opts: SubscriptionOpts) {
super()
const {executor, timeout, filters} = this.opts
// If we have a timeout, close the subscription automatically
if (timeout) {
setTimeout(this.close, timeout)
}
// If one of our connections gets closed make sure to kill our sub
executor.target.connections.forEach(con => {
const handler = () => {
this.dead.add(con.url)
if (this.dead.size === executor.target.connections.length) {
this.close()
}
}
this.closeHandlers.set(con.url, handler)
con.on("close", handler)
})
// Start our subscription
const sub = executor.subscribe(filters, {
onEvent: this.onEvent,
onEose: this.onEose,
})
this.unsubscribe = sub.unsubscribe
}
hasSeen = (event: Event, url: string) => {
if (this.opts.hasSeen) {
return this.opts.hasSeen(event, url)
}
if (this.seen.has(event.id)) {
return true
}
this.seen.add(event.id)
return false
}
hasValidSignature = (event: Event, url: string) => {
if (this.opts.shouldValidate && !this.opts.shouldValidate(event, url)) {
return true
}
return hasValidSignature(event)
}
onEvent = (url: string, event: Event) => {
// If we've seen this event, don't re-validate
// Otherwise, check the signature and filters
if (this.hasSeen(event, url)) {
this.emit("duplicate", event, url)
} else {
if (!this.hasValidSignature(event, url)) {
this.emit("invalid-signature", event, url)
} else if (!matchFilters(this.opts.filters, event)) {
this.emit("failed-filter", event, url)
} else {
this.emit("event", event, url)
}
}
}
onEose = (url: string) => {
const {executor, closeOnEose} = this.opts
this.emit("eose", url)
this.eose.add(url)
if (closeOnEose && this.eose.size >= executor.target.connections.length) {
this.close()
}
}
close = () => {
if (!this.closed) {
const {target} = this.opts.executor
this.closed = Date.now()
this.unsubscribe()
this.emit("close")
this.removeAllListeners()
target.connections.forEach((con: Connection) => con.off("close", this.closeHandlers.get(con.url)))
target.cleanup()
}
}
}
+10
View File
@@ -0,0 +1,10 @@
export * from "./Connection"
export * from "./ConnectionMeta"
export * from "./Executor"
export * from "./Pool"
export * from "./Socket"
export * from "./Subscription"
export * from "./target/Multi"
export * from "./target/Plex"
export * from "./target/Relay"
export * from "./target/Relays"
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@coracle.social/network",
"version": "0.0.2",
"author": "hodlbod",
"license": "MIT",
"description": "Utilities for connecting with nostr relays.",
"publishConfig": {
"access": "public"
},
"type": "module",
"files": [
"build"
],
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.mjs",
"require": "./build/index.cjs"
}
},
"scripts": {
"pub": "npm run lint && npm run rebuild && npm publish",
"rebuild": "npm run clean && npm run build",
"build": "tsc-multi",
"clean": "gts clean",
"lint": "gts lint",
"fix": "gts fix"
},
"devDependencies": {
"@types/events": "^3.0.3",
"gts": "^5.0.1",
"tsc-multi": "^1.1.0",
"typescript": "~5.1.6"
},
"dependencies": {
"@coracle.social/util": "^0.0.2",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
import {Emitter} from '@coracle.social/lib'
import type {Target} from '../Executor'
import type {Message} from '../Socket'
export class Multi extends Emitter {
constructor(readonly targets: Target[]) {
super()
targets.forEach(t => {
t.on('*', (verb, ...args) => this.emit(verb, ...args))
})
}
get connections() {
return this.targets.flatMap(t => t.connections)
}
send(...payload: Message) {
this.targets.forEach(t => t.send(...payload))
}
cleanup = () => {
this.removeAllListeners()
this.targets.forEach(t => t.cleanup())
}
}
+28
View File
@@ -0,0 +1,28 @@
import {Emitter} from '@coracle.social/lib'
import type {PlexMessage, Message} from '../Socket'
import type {Connection} from '../Connection'
export class Plex extends Emitter {
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)
}
}
+28
View File
@@ -0,0 +1,28 @@
import {Emitter} from '@coracle.social/lib'
import type {Message} from '../Socket'
import type {Connection} from '../Connection'
export class Relay extends Emitter {
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)
}
}
+30
View File
@@ -0,0 +1,30 @@
import {Emitter} from '@coracle.social/lib'
import type {Message} from '../Socket'
import type {Connection} from '../Connection'
export class Relays extends Emitter {
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)
})
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"targets": [
{"extname": ".cjs", "module": "commonjs"},
{"extname": ".mjs", "module": "esnext", "moduleResolution": "node"}
],
"projects": ["tsconfig.json"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../node_modules/gts/tsconfig-google.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "build",
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["es2019", "dom"]
},
"include": ["**/*.ts"]
}