presidium-websocket
Version:
Presidium WebSocket client and server for Node.js
418 lines (377 loc) • 10.9 kB
JavaScript
/**
* presidium-websocket v0.4.1
* https://github.com/richytong/presidium-websocket
* (c) 2025 Richard Tong
* presidium-websocket may be freely distributed under the MIT license.
*/
const http = require('http')
const https = require('https')
const crypto = require('crypto')
const events = require('events')
const decodeWebSocketFrame = require('./_internal/decodeWebSocketFrame')
const ServerWebSocket = require('./_internal/ServerWebSocket')
const LinkedList = require('./_internal/LinkedList')
const __ = require('./_internal/placeholder')
const curry3 = require('./_internal/curry3')
const append = require('./_internal/append')
const call = require('./_internal/call')
const remove = require('./_internal/remove')
const thunkify1 = require('./_internal/thunkify1')
const thunkify2 = require('./_internal/thunkify2')
const thunkify4 = require('./_internal/thunkify4')
const thunkify5 = require('./_internal/thunkify5')
const functionConcatSync = require('./_internal/functionConcatSync')
const {
kBuffer,
kBufferCb
} = require('./_internal/stream_base_commons')
const _onread = require('./_internal/_onread')
/**
* @name WebSocketServer
*
* @docs
* Presidium WebSocket server.
*
* ```coffeescript [specscript]
* module http 'https://nodejs.org/api/http.html'
* module net 'https://nodejs.org/api/net.html'
*
* websocketHandler (websocket WebSocket)=>()
* httpHandler (request http.ClientRequest, response http.ServerResponse)=>()
* upgradeHandler (request http.ClientRequest, socket net.Socket, head Buffer)=>()
*
* new WebSocketServer() -> server WebSocketServer
* new WebSocketServer(websocketHandler) -> server WebSocketServer
*
* new WebSocketServer(websocketHandler, options {
* httpHandler: httpHandler,
* secure: boolean,
* key: string,
* cert: string,
* passphrase: string
* }) -> server WebSocketServer
*
* new WebSocketServer(options {
* websocketHandler: websocketHandler,
* httpHandler: httpHandler,
* secure: boolean,
* key: string,
* cert: string,
* passphrase: string
* }) -> server WebSocketServer
*
* server.on('connection', websocketHandler) -> ()
* server.on('request', httpHandler) -> ()
* server.on('upgrade', upgradeHandler) -> ()
* server.on('error', (error Error)=>()) -> ()
* server.on('close', ()=>()) -> ()
*
* server.on('connection', (websocket WebSocket) => {
* websocket.on('open', ()=>()) -> ()
* websocket.on('message', (message Buffer)=>()) -> ()
* websocket.on('ping', ()=>()) -> ()
* websocket.on('pong', ()=>()) -> ()
* websocket.on('error', (error Error)=>()) -> ()
* websocket.on('close', ()=>()) -> ()
* })
* ```
*/
class WebSocketServer extends events.EventEmitter {
constructor(...args) {
super()
let options
if (args.length == 0) {
options = {}
} else if (args.length == 1) {
if (typeof args[0] == 'function') {
this._websocketHandler = args[0]
options = {}
} else if (typeof args[0] == 'object') {
options = args[0] ?? {}
} else {
throw new TypeError('bad options')
}
} else {
this._websocketHandler = args[0]
options = args[1] ?? {}
}
this._httpHandler = options.httpHandler ?? defaultHttpHandler
if (options.supportPerMessageDeflate) {
this._supportPerMessageDeflate = true
}
this._maxMessageLength = options.maxMessageLength ?? 4 * 1024
this._socketBufferLength = options.socketBufferLength ?? 100 * 1024
if (options.secure) {
this._server = https.createServer({
key: options.key,
cert: options.cert,
passphrase: options.passphrase
})
} else {
this._server = http.createServer()
}
this._server.on('request', this._handleRequest.bind(this))
this._server.on('upgrade', this._handleUpgrade.bind(this))
this.connections = []
}
/**
* @name _handleRequest
*
* @docs
* ```coffeescript [specscript]
* server._handleRequest(
* request http.ClientRequest,
* response http.ServerResponse
* ) -> ()
* ```
*/
_handleRequest(request, response) {
this.emit('request', request)
this._httpHandler(request, response)
}
/**
* @name _handleUpgrade
*
* @docs
* ```coffeescript [specscript]
* server._handleUpgrade(
* request http.ClientRequest,
* socket net.Socket,
* head Buffer # contains first packet of the upgraded stream
* ) -> ()
* ```
*/
_handleUpgrade(request, socket, head) {
this._changeBuffer(socket)
if (
request.headers['upgrade'] == 'websocket'
&& typeof request.headers['sec-websocket-key'] == 'string'
) {
const guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
const hash = crypto.createHash('sha1')
hash.update(request.headers['sec-websocket-key'] + guid)
const acceptKey = hash.digest('base64')
const clientRequestedPerMessageDeflate =
request.headers['sec-websocket-extensions']
?.includes('permessage-deflate')
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey}\r\n${clientRequestedPerMessageDeflate && this._supportPerMessageDeflate ? 'Sec-WebSocket-Extensions: permessage-deflate\r\n' : ''}\r\n`
)
if (clientRequestedPerMessageDeflate && this._supportPerMessageDeflate) {
socket._perMessageDeflate = true
}
this.emit('upgrade', request, socket, head)
this._handleUpgradedConnection(socket, request, head)
} else {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
}
}
/**
* @name _changeBuffer
*
* @docs
* ```coffeescript [specscript]
* _changeBuffer(socket net.Socket) -> ()
* ```
*/
_changeBuffer(socket) {
const buffer = Buffer.alloc(this._socketBufferLength)
socket._handle.useUserBuffer(buffer)
socket[kBuffer] = buffer
socket[kBufferCb] = _onread.bind(socket)
}
/**
* @name _handleUpgradedConnection
*
* @docs
* ```coffeescript [specscript]
* server._handleUpgradedConnection(
* socket net.Socket,
* request http.ClientRequest,
* head Buffer
* ) -> ()
* ```
*/
_handleUpgradedConnection(socket, request, head) {
const chunks = new LinkedList()
const websocket = new ServerWebSocket(socket, {
maxMessageLength: this._maxMessageLength,
socketBufferLength: this._socketBufferLength
})
this.emit('connection', websocket, request, head)
if (typeof this._websocketHandler == 'function') {
this._websocketHandler(websocket, request, head)
}
this.connections.push(websocket)
websocket.once('ping', thunkify5(
call,
this._handleOpen,
this,
websocket,
request,
head
))
websocket.on('close', thunkify2(
remove,
this.connections,
websocket
))
socket.on('data', functionConcatSync(
curry3(append, chunks, __, 'WebSocketServer'),
thunkify1(
process.nextTick,
thunkify4(call, this._processChunk, this, chunks, websocket)
)
))
}
/**
* @name _handleOpen
*
* @docs
* ```coffeescript [specscript]
* _handleOpen(websocket ServerWebSocket) -> ()
* ```
*/
_handleOpen(websocket) {
websocket.readyState = 1
websocket.emit('open')
}
/**
* @name _processChunk
*
* @docs
* ```coffeescript [specscript]
* server._processChunk(
* chunks Array<Buffer>,
* websocket ServerWebSocket
* ) -> ()
* ```
*/
async _processChunk(chunks, websocket) {
while (chunks.length > 0) { // process data frames
let chunk = chunks.shift()
let decodeResult = await decodeWebSocketFrame.call(websocket, chunk, websocket._perMessageDeflate)
while (decodeResult == null && chunks.length > 0) {
chunk = Buffer.concat([chunk, chunks.shift()])
decodeResult = await decodeWebSocketFrame.call(websocket, chunk, websocket._perMessageDeflate)
}
if (decodeResult == null) {
chunks.prepend(chunk)
return undefined
}
const { fin, opcode, payload, remaining, masked } = decodeResult
// The server must close the connection upon receiving a frame that is not masked
if (!masked) {
websocket.sendClose('unmasked frame')
websocket.destroy()
break
}
if (remaining.length > 0) {
chunks.prepend(remaining)
}
this._handleDataFrame(websocket, payload, opcode, fin)
}
}
/**
* @name _handleDataFrame
*
* @docs
* ```coffeescript [specscript]
* server._handleDataFrame(
* websocket ServerWebSocket,
* payload Buffer,
* opcode number,
* fin boolean
* ) -> ()
* ```
*/
_handleDataFrame(websocket, payload, opcode, fin) {
if (opcode === 0x0) { // continuation frame
websocket._continuationPayloads.push(payload)
if (fin) { // last continuation frame
websocket.emit('message', Buffer.concat(websocket._continuationPayloads))
websocket._continuationPayloads = []
}
} else if (fin) { // unfragmented message
switch (opcode) {
case 0x1: // text frame
case 0x2: // binary frame
websocket.emit('message', payload)
break
case 0x8: // close frame
if (websocket.sentClose) {
websocket.destroy(payload)
} else {
websocket.sendClose(payload)
websocket.destroy(payload)
}
break
case 0x9: // ping frame
websocket.emit('ping', payload)
websocket.sendPong(payload)
break
case 0xA: // pong frame
websocket.emit('pong', payload)
break
}
} else { // fragmented message, wait for continuation frames
websocket._continuationPayloads.push(payload)
}
}
/**
* @name listen
*
* @docs
* ```coffeescript [specscript]
* server.listen(port number, callback? function) -> ()
* ```
*/
listen(...args) {
this._server.listen(...args)
}
/**
* @name close
*
* @docs
* ```coffeescript [specscript]
* server.close() -> ()
* ```
*/
close() {
this._server.close()
this.closed = true
this.connections.forEach(connection => {
connection.close()
})
this.emit('close')
}
}
/**
* @name noop
*
* @docs
* Function that doesn't do anything
*
* ```coffeescript [specscript]
* noop() -> ()
* ```
*/
function noop() {
}
/**
* @name defaultHttpHandler
*
* @docs
* Default HTTP handler. Responds with `200 OK`.
*
* ```coffeescript [specscript]
* defaultHttpHandler(request http.ClientRequest, response http.ServerResponse) -> ()
* ```
*/
function defaultHttpHandler(request, response) {
response.writeHead(200, {
'Content-Type': 'text/plain',
})
response.end('OK')
}
module.exports = WebSocketServer