Add foreground service, some cleanup, and start websocket server

This commit is contained in:
Garrett Mills 2022-07-13 21:35:18 -05:00
parent 4d7769de56
commit 6476416c67
12 changed files with 158 additions and 43 deletions

View File

@ -25,6 +25,7 @@
"@types/rimraf": "^3.0.0", "@types/rimraf": "^3.0.0",
"@types/ssh2": "^0.5.46", "@types/ssh2": "^0.5.46",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/ws": "^8.5.3",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"busboy": "^0.3.1", "busboy": "^0.3.1",
"cli-table": "^0.3.6", "cli-table": "^0.3.6",
@ -48,6 +49,7 @@
"typedoc-plugin-sourcefile-url": "^1.0.6", "typedoc-plugin-sourcefile-url": "^1.0.6",
"typescript": "^4.2.3", "typescript": "^4.2.3",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"ws": "^8.8.0",
"zod": "^3.11.6" "zod": "^3.11.6"
}, },
"scripts": { "scripts": {

View File

@ -21,6 +21,7 @@ specifiers:
'@types/sinon': ^10.0.6 '@types/sinon': ^10.0.6
'@types/ssh2': ^0.5.46 '@types/ssh2': ^0.5.46
'@types/uuid': ^8.3.0 '@types/uuid': ^8.3.0
'@types/ws': ^8.5.3
'@types/wtfnode': ^0.7.0 '@types/wtfnode': ^0.7.0
'@typescript-eslint/eslint-plugin': ^4.26.0 '@typescript-eslint/eslint-plugin': ^4.26.0
'@typescript-eslint/parser': ^4.26.0 '@typescript-eslint/parser': ^4.26.0
@ -51,6 +52,7 @@ specifiers:
typedoc-plugin-sourcefile-url: ^1.0.6 typedoc-plugin-sourcefile-url: ^1.0.6
typescript: ^4.2.3 typescript: ^4.2.3
uuid: ^8.3.2 uuid: ^8.3.2
ws: ^8.8.0
wtfnode: ^0.9.1 wtfnode: ^0.9.1
zod: ^3.11.6 zod: ^3.11.6
@ -72,6 +74,7 @@ dependencies:
'@types/rimraf': 3.0.0 '@types/rimraf': 3.0.0
'@types/ssh2': 0.5.46 '@types/ssh2': 0.5.46
'@types/uuid': 8.3.0 '@types/uuid': 8.3.0
'@types/ws': 8.5.3
bcrypt: 5.0.1 bcrypt: 5.0.1
busboy: 0.3.1 busboy: 0.3.1
cli-table: 0.3.6 cli-table: 0.3.6
@ -95,6 +98,7 @@ dependencies:
typedoc-plugin-sourcefile-url: 1.0.6_typedoc@0.20.36 typedoc-plugin-sourcefile-url: 1.0.6_typedoc@0.20.36
typescript: 4.2.3 typescript: 4.2.3
uuid: 8.3.2 uuid: 8.3.2
ws: 8.8.0
zod: 3.11.6 zod: 3.11.6
devDependencies: devDependencies:
@ -433,7 +437,7 @@ packages:
/@types/ssh2-streams/0.1.8: /@types/ssh2-streams/0.1.8:
resolution: {integrity: sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA==} resolution: {integrity: sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA==}
dependencies: dependencies:
'@types/node': 14.17.1 '@types/node': 14.17.6
dev: false dev: false
/@types/ssh2/0.5.46: /@types/ssh2/0.5.46:
@ -451,6 +455,12 @@ packages:
resolution: {integrity: sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==} resolution: {integrity: sha512-0LbEEx1zxrYB3pgpd1M5lEhLcXjKJnYghvhTRgaBeUivLHMDM1TzF3IJ6hXU2+8uA4Xz+5BA63mtZo5DjVT8iA==}
dev: false 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: /@types/wtfnode/0.7.0:
resolution: {integrity: sha512-kdBHgE9+M1Os7UqWZtiLhKye5reFl8cPBYyCsP2fatwZRz7F7GdIxIHZ20Kkc0hYBfbXE+lzPOTUU1I0qgjtHA==} resolution: {integrity: sha512-kdBHgE9+M1Os7UqWZtiLhKye5reFl8cPBYyCsP2fatwZRz7F7GdIxIHZ20Kkc0hYBfbXE+lzPOTUU1I0qgjtHA==}
dev: true dev: true
@ -3120,6 +3130,19 @@ packages:
/wrappy/1.0.2: /wrappy/1.0.2:
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} 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: /wtfnode/0.9.1:
resolution: {integrity: sha512-Ip6C2KeQPl/F3aP1EfOnPoQk14Udd9lffpoqWDNH3Xt78svxPbv53ngtmtfI0q2Te3oTq79XKTnRNXVIn/GsPA==} resolution: {integrity: sha512-Ip6C2KeQPl/F3aP1EfOnPoQk14Udd9lffpoqWDNH3Xt78svxPbv53ngtmtfI0q2Te3oTq79XKTnRNXVIn/GsPA==}
hasBin: true hasBin: true

View File

@ -107,7 +107,7 @@ export class Request extends ScopedContainer implements DataContainer {
protected clientRequest: IncomingMessage, protected clientRequest: IncomingMessage,
/** The native Node.js response. */ /** The native Node.js response. */
protected serverResponse: ServerResponse, protected serverResponse?: ServerResponse,
) { ) {
super(Container.getContainer()) super(Container.getContainer())
this.registerSingletonInstance(Request, this) this.registerSingletonInstance(Request, this)

View File

@ -66,7 +66,7 @@ export class Response {
public readonly request: Request, public readonly request: Request,
/** The native Node.js ServerResponse. */ /** The native Node.js ServerResponse. */
protected readonly serverResponse: ServerResponse, protected readonly serverResponse?: ServerResponse,
) { } ) { }
protected get logging(): Logging { protected get logging(): Logging {
@ -173,6 +173,11 @@ export class Response {
*/ */
public sendHeaders(): this { public sendHeaders(): this {
this.logging.verbose(`Sending headers...`) 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 headers = {} as any
const setCookieHeaders = this.cookies.getSetCookieHeaders() const setCookieHeaders = this.cookies.getSetCookieHeaders()
@ -220,8 +225,14 @@ export class Response {
* @param data * @param data
*/ */
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> { public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
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<void>((res, rej) => { return new Promise<void>((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 ) { if ( this.responseEnded || this.serverResponse.destroyed ) {
throw new ErrorWithContext('Tried to write to Response after lifecycle ended.') throw new ErrorWithContext('Tried to write to Response after lifecycle ended.')
} }
@ -274,7 +285,7 @@ export class Response {
* or the connection has been destroyed. * or the connection has been destroyed.
*/ */
public canSend(): boolean { 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.sentHeaders = true
this.serverResponse.end() this.serverResponse?.end()
return this return this
} }

View File

@ -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)?' 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') ) { } 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.' 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 '' return ''

View File

@ -136,6 +136,14 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
return new Route(method, endpoint) return new Route(method, endpoint)
} }
/**
* 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)
}
/** /**
* Create a new GET route on the given endpoint. * Create a new GET route on the given endpoint.
*/ */
@ -188,10 +196,14 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
protected displays: Collection<{stage: 'pre'|'post'|'handler', display: string}> = new Collection() protected displays: Collection<{stage: 'pre'|'post'|'handler', display: string}> = new Collection()
constructor( constructor(
protected method: HTTPMethod | HTTPMethod[], protected method: 'ws' | HTTPMethod | HTTPMethod[],
protected route: string, protected route: string,
) {} ) {}
public isForWebSocket(): boolean {
return this.method === 'ws'
}
/** /**
* Set a programmatic name for this route. * Set a programmatic name for this route.
* @param name * @param name
@ -212,6 +224,10 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
* Get the string-form methods supported by the route. * Get the string-form methods supported by the route.
*/ */
public getMethods(): HTTPMethod[] { public getMethods(): HTTPMethod[] {
if ( this.method === 'ws' ) {
return []
}
if ( !Array.isArray(this.method) ) { if ( !Array.isArray(this.method) ) {
return [this.method] return [this.method]
} }
@ -250,10 +266,14 @@ export class Route<TReturn extends ResponseObject, THandlerParams extends unknow
* @param method * @param method
* @param potential * @param potential
*/ */
public match(method: HTTPMethod, potential: string): boolean { public match(method: 'ws' | HTTPMethod, potential: string): boolean {
if ( Array.isArray(this.method) && !this.method.includes(method) ) { if ( method === 'ws' && !this.isForWebSocket() ) {
return false return false
} else if ( !Array.isArray(this.method) && this.method !== method ) { } else if ( method !== 'ws' && this.isForWebSocket() ) {
return false
} else if ( method !== 'ws' && Array.isArray(this.method) && !this.method.includes(method) ) {
return false
} else if ( method !== 'ws' && !Array.isArray(this.method) && this.method !== method ) {
return false return false
} }

View File

@ -80,9 +80,11 @@ export * from './service/Config'
export * from './service/Controllers' export * from './service/Controllers'
export * from './service/Files' export * from './service/Files'
export * from './service/HTTPServer' export * from './service/HTTPServer'
export * from './service/WebsocketServer'
export * from './service/Routing' export * from './service/Routing'
export * from './service/Middlewares' export * from './service/Middlewares'
export * from './service/Discovery' export * from './service/Discovery'
export * from './service/Foreground'
export * from './support/redis/Redis' export * from './support/redis/Redis'
export * from './support/cache/MemoryCache' export * from './support/cache/MemoryCache'

View File

@ -8,7 +8,6 @@ import {Inject} from '../di'
import * as nodePath from 'path' import * as nodePath from 'path'
import {Unit} from '../lifecycle/Unit' import {Unit} from '../lifecycle/Unit'
import {isCanonicalReceiver} from '../support/CanonicalReceiver' import {isCanonicalReceiver} from '../support/CanonicalReceiver'
import {env} from '../lifecycle/Application'
/** /**
* Interface describing a definition of a single canonical item loaded from the app. * Interface describing a definition of a single canonical item loaded from the app.

24
src/service/Foreground.ts Normal file
View File

@ -0,0 +1,24 @@
import {Unit} from '../lifecycle/Unit'
import {Inject} from '../di'
import {Logging} from './Logging'
import * as process from 'process'
export class Foreground extends Unit {
@Inject()
protected readonly logging!: Logging
protected resolver?: () => unknown
public up(): Promise<void> {
return new Promise<void>(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?.()
}
}

View File

@ -1,5 +1,5 @@
import {Inject, Singleton} from '../di' import {Inject, Singleton} from '../di'
import {ErrorWithContext, HTTPStatus, withTimeout} from '../util' import {ErrorWithContext} from '../util'
import {Unit} from '../lifecycle/Unit' import {Unit} from '../lifecycle/Unit'
import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http' import {createServer, IncomingMessage, RequestListener, Server, ServerResponse} from 'http'
import {Logging} from './Logging' import {Logging} from './Logging'
@ -67,10 +67,9 @@ export class HTTPServer extends Unit {
this.server = createServer(this.handler) this.server = createServer(this.handler)
this.server.listen(port, () => { 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 { public getServer(): Server {
// const timeout = this.config.get('server.timeout', 10000) if ( !this.server ) {
// const timeout = 0 // temporarily disable this because it is causing problems 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) => { return async (request: IncomingMessage, response: ServerResponse) => {
const extolloReq = new Request(request, response) const extolloReq = new Request(request, response)
await this.requestLocalStorage.run(extolloReq, async () => { 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}`) this.logging.info(`${extolloReq.method} ${extolloReq.path}`)
try { try {

View File

@ -12,7 +12,6 @@ import {PackageDiscovered} from '../support/PackageDiscovered'
import {staticServer} from '../http/servers/static' import {staticServer} from '../http/servers/static'
import {Bus} from '../support/bus' import {Bus} from '../support/bus'
import {RequestLocalStorage} from '../http/RequestLocalStorage' 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. * 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 method
* @param path * @param path
*/ */
public match(method: HTTPMethod, path: string): Route<unknown, unknown[]> | undefined { public match(method: 'ws' | HTTPMethod, path: string): Route<unknown, unknown[]> | undefined {
return this.compiledRoutes.firstWhere(route => { return this.compiledRoutes.firstWhere(route => {
return route.match(method, path) return route.match(method, path)
}) })

View File

@ -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<void> {
// 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<WebSocket.WebSocket>({
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<void> {
return new Promise(res => {
// Stop the websocket server, if it exists
if ( this.server ) {
this.server.close(() => res())
}
})
}
}