Add foreground service, some cleanup, and start websocket server

master
Garrett Mills 2 years ago
parent 4d7769de56
commit 6476416c67

@ -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": {

@ -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

@ -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)

@ -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<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) => {
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
}

@ -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 ''

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

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

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

@ -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?.()
}
}

@ -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 {

@ -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<unknown, unknown[]> | undefined {
public match(method: 'ws' | HTTPMethod, path: string): Route<unknown, unknown[]> | undefined {
return this.compiledRoutes.firstWhere(route => {
return route.match(method, path)
})

@ -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())
}
})
}
}
Loading…
Cancel
Save