Add foreground service, some cleanup, and start websocket server
This commit is contained in:
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 ( !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
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
24
src/service/Foreground.ts
Normal file
24
src/service/Foreground.ts
Normal 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?.()
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
||||
|
50
src/service/WebsocketServer.ts
Normal file
50
src/service/WebsocketServer.ts
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user