story-research-zapwall/lib/websocketService.ts
2026-01-07 01:51:26 +01:00

278 lines
7.6 KiB
TypeScript

/**
* WebSocket service - manages WebSocket connections and communicates with Service Workers
*
* Rôle principal : Gestion des connexions WebSocket
* Rôles secondaires : Routage des messages, gestion de l'état de connexion
* Communication avec Service Workers : via postMessage
*/
import { SimplePool } from 'nostr-tools'
import { swClient } from './swClient'
import type { Event } from 'nostr-tools'
interface ConnectionState {
relayUrl: string
connected: boolean
lastConnectedAt: number | null
lastDisconnectedAt: number | null
reconnectAttempts: number
}
class WebSocketService {
private pool: SimplePool | null = null
private initialized = false
private connectionStates: Map<string, ConnectionState> = new Map()
private reconnectIntervals: Map<string, number> = new Map()
private readonly MAX_RECONNECT_ATTEMPTS = 5
private readonly RECONNECT_DELAY_MS = 5000
/**
* Initialize the WebSocket service
* Rôle principal : Gestion des connexions
*/
async initialize(): Promise<void> {
if (this.initialized) {
return
}
if (typeof window === 'undefined') {
return
}
this.pool = new SimplePool()
this.initialized = true
// Notify Service Worker of initialization
void swClient.sendMessage({
type: 'WEBSOCKET_SERVICE_INITIALIZED',
})
console.warn('[WebSocketService] Initialized')
}
/**
* Get the pool instance
*/
getPool(): SimplePool | null {
return this.pool
}
/**
* Get connection state for a relay
* Rôle secondaire : Gestion de l'état de connexion
*/
getConnectionState(relayUrl: string): ConnectionState | null {
return this.connectionStates.get(relayUrl) ?? null
}
/**
* Update connection state
* Rôle secondaire : Gestion de l'état de connexion
*/
private updateConnectionState(relayUrl: string, connected: boolean): void {
const current = this.connectionStates.get(relayUrl) ?? {
relayUrl,
connected: false,
lastConnectedAt: null,
lastDisconnectedAt: null,
reconnectAttempts: 0,
}
const now = Date.now()
const updated: ConnectionState = {
...current,
connected,
lastConnectedAt: connected ? now : current.lastConnectedAt,
lastDisconnectedAt: connected ? null : now,
reconnectAttempts: connected ? 0 : current.reconnectAttempts,
}
this.connectionStates.set(relayUrl, updated)
// Notify Service Worker of connection state change via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_CONNECTION_STATE_CHANGED',
data: { relayUrl, connected, state: updated },
})
}
/**
* Handle reconnection for a relay
* Rôle principal : Gestion des reconnexions
*/
private async handleReconnection(relayUrl: string): Promise<void> {
const state = this.connectionStates.get(relayUrl)
if (!state || state.connected) {
return
}
if (state.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
console.warn(`[WebSocketService] Max reconnect attempts reached for ${relayUrl}`)
return
}
const updated: ConnectionState = {
...state,
reconnectAttempts: state.reconnectAttempts + 1,
}
this.connectionStates.set(relayUrl, updated)
// Notify Service Worker of reconnection attempt via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_RECONNECT_ATTEMPT',
data: { relayUrl, attempt: updated.reconnectAttempts },
})
// Schedule reconnection
const timeoutId = window.setTimeout(() => {
void this.attemptReconnection(relayUrl)
}, this.RECONNECT_DELAY_MS)
this.reconnectIntervals.set(relayUrl, timeoutId)
}
/**
* Attempt to reconnect to a relay
*/
private async attemptReconnection(relayUrl: string): Promise<void> {
// The pool will handle reconnection automatically
// We just need to track the state
console.warn(`[WebSocketService] Attempting to reconnect to ${relayUrl}`)
}
/**
* Publish event to relays via WebSocket
* Rôle secondaire : Routage des messages
* Communicates with Service Worker via postMessage
*/
async publishEvent(event: Event, relays: string[]): Promise<{ success: boolean; error?: string }[]> {
if (!this.pool) {
await this.initialize()
}
if (!this.pool) {
throw new Error('WebSocket service not initialized')
}
// Update connection states
relays.forEach((relayUrl) => {
if (!this.connectionStates.has(relayUrl)) {
this.updateConnectionState(relayUrl, true) // Assume connected when publishing
}
})
// Publish to relays
const pubs = this.pool.publish(relays, event)
const results = await Promise.allSettled(pubs)
const statuses: Array<{ success: boolean; error?: string }> = []
results.forEach((result, index) => {
const relayUrl = relays[index]
if (!relayUrl) {
return
}
if (result.status === 'fulfilled') {
statuses.push({ success: true })
this.updateConnectionState(relayUrl, true)
// Notify Service Worker of successful publication via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_PUBLISH_SUCCESS',
data: { eventId: event.id, relayUrl },
})
} else {
const error = result.reason instanceof Error ? result.reason.message : String(result.reason)
statuses.push({ success: false, error })
this.updateConnectionState(relayUrl, false)
// Notify Service Worker of failed publication via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_PUBLISH_FAILED',
data: { eventId: event.id, relayUrl, error },
})
// Trigger reconnection
void this.handleReconnection(relayUrl)
}
})
return statuses
}
/**
* Subscribe to events from relays
* Rôle secondaire : Routage des messages
* Communicates with Service Worker via postMessage
*/
async subscribe(
relays: string[],
filters: Array<Record<string, unknown>>,
onEvent: (event: Event) => void
): Promise<() => void> {
if (!this.pool) {
await this.initialize()
}
if (!this.pool) {
throw new Error('WebSocket service not initialized')
}
// Update connection states
relays.forEach((relayUrl) => {
this.updateConnectionState(relayUrl, true) // Assume connected when subscribing
})
// Create subscription
const sub = this.pool.subscribe(relays, filters, {
onevent: (event: Event): void => {
// Notify Service Worker of new event via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_EVENT_RECEIVED',
data: { event },
})
onEvent(event)
},
oneose: (): void => {
// Notify Service Worker that subscription is complete via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_EOSE',
data: { relays },
})
},
})
return (): void => {
sub.close()
}
}
/**
* Close all connections
* Rôle principal : Gestion des connexions
*/
close(): void {
// Clear reconnect intervals
this.reconnectIntervals.forEach((timeoutId) => {
clearTimeout(timeoutId)
})
this.reconnectIntervals.clear()
if (this.pool) {
this.pool.close()
this.pool = null
}
// Update all connection states
this.connectionStates.forEach((state, relayUrl) => {
this.updateConnectionState(relayUrl, false)
})
this.initialized = false
// Notify Service Worker via postMessage
void swClient.sendMessage({
type: 'WEBSOCKET_SERVICE_CLOSED',
})
}
}
export const websocketService = new WebSocketService()