Getting rid of placeholder controller libs

This commit is contained in:
Chris Troutner
2025-11-09 19:24:48 -08:00
parent 3fff9000bf
commit 78197762e4
7 changed files with 2 additions and 768 deletions
-241
View File
@@ -1,241 +0,0 @@
/*
Nostr Relay WebSocket adapter.
Handles WebSocket connections to Nostr relays and manages message sending/receiving.
*/
import WebSocket from 'ws'
import config from '../config/index.js'
import wlogger from './wlogger.js'
class NostrRelayAdapter {
constructor (localConfig = {}) {
this.config = config
this.relayUrl = localConfig.relayUrl || config.nostrRelayUrl
this.ws = null
this.isConnected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectDelay = 5000 // 5 seconds
this.messageHandlers = new Map() // Map subscription_id to handlers
this.pendingMessages = [] // Queue messages while disconnected
this.eventResolvers = new Map() // Map event_id to promise resolvers for OK responses
this.subscriptionHandlers = new Map() // Map subscription_id to event handlers
// Bind methods
this.connect = this.connect.bind(this)
this.disconnect = this.disconnect.bind(this)
this.sendEvent = this.sendEvent.bind(this)
this.sendReq = this.sendReq.bind(this)
this.sendClose = this.sendClose.bind(this)
this.handleMessage = this.handleMessage.bind(this)
this.handleError = this.handleError.bind(this)
this.handleClose = this.handleClose.bind(this)
}
async connect () {
if (this.ws && this.isConnected) {
return true
}
return new Promise((resolve, reject) => {
try {
wlogger.info(`Connecting to Nostr relay: ${this.relayUrl}`)
this.ws = new WebSocket(this.relayUrl)
this.ws.on('open', () => {
wlogger.info('Connected to Nostr relay')
this.isConnected = true
this.reconnectAttempts = 0
// Send any pending messages
while (this.pendingMessages.length > 0) {
const message = this.pendingMessages.shift()
this.ws.send(JSON.stringify(message))
}
resolve(true)
})
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
this.handleMessage(message)
} catch (err) {
wlogger.error('Error parsing relay message:', err)
}
})
this.ws.on('error', this.handleError)
this.ws.on('close', this.handleClose)
// Timeout after 10 seconds
setTimeout(() => {
if (!this.isConnected) {
reject(new Error('Connection timeout'))
}
}, 10000)
} catch (err) {
wlogger.error('Error connecting to relay:', err)
reject(err)
}
})
}
async disconnect () {
if (this.ws) {
this.ws.close()
this.ws = null
this.isConnected = false
wlogger.info('Disconnected from Nostr relay')
}
}
handleMessage (message) {
if (!Array.isArray(message) || message.length === 0) {
return
}
const [type, ...args] = message
switch (type) {
case 'EVENT':
// ["EVENT", <subscription_id>, <event>]
if (args.length >= 2) {
const subscriptionId = args[0]
const event = args[1]
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onEvent(event)
}
}
break
case 'OK':
// ["OK", <event_id>, <true|false>, <message>]
if (args.length >= 2) {
const eventId = args[0]
const accepted = args[1]
const message = args[2] || ''
const resolver = this.eventResolvers.get(eventId)
if (resolver) {
resolver({ accepted, message })
this.eventResolvers.delete(eventId)
}
}
break
case 'EOSE':
// ["EOSE", <subscription_id>]
if (args.length >= 1) {
const subscriptionId = args[0]
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onEose()
}
}
break
case 'CLOSED':
// ["CLOSED", <subscription_id>, <message>]
if (args.length >= 1) {
const subscriptionId = args[0]
const message = args[1] || ''
const handler = this.subscriptionHandlers.get(subscriptionId)
if (handler) {
handler.onClosed(message)
}
}
break
case 'NOTICE':
// ["NOTICE", <message>]
if (args.length >= 1) {
const message = args[0]
wlogger.warn('Relay notice:', message)
}
break
default:
wlogger.warn('Unknown message type from relay:', type)
}
}
handleError (error) {
wlogger.error('WebSocket error:', error)
this.isConnected = false
}
handleClose () {
const now = new Date()
wlogger.warn(`WebSocket connection closed at ${now.toLocaleString()}`)
this.isConnected = false
// Attempt to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
wlogger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
setTimeout(() => {
this.connect().catch(err => {
wlogger.error('Reconnection failed:', err)
})
}, this.reconnectDelay)
}
}
async sendMessage (message) {
if (!this.isConnected || !this.ws) {
// Queue message for when connection is established
this.pendingMessages.push(message)
await this.connect()
return
}
try {
this.ws.send(JSON.stringify(message))
} catch (err) {
wlogger.error('Error sending message:', err)
throw err
}
}
async sendEvent (event) {
// ["EVENT", <event>]
const message = ['EVENT', event]
await this.sendMessage(message)
// Return a promise that resolves when we get the OK response
return new Promise((resolve, reject) => {
this.eventResolvers.set(event.id, resolve)
// Timeout after 30 seconds
setTimeout(() => {
if (this.eventResolvers.has(event.id)) {
this.eventResolvers.delete(event.id)
reject(new Error('Timeout waiting for OK response'))
}
}, 30000)
})
}
async sendReq (subscriptionId, filters, handlers) {
// ["REQ", <subscription_id>, <filters>]
await this.connect()
// Store handlers for this subscription
this.subscriptionHandlers.set(subscriptionId, handlers)
const message = ['REQ', subscriptionId, ...filters]
await this.sendMessage(message)
}
async sendClose (subscriptionId) {
// ["CLOSE", <subscription_id>]
const message = ['CLOSE', subscriptionId]
await this.sendMessage(message)
// Clean up handlers
this.subscriptionHandlers.delete(subscriptionId)
this.messageHandlers.delete(subscriptionId)
}
}
export default NostrRelayAdapter
@@ -1,99 +0,0 @@
/*
REST API Controller library for the /event route
*/
// Local libraries
import wlogger from '../../../adapters/wlogger.js'
class EventRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /event REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /event REST Controller.'
)
}
// Bind 'this' object to all subfunctions
this.publishEvent = this.publishEvent.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {post} /event Publish a Nostr event
* @apiPermission public
* @apiName PublishEvent
* @apiGroup Event
*
* @apiDescription Publish a signed Nostr event to the relay. Maps to the Nostr WebSocket protocol message: ["EVENT", <event>]
*
* @apiParam {String} id Event ID (32-bytes lowercase hex-encoded sha256)
* @apiParam {String} pubkey Public key of event creator (32-bytes lowercase hex-encoded)
* @apiParam {Number} created_at Unix timestamp in seconds
* @apiParam {Number} kind Integer between 0 and 65535
* @apiParam {Array} tags Array of tag arrays
* @apiParam {String} content Event content (arbitrary string)
* @apiParam {String} sig Signature (64-bytes lowercase hex)
*
* @apiExample {json} Example usage:
* {
* "id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
* "pubkey": "2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9",
* "created_at": 1672531200,
* "kind": 1,
* "tags": [],
* "content": "Hello, Nostr!",
* "sig": "abc123..."
* }
*
* @apiSuccess {Boolean} accepted Whether the event was accepted by the relay
* @apiSuccess {String} message Optional message from the relay
* @apiSuccess {String} eventId The event ID
*
* @apiError {String} error Error message
*/
async publishEvent (req, res) {
try {
const eventData = req.body
// Check if eventData is missing or empty
if (!eventData || (typeof eventData === 'object' && Object.keys(eventData).length === 0)) {
return res.status(400).json({
error: 'Event data is required'
})
}
const result = await this.useCases.publishEvent.execute(eventData)
if (result.accepted) {
return res.status(200).json(result)
} else {
return res.status(400).json(result)
}
} catch (err) {
return this.handleError(err, req, res)
}
}
handleError (err, req, res) {
wlogger.error('Error in EventRESTController:', err)
// Return 400 for validation errors, 500 for other errors
// Validation errors indicate the client sent bad data
const isValidationError = err.message && err.message.includes('Invalid event structure')
const statusCode = isValidationError ? 400 : 500
return res.status(statusCode).json({
error: err.message || 'Internal server error'
})
}
}
export default EventRESTControllerLib
-55
View File
@@ -1,55 +0,0 @@
/*
REST API library for the /event route.
*/
// Public npm libraries.
import express from 'express'
// Local libraries.
import EventRESTControllerLib from './controller.js'
class EventRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Event REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Event REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.eventRESTController = new EventRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
this.baseUrl = '/event'
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', this.eventRESTController.publishEvent)
// Attach the Controller routes to the Express app.
app.use(this.baseUrl, this.router)
}
}
export default EventRouter
-296
View File
@@ -1,296 +0,0 @@
/*
REST API Controller library for the /req route
*/
// Local libraries
import wlogger from '../../../adapters/wlogger.js'
class ReqRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /req REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /req REST Controller.'
)
}
// Bind 'this' object to all subfunctions
this.queryEvents = this.queryEvents.bind(this)
this.createSubscription = this.createSubscription.bind(this)
this.closeSubscription = this.closeSubscription.bind(this)
this.handleError = this.handleError.bind(this)
}
/**
* @api {get} /req/:subId Query events (stateless)
* @apiPermission public
* @apiName QueryEvents
* @apiGroup Request
*
* @apiDescription Query events from the relay with filters. Returns events immediately. Maps to: ["REQ", <sub_id>, <filters>]
*
* @apiParam {String} subId Subscription ID (unique identifier)
* @apiParam {String} filters JSON-encoded filters object (query parameter)
*
* @apiExample {curl} Example usage:
* curl -X GET "http://localhost:3000/req/sub1?filters=[{\"kinds\":[1],\"limit\":10}]"
*
* @apiSuccess {Array} events Array of Nostr events
*
* @apiError {String} error Error message
*/
async queryEvents (req, res) {
try {
const { subId } = req.params
let filters = req.query.filters
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
// Parse filters from query string
if (typeof filters === 'string') {
try {
filters = JSON.parse(filters)
} catch (err) {
return res.status(400).json({
error: 'Invalid filters JSON'
})
}
} else if (!filters) {
// If no filters provided, accept filters from query params
filters = {}
if (req.query.kinds) {
filters.kinds = JSON.parse(req.query.kinds)
}
if (req.query.authors) {
filters.authors = JSON.parse(req.query.authors)
}
if (req.query.ids) {
filters.ids = JSON.parse(req.query.ids)
}
if (req.query.limit) {
filters.limit = parseInt(req.query.limit)
}
if (req.query.since) {
filters.since = parseInt(req.query.since)
}
if (req.query.until) {
filters.until = parseInt(req.query.until)
}
}
// Ensure filters is an array (Nostr protocol expects array of filters)
const filtersArray = Array.isArray(filters) ? filters : [filters]
const events = await this.useCases.queryEvents.execute(filtersArray, subId)
return res.status(200).json(events)
} catch (err) {
return this.handleError(err, req, res)
}
}
/**
* @api {post} /req/:subId Create subscription (SSE)
* @apiPermission public
* @apiName CreateSubscription
* @apiGroup Request
*
* @apiDescription Create a subscription for Server-Sent Events. Maps to: ["REQ", <sub_id>, <filters>]
*
* @apiParam {String} subId Subscription ID (unique identifier)
* @apiParam {Object} filters Filters object in request body
*
* @apiExample {json} Example usage:
* {
* "kinds": [1],
* "authors": ["2c7e76c0f8dc1dca9d0197c7d19be580a8d074ccada6a2f6ebe056ae41092e9"]
* }
*
* @apiSuccess {String} message Success message
*
* @apiError {String} error Error message
*/
async createSubscription (req, res) {
try {
const { subId } = req.params
const filters = req.body
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
if (!filters || (typeof filters === 'object' && Object.keys(filters).length === 0)) {
return res.status(400).json({
error: 'Filters are required'
})
}
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters]
// Set up Server-Sent Events
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no') // Disable buffering in nginx
// Track if response is still writable
let isResponseWritable = true
// Helper function to safely write to SSE stream
const safeWrite = (data) => {
if (!isResponseWritable) {
return false
}
try {
if (!res.writable || res.destroyed || res.closed) {
isResponseWritable = false
return false
}
return res.write(data)
} catch (err) {
wlogger.warn(`Error writing to SSE stream for subscription ${subId}:`, err.message)
isResponseWritable = false
return false
}
}
// Handle response stream errors
res.on('error', (err) => {
wlogger.warn(`Response stream error for subscription ${subId}:`, err.message)
isResponseWritable = false
// Clean up subscription on stream error
this.useCases.manageSubscription.closeSubscription(subId).catch(closeErr => {
wlogger.error('Error closing subscription on stream error:', closeErr)
})
})
// Send initial connection message
if (!safeWrite(`data: ${JSON.stringify({ type: 'connected', subscriptionId: subId })}\n\n`)) {
wlogger.warn(`Failed to send initial connection message for subscription ${subId}`)
return res.status(500).json({ error: 'Failed to establish SSE connection' })
}
// Handle events
const onEvent = (event) => {
if (!safeWrite(`data: ${JSON.stringify({ type: 'event', data: event })}\n\n`)) {
wlogger.debug(`Cannot write event to SSE stream for subscription ${subId} - connection may be closed`)
}
}
// Handle EOSE
const onEose = () => {
if (!safeWrite(`data: ${JSON.stringify({ type: 'eose' })}\n\n`)) {
wlogger.debug(`Cannot write EOSE to SSE stream for subscription ${subId} - connection may be closed`)
}
}
// Handle CLOSED
const onClosed = (message) => {
if (safeWrite(`data: ${JSON.stringify({ type: 'closed', message })}\n\n`)) {
try {
if (!res.destroyed && !res.closed) {
res.end()
}
} catch (err) {
wlogger.warn(`Error ending SSE stream for subscription ${subId}:`, err.message)
}
}
isResponseWritable = false
}
// Create subscription
await this.useCases.manageSubscription.createSubscription(
subId,
filtersArray,
onEvent,
onEose,
onClosed
)
// Handle client disconnect
req.on('close', () => {
wlogger.info(`Client disconnected from subscription ${subId}`)
isResponseWritable = false
this.useCases.manageSubscription.closeSubscription(subId).catch(err => {
wlogger.error('Error closing subscription on disconnect:', err)
})
})
// Handle response finish
res.on('finish', () => {
isResponseWritable = false
})
} catch (err) {
return this.handleError(err, req, res)
}
}
/**
* @api {put} /req/:subId Create subscription (SSE) - alternative method
* @apiPermission public
* @apiName CreateSubscriptionPut
* @apiGroup Request
*
* @apiDescription Same as POST /req/:subId - create a subscription for Server-Sent Events
*/
async createSubscriptionPut (req, res) {
return this.createSubscription(req, res)
}
/**
* @api {delete} /req/:subId Close subscription
* @apiPermission public
* @apiName CloseSubscription
* @apiGroup Request
*
* @apiDescription Close an existing subscription. Maps to: ["CLOSE", <sub_id>]
*
* @apiParam {String} subId Subscription ID to close
*
* @apiSuccess {String} message Success message
*
* @apiError {String} error Error message
*/
async closeSubscription (req, res) {
try {
const { subId } = req.params
if (!subId) {
return res.status(400).json({
error: 'Subscription ID is required'
})
}
await this.useCases.manageSubscription.closeSubscription(subId)
return res.status(200).json({
message: `Subscription ${subId} closed successfully`
})
} catch (err) {
return this.handleError(err, req, res)
}
}
handleError (err, req, res) {
wlogger.error('Error in ReqRESTController:', err)
return res.status(500).json({
error: err.message || 'Internal server error'
})
}
}
export default ReqRESTControllerLib
-75
View File
@@ -1,75 +0,0 @@
/*
REST API library for the /req route.
*/
// Public npm libraries.
import express from 'express'
// Local libraries.
import ReqRESTControllerLib from './controller.js'
class ReqRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Req REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Req REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.reqRESTController = new ReqRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
this.router = express.Router()
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
// Handle empty subId case first
this.router.get('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
this.router.post('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
this.router.delete('/', (req, res) => {
res.status(400).json({
error: 'Subscription ID is required'
})
})
// Routes with subId parameter
this.router.get('/:subId', this.reqRESTController.queryEvents)
this.router.post('/:subId', this.reqRESTController.createSubscription)
this.router.put('/:subId', this.reqRESTController.createSubscriptionPut)
this.router.delete('/:subId', this.reqRESTController.closeSubscription)
// Attach the Controller routes to the Express app.
app.use('/req', this.router)
}
}
export default ReqRouter
@@ -2,7 +2,7 @@
Use cases for interacting with the BCH full node blockchain RPC interface.
*/
import wlogger from '../../adapters/wlogger.js'
import wlogger from '../adapters/wlogger.js'
class BlockchainUseCases {
constructor (localConfig = {}) {
+1 -1
View File
@@ -8,7 +8,7 @@
// import PublishEventUseCase from './publish-event.js'
// import QueryEventsUseCase from './query-events.js'
// import ManageSubscriptionUseCase from './manage-subscription.js'
import BlockchainUseCases from './blockchain/index.js'
import BlockchainUseCases from './full-node-blockchain-use-cases.js'
class UseCases {
constructor (localConfig = {}) {