Implement websocket server
This commit is contained in:
parent
dc663ec8f5
commit
33a64b99ff
@ -35,16 +35,6 @@ export interface ModuleRegistrationFluency {
|
|||||||
core: () => HTTPKernel,
|
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.
|
* A singleton class that handles requests, applying logic in modular layers.
|
||||||
*/
|
*/
|
||||||
@ -140,8 +130,6 @@ export class HTTPKernel extends AppClass {
|
|||||||
|
|
||||||
if ( typeof foundIdx !== 'undefined' ) {
|
if ( typeof foundIdx !== 'undefined' ) {
|
||||||
this.postflight = this.postflight.put(foundIdx, this.app().make(module))
|
this.postflight = this.postflight.put(foundIdx, this.app().make(module))
|
||||||
} else {
|
|
||||||
throw new KernelModuleNotFoundError(other.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@ -162,8 +150,6 @@ export class HTTPKernel extends AppClass {
|
|||||||
|
|
||||||
if ( typeof foundIdx !== 'undefined' ) {
|
if ( typeof foundIdx !== 'undefined' ) {
|
||||||
this.postflight = this.postflight.put(foundIdx + 1, this.app().make(module))
|
this.postflight = this.postflight.put(foundIdx + 1, this.app().make(module))
|
||||||
} else {
|
|
||||||
throw new KernelModuleNotFoundError(other.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
|
@ -5,6 +5,7 @@ import {ActivatedRoute} from '../../routing/ActivatedRoute'
|
|||||||
import {ResponseObject} from '../../routing/Route'
|
import {ResponseObject} from '../../routing/Route'
|
||||||
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
import {AbstractResolvedRouteHandlerHTTPModule} from './AbstractResolvedRouteHandlerHTTPModule'
|
||||||
import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
|
import {collect, isLeft, unleft, unright, withErrorContext} from '../../../util'
|
||||||
|
import {MountWebSocketRouteHTTPModule} from './MountWebSocketRouteHTTPModule'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP Kernel module that executes the preflight handlers for the route.
|
* 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 {
|
export class ExecuteResolvedRoutePreflightHTTPModule extends AbstractResolvedRouteHandlerHTTPModule {
|
||||||
public static register(kernel: HTTPKernel): void {
|
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> {
|
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.
|
* 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> {
|
export interface APIResponse<T> {
|
||||||
|
eventName?: string,
|
||||||
|
eventUuid?: string,
|
||||||
success: boolean,
|
success: boolean,
|
||||||
message?: string,
|
message?: string,
|
||||||
data?: T,
|
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.
|
* Formats a mesage as a successful API response.
|
||||||
* @param {string} displayMessage
|
* @param {string} displayMessage
|
||||||
@ -56,7 +116,7 @@ export function many<T>(records: T[]): APIResponse<{records: T[], total: number}
|
|||||||
* @return APIResponse
|
* @return APIResponse
|
||||||
* @param thrownError
|
* @param thrownError
|
||||||
*/
|
*/
|
||||||
export function error(thrownError: string | Error): APIResponse<void> {
|
export function error(thrownError: string | Error): APIResponse<undefined> {
|
||||||
if ( typeof thrownError === 'string' ) {
|
if ( typeof thrownError === 'string' ) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
@ -9,6 +9,7 @@ import {RouteGroup} from './RouteGroup'
|
|||||||
import {Config} from '../../service/Config'
|
import {Config} from '../../service/Config'
|
||||||
import {Application} from '../../lifecycle/Application'
|
import {Application} from '../../lifecycle/Application'
|
||||||
import {Logging} from '../../service/Logging'
|
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.
|
* 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.
|
* Create a new WebSocket route on the given endpoint.
|
||||||
* @param endpoint
|
* @param endpoint
|
||||||
*/
|
*/
|
||||||
public static socket(endpoint: string): Route<void, [void]> {
|
public static socket(endpoint: string): Route<Awaitable<void>, [WebSocketBus]> {
|
||||||
return new Route<void, [void]>('ws', endpoint)
|
return new Route<Awaitable<void>, [WebSocketBus]>('ws', endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,6 +35,7 @@ export * from './http/kernel/HTTPCookieJar'
|
|||||||
|
|
||||||
export * from './http/lifecycle/Request'
|
export * from './http/lifecycle/Request'
|
||||||
export * from './http/lifecycle/Response'
|
export * from './http/lifecycle/Response'
|
||||||
|
export * from './http/lifecycle/WebSocketCloseEvent'
|
||||||
export * from './http/RequestLocalStorage'
|
export * from './http/RequestLocalStorage'
|
||||||
|
|
||||||
export * from './make'
|
export * from './make'
|
||||||
|
@ -5,6 +5,20 @@ import {HTTPServer} from './HTTPServer'
|
|||||||
import {Logging} from './Logging'
|
import {Logging} from './Logging'
|
||||||
import {ErrorWithContext} from '../util'
|
import {ErrorWithContext} from '../util'
|
||||||
import {Request} from '../http/lifecycle/Request'
|
import {Request} from '../http/lifecycle/Request'
|
||||||
|
import {AsyncResource, executionAsyncId} from 'async_hooks'
|
||||||
|
import {HTTPKernel} from '../http/kernel/HTTPKernel'
|
||||||
|
import {InjectSessionHTTPModule} from '../http/kernel/module/InjectSessionHTTPModule'
|
||||||
|
import {ExecuteResolvedRoutePreflightHTTPModule} from '../http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule'
|
||||||
|
import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBodyHTTPModule'
|
||||||
|
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
|
||||||
|
import {RequestLocalStorage} from '../http/RequestLocalStorage'
|
||||||
|
import {MountWebSocketRouteHTTPModule} from '../http/kernel/module/MountWebSocketRouteHTTPModule'
|
||||||
|
import {SetSessionCookieHTTPModule} from '../http/kernel/module/SetSessionCookieHTTPModule'
|
||||||
|
import {MonitorWebSocketConnectionHTTPModule} from '../http/kernel/module/MonitorWebSocketConnectionHTTPModule'
|
||||||
|
import {Bus, WebSocketBus} from '../support/bus'
|
||||||
|
import {WebSocketCloseEvent} from '../http/lifecycle/WebSocketCloseEvent'
|
||||||
|
import {ExecuteResolvedWebSocketHandlerHTTPModule} from '../http/kernel/module/ExecuteResolvedWebSocketHandlerHTTPModule'
|
||||||
|
import {InjectRequestLocale} from '../i18n'
|
||||||
|
|
||||||
@Singleton()
|
@Singleton()
|
||||||
export class WebsocketServer extends Unit {
|
export class WebsocketServer extends Unit {
|
||||||
@ -14,8 +28,36 @@ export class WebsocketServer extends Unit {
|
|||||||
@Inject()
|
@Inject()
|
||||||
protected readonly logging!: Logging
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly requestLocalStorage!: RequestLocalStorage
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: Bus
|
||||||
|
|
||||||
|
protected kernel?: HTTPKernel
|
||||||
|
|
||||||
protected server?: WebSocket.Server
|
protected server?: WebSocket.Server
|
||||||
|
|
||||||
|
protected getKernel(): HTTPKernel {
|
||||||
|
if ( !this.kernel ) {
|
||||||
|
const kernel = this.app().makeNew<HTTPKernel>(HTTPKernel)
|
||||||
|
|
||||||
|
SetSessionCookieHTTPModule.register(kernel)
|
||||||
|
InjectSessionHTTPModule.register(kernel)
|
||||||
|
MountWebSocketRouteHTTPModule.register(kernel)
|
||||||
|
ExecuteResolvedRoutePreflightHTTPModule.register(kernel)
|
||||||
|
ExecuteResolvedWebSocketHandlerHTTPModule.register(kernel)
|
||||||
|
ParseIncomingBodyHTTPModule.register(kernel)
|
||||||
|
InjectRequestEventBusHTTPModule.register(kernel)
|
||||||
|
MonitorWebSocketConnectionHTTPModule.register(kernel)
|
||||||
|
InjectRequestLocale.register(kernel)
|
||||||
|
|
||||||
|
this.kernel = kernel
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.kernel
|
||||||
|
}
|
||||||
|
|
||||||
public async up(): Promise<void> {
|
public async up(): Promise<void> {
|
||||||
// Make sure the HTTP server is started. Otherwise, this is going to fail anyway
|
// Make sure the HTTP server is started. Otherwise, this is going to fail anyway
|
||||||
if ( this.http.status !== UnitStatus.Started ) {
|
if ( this.http.status !== UnitStatus.Started ) {
|
||||||
@ -30,20 +72,58 @@ export class WebsocketServer extends Unit {
|
|||||||
server: this.http.getServer(),
|
server: this.http.getServer(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Turns out that the websocket server was causing context loss bc of the way
|
||||||
|
// its callback structure works. So, to allow us to access the global container
|
||||||
|
// from w/in the server handler, we need to give Node some guidance on which
|
||||||
|
// async context we're using.
|
||||||
|
const resource = new AsyncResource('WebsocketServer', {
|
||||||
|
triggerAsyncId: executionAsyncId(),
|
||||||
|
requireManualDestroy: false,
|
||||||
|
})
|
||||||
|
|
||||||
// Register the websocket handler
|
// Register the websocket handler
|
||||||
this.server.on('connection', (ws, request) => {
|
this.server.on('connection', (ws, request) => {
|
||||||
this.logging.info('Got WebSocket connection! ' + request.method)
|
resource.runInAsyncScope(() => {
|
||||||
const extolloReq = new Request(request)
|
this.logging.info('Got WebSocket connection! ' + request.method)
|
||||||
this.logging.debug(ws)
|
const extolloReq = new Request(request)
|
||||||
this.logging.debug(request)
|
|
||||||
|
this.requestLocalStorage.run(extolloReq, async () => {
|
||||||
|
this.logging.info(`WebSocket connection: ${extolloReq.path}`)
|
||||||
|
|
||||||
|
// Register the websocket with the request container
|
||||||
|
extolloReq.registerSingletonInstance(WebSocket.WebSocket, ws)
|
||||||
|
|
||||||
|
// Register the websocket bus with the request container
|
||||||
|
const wsBus = extolloReq.makeNew<WebSocketBus>(WebSocketBus)
|
||||||
|
await wsBus.up()
|
||||||
|
extolloReq.registerSingletonInstance(WebSocketBus, wsBus)
|
||||||
|
|
||||||
|
// Register the listener to clean up this connection when it dies
|
||||||
|
extolloReq.onResolve<Bus>(Bus)
|
||||||
|
.then(bus => bus.subscribe(WebSocketCloseEvent, () => {
|
||||||
|
extolloReq.destroy()
|
||||||
|
ws.terminate()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Run the request through the kernel to get the setup
|
||||||
|
await this.getKernel().handle(extolloReq)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public down(): Promise<void> {
|
public down(): Promise<void> {
|
||||||
|
// Stop the websocket server, if it exists
|
||||||
return new Promise(res => {
|
return new Promise(res => {
|
||||||
// Stop the websocket server, if it exists
|
|
||||||
if ( this.server ) {
|
if ( this.server ) {
|
||||||
this.server.close(() => res())
|
// Since all the request busses connect to the global app bus,
|
||||||
|
// we can broadcast a global close event to get all in-progress
|
||||||
|
// connections to close their sockets.
|
||||||
|
const event = new WebSocketCloseEvent()
|
||||||
|
event.shouldBroadcast = true
|
||||||
|
|
||||||
|
this.bus.push(event)
|
||||||
|
.then(() => this.server?.close?.(() => res()))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -101,9 +101,7 @@ export class RedisBus implements EventBus, AwareOfContainerLifecycle {
|
|||||||
|
|
||||||
await this.subscriptions
|
await this.subscriptions
|
||||||
.where('eventName', '=', name)
|
.where('eventName', '=', name)
|
||||||
.pluck('handler')
|
.awaitMapCall('handler', event)
|
||||||
.map(handler => handler(event))
|
|
||||||
.awaitAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
|
123
src/support/bus/WebSocketBus.ts
Normal file
123
src/support/bus/WebSocketBus.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import {AwareOfContainerLifecycle, Container, Inject, Injectable, StaticInstantiable} from '../../di'
|
||||||
|
import {
|
||||||
|
EventBus,
|
||||||
|
Event,
|
||||||
|
EventHandler,
|
||||||
|
EventHandlerSubscription,
|
||||||
|
BusSubscriber,
|
||||||
|
EventHandlerReturn,
|
||||||
|
} from './types'
|
||||||
|
import * as WebSocket from 'ws'
|
||||||
|
import {NoSerializerError, Serialization} from './serial/Serialization'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Awaitable, Collection, Pipeline, uuid4} from '../../util'
|
||||||
|
import {getEventName} from './getEventName'
|
||||||
|
import {Bus} from './Bus'
|
||||||
|
import {WebSocketCloseEvent} from '../../http/lifecycle/WebSocketCloseEvent'
|
||||||
|
import {apiEvent, error} from '../../http/response/api'
|
||||||
|
import {AsyncResource, executionAsyncId} from 'async_hooks'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebSocketBus implements EventBus, AwareOfContainerLifecycle {
|
||||||
|
awareOfContainerLifecycle: true = true
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly ws!: WebSocket.WebSocket
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: Bus
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly serial!: Serialization
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
public readonly uuid = uuid4()
|
||||||
|
|
||||||
|
private connected = false
|
||||||
|
|
||||||
|
/** List of local subscriptions on this bus. */
|
||||||
|
protected subscriptions: Collection<BusSubscriber<Event>> = new Collection()
|
||||||
|
|
||||||
|
/** Get a Promise that resolves then the socket closes. */
|
||||||
|
onClose(): Promise<WebSocketCloseEvent> {
|
||||||
|
return new Promise<WebSocketCloseEvent>(res => {
|
||||||
|
this.bus.subscribe(WebSocketCloseEvent, event => res(event))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe<T extends Event>(eventKey: StaticInstantiable<T>, line: Pipeline<T, EventHandlerReturn>): Awaitable<EventHandlerSubscription> {
|
||||||
|
return this.subscribe(eventKey, event => line.apply(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
async push(event: Event): Promise<void> {
|
||||||
|
this.logging.verbose(`Pushing event to WebSocket: ${event.eventName}`)
|
||||||
|
this.logging.verbose(event)
|
||||||
|
|
||||||
|
await this.ws.send(await this.serial.encodeJSON(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe<T extends Event>(eventKey: StaticInstantiable<T>, handler: EventHandler<T>): Promise<EventHandlerSubscription> {
|
||||||
|
const uuid = uuid4()
|
||||||
|
const subscriber: BusSubscriber<Event> = {
|
||||||
|
eventName: getEventName(eventKey),
|
||||||
|
eventKey,
|
||||||
|
handler,
|
||||||
|
uuid,
|
||||||
|
} as unknown as BusSubscriber<Event>
|
||||||
|
|
||||||
|
this.logging.verbose(`Creating WebSocket subscriber ${uuid}...`)
|
||||||
|
this.logging.verbose(subscriber)
|
||||||
|
|
||||||
|
this.subscriptions.push(subscriber)
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.subscriptions = this.subscriptions.where('uuid', '!=', uuid)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onMessage(message: string): Promise<void> {
|
||||||
|
const payload = await this.serial.decodeJSON<Event>(message) // FIXME validation
|
||||||
|
|
||||||
|
await this.subscriptions
|
||||||
|
.where('eventName', '=', payload.eventName)
|
||||||
|
.awaitMapCall('handler', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
up(): void {
|
||||||
|
const resource = new AsyncResource('WebSocketBus', {
|
||||||
|
triggerAsyncId: executionAsyncId(),
|
||||||
|
requireManualDestroy: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.ws.on('message', async data => {
|
||||||
|
await resource.runInAsyncScope(async () => {
|
||||||
|
this.logging.verbose(`Got data from websocket: ${data}`)
|
||||||
|
try {
|
||||||
|
Container.getContainer()
|
||||||
|
await this.onMessage(`${data}`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof NoSerializerError ) {
|
||||||
|
this.logging.warn(`Discarding message as no validator could be found to deserialize it: ${data}`)
|
||||||
|
this.push(apiEvent(error('Invalid message format or serializer.')))
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.connected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
down(): void {
|
||||||
|
this.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected // FIXME: monitor for bad connections
|
||||||
|
}
|
||||||
|
}
|
@ -4,10 +4,13 @@ export * from './serial/BaseSerializer'
|
|||||||
export * from './serial/SimpleCanonicalItemSerializer'
|
export * from './serial/SimpleCanonicalItemSerializer'
|
||||||
export * from './serial/Serialization'
|
export * from './serial/Serialization'
|
||||||
export * from './serial/decorators'
|
export * from './serial/decorators'
|
||||||
|
export * from './serial/NamedEventPayload'
|
||||||
|
export * from './serial/JSONMessageEvent'
|
||||||
|
|
||||||
export * from './Bus'
|
export * from './Bus'
|
||||||
export * from './LocalBus'
|
export * from './LocalBus'
|
||||||
export * from './RedisBus'
|
export * from './RedisBus'
|
||||||
|
export * from './WebSocketBus'
|
||||||
|
|
||||||
export * from './queue/event/PushingToQueue'
|
export * from './queue/event/PushingToQueue'
|
||||||
export * from './queue/event/PushedToQueue'
|
export * from './queue/event/PushedToQueue'
|
||||||
|
35
src/support/bus/serial/JSONMessageEvent.ts
Normal file
35
src/support/bus/serial/JSONMessageEvent.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {BaseSerializer} from './BaseSerializer'
|
||||||
|
import {Awaitable, JSONState, uuid4} from '../../../util'
|
||||||
|
import {ObjectSerializer} from './decorators'
|
||||||
|
import {Event} from '../types'
|
||||||
|
|
||||||
|
export class JSONMessageEvent<T extends JSONState> implements Event {
|
||||||
|
constructor(
|
||||||
|
public readonly value: T,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
eventName = '@extollo/lib:JSONMessageEvent'
|
||||||
|
|
||||||
|
eventUuid = uuid4()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectSerializer()
|
||||||
|
export class JSONMessageEventSerializer extends BaseSerializer<JSONMessageEvent<JSONState>, { value: JSONState }> {
|
||||||
|
protected decodeSerial(serial: { value: JSONState }): Awaitable<JSONMessageEvent<JSONState>> {
|
||||||
|
return new JSONMessageEvent(serial.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected encodeActual(actual: JSONMessageEvent<JSONState>): Awaitable<{ value: JSONState }> {
|
||||||
|
return {
|
||||||
|
value: actual.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib:JSONMessageEventSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: JSONMessageEvent<JSONState>): boolean {
|
||||||
|
return some instanceof JSONMessageEvent
|
||||||
|
}
|
||||||
|
}
|
36
src/support/bus/serial/NamedEventPayload.ts
Normal file
36
src/support/bus/serial/NamedEventPayload.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {Event, SerialPayload} from '../types'
|
||||||
|
import {ObjectSerializer} from './decorators'
|
||||||
|
import {BaseSerializer} from './BaseSerializer'
|
||||||
|
import {JSONState} from '../../../util'
|
||||||
|
|
||||||
|
export class NamedEventPayload {
|
||||||
|
constructor(
|
||||||
|
public readonly eventName: string,
|
||||||
|
public readonly event: Event,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectSerializer()
|
||||||
|
export class NamedEventPayloadSerializer extends BaseSerializer<NamedEventPayload, { eventName: string, event: SerialPayload<Event, JSONState> }> {
|
||||||
|
protected async decodeSerial(serial: { eventName: string; event: SerialPayload<Event, JSONState> }): Promise<NamedEventPayload> {
|
||||||
|
return new NamedEventPayload(
|
||||||
|
serial.eventName,
|
||||||
|
await this.getSerialization().decode(serial.event),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async encodeActual(actual: NamedEventPayload): Promise<{ eventName: string; event: SerialPayload<Event, JSONState> }> {
|
||||||
|
return {
|
||||||
|
eventName: actual.eventName,
|
||||||
|
event: await this.getSerialization().encode(actual.event),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib:NamedEventPayloadSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: NamedEventPayload): boolean {
|
||||||
|
return some instanceof NamedEventPayload
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user