angelia.io
Version:
WebSockets Server and Client API for node.js and the browser, with rooms support.
294 lines (248 loc) • 6.48 kB
JavaScript
import {
empty,
frame,
inspect,
ListenerTemplate,
now,
stringify,
} from './utils.js'
import { Socket } from './Socket.js'
import { Emitter } from './Emitter.js'
import { nextTick } from 'node:process'
import * as http from 'http'
import formidable from 'formidable'
import { WebSocketServer } from 'ws'
/** Creates a new Socket Server */
export default new (class Server extends Emitter {
constructor() {
super()
this.hostname = ''
this.port = 3001
this.timeout = 60 * 1000
this.timeoutCheck = 25 * 1000
this.maxMessageSize = 5
this.maxPostSize = 50
this.since = 0
this.now = 0
this.served = 0
this.bytesReceived = 0
this.bytesSent = 0
this.messagesGarbage = 0
this.messagesReceived = 0
this.messagesSent = 0
this.messagesSentCacheHit = 0
this.serverErrors = 0
this.socketErrors = 0
this.queue = []
this.cacheSymbol = Symbol('cache')
this.cached = 1
this.cache = empty()
this.events = empty()
this.pingData = frame('')
this.disconnectData = frame(stringify([['disconnect', true]]))
}
/**
* Start server listening
*
* @param {{
* hostname?: string
* port?: number
* maxMessageSize?: number
* maxPostSize?: number
* skipUTF8Validation?: boolean
* timeout?: number
* }} [options]
*/
listen(options = {}) {
this.hostname = options.hostname || this.hostname
this.port =
+options.port > 0 && +options.port <= 65535
? +options.port
: this.port
this.maxMessageSize =
+options.maxMessageSize > 0 ? +options.maxMessageSize : 5
this.maxPostSize =
+options.maxPostSize > 0 ? +options.maxPostSize : 50
this.now = now()
this.since = this.now
this.timeout =
+options.timeout >= 10000 ? +options.timeout : 60 * 1000
// updates ping and checks for disconnections
this.timeoutCheck = this.timeout / 2
setInterval(this.ping, this.timeoutCheck)
this.timeoutCheck -= 5000
setInterval(this.updateNow, 500)
// fires the server
const server = http.createServer(async (request, response) => {
/*
need to take care of rooms before handling this
if (
request.url === '/angelia/upload' &&
request.method === 'POST'
) {
// parse a file upload
const form = formidable({})
try {
const [fields, files] = await form.parse(request)
} catch (err) {
console.error(err)
response.writeHead(err.httpCode || 400, {
'Content-Type': 'text/plain',
})
response.end(String(err))
return
}
response.writeHead(200, {
'Content-Type': 'application/json',
})
response.end(JSON.stringify({ fields, files }, null, 2))
return
} else {
response.writeHead(200, { 'Content-Type': 'text/plain' })
response.end()
}
*/
})
const io = new WebSocketServer({
perMessageDeflate: false,
clientTracking: false,
server: server,
path: '/angelia',
maxPayload: this.maxMessageSize * 1024 * 1024,
backlog: 1024,
skipUTF8Validation:
options.skipUTF8Validation !== undefined
? options.skipUTF8Validation
: false,
})
io.on('connection', this.onconnect)
io.on('error', this.onerror)
server.listen(this.port, this.hostname || undefined)
this.events.listen && this.events.listen()
console.log('Socket Server Started Listening On Port', this.port)
}
/**
* Listen for events
*
* @template T
* @param {T} listener
*/
on(listener) {
const instance = new listener()
const methods = listener.listeners || [
// todo maybe use getPrototypeOf ?
...Object.getOwnPropertyNames(instance.__proto__),
...Object.getOwnPropertyNames(instance),
]
for (const m of methods) {
// '' listener is reserved
if (m !== 'constructor' && m !== '') {
if (typeof instance[m] === 'function') {
const method = instance[m].bind(instance)
this.events[m] = this.events[m] || ListenerTemplate()
this.events[m].fns.push(method)
}
}
}
return instance
}
// private
onconnect = (socket, request) => {
socket = new Socket(socket, this, request)
this.served++
socket.listen()
this.sockets.add(socket)
this.events.connect && this.events.connect(socket, request)
if (socket.params.angelia) {
socket.onmessage(socket.params.angelia)
}
// ping on connect is usually high
setTimeout(() => this.pingSocket(socket), 200)
}
onerror = err => {
this.serverErrors++
console.error('Server.onerror', err)
}
// queue
nextQueue(socket) {
if (!this.queue.length) {
nextTick(this.processQueue)
}
this.queue.push(socket)
}
processQueue = () => {
const queue = this.queue
this.queue = []
for (const socket of queue) {
socket.processQueue()
}
this.cache = empty()
}
cacheMessages(messages, socket) {
let id = ''
for (const m of messages) {
if (!m[this.cacheSymbol]) {
m[this.cacheSymbol] = this.cached++
}
id += m[this.cacheSymbol] + ','
}
if (!this.cache[id]) {
const json = stringify(messages)
this.bytesSent += json.length
socket.bytesSent += json.length
this.cache[id] = frame(json)
} else {
this.messagesSentCacheHit++
}
return this.cache[id]
}
// ping
updateNow = () => {
this.now = now()
}
ping = () => {
this.updateNow()
for (const socket of this.sockets) {
const delay = this.now - socket.seen
if (delay > this.timeout) {
socket.timedout = true
this.events.timeout && this.events.timeout(socket, delay)
socket.io.terminate()
} else if (delay > this.timeoutCheck) {
/**
* In an example: if timeout is set to 60 seconds, then we
* check for timed out sockets every 30 seconds. If the socket
* was last seen 29 seconds ago, then the next check will be
* in another 30 seconds. That means that if the socket doesnt
* sends any message since then, then the last seen will be 59
* seconds the next time. This gives very little amount of
* time to check. For this reason we remove 5 seconds on the
* condition by using `this.timeoutCheck`
*/
this.pingSocket(socket)
}
}
}
pingSocket(socket) {
this.updateNow()
socket.contacted = this.now
if (socket.io.readyState === 1) {
socket.write(this.pingData)
}
}
pong(socket) {
this.updateNow()
socket.seen = this.now
socket.ping = this.now - socket.contacted
this.events.ping && this.events.ping(socket)
}
toJSON() {
return '[content of server object omitted for toJSON]'
}
[inspect]() {
return {
...this,
sockets: 'omitted',
}
}
})()