Implement websocket server

This commit is contained in:
2022-07-14 01:15:16 -05:00
parent dc663ec8f5
commit 33a64b99ff
15 changed files with 546 additions and 27 deletions

View File

@@ -35,16 +35,6 @@ export interface ModuleRegistrationFluency {
core: () => HTTPKernel,
}
/**
* Error thrown when a kernel module is requested that does not exist w/in the kernel.
* @extends Error
*/
export class KernelModuleNotFoundError extends Error {
constructor(name: string) {
super(`The kernel module ${name} is not registered with the kernel.`)
}
}
/**
* A singleton class that handles requests, applying logic in modular layers.
*/
@@ -140,8 +130,6 @@ export class HTTPKernel extends AppClass {
if ( typeof foundIdx !== 'undefined' ) {
this.postflight = this.postflight.put(foundIdx, this.app().make(module))
} else {
throw new KernelModuleNotFoundError(other.name)
}
return this
@@ -162,8 +150,6 @@ export class HTTPKernel extends AppClass {
if ( typeof foundIdx !== 'undefined' ) {
this.postflight = this.postflight.put(foundIdx + 1, this.app().make(module))
} else {
throw new KernelModuleNotFoundError(other.name)
}
return this

View File

@@ -5,6 +5,7 @@ import {ActivatedRoute} from '../../routing/ActivatedRoute'
import {ResponseObject} from '../../routing/Route'
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
import {MountWebSocketRouteHTTPModule} from './MountWebSocketRouteHTTPModule'
/**
* HTTP Kernel module that executes the preflight handlers for the route.
@@ -13,7 +14,9 @@ import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
*/
export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
public static register(kernel: HTTPKernel): void {
kernel.register(this).after(MountActivatedRouteHTTPModule)
const reg = kernel.register(this)
reg.after(MountWebSocketRouteHTTPModule)
reg.after(MountActivatedRouteHTTPModule)
}
public async apply(request: Request): Promise<Request> {

View File

@@ -0,0 +1,35 @@
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {ActivatedRoute} from '../../routing/ActivatedRoute'
import {withErrorContext} from '../../../util'
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
import {ExecuteResolvedRoutePreflightHTTPModule} from './ExecuteResolvedRoutePreflightHTTPModule'
import {WebSocketBus} from '../../../support/bus'
/**
* HTTP kernel module that runs the web socket handler for the socket connection's route.
*/
export class ExecuteResolvedWebSocketHandlerHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
public static register(kernel: HTTPKernel): void {
kernel.register(this).after(ExecuteResolvedRoutePreflightHTTPModule)
}
public async apply(request: Request): Promise<Request> {
const route = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute)
const params = route.resolvedParameters
if ( !params ) {
throw new Error('Attempted to call route handler without resolved parameters.')
}
await withErrorContext(async () => {
const ws = request.make<WebSocketBus>(WebSocketBus)
await route.handler
.tap(handler => handler(ws, ...params))
.apply(request)
}, {
route,
})
return request
}
}

View File

@@ -0,0 +1,96 @@
import {HTTPKernelModule} from '../HTTPKernelModule'
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
import {setInterval} from 'timers'
import {Logging} from '../../../service/Logging'
import {WebSocketCloseEvent} from '../../lifecycle/WebSocketCloseEvent'
import Timeout = NodeJS.Timeout;
import {Bus} from '../../../support/bus'
import * as WebSockets from 'ws'
import {Maybe} from '../../../util'
@Injectable()
export class MonitorWebSocketConnectionHTTPModule extends HTTPKernelModule {
@Inject()
protected readonly config!: Config
@Inject()
protected readonly logging!: Logging
public static register(kernel: HTTPKernel): void {
kernel.register(this).core()
}
async apply(request: Request): Promise<Request> {
const ws = request.make<WebSockets.WebSocket>(WebSockets.WebSocket)
// Time to wait between pings
const pollIntervalMs = this.config.safe('server.socket.pollIntervalMs')
.or(30000)
.integer()
// Time to wait for a response
const pollResponseTimeoutMs = this.config.safe('server.socket.pollResponseTimeoutMs')
.or(3000)
.integer()
// Max # of failures before the connection is closed
const maxFailedPolls = this.config.safe('server.socket.maxFailedPolls')
.or(5)
.integer()
let failedPolls = 0
let interval: Maybe<Timeout> = undefined
await new Promise<void>(res => {
let gotResponse = false
// Listen for pong responses
ws.on('pong', () => {
this.logging.verbose('Got pong response from socket.')
gotResponse = true
})
// Listen for close event
ws.on('close', () => {
this.logging.debug('Got close event from socket.')
res()
})
interval = setInterval(async () => {
// Every interval, send a ping request and set a timeout for the response
this.logging.verbose('Sending ping request to socket...')
gotResponse = false
ws.ping()
await new Promise<void>(res2 => setTimeout(res2, pollResponseTimeoutMs))
// If no pong response is received before the timeout occurs, tick the # of failed response
if ( !gotResponse ) {
this.logging.verbose('Socket failed to respond in time.')
failedPolls += 1
} else {
// Otherwise, reset the failure counter
failedPolls = 0
}
// Once the failed responses exceeds the threshold, kill the connection
if ( failedPolls > maxFailedPolls ) {
this.logging.debug('Socket exceeded maximum # of failed pings. Killing.')
res()
}
}, pollIntervalMs)
})
if ( interval ) {
clearInterval(interval)
}
// Tell the server to close the socket connection
const bus = request.make<Bus>(Bus)
await bus.push(new WebSocketCloseEvent())
return request
}
}

View File

@@ -0,0 +1,51 @@
import {Injectable, Inject} from '../../../di'
import {HTTPKernelModule} from '../HTTPKernelModule'
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {Routing} from '../../../service/Routing'
import {ActivatedRoute} from '../../routing/ActivatedRoute'
import {Logging} from '../../../service/Logging'
import {apiEvent, error} from '../../response/api'
import {Bus, WebSocketBus} from '../../../support/bus'
import {WebSocketCloseEvent} from '../../lifecycle/WebSocketCloseEvent'
/**
* HTTP kernel middleware that tries to find a registered route matching the request's
* path and creates an ActivatedRoute instance from it, limited to websocket handling
* routes.
*/
@Injectable()
export class MountWebSocketRouteHTTPModule extends HTTPKernelModule {
public readonly executeWithBlockingWriteback = true
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly logging!: Logging
public static register(kernel: HTTPKernel): void {
kernel.register(this).before()
}
public async apply(request: Request): Promise<Request> {
const route = this.routing.match('ws', request.path)
if ( route ) {
this.logging.verbose(`Mounting activated WebSocket route: ${request.path} -> ${route}`)
const activated = <ActivatedRoute<unknown, unknown[]>> request.make(ActivatedRoute, route, request.path)
request.registerSingletonInstance<ActivatedRoute<unknown, unknown[]>>(ActivatedRoute, activated)
} else {
this.logging.debug(`No matching WebSocket route found for: ${request.method} -> ${request.path}`)
// Send an error response on the socket to the client
const ws = request.make<WebSocketBus>(WebSocketBus)
await ws.push(apiEvent(error('Endpoint is not a configured socket listener.')))
// Then, terminate the request & socket connections
await request.make<Bus>(Bus).push(new WebSocketCloseEvent())
request.response.blockingWriteback(true)
}
return request
}
}