From 6476416c6783abb87fb5ddf8225a0c6db3779afc Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 13 Jul 2022 21:35:18 -0500 Subject: [PATCH] Add foreground service, some cleanup, and start websocket server --- package.json | 2 + pnpm-lock.yaml | 25 +++++++++++- src/http/lifecycle/Request.ts | 2 +- src/http/lifecycle/Response.ts | 19 +++++++-- src/http/response/ErrorResponseFactory.ts | 4 ++ src/http/routing/Route.ts | 28 +++++++++++-- src/index.ts | 2 + src/service/Canonical.ts | 1 - src/service/Foreground.ts | 24 +++++++++++ src/service/HTTPServer.ts | 41 +++++-------------- src/service/Routing.ts | 3 +- src/service/WebsocketServer.ts | 50 +++++++++++++++++++++++ 12 files changed, 158 insertions(+), 43 deletions(-) create mode 100644 src/service/Foreground.ts create mode 100644 src/service/WebsocketServer.ts diff --git a/package.json b/package.json index 9b810f6..47af9b9 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@types/rimraf": "^3.0.0", "@types/ssh2": "^0.5.46", "@types/uuid": "^8.3.0", + "@types/ws": "^8.5.3", "bcrypt": "^5.0.1", "busboy": "^0.3.1", "cli-table": "^0.3.6", @@ -48,6 +49,7 @@ "typedoc-plugin-sourcefile-url": "^1.0.6", "typescript": "^4.2.3", "uuid": "^8.3.2", + "ws": "^8.8.0", "zod": "^3.11.6" }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b33927c..e54937d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,7 @@ specifiers: '@types/sinon': ^10.0.6 '@types/ssh2': ^0.5.46 '@types/uuid': ^8.3.0 + '@types/ws': ^8.5.3 '@types/wtfnode': ^0.7.0 '@typescript-eslint/eslint-plugin': ^4.26.0 '@typescript-eslint/parser': ^4.26.0 @@ -51,6 +52,7 @@ specifiers: typedoc-plugin-sourcefile-url: ^1.0.6 typescript: ^4.2.3 uuid: ^8.3.2 + ws: ^8.8.0 wtfnode: ^0.9.1 zod: ^3.11.6 @@ -72,6 +74,7 @@ dependencies: '@types/rimraf': 3.0.0 '@types/ssh2': 0.5.46 '@types/uuid': 8.3.0 + '@types/ws': 8.5.3 bcrypt: 5.0.1 busboy: 0.3.1 cli-table: 0.3.6 @@ -95,6 +98,7 @@ dependencies: typedoc-plugin-sourcefile-url: 1.0.6_typedoc@0.20.36 typescript: 4.2.3 uuid: 8.3.2 + ws: 8.8.0 zod: 3.11.6 devDependencies: @@ -433,7 +437,7 @@ packages: /@types/ssh2-streams/0.1.8: resolution: {integrity: sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA==} dependencies: - '@types/node': 14.17.1 + '@types/node': 14.17.6 dev: false /@types/ssh2/0.5.46: @@ -451,6 +455,12 @@ packages: resolution: {integrity: sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==} dev: false + /@types/ws/8.5.3: + resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==} + dependencies: + '@types/node': 14.17.6 + dev: false + /@types/wtfnode/0.7.0: resolution: {integrity: sha512-kdBHgE9+M1Os7UqWZtiLhKye5reFl8cPBYyCsP2fatwZRz7F7GdIxIHZ20Kkc0hYBfbXE+lzPOTUU1I0qgjtHA==} dev: true @@ -3120,6 +3130,19 @@ packages: /wrappy/1.0.2: resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + /ws/8.8.0: + resolution: {integrity: sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /wtfnode/0.9.1: resolution: {integrity: sha512-Ip6C2KeQPl/F3aP1EfOnPoQk14Udd9lffpoqWDNH3Xt78svxPbv53ngtmtfI0q2Te3oTq79XKTnRNXVIn/GsPA==} hasBin: true diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index 4afb702..7af4379 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -107,7 +107,7 @@ export class Request extends ScopedContainer implements DataContainer { protected clientRequest: IncomingMessage, /** The native Node.js response. */ - protected serverResponse: ServerResponse, + protected serverResponse?: ServerResponse, ) { super(Container.getContainer()) this.registerSingletonInstance(Request, this) diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index 2766a39..6aa4ff2 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -66,7 +66,7 @@ export class Response { public readonly request: Request, /** The native Node.js ServerResponse. */ - protected readonly serverResponse: ServerResponse, + protected readonly serverResponse?: ServerResponse, ) { } protected get logging(): Logging { @@ -173,6 +173,11 @@ export class Response { */ public sendHeaders(): this { this.logging.verbose(`Sending headers...`) + if ( !this.serverResponse ) { + throw new ErrorWithContext('Unable to send headers: Response has no underlying connection.', { + suggestion: 'This usually means the Request was created by an alternative server, like WebsocketServer. You should use that server to handle the request.', + }) + } const headers = {} as any const setCookieHeaders = this.cookies.getSetCookieHeaders() @@ -220,8 +225,14 @@ export class Response { * @param data */ public async write(data: string | Buffer | Uint8Array | Readable): Promise { - this.logging.verbose(`Writing headers & data to response... (destroyed? ${this.serverResponse.destroyed})`) + this.logging.verbose(`Writing headers & data to response... (destroyed? ${!this.serverResponse || this.serverResponse.destroyed})`) return new Promise((res, rej) => { + if ( !this.serverResponse ) { + throw new ErrorWithContext('Unable to write response: Response has no underlying connection.', { + suggestion: 'This usually means the Request was created by an alternative server, like WebsocketServer. You should use that server to handle the request.', + }) + } + if ( this.responseEnded || this.serverResponse.destroyed ) { throw new ErrorWithContext('Tried to write to Response after lifecycle ended.') } @@ -274,7 +285,7 @@ export class Response { * or the connection has been destroyed. */ public canSend(): boolean { - return !(this.responseEnded || this.serverResponse.destroyed) + return !(this.responseEnded || !this.serverResponse || this.serverResponse.destroyed) } /** @@ -286,7 +297,7 @@ export class Response { } this.sentHeaders = true - this.serverResponse.end() + this.serverResponse?.end() return this } diff --git a/src/http/response/ErrorResponseFactory.ts b/src/http/response/ErrorResponseFactory.ts index ce24283..49b302a 100644 --- a/src/http/response/ErrorResponseFactory.ts +++ b/src/http/response/ErrorResponseFactory.ts @@ -109,6 +109,10 @@ ${Object.keys(context).map(key => ` - ${key} : ${JSON.stringify(context[key]) return 'It looks like this route relies on the security framework. Is the route you are accessing inside a middleware (e.g. SessionAuthMiddleware)?' } else if ( this.thrownError.message.startsWith('Unable to resolve schema for validator') ) { return 'Make sure the directory in which the interface file is located is listed in extollo.cc.zodify in package.json, and that it ends with the proper .type.ts suffix.' + } else if ( this.thrownError instanceof ErrorWithContext ) { + if ( typeof this.thrownError.context.suggestion === 'string' ) { + return this.thrownError.context.suggestion + } } return '' diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts index 79fdff9..ab3f5cf 100644 --- a/src/http/routing/Route.ts +++ b/src/http/routing/Route.ts @@ -136,6 +136,14 @@ export class Route { + return new Route('ws', endpoint) + } + /** * Create a new GET route on the given endpoint. */ @@ -188,10 +196,14 @@ export class Route = new Collection() constructor( - protected method: HTTPMethod | HTTPMethod[], + protected method: 'ws' | HTTPMethod | HTTPMethod[], protected route: string, ) {} + public isForWebSocket(): boolean { + return this.method === 'ws' + } + /** * Set a programmatic name for this route. * @param name @@ -212,6 +224,10 @@ export class Route unknown + + public up(): Promise { + return new Promise(res => { + this.resolver = res + this.logging.success(`Application started! Press ^C or send SIGINT to stop.`) + process.stdin.resume() + process.on('SIGINT', res) + }) + } + + public down(): void { + this.resolver?.() + } +} diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index f84dbbc..cc5be69 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -1,5 +1,5 @@ import {Inject, Singleton} from '../di' -import {ErrorWithContext, HTTPStatus, withTimeout} from '../util' +import {ErrorWithContext} from '../util' import {Unit} from '../lifecycle/Unit' import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http' import {Logging} from './Logging' @@ -67,10 +67,9 @@ export class HTTPServer extends Unit { this.server = createServer(this.handler) this.server.listen(port, () => { - this.logging.success(`Server listening on port ${port}. Press ^C to stop.`) + this.logging.success(`Server listening on port ${port}.`) + res() }) - - process.on('SIGINT', res) }) } @@ -85,37 +84,19 @@ export class HTTPServer extends Unit { } } - public get handler(): RequestListener { - // const timeout = this.config.get('server.timeout', 10000) - // const timeout = 0 // temporarily disable this because it is causing problems + public getServer(): Server { + if ( !this.server ) { + throw new ErrorWithContext('Unable to access server: it has not yet been created') + } + return this.server + } + + public get handler(): RequestListener { return async (request: IncomingMessage, response: ServerResponse) => { const extolloReq = new Request(request, response) await this.requestLocalStorage.run(extolloReq, async () => { - /* withTimeout(timeout, extolloReq.response.sent$.toPromise()) - .onTime(() => { - this.logging.verbose(`Request lifecycle finished on time. (Path: ${extolloReq.path})`) - }) - .late(() => { - if ( !extolloReq.bypassTimeout ) { - this.logging.warn(`Request lifecycle finished late, so an error response was returned! (Path: ${extolloReq.path})`) - } - }) - .timeout(() => { - if ( extolloReq.bypassTimeout ) { - this.logging.info(`Request lifecycle has timed out, but bypassRequest was set. (Path: ${extolloReq.path})`) - return - } - - this.logging.error(`Request lifecycle has timed out. Will send error response instead. (Path: ${extolloReq.path})`) - extolloReq.response.setStatus(HTTPStatus.REQUEST_TIMEOUT) - extolloReq.response.body = 'Sorry, your request timed out.' - extolloReq.response.send() - }) - .run() - .catch(e => this.logging.error(e))*/ - this.logging.info(`${extolloReq.method} ${extolloReq.path}`) try { diff --git a/src/service/Routing.ts b/src/service/Routing.ts index abe0843..11f373f 100644 --- a/src/service/Routing.ts +++ b/src/service/Routing.ts @@ -12,7 +12,6 @@ import {PackageDiscovered} from '../support/PackageDiscovered' import {staticServer} from '../http/servers/static' import {Bus} from '../support/bus' import {RequestLocalStorage} from '../http/RequestLocalStorage' -import {env} from '../lifecycle/Application' /** * Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers. @@ -106,7 +105,7 @@ export class Routing extends Unit { * @param method * @param path */ - public match(method: HTTPMethod, path: string): Route | undefined { + public match(method: 'ws' | HTTPMethod, path: string): Route | undefined { return this.compiledRoutes.firstWhere(route => { return route.match(method, path) }) diff --git a/src/service/WebsocketServer.ts b/src/service/WebsocketServer.ts new file mode 100644 index 0000000..95ed5c3 --- /dev/null +++ b/src/service/WebsocketServer.ts @@ -0,0 +1,50 @@ +import {Unit, UnitStatus} from '../lifecycle/Unit' +import {Inject, Singleton} from '../di' +import * as WebSocket from 'ws' +import {HTTPServer} from './HTTPServer' +import {Logging} from './Logging' +import {ErrorWithContext} from '../util' +import {Request} from '../http/lifecycle/Request' + +@Singleton() +export class WebsocketServer extends Unit { + @Inject() + protected readonly http!: HTTPServer + + @Inject() + protected readonly logging!: Logging + + protected server?: WebSocket.Server + + public async up(): Promise { + // Make sure the HTTP server is started. Otherwise, this is going to fail anyway + if ( this.http.status !== UnitStatus.Started ) { + throw new ErrorWithContext('Cannot start WebsocketServer without HTTPServer.', { + suggestion: 'Make sure the HTTPServer is registered in your Units.extollo.ts file, and it is listed before the WebsocketServer.', + }) + } + + // Start the websocket server + this.logging.info('Starting WebSocket server...') + this.server = new WebSocket.Server({ + server: this.http.getServer(), + }) + + // Register the websocket handler + this.server.on('connection', (ws, request) => { + this.logging.info('Got WebSocket connection! ' + request.method) + const extolloReq = new Request(request) + this.logging.debug(ws) + this.logging.debug(request) + }) + } + + public down(): Promise { + return new Promise(res => { + // Stop the websocket server, if it exists + if ( this.server ) { + this.server.close(() => res()) + } + }) + } +}