Summary
I am using NestJS with Node.JS 20 to build up a very simple websocket server and it’s internal websocket client that does ping/pong.
I can see there is a bunch of old issue and I doubt they haven’t been resolve since 2022:
- https://github.com/vercel/next.js/discussions/50894
- https://github.com/websockets/ws/issues/1508
- https://github.com/websockets/ws/issues/1508
This is the most minimalist example I have been trying to debug and I can ´t understand why it keep hanging up.
Perhaps here people can help?
Error: socket hang up
at connResetException (node:internal/errors:787:14)
at Socket.socketOnEnd (node:_http_client:519:23)
at Socket.emit (node:events:526:35)
at endReadableNT (node:internal/streams/readable:1589:12)
at processTicksAndRejections (node:internal/process/task_queues:82:21)
I’d also like to know why for one pong
event, the websocket serveur receive two of the same pong (I checked, the client emit only one pong and the server only one ping):
2024-09-01T17:03:06.383Z [debug] Client connected: 167e6c3c-ccb4-4b51-99e6-299db3b1740d
2024-09-01T17:03:06.400Z [info] Internal WebSocket client connected
2024-09-01T17:03:11.341Z [debug] Internal WebSocket client received ping and send pong
2024-09-01T17:03:11.341Z [debug] Received pong from client 167e6c3c-ccb4-4b51-99e6-299db3b1740d
2024-09-01T17:03:11.342Z [debug] Received pong from client 167e6c3c-ccb4-4b51-99e6-299db3b1740d
Additional information
There are my nestjs versions:
{
"@nestjs/cli": "^10.4.5",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.1",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/mapped-types": "*",
"@nestjs/microservices": "^10.4.1",
"@nestjs/platform-express": "^10.4.1",
"@nestjs/platform-socket.io": "^10.4.1",
"@nestjs/platform-ws": "^10.4.1",
"@nestjs/schematics": "^10.1.4",
"@nestjs/swagger": "^7.4.0",
"@nestjs/testing": "^10.4.1",
"@nestjs/websockets": "^10.4.1"
}
This is internal-websocket-client.service.ts
:
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import WebSocket from 'ws'
import { LoggerService } from '../logger/logger.service'
@Injectable()
export class InternalWebSocketClientService {
private static NAME = 'Internal WebSocket client'
private ws?: WebSocket
private url: string = 'ws://localhost'
private reconnectDelay = 1000
constructor(
private readonly logger: LoggerService,
private readonly configService: ConfigService,
) {
const port = this.configService.get<string>('PORT')!
this.url = port ? `${this.url}:${port}` : this.url
this.connect()
}
private async connect() {
this.ws = new WebSocket(this.url, {
handshakeTimeout: 10000,
})
this.ws.on('open', () => {
this.logger.log(`${InternalWebSocketClientService.NAME} connected`)
})
this.ws.on('ping', () => {
let message = `${InternalWebSocketClientService.NAME} received ping`
if (!this.ws) {
this.logger.debug(message)
} else {
this.logger.debug(`${message} and send pong`)
this.ws.pong()
}
})
this.ws.on('pong', () => {
this.logger.debug(`${InternalWebSocketClientService.NAME} received pong`)
})
this.ws.on('close', () => {
this.logger.log(
`${InternalWebSocketClientService.NAME} disconnected. Reconnecting...`,
)
setTimeout(() => this.connect(), 1000)
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000)
})
this.ws.on('error', (error) => {
this.logger.error(`${InternalWebSocketClientService.NAME} error:`, error)
})
}
}
This is app.gateway.ts
:
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets'
import { keccak256, toUtf8Bytes } from 'ethers'
import { v4 as uuidv4 } from 'uuid'
import { RawData, Server, WebSocket } from 'ws'
import { AppService } from './app.service'
import { LoggerService } from './logger/logger.service'
type WebSocketWithClientId = {
clientId: string
} & WebSocket
@WebSocketGateway()
export class AppGateway
implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit
{
private static PING_INTERVAL_MS = 5000
private pingInterval?: NodeJS.Timeout | null = null
@WebSocketServer() server!: Server
private clients: Map<string, WebSocketWithClientId> = new Map()
constructor(
private readonly appService: AppService,
private readonly logger: LoggerService,
) {}
onModuleDestroy() {
if (this.pingInterval) {
const intervalId = this.pingInterval[Symbol.toPrimitive]()
clearInterval(intervalId)
this.pingInterval = null
}
this.clients.forEach((client, clientId) => {
client.terminate()
this.clients.delete(clientId)
})
}
handleConnection(client: WebSocketWithClientId) {
const clientId = uuidv4()
client.clientId = clientId
this.clients.set(clientId, client)
this.logger.debug(`Client connected: ${clientId}`)
client.on('message', (message) => this.handleMessage(clientId, message))
client.on('close', (code, reason) => {
this.logger.debug(
`Client disconnected: ${clientId} with code: ${code} reason: ${reason}`,
)
this.handleDisconnect(client)
})
client.on('pong', () => {
this.logger.debug(`Received pong from client ${clientId}`)
})
client.on('ping', () => {
this.logger.debug(`Received ping from client ${clientId}`)
})
client.on('error', (error) => {
this.logger.error(`WebSocket error for client ${clientId}:`, error)
this.clients.delete(clientId)
client.terminate()
})
}
handleDisconnect(client: WebSocket) {
let disconnectedClientId: string | undefined
this.clients.forEach((value, key) => {
if (value === client) {
disconnectedClientId = key
}
})
if (disconnectedClientId) {
this.clients.delete(disconnectedClientId)
this.logger.debug(`Client disconnected: ${disconnectedClientId}`)
}
}
afterInit() {
this.logger.log('WebSocket Gateway initialized')
this.pingInterval = setInterval(() => {
this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
this.logger.debug(`Sending ping to client ${client.clientId}`)
client.ping()
}
})
}, AppGateway.PING_INTERVAL_MS)
}
private handleMessage(clientId: string, message: RawData) {
const client = this.clients.get(clientId)
if (!client) return
try {
const payload = JSON.parse(message.toString())
const subscription = keccak256(
toUtf8Bytes(clientId + JSON.stringify(payload.params)),
).toString()
const confirmationMessage = JSON.stringify({
jsonrpc: '2.0',
id: payload.id,
result: subscription,
})
client.send(confirmationMessage)
this.logger.debug(`Received message from client ${clientId}: ${message}`)
} catch (error) {
this.logger.warn(
`Received non-JSON message from client ${clientId}: ${message}`,
)
}
}
}
As you can see, I implemented a ping and pong, retry connexion etc. I am not even in production, and I guess I won’t go in production. sad to see this issue hang since 2021.
Did anyone find a workaround for the socket hang up error ?