Implement websocket server
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
51
src/http/kernel/module/MountWebSocketRouteHTTPModule.ts
Normal file
51
src/http/kernel/module/MountWebSocketRouteHTTPModule.ts
Normal 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
|
||||
}
|
||||
}
|
||||
11
src/http/lifecycle/WebSocketCloseEvent.ts
Normal file
11
src/http/lifecycle/WebSocketCloseEvent.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {Event} from '../../support/bus'
|
||||
import {uuid4} from '../../util'
|
||||
|
||||
/** Event used to tell the server to close the websocket connection. */
|
||||
export class WebSocketCloseEvent implements Event {
|
||||
eventName = '@extollo/lib:WebSocketCloseEvent'
|
||||
|
||||
eventUuid = uuid4()
|
||||
|
||||
shouldBroadcast = false
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Base type for an API response format.
|
||||
*/
|
||||
import {BaseSerializer, Event, ObjectSerializer} from '../../support/bus'
|
||||
import {Awaitable, ErrorWithContext, hasOwnProperty, JSONState, uuid4} from '../../util'
|
||||
|
||||
export interface APIResponse<T> {
|
||||
eventName?: string,
|
||||
eventUuid?: string,
|
||||
success: boolean,
|
||||
message?: string,
|
||||
data?: T,
|
||||
@@ -12,6 +17,61 @@ export interface APIResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export function isAPIResponse(what: unknown): what is APIResponse<unknown> {
|
||||
return typeof what === 'object' && what !== null
|
||||
&& hasOwnProperty(what, 'success')
|
||||
&& typeof what.success === 'boolean'
|
||||
&& (!hasOwnProperty(what, 'message') || typeof what.message === 'string')
|
||||
&& (!hasOwnProperty(what, 'error') || (
|
||||
typeof what.error === 'object' && what.error !== null
|
||||
&& hasOwnProperty(what.error, 'name') && typeof what.error.name === 'string'
|
||||
&& hasOwnProperty(what.error, 'message') && typeof what.error.message === 'string'
|
||||
&& (!hasOwnProperty(what.error, 'stack') || (
|
||||
Array.isArray(what.error.stack) && what.error.stack.every(x => typeof x === 'string')
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function apiEvent<T>(response: APIResponse<T>): APIResponse<T> & Event {
|
||||
if ( !response.eventName ) {
|
||||
response.eventName = '@extollo/lib:APIResponse'
|
||||
}
|
||||
|
||||
if ( !response.eventUuid ) {
|
||||
response.eventUuid = uuid4()
|
||||
}
|
||||
|
||||
return response as APIResponse<T> & Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializer implementation that can encode/decode APIResponse objects.
|
||||
*/
|
||||
@ObjectSerializer()
|
||||
export class APIResponseSerializer extends BaseSerializer<APIResponse<unknown>, JSONState> {
|
||||
protected decodeSerial(serial: JSONState): Awaitable<APIResponse<unknown>> {
|
||||
if ( isAPIResponse(serial) ) {
|
||||
return serial
|
||||
}
|
||||
|
||||
throw new ErrorWithContext('Could not decode API response: object is malformed')
|
||||
}
|
||||
|
||||
protected encodeActual(actual: APIResponse<unknown>): Awaitable<JSONState> {
|
||||
return actual as unknown as JSONState
|
||||
}
|
||||
|
||||
protected getName(): string {
|
||||
return '@extollo/lib:APIResponseSerializer'
|
||||
}
|
||||
|
||||
matchActual(some: APIResponse<unknown>): boolean {
|
||||
return isAPIResponse(some)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a mesage as a successful API response.
|
||||
* @param {string} displayMessage
|
||||
@@ -56,7 +116,7 @@ export function many<T>(records: T[]): APIResponse<{records: T[], total: number}
|
||||
* @return APIResponse
|
||||
* @param thrownError
|
||||
*/
|
||||
export function error(thrownError: string | Error): APIResponse<void> {
|
||||
export function error(thrownError: string | Error): APIResponse<undefined> {
|
||||
if ( typeof thrownError === 'string' ) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {RouteGroup} from './RouteGroup'
|
||||
import {Config} from '../../service/Config'
|
||||
import {Application} from '../../lifecycle/Application'
|
||||
import {Logging} from '../../service/Logging'
|
||||
import {WebSocketBus} from '../../support/bus/WebSocketBus'
|
||||
|
||||
/**
|
||||
* Type alias for an item that is a valid response object, or lack thereof.
|
||||
@@ -140,8 +141,8 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
|
||||
* Create a new WebSocket route on the given endpoint.
|
||||
* @param endpoint
|
||||
*/
|
||||
public static socket(endpoint: string): Route<void, [void]> {
|
||||
return new Route<void, [void]>('ws', endpoint)
|
||||
public static socket(endpoint: string): Route<Awaitable<void>, [WebSocketBus]> {
|
||||
return new Route<Awaitable<void>, [WebSocketBus]>('ws', endpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user