1 | ;
|
2 |
|
3 | const AsyncEventEmitter = require('./util/event_emitter');
|
4 | const coreSymbols = require('./core/symbols');
|
5 | const Rest = require('./util/rest');
|
6 |
|
7 | const sInitialized = Symbol('isInitialized');
|
8 | const sListening = Symbol('isListening');
|
9 | const sLog = Symbol('$log');
|
10 | const sErr = Symbol('$err');
|
11 | const sCWD = Symbol('cwd');
|
12 | const sCallback = Symbol('callback');
|
13 | const sServer = Symbol('server');
|
14 | const sUncaughtRejections = Symbol('uncaughtRejections');
|
15 | const sKeygripKeys = Symbol('keygripKeys');
|
16 | const sKeygrip = Symbol('keygrip');
|
17 |
|
18 | /**
|
19 | * This class provides Ravel, a lightweight but powerful framework
|
20 | * for the rapid creation of enterprise Node.js applications which
|
21 | * scale horizontally with ease and support the latest in web technology.
|
22 | *
|
23 | * @example
|
24 | * const Ravel = require('ravel');
|
25 | * const app = new Ravel();
|
26 | */let
|
27 | Ravel = class Ravel extends AsyncEventEmitter {
|
28 | /**
|
29 | * Creates a new Ravel app instance.
|
30 | *
|
31 | * @example
|
32 | * const Ravel = require('ravel');
|
33 | * const app = new Ravel();
|
34 | */
|
35 | constructor() {
|
36 | super();
|
37 |
|
38 | this[sUncaughtRejections] = 0;
|
39 | this[sInitialized] = false;
|
40 | this[sListening] = false;
|
41 | this[sKeygripKeys] = [];
|
42 |
|
43 | // how we store modules for dependency injection
|
44 | this[coreSymbols.modules] = Object.create(null);
|
45 | // how we store routes for later access
|
46 | this[coreSymbols.routes] = Object.create(null);
|
47 | // how we store resources for later access
|
48 | this[coreSymbols.resource] = Object.create(null);
|
49 | // how we store middleware for dependency injection
|
50 | this[coreSymbols.middleware] = Object.create(null);
|
51 |
|
52 | // current working directory of the app using the
|
53 | // ravel library, so that client modules can be
|
54 | // loaded with relative paths.
|
55 | this[sCWD] = process.cwd();
|
56 |
|
57 | this[coreSymbols.knownParameters] = Object.create(null);
|
58 | this[coreSymbols.params] = Object.create(null);
|
59 | // a list of registered module factories which haven't yet been instantiated
|
60 | this[coreSymbols.moduleFactories] = Object.create(null);
|
61 | this[coreSymbols.resourceFactories] = Object.create(null);
|
62 | this[coreSymbols.routesFactories] = Object.create(null);
|
63 | // Allows us to detect duplicate binds
|
64 | this[coreSymbols.endpoints] = new Map();
|
65 | // a list of known modules, resources and routes so that metadata can be retrieved from them
|
66 | this[coreSymbols.knownComponents] = Object.create(null);
|
67 |
|
68 | // init errors
|
69 | this[sErr] = require('./util/application_error');
|
70 |
|
71 | // init logging system
|
72 | this[sLog] = new (require('./util/log'))(this);
|
73 |
|
74 | // init dependency injection utility, which is used by most everything else
|
75 | this[coreSymbols.injector] = new (require('./core/injector'))(this,
|
76 | module.parent !== null ? module.parent : module);
|
77 |
|
78 | // Register known ravel parameters
|
79 | this.registerParameter('enable websockets', true, true);
|
80 | // redis parameters
|
81 | this.registerParameter('redis host', false);
|
82 | this.registerParameter('redis port', true, 6379);
|
83 | this.registerParameter('redis password');
|
84 | this.registerParameter('redis max retries', true, 10);
|
85 | this.registerParameter('redis keepalive interval', true, 1000);
|
86 | this.registerParameter('redis websocket channel prefix', true, 'ravel.ws');
|
87 | // Node/koa parameters
|
88 | this.registerParameter('port', true, 8080);
|
89 | this.registerParameter('public directory');
|
90 | this.registerParameter('favicon path');
|
91 | this.registerParameter('keygrip keys', true);
|
92 | // session parameters
|
93 | this.registerParameter('session key', true, 'ravel.sid');
|
94 | this.registerParameter('session max age', true, null);
|
95 | // Passport parameters
|
96 | this.registerParameter('app route', false, '/');
|
97 | this.registerParameter('login route', false, '/login');
|
98 | }
|
99 |
|
100 | /**
|
101 | * The current working directory of the app using the `ravel` library.
|
102 | *
|
103 | * @type String
|
104 | */
|
105 | get cwd() {
|
106 | return this[sCWD];
|
107 | }
|
108 |
|
109 | /**
|
110 | * Return the app instance of Logger.
|
111 | * See [`Logger`](#logger) for more information.
|
112 | *
|
113 | * @type Logger
|
114 | * @example
|
115 | * app.$log.error('Something terrible happened!');
|
116 | */
|
117 | get $log() {
|
118 | return this[sLog];
|
119 | }
|
120 |
|
121 | /**
|
122 | * A hash of all built-in error types. See [`$err`](#$err) for more information.
|
123 | *
|
124 | * @type Object
|
125 | */
|
126 | get $err() {
|
127 | return this[sErr];
|
128 | }
|
129 |
|
130 | /**
|
131 | * Value is `true` iff init() or start() has been called on this app instance.
|
132 | *
|
133 | * @type boolean
|
134 | */
|
135 | get initialized() {
|
136 | return this[sInitialized];
|
137 | }
|
138 |
|
139 | /**
|
140 | * Value is `true` iff `listen()` or `start()` has been called on this app instance.
|
141 | *
|
142 | * @type boolean
|
143 | */
|
144 | get listening() {
|
145 | return this[sListening];
|
146 | }
|
147 |
|
148 | /**
|
149 | * The underlying koa callback for this Ravel instance.
|
150 | * Useful for [testing](#testing-ravel-applications) with `supertest`.
|
151 | * Only available after `init()` (i.e. use `@postinit`).
|
152 | *
|
153 | * @type {Function}
|
154 | */
|
155 | get callback() {
|
156 | return this[sCallback];
|
157 | }
|
158 |
|
159 | /**
|
160 | * The underlying HTTP server for this Ravel instance.
|
161 | * Only available after `init()` (i.e. use `@postinit`).
|
162 | * Useful for [testing](#testing-ravel-applications) with `supertest`.
|
163 | *
|
164 | * @type {http.Server}
|
165 | */
|
166 | get server() {
|
167 | return this[sServer];
|
168 | }
|
169 |
|
170 | /**
|
171 | * The underlying Keygrip instance for cookie signing.
|
172 | *
|
173 | * @type {Keygrip}
|
174 | */
|
175 | get keys() {
|
176 | return this[sKeygrip];
|
177 | }
|
178 |
|
179 | /**
|
180 | * Register a provider, such as a DatabaseProvider or AuthenticationProvider.
|
181 | *
|
182 | * @param {Function} ProviderClass - The provider class.
|
183 | * @param {...any} args - Arguments to be provided to the provider's constructor
|
184 | * (prepended automatically with a reference to app).
|
185 | */
|
186 | registerProvider(ProviderClass, ...args) {
|
187 | return new ProviderClass(this, ...args);
|
188 | }
|
189 |
|
190 | /**
|
191 | * Initializes the application, when the client is finished
|
192 | * supplying parameters and registering modules, resources
|
193 | * routes and rooms.
|
194 | *
|
195 | * @example
|
196 | * await app.init();
|
197 | */
|
198 | async init() {
|
199 | // application configuration is completed in constructor
|
200 | await this.emit('pre init');
|
201 |
|
202 | // log uncaught errors to prevent ES6 promise error swallowing
|
203 | // https://www.hacksrus.net/blog/2015/08/a-solution-to-swallowed-exceptions-in-es6s-promises/
|
204 | process.removeAllListeners('unhandledRejection');
|
205 | process.on('unhandledRejection', err => {
|
206 | this[sUncaughtRejections] += 1;
|
207 | const message = `Detected uncaught error in promise: \n ${err ? err.stack : err}`;
|
208 | if (this[sUncaughtRejections] >= 10) {
|
209 | this.$log.error(message);
|
210 | this.$log.error(`Encountered ${this[sUncaughtRejections]} or more uncaught rejections. Re-run ` +
|
211 | 'node and ravel with full logging output for more information:' +
|
212 | '\n- node --trace-warnings app.js' +
|
213 | '\n- app.set(\'log level\', app.$log.TRACE);');
|
214 | } else {
|
215 | this.$log.debug(message);
|
216 | }
|
217 | });
|
218 |
|
219 | // load parameters from .ravelrc.json file, if any
|
220 | await this.emit('pre load parameters');
|
221 | this[coreSymbols.loadParameters]();
|
222 | await this.emit('post load parameters');
|
223 |
|
224 | this[sInitialized] = true;
|
225 |
|
226 | this.$db = require('./db/database')(this);
|
227 | this.$kvstore = require('./util/kvstore')(this);
|
228 |
|
229 | // App dependencies.
|
230 | const http = require('http'); // https will be provided by reverse proxy
|
231 | const upath = require('upath');
|
232 | const Koa = require('koa');
|
233 | const session = require('koa-session');
|
234 | const compression = require('koa-compress');
|
235 | const favicon = require('koa-favicon');
|
236 | const router = new (require('koa-router'))();
|
237 |
|
238 | // configure koa
|
239 | const app = new Koa();
|
240 | app.proxy = true;
|
241 |
|
242 | // the first piece of middleware is the exception handler
|
243 | // catch all errors and return appropriate error codes
|
244 | // to the client
|
245 | app.use(new Rest(this).errorHandler());
|
246 |
|
247 | // enable gzip compression
|
248 | app.use(compression());
|
249 |
|
250 | // configure redis session store
|
251 | this[sKeygripKeys] = [...this.get('keygrip keys')];
|
252 | this[sKeygrip] = require('keygrip')(this[sKeygripKeys], 'sha256', 'base64');
|
253 | app.keys = this[sKeygrip];
|
254 | app.use(session({
|
255 | store: new (require('./util/redis_session_store'))(this),
|
256 | key: this.get('session key'),
|
257 | maxAge: Number(this.get('session max age')),
|
258 | overwrite: true, /* (boolean) can overwrite or not (default true) */
|
259 | httpOnly: true, /* (boolean) httpOnly or not (default true) */
|
260 | signed: true, /* (boolean) signed or not (default true) */
|
261 | rolling: false /* (boolean) Force a session identifier cookie to be set on every response.
|
262 | The expiration is reset to the original maxAge, resetting the expiration
|
263 | countdown. default is false */ },
|
264 | app));
|
265 |
|
266 | // favicon
|
267 | if (this.get('favicon path')) {
|
268 | app.use(favicon(upath.toUnix(upath.posix.join(this.cwd, this.get('favicon path')))));
|
269 | }
|
270 |
|
271 | // static file serving
|
272 | if (this.get('public directory')) {
|
273 | const koaStatic = require('koa-static');
|
274 | const root = upath.toUnix(upath.posix.join(this.cwd, this.get('public directory')));
|
275 | app.use(koaStatic(root, {
|
276 | gzip: false // this should be handled by koa-compressor?
|
277 | }));
|
278 | }
|
279 |
|
280 | // initialize authentication/authentication
|
281 | require('./auth/passport_init.js')(this, router);
|
282 |
|
283 | // create websocket broker
|
284 | const WebsocketBroker = require('./core/websocket.js');
|
285 | this[coreSymbols.websocketBroker] = new WebsocketBroker(this);
|
286 |
|
287 | // basic koa configuration is completed
|
288 | await this.emit('post config koa', app);
|
289 |
|
290 | // create registered modules using factories
|
291 | this[coreSymbols.moduleInit]();
|
292 |
|
293 | await this.emit('post module init');
|
294 |
|
295 | await this.emit('pre routes init', app);
|
296 |
|
297 | // create registered resources using factories
|
298 | this[coreSymbols.resourceInit](router);
|
299 |
|
300 | // create routes using factories
|
301 | this[coreSymbols.routesInit](router);
|
302 |
|
303 | // include routes as middleware
|
304 | app.use(router.routes());
|
305 | app.use(router.allowedMethods());
|
306 |
|
307 | // Create koa callback and server
|
308 | this[sCallback] = app.callback();
|
309 | this[sServer] = http.createServer(this[sCallback]);
|
310 |
|
311 | // application configuration is completed
|
312 | await this.emit('post init');
|
313 | }
|
314 |
|
315 | /**
|
316 | * Rotate a new cookie signing key in, and the oldest key out.
|
317 | *
|
318 | * @param {string} newKey - The new key.
|
319 | */
|
320 | rotateKeygripKey(newKey) {
|
321 | this[sKeygripKeys].unshift(newKey);
|
322 | this[sKeygripKeys].pop();
|
323 | }
|
324 |
|
325 | /**
|
326 | * Starts the application. Must be called after `initialize()`.
|
327 | *
|
328 | * @example
|
329 | * await app.listen();
|
330 | */
|
331 | async listen() {
|
332 | if (!this[sInitialized]) {
|
333 | throw new this.$err.NotAllowed('Cannot call Ravel.listen() before Ravel.init()');
|
334 | } else {
|
335 | await this.emit('pre listen');
|
336 | return new Promise((resolve, reject) => {
|
337 | // validate parameters
|
338 | this[coreSymbols.validateParameters]();
|
339 | // Start Koa server
|
340 | this[sServer].listen(this.get('port'), () => {
|
341 | this.$log.info('Application server listening on port ' + this.get('port'));
|
342 | this[sListening] = true;
|
343 | this.emit('post listen');
|
344 | resolve();
|
345 | });
|
346 | });
|
347 | }
|
348 | }
|
349 |
|
350 | /**
|
351 | * Intializes and starts the application.
|
352 | *
|
353 | * @example
|
354 | * await app.start();
|
355 | */
|
356 | async start() {
|
357 | await this.init();
|
358 | return this.listen();
|
359 | }
|
360 |
|
361 | /**
|
362 | * Stops the application. A no op if the server isn't running.
|
363 | *
|
364 | * @example
|
365 | * await app.close();
|
366 | */
|
367 | async close() {
|
368 | await this.emit('end');
|
369 | if (this[sServer] && this[sListening]) {
|
370 | return new Promise(resolve => {
|
371 | this[sServer].close(() => {
|
372 | this.$log.info('Application server terminated.');
|
373 | this[sListening] = false;
|
374 | resolve();
|
375 | });
|
376 | });
|
377 | }
|
378 | }};
|
379 |
|
380 |
|
381 | /**
|
382 | * The base class of Ravel `Error`s, which associate
|
383 | * HTTP status codes with your custom errors.
|
384 | * @example
|
385 | * const Ravel = require('ravel');
|
386 | * class NotFoundError extends Ravel.Error {
|
387 | * constructor (msg) {
|
388 | * super(msg, Ravel.httpCodes.NOT_FOUND);
|
389 | * }
|
390 | * }
|
391 | */
|
392 | Ravel.Error = require('./util/application_error').General;
|
393 |
|
394 | /**
|
395 | * The base class for Ravel `DatabaseProvider`s. See
|
396 | * [`DatabaseProvider`](#databaseprovider) for more information.
|
397 | * @example
|
398 | * const DatabaseProvider = require('ravel').DatabaseProvider;
|
399 | * class MySQLProvider extends DatabaseProvider {
|
400 | * // ...
|
401 | * }
|
402 | */
|
403 | Ravel.DatabaseProvider = require('./db/database_provider').DatabaseProvider;
|
404 |
|
405 | /**
|
406 | * Return a list of all registered `DatabaseProvider`s. See
|
407 | * [`DatabaseProvider`](#databaseprovider) for more information.
|
408 | * @returns {Array} a list of `DatabaseProvider`s
|
409 | * @private
|
410 | */
|
411 | require('./db/database_provider')(Ravel);
|
412 |
|
413 | /**
|
414 | * The base class for Ravel `AuthenticationProvider`s. See
|
415 | * [`AuthenticationProvider`](#authenticationprovider) for more information.
|
416 | * @example
|
417 | * const AuthenticationProvider = require('ravel').AuthenticationProvider;
|
418 | * class GoogleOAuth2Provider extends AuthenticationProvider {
|
419 | * // ...
|
420 | * }
|
421 | */
|
422 | Ravel.AuthenticationProvider = require('./auth/authentication_provider').AuthenticationProvider;
|
423 |
|
424 | /**
|
425 | * Return a list of all registered `AuthenticationProvider`s. See
|
426 | * [`AuthenticationProvider`](#authenticationprovider) for more information.
|
427 | * @returns {Array} a list of `AuthenticationProvider`s
|
428 | * @private
|
429 | */
|
430 | require('./auth/authentication_provider')(Ravel);
|
431 |
|
432 | /*
|
433 | * Makes the `@inject` decorator available as `Ravel.inject`
|
434 | * @example
|
435 | * const Ravel = require('ravel');
|
436 | * const inject = Ravel.inject;
|
437 | */
|
438 | Ravel.inject = require('./core/decorators/inject');
|
439 |
|
440 | /*
|
441 | * Makes the `@autoinject` decorator available as `Ravel.autoinject`
|
442 | * @example
|
443 | * const Ravel = require('ravel');
|
444 | * const autoinject = Ravel.autoinject;
|
445 | */
|
446 | Ravel.autoinject = require('./core/decorators/autoinject');
|
447 |
|
448 | /**
|
449 | * A dictionary of useful http status codes. See [HTTPCodes](#httpcodes) for more information.
|
450 | * @example
|
451 | * const Ravel = require('ravel');
|
452 | * console.log(Ravel.httpCodes.NOT_FOUND);
|
453 | */
|
454 | Ravel.httpCodes = require('./util/http_codes');
|
455 |
|
456 | /**
|
457 | * Requires Ravel's parameter system
|
458 | * See `core/params` for more information.
|
459 | * @example
|
460 | * app.registerParameter('my parameter', true, 'default value');
|
461 | * const value = app.get('my parameter');
|
462 | * app.set('my parameter', 'another value');
|
463 | * @private
|
464 | */
|
465 | require('./core/params')(Ravel);
|
466 |
|
467 | /**
|
468 | * The base decorator for Ravel `Module`s. See
|
469 | * [`Module`](#module) for more information.
|
470 | * @example
|
471 | * const Module = require('ravel').Module;
|
472 | * @Module
|
473 | * class MyModule {
|
474 | * // ...
|
475 | * }
|
476 | * module.exports = MyModule;
|
477 | */
|
478 | Ravel.Module = require('./core/module').Module;
|
479 |
|
480 | /**
|
481 | * Requires Ravel's `Module` retrieval and registration system
|
482 | * See [`Module`](#module) for more information.
|
483 | * @example
|
484 | * app.scan('./modules/mymodule');
|
485 | * @private
|
486 | */
|
487 | require('./core/module')(Ravel);
|
488 |
|
489 | /**
|
490 | * Requires Ravel's recursive component registration system.
|
491 | *
|
492 | * See [`Module`](#module), [`Resource`](#resource) and [`Routes`](#routes) for more information.
|
493 | * @example
|
494 | * // recursively load all Modules in a directory
|
495 | * app.scan('./modules');
|
496 | * // a Module 'modules/test.js' in ./modules can be injected as `@inject('test')`
|
497 | * // a Module 'modules/stuff/test.js' in ./modules can be injected as `@inject('stuff.test')`
|
498 | * @example
|
499 | * // recursively load all Resources in a directory
|
500 | * app.scan('./resources');
|
501 | * // recursively load all Resources in a directory
|
502 | * app.scan('./routes');
|
503 | * @private
|
504 | */
|
505 | require('./core/scan')(Ravel);
|
506 |
|
507 | /**
|
508 | * The base class for Ravel `Routes`. See
|
509 | * [`Routes`](#routes) for more information.
|
510 | * @example
|
511 | * const Routes = require('ravel').Routes;
|
512 | * // @Routes('/')
|
513 | * class MyRoutes {
|
514 | * // ...
|
515 | * }
|
516 | * module.exports = MyRoutes;
|
517 | */
|
518 | Ravel.Routes = require('./core/routes').Routes;
|
519 |
|
520 | /**
|
521 | * Requires Ravel's `Routes` registration system
|
522 | * See [`Routes`](#routes) for more information.
|
523 | * @example
|
524 | * app.routes('./routes/myroutes');
|
525 | * @private
|
526 | */
|
527 | require('./core/routes')(Ravel);
|
528 |
|
529 | /**
|
530 | * The base class for Ravel `Resource`. See
|
531 | * [`Resource`](#resource) for more information.
|
532 | * @example
|
533 | * const Resource = require('ravel').Resource;
|
534 | * // @Resource('/')
|
535 | * class MyResource {
|
536 | * // ...
|
537 | * }
|
538 | * module.exports = MyResource;
|
539 | */
|
540 | Ravel.Resource = require('./core/resource').Resource;
|
541 |
|
542 | /**
|
543 | * Requires Ravel's `Resource` registration system
|
544 | * See [`Resource`](#resource) for more information.
|
545 | * @example
|
546 | * app.resource('./resources/myresource');
|
547 | * @private
|
548 | */
|
549 | require('./core/resource')(Ravel);
|
550 |
|
551 | /**
|
552 | * Requires Ravel's lightweight reflection/metadata system.
|
553 | *
|
554 | * See `core/reflect` for more information.
|
555 | * @example
|
556 | * // examine a registered Module, Resource or Route by file path
|
557 | * app.reflect('./modules/mymodule.js');
|
558 | * @private
|
559 | */
|
560 | require('./core/reflect')(Ravel);
|
561 |
|
562 | module.exports = Ravel; |
\ | No newline at end of file |