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

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

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

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

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

@ -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
} else if ( method !== 'ws' && this.isForWebSocket() ) {
return false
} else if ( method !== 'ws' && Array.isArray(this.method) && !this.method.includes(method) ) {
return false return false
} else if ( !Array.isArray(this.method) && this.method !== method ) { } else if ( method !== 'ws' && !Array.isArray(this.method) && this.method !== method ) {
return false return false
} }

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

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

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

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

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