• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nodecraft / spawnpoint / 8483328293

29 Mar 2024 04:44PM UTC coverage: 91.657% (-0.2%) from 91.895%
8483328293

push

github

web-flow
Merge pull request #87 from nodecraft/dependabot/npm_and_yarn/examples/framework-express/express-4.19.2

257 of 297 branches covered (86.53%)

Branch coverage included in aggregate %.

512 of 542 relevant lines covered (94.46%)

643.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.72
/lib/spawnpoint.js
1
'use strict';
2

3
// Include core libs
4
const assert = require('node:assert');
18✔
5
const child_process = require('node:child_process');
18✔
6
const EventEmitter = require('node:events');
18✔
7
const fs = require('node:fs');
18✔
8
const path = require('node:path');
18✔
9
const { format } = require('node:util');
18✔
10

11
// Include external libraries
12
const async = require('async');
18✔
13
const kleur = require('kleur');
18✔
14
const _ = require('lodash');
18✔
15
const minimist = require('minimist');
18✔
16
const { nanoid } = require('nanoid');
18✔
17

18
// Define private helper functions
19
const helpers = require('./helpers.js');
18✔
20

21
/**
22
 * Agnostic JS framework that empowers devs to focus on quickly building apps, rather than focusing on application
23
 * config, health-checks, application structure, or architecture to build a 12 factor app in Docker.
24
 *
25
 * Spawnpoint can be configured to manage the entire application life-cycle or standalone as a utility library.
26
 * @class
27
 */
28
class spawnpoint extends EventEmitter {
29
        /**
30
         * Creates new instance of spawnpoint
31
         * @param  {string} [configFile] Sets the JSON file spawnpoint uses to setup the framework.
32
         * @return {this}
33
         */
34
        constructor(configFile = '/config/app.json') {
120✔
35
                // init EventEmitter
36
                super();
188✔
37

38
                if (typeof(configFile) !== 'string') {
188✔
39
                        throw new TypeError('`configFile` must be a path string to a Spawnpoint config file.');
8✔
40
                }
41
                if (!configFile.endsWith('.json') && !configFile.endsWith('.js')) {
180✔
42
                        configFile = configFile + '.json';
28✔
43
                }
44
                this.configFile = configFile;
180✔
45

46
                // set the folder for config to be autoloaded
47

48
                // Used to track if the application expects itself to continue running or not
49
                this.status = {
180✔
50
                        setup: false, // has the app finished setup
51
                        running: false, // is it in the running state. When false is attempting to shutdown
52
                        stopping: false, // is it in a stopping state. when true is attempting to stop
53
                        stopAttempts: 0, // how many attempts to stop have been triggered
54
                };
55

56
                // app CWD
57
                this.cwd = process.cwd();
180✔
58

59
                // detect if we are in a container
60
                this.containerized = helpers.isContainerized();
180✔
61

62
                // list of ENV configs that are blocklisted
63
                this.configBlocklist = require('../config-blocklist.json');
180✔
64

65
                // plugin registery
66
                this.register = [];
180✔
67

68
                // config object to store all application config
69
                this.config = {};
180✔
70

71
                // codes object to store all Spawnpoint codes
72
                this.codes = {};
180✔
73

74
                // errorMaps help wrap custom Error types to Spawnpoint codes.
75
                this.errorMaps = {};
180✔
76

77
                // error tracking, debounce detection
78
                this.limitMaps = {};
180✔
79

80
                // which plugins are loaded
81
                this.plugins = {};
180✔
82

83

84
                // make errorCode and failCode available
85
                this._errorCode = require('./errorCode.js');
180✔
86
                this._failCode = require('./failCode.js');
180✔
87
                this._roundRobin = require('./roundRobin.js')(this);
180✔
88
                this._getAndLock = require('./getAndLock.js')(this);
180✔
89

90
                // log formatting
91
                this.logs = {
180✔
92
                        prefix: null,
93
                        date: null,
94
                };
95

96
                return this;
180✔
97
        }
98

99
        /**
100
         * Initializes framework to read the `configFile`, init config, Spawnpoint plugins, errorCodes and autoload
101
         * folders. This also starts the application life-cycle so the app can stop gracefully.
102
         * @callback {Function} [callback] Triggered once the `app.ready` event triggers.
103
         * @return {this}
104
         */
105
        setup(callback = () => {}) {
24✔
106
                // force .json parsing with comments :)
107
                this.setupJSONHandler();
64✔
108

109
                // prevent repeated setup
110
                if (this.status.setup) {
64✔
111
                        return callback(this.errorCode('spawnpoint.already_setup'));
2✔
112
                }
113
                this.status.setup = true;
62✔
114

115
                // App loading process
116
                this.initConfig();
62✔
117
                this.initCodes();
62✔
118
                this.initRegistry();
62✔
119
                this.loadPlugins();
62✔
120
                this.loadConfig();
62✔
121
                this.loadCodes();
62✔
122
                this.initLimitListeners();
62✔
123
                this.loadErrorMap();
62✔
124
                const jobs = [];
62✔
125

126
                _.each(this.plugins, (plugin) => {
62✔
127
                        if (plugin.callback) {
8✔
128
                                return jobs.push(cb => plugin.exports(this, cb));
4✔
129
                        }
130
                        jobs.push((cb) => {
4✔
131
                                plugin.exports(this);
4✔
132
                                return cb();
4✔
133
                        });
134
                });
135
                // load framework files
136
                _.each(this.config.autoload, (jobDetails) => {
62✔
137
                        this.log('Autoloading %s', jobDetails.name || jobDetails.folder);
10✔
138
                        const list = this.recursiveList(format('%s/%s', this.cwd, jobDetails.folder), jobDetails.extension || '.js');
10✔
139
                        if (jobDetails.callback) {
10✔
140
                                return jobs.push((callback) => {
4✔
141
                                        async.eachSeries(list, (file, acb) => {
4✔
142
                                                const modelCallback = (err) => {
4✔
143
                                                        if (err) {
2!
144
                                                                this.error('Failed to load', file);
×
145
                                                                return acb(err);
×
146
                                                        }
147
                                                        this.debug('Successfully loaded', file);
2✔
148
                                                        return acb();
2✔
149
                                                };
150
                                                this.debug('Loading', file);
4✔
151
                                                let error;
152
                                                try {
4✔
153
                                                        require(file)(this, modelCallback);
4✔
154
                                                } catch (err) {
155
                                                        error = err;
2✔
156
                                                }
157
                                                if (error) {
4✔
158
                                                        return acb(error);
2✔
159
                                                }
160
                                        }, callback);
161
                                });
162
                        }
163
                        jobs.push((callback) => {
6✔
164
                                _.each(list, (file) => {
6✔
165
                                        this.debug('Loading', file);
6✔
166
                                        let error;
167
                                        try {
6✔
168
                                                require(file)(this);
6✔
169
                                                this.debug('Successfully loaded', file);
4✔
170
                                        } catch (err) {
171
                                                error = err;
2✔
172
                                        }
173
                                        if (error) {
6✔
174
                                                return console.error(`Failed to load [${file}]`, error);
2✔
175
                                        }
176
                                });
177
                                return callback();
6✔
178
                        });
179
                });
180
                process.nextTick(() => {
62✔
181
                        async.series(jobs, (err) => {
62✔
182
                                if (err) {
62✔
183
                                        this.error('Failed to start up').debug(err);
2✔
184
                                        this.emit('app.exit');
2✔
185
                                        return callback(err);
2✔
186
                                }
187
                                this.log('%s is ready.', this.config.name);
60✔
188
                                this.emit('app.ready');
60✔
189
                                return callback();
60✔
190
                        });
191
                });
192
                this.emit('app.setup.done');
62✔
193
                return this;
62✔
194
        }
195

196
        /**
197
         * Recursively list files in a directory by an optional file extension.
198
         * NOTE: This is an event blocking sync method.
199
         * @param  {String} dir  Directory to list files from.
200
         * @param  {Array|string} [exts] Optional list of file extensions to return. Defaults to .js files. Set to a falsy value to disable this filter.
201
         * @return {Array} Absolute/full path of filenames found.
202
         */
203
        recursiveList(dir, exts = ['.js']) {
14✔
204
                assert(typeof(dir) === 'string', '`dir` must be a string');
376✔
205
                if (typeof(exts) === 'string') {
366✔
206
                        exts = [exts];
158✔
207
                }
208
                const parent = this;
366✔
209
                let stat; let
210
                        list = [];
366✔
211
                try {
366✔
212
                        stat = fs.statSync(dir);
366✔
213
                } catch {
214
                        stat = false;
72✔
215
                }
216
                if (!stat || !stat.isDirectory()) {
366✔
217
                        return list;
72✔
218
                }
219
                dir = String(dir + '/').replace(/\//g, '/'); // ensure proper trailing slash
294✔
220
                _.each(fs.readdirSync(dir), (file) => {
294✔
221
                        const isDir = fs.statSync(dir + file).isDirectory();
1,472✔
222
                        if (isDir && exts && exts.includes('/')) {
1,472✔
223
                                list.push(dir + file);
16✔
224
                        } else if (isDir) {
1,456✔
225
                                const recursive = parent.recursiveList(dir + file, exts);
70✔
226
                                if (Array.isArray(recursive) && recursive.length > 0) {
70✔
227
                                        list = [...list, ...recursive]; // add results
66✔
228
                                }
229
                        } else if (!exts || exts.includes(path.extname(file))) {
1,386✔
230
                                list.push(dir + file);
1,320✔
231
                        }
232
                });
233
                list.sort(); // windows won't sort this like unix will
294✔
234
                return list;
294✔
235
        }
236

237
        /**
238
         * Utility: Create random string.
239
         * @param  {Number} [length] How long of a random string to create.
240
         * @param  {String} [hashMethod] Which crypto hash method to use.
241
         * @return {String} Random string of characters.
242
         */
243
        random(length = 16) {
20,000✔
244
                length = Number.parseInt(length);
20,016✔
245
                assert(!Number.isNaN(length), '`length` must be a number');
20,016✔
246
                if (Number.isNaN(length) || length < 1) {
20,004✔
247
                        length = 16; // TODO: throw an error in an update
4✔
248
                }
249
                return nanoid(length);
20,004✔
250
        }
251

252
        /**
253
         * Utility: get random element from `collection`.
254
         * This is a copy of the lodash _.sample method.
255
         * @param  {Array|Object} items The collection to sample.
256
         * @return {*} Returns the random element.
257
         */
258
        sample(items) {
259
                return _.sample(items);
12✔
260
        }
261

262
        /**
263
         * Utility: Creates new `roundRobin` class with collection.
264
         * @param  {Array|Object} items The collection to sample.
265
         * @return {roundRobin} Returns new instance of `roundRobin` class.
266
         */
267
        roundRobin(items) {
268
                return new this._roundRobin(items);
24✔
269
        }
270

271
        /**
272
         * Utility: get random element from `collection` in an async lock.
273
         * @param  {Array|Object} items The collection to sample.
274
         * @return {roundRobin} Returns new instance of `roundRobin` class.
275
         */
276
        getAndLock(items) {
277
                return new this._getAndLock(items);
24✔
278
        }
279

280
        /**
281
         * Utility: omit keys from an object. Similar to Lodash omit, but much faster.
282
         * @param  {Object} items The source object.
283
         * @param  {Array} keysToOmit Keys to omit from the object.
284
         * @return {Object} Returns object with requested keys removed.
285
         */
286
        omit(obj, keysToOmit = []) {
×
287
                return helpers.omit(obj, keysToOmit);
×
288
        }
289

290
        /**
291
         * Checks if the current application runtime is running as a root user/group.
292
         * @return {Boolean} When true: the application is running as a root user/group.
293
         */
294
        isRoot() {
295
                if (this.isSecure() === true) {
2!
296
                        return false;
2✔
297
                }
298
                return true;
×
299
        }
300

301
        /**
302
         * Checks if the current application runtime is running as a specific `uid` and/or `gid`.
303
         * @param  {Number}  [uid] Unix `uid` to check against.
304
         * @param  {Number}  [gid] Unix `gid` to check against. When not set will match `uid`.
305
         * @return {Boolean} When true: the application is running as the user/group.
306
         */
307
        isSecure(uid, gid) {
308
                // TODO: Fix testing on non UNIX (windows)?
309
                if (typeof(process.getuid) !== 'function' || typeof(process.getgid) !== 'function') {
4!
310
                        return true; // TODO: throw error
×
311
                }
312

313
                if (uid && !gid) {
4!
314
                        gid = uid;
×
315
                }
316
                const checks = {
4✔
317
                        uid: process.getuid(),
318
                        gid: process.getgid(),
319
                        groups: String(child_process.execSync('groups')),
320
                };
321
                if (checks.uid === 0 || checks.gid === 0) {
4!
322
                        return this.errorCode('usercheck.is_root', { checks: checks });
×
323
                }
324
                if (checks.groups.includes('root')) {
4!
325
                        return this.errorCode('usercheck.is_root_group', { checks: checks });
×
326
                }
327
                if (uid && gid && (uid !== checks.uid || gid !== checks.gid)) {
4!
328
                        return this.errorCode('usercheck.incorrect_user', { checks: checks });
×
329
                }
330
                return true;
4✔
331
        }
332

333
        /**
334
         * Helper method that requires a file and hoists the current spawnpoint application reference.
335
         * @param  {String} filePath File path to require.
336
         */
337
        require(filePath) {
338
                if (!filePath.startsWith(this.cwd)) {
2!
339
                        filePath = path.join(this.cwd, filePath);
2✔
340
                }
341
                return require(filePath)(this);
2✔
342
        }
343

344
        /**
345
         * Builds a Spawnpoint code object. Codes are used to create a link between a human readable message
346
         * and a computer readable string. Example: `file.not_found` -> "The requested file was not found."
347
         * @param {String} code computer readable string code.
348
         * @param {Object} [data] Object to extend the code Object with
349
         * @return {Object} Code Object with a `message` with the computer readable message and the `code` matching the input code.
350
         */
351
        code(code, data = {}) {
108✔
352
                assert(code && typeof(code) === 'string', '`code` must be an string.');
120✔
353
                assert(typeof(data) === 'object', '`data` must be an object.');
90✔
354
                if (!this.codes[code]) {
90✔
355
                        throw new Error('No return code found with code: ' + code); // TODO: convert this to an errorCode
6✔
356
                }
357
                return _.defaults(data, {
84✔
358
                        code: code,
359
                        message: this.codes[code],
360
                });
361
        }
362

363
        /**
364
         * Spawnpoint code that wraps a Javascript `Error` as a hard application error.
365
         * @param {String} code computer readable string code.
366
         * @param {Object} [data] Object to extend the code Object with
367
         * @return {Object} Error Code Object with a `message` with the computer readable message and the `code` matching the input code.
368
         */
369
        errorCode(code, data) {
370
                const getCode = this.code(code, data);
72✔
371
                this.emit('errorCode', getCode);
60✔
372
                return new this._errorCode(getCode);
60✔
373
        }
374

375
        /**
376
         * Spawnpoint code that wraps a Javascript `Error`, as a soft error.
377
         * @param {String} code computer readable string code.
378
         * @param {Object} [data] Object to extend the code Object with
379
         * @return {Object} Error Code Object with a `message` with the computer readable message and the `code` matching the input code.
380
         */
381
        failCode(code, data) {
382
                const getCode = this.code(code, data);
30✔
383
                this.emit('failCode', getCode);
18✔
384
                return new this._failCode(getCode);
18✔
385
        }
386

387
        /**
388
         * Error Monitoring, when enabled. This allows you to track how often an error occurs and issue a callback once that threadhold is met.
389
         * @param  {String} code Spawnpoint code to match against
390
         * @param  {Number} threshold Number of occurrences required to trigger callback.
391
         * @param  {Object} options Extra limit options
392
         * @param  {Object} [options.time] When set, number of milliseconds that the threshold cools down. On each tick this will reduce bv one until it reaches zero.
393
         * @param  {Callback} callback Triggered when threshold is met.
394
         * @return {this}
395
         */
396
        registerLimit(code, threshold, options, callback) {
397
                if (!callback && options) {
8✔
398
                        callback = options;
2✔
399
                        options = {};
2✔
400
                }
401
                const opts = _.defaults(options, {
8✔
402
                        callback: callback,
403
                        threshold: threshold,
404
                        error: 'errorCode', // or failCode
405
                        index: null, // 'object.to.path' of unique index to track by
406
                        reset: 1, // reset balance counter to this on a subsequent callback. Give it a negative number to disable this.
407
                        time: null,
408
                });
409

410
                opts.uuid = _.uniqueId();
8✔
411

412
                if (!this.limitMaps[opts.error]) {
8!
413
                        this.limitMaps[opts.error] = {};
8✔
414
                }
415
                if (!this.limitMaps[opts.error][code]) {
8!
416
                        this.limitMaps[opts.error][code] = [];
8✔
417
                }
418
                this.limitMaps[opts.error][code].push(opts);
8✔
419
                return this;
8✔
420
        }
421

422
        /**
423
         * Console.log wrapper that only triggers with when `config.debug` is enabled.
424
         * @params {*} [args..] Arguments to be passed to logging.
425
         * @return {this}
426
         */
427
        debug() {
428
                if (this.config.debug) {
1,434✔
429
                        Reflect.apply(console.log, this, arguments);
2✔
430
                }
431
                return this;
1,434✔
432
        }
433

434
        /**
435
         * Console.log wrapper that adds an INFO tag and timestamp to the log.
436
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
437
         * @return {this}
438
         */
439
        info() {
440
                helpers.log({
36✔
441
                        logs: this.logs,
442
                        config: this.config.log,
443
                        type: helpers.tag('INFO', kleur.green),
444
                        line: kleur.white(Reflect.apply(format, this, arguments)),
445
                });
446
                return this;
36✔
447
        }
448

449
        /**
450
         * Console.log wrapper that adds an LOG tag and timestamp to the log.
451
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
452
         * @return {this}
453
         */
454
        log() {
455
                helpers.log({
8,100✔
456
                        logs: this.logs,
457
                        config: this.config.log,
458
                        type: helpers.tag('LOG', kleur.cyan),
459
                        line: kleur.white(Reflect.apply(format, this, arguments)),
460
                });
461
                return this;
8,100✔
462
        }
463

464
        /**
465
         * Console.error` wrapper that adds an WARN tag and timestamp to the log. This prints to STDERR.
466
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
467
         * @return {this}
468
         */
469
        warn() {
470
                helpers.log({
42✔
471
                        logs: this.logs,
472
                        config: this.config.log,
473
                        type: helpers.tag('WARN', kleur.yellow),
474
                        line: kleur.yellow(Reflect.apply(format, this, arguments)),
475
                }, 'error');
476
                return this;
42✔
477
        }
478

479
        /**
480
         * Console.error` wrapper that adds an ERROR tag and timestamp to the log. This prints to STDERR.
481
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
482
         * @return {this}
483
         */
484
        error() {
485
                helpers.log({
28✔
486
                        logs: this.logs,
487
                        config: this.config.log,
488
                        type: helpers.tag('ERROR', kleur.red.bold),
489
                        line: kleur.red(Reflect.apply(format, this, arguments)),
490
                }, 'error');
491
                return this;
28✔
492
        }
493

494
        /**
495
         * Registers multiple custom Errors to a specific errorCode. This helps wrap errors into a singular errorCode system.
496
         * @param {String} code The errorCode human readable Spawnpoint code.
497
         * @param {Error} error Instance of the error to map to..
498
         * @return {this}
499
         */
500
        registerError(code, error) {
501
                this.errorMaps[code] = error;
28✔
502
                return this;
28✔
503
        }
504

505
        /**
506
         * Registers multiple custom Errors to a specific errorCode, using the `registerError` method.
507
         * @param  {Object} errors Errors being registered. Each index/key is the errorCode string that the custom Error represents. The Value must be an uninitialized instance of the error.
508
         * @return {this}
509
         */
510
        registerErrors(errors) {
511
                _.each(errors, (error, code) => {
2✔
512
                        this.registerError(code, error);
2✔
513
                });
514
                return this;
2✔
515
        }
516

517
        /**
518
         * Checks for Spawnpoint wrapped code, errorCode, or failCode when a potential error map is found (and previously registered). This method is useful as middleware to your application
519
         * error handling so that you don't have to have the server reply with a generic error.
520
         * @param  {Error} error Error to check for mapped error.
521
         * @return {errorCode|false} Returns Spawnpoint mapped code, errorCode, or failCode or false when no mapped error was found.
522
         */
523
        maskErrorToCode(error, type = 'code') {
2✔
524
                const validTypes = ['errorCode', 'failCode', 'code'];
8✔
525
                let returnedError = false;
8✔
526
                if (!validTypes.includes(type)) {
8✔
527
                        throw new Error('Invalid `type` provided. Valid types:' + validTypes.join(',')); // TODO: convert to errorCode
2✔
528
                }
529
                _.each(this.errorMaps, (currentError, code) => {
6✔
530
                        if (!returnedError && error instanceof currentError) {
12✔
531
                                returnedError = this[type](code, error);
6✔
532
                        }
533
                });
534
                return returnedError;
6✔
535
        }
536

537
        /**
538
         * Internal: Initializes the Spawnpoint `config` object. Reads package.json and `configFile` file to build config.
539
         * This is step 1 of 8 to startup Spawnpoint
540
         * @param  {String} [configFile]  Sets the JSON file Spawnpoint uses to setup the framework.
541
         * @return {this}
542
         * @private
543
         */
544
        initConfig(configFile = null) {
74✔
545
                const self = this;
76✔
546
                if (configFile) {
76✔
547
                        this.configFile = configFile;
2✔
548
                }
549
                // reset config variable for reloading
550
                this.config = _.defaults(require(path.join(this.cwd, this.configFile)), {
76✔
551
                        debug: false,
552
                        plugins: [],
553
                        autoload: [],
554
                        secrets: '/run/secrets',
555
                        codes: '/config/codes',
556
                        configs: '/config',
557
                        configOverride: null,
558
                        signals: {
559
                                close: ['SIGINT', 'SIGUSR2'],
560
                                debug: ['SIGUSR1'],
561
                        },
562
                        catchExceptions: true,
563
                        stopAttempts: 3,
564
                        stopTimeout: 15000,
565
                        trackErrors: false,
566
                        log: {
567
                                format: '{date} {type}: {line}',
568
                                time: 'HH:mm:ss',
569
                                date: 'dddd, MMMM DD YYYY',
570
                        },
571
                });
572
                if (this.config.debug && !this.config.configOverride) {
76✔
573
                        this.config.configOverride = 'dev-config.json';
2✔
574
                }
575
                if (this.config.resetConfigBlockListDefaults) {
76✔
576
                        this.configBlocklist = {
2✔
577
                                env: { list: [], patterns: [] },
578
                                secrets: { list: [], patterns: [] },
579
                                args: { list: [], patterns: [] },
580
                        };
581
                }
582
                if (this.config.configBlocklist) {
76✔
583
                        _.merge(this.configBlocklist, this.config.configBlocklist);
2✔
584
                }
585
                _.each(this.configBlocklist, (items) => {
76✔
586
                        items.patterns = _.map(items.patterns, pattern => new RegExp(pattern));
228✔
587
                });
588
                let packageData = {};
76✔
589
                try {
76✔
590
                        packageData = require(path.join(this.cwd, '/package.json'));
76✔
591
                } catch {
592
                        // do nothing
593
                }
594
                // allow package.json version & name to set app.config vars
595
                if (packageData.version) {
76!
596
                        this.config.version = this.config.version || packageData.version;
×
597
                }
598
                if (packageData.name) {
76!
599
                        this.config.name = this.config.name || packageData.name || 'unnamed project';
×
600
                }
601

602
                // setup all of the required functions mounted on the `config` object
603

604
                /**
605
                 * Helper method to safely get a nested config item.
606
                 * @param  {String} path The path of the property to get.
607
                 * @param  {*} [defaultValue=false] The value returned for undefined resolved values.
608
                 * @return {*} Returns the resolved value.
609
                 */
610
                this.config.get = function(path, defaultValue) {
76✔
611
                        return _.get(self.config, path, defaultValue);
10✔
612
                };
613

614
                /**
615
                 * Helper method to safely check if a nested config item exists
616
                 * @param  {String} path The path to check.
617
                 * @memberOf config
618
                 * @namespace config.has
619
                 * @return {*} Returns `true` if path exists, else `false`.
620
                 */
621
                this.config.has = function(path) {
76✔
622
                        return _.has(self.config, path);
2✔
623
                };
624

625
                /**
626
                 * Helper method to get a random element from a Spawnpoint `config` item `collection`.
627
                 * @param  {path} path The path to return items from.
628
                 * @memberOf config
629
                 * @namespace config.getRandom
630
                 * @return {*} Returns random element from the collection.
631
                 */
632
                this.config.getRandom = function(path) {
76✔
633
                        const items = self.config.get(path);
4✔
634
                        if (!items) {
4✔
635
                                throw self.errorCode('spawnpoint.config.sample_not_collection'); // TODO: choose better name
2✔
636
                        }
637
                        return _.sample(items);
2✔
638
                };
639

640
                const rrKeys = {};
76✔
641
                /**
642
                 * Helper method to get get random element from Spawnpoint `config` item `Array` with Round Robin ordering
643
                 * This ensures no single item is returned more than it's siblings.
644
                 * @param  {path} path The path to return items from.
645
                 * @memberOf config
646
                 * @namespace config.getRoundRobin
647
                 * @return {*} Returns random element from the collection.
648
                 */
649
                this.config.getRoundRobin = function(path) {
76✔
650
                        if (!rrKeys[path]) {
10✔
651
                                const items = self.config.get(path);
2✔
652
                                rrKeys[path] = self.roundRobin(items);
2✔
653
                        }
654
                        return rrKeys[path].next();
10✔
655
                };
656

657
                const lockedKeys = {};
76✔
658
                /**
659
                 * Helper method to get get random element from Spawnpoint `config` item `Array` with async locking queue.
660
                 * This ensures no item is used at the same time as another async operation.
661
                 * @param  {path} path The path to return items from.
662
                 * @memberOf config
663
                 * @namespace config.getAndLock
664
                 * @return {*} Returns random element from the collection.
665
                 */
666
                this.config.getAndLock = function(path, timeout, callback) {
76✔
667
                        if (!lockedKeys[path]) {
150✔
668
                                const items = self.config.get(path);
2✔
669
                                lockedKeys[path] = self.getAndLock(items);
2✔
670
                        }
671
                        return lockedKeys[path].next(timeout, callback);
150✔
672
                };
673

674
                this.emit('app.setup.initConfig');
76✔
675
                return this;
76✔
676
        }
677

678
        /**
679
         * Internal: Registers Spawnpoint `config` by merging or setting values to the `config` object.
680
         * @param  {String} name `config` top level key
681
         * @param  {*} config value or object of the config
682
         * @param  {String} [allowListCheck] Defines which allowlist to check against before merging. This is designed to prevent ENV or other config options that should be ignored.
683
         * @return {Object} returns `this.config` new value
684
         * @private
685
         */
686
        registerConfig(name, config, allowListCheck = '') {
660✔
687
                let data = {};
10,068✔
688

689
                if (allowListCheck && this.configBlocklist[allowListCheck]) {
10,068✔
690
                        if (this.configBlocklist[allowListCheck].list.includes(name)) { return this.debug('ignoring blocklist', name); }
9,408✔
691
                        let found = false;
9,032✔
692
                        _.each(this.configBlocklist[allowListCheck].patterns, (pattern) => {
9,032✔
693
                                if (!found && pattern.test(name)) {
26,904✔
694
                                        found = true;
1,024✔
695
                                }
696
                        });
697
                        if (found) { return this.debug('ignoring blocklist pattern', name); }
9,032✔
698
                        this.log('Setting %s ENV variable [%s]', allowListCheck, name);
8,008✔
699
                }
700
                if (name && !config) {
8,668✔
701
                        data = name;
384✔
702
                } else {
703
                        data[name] = config;
8,284✔
704
                }
705
                if (allowListCheck === 'env' || allowListCheck === 'secrets' || allowListCheck === 'config-hoist') {
8,668✔
706
                        return _.set(this.config, name, config);
7,944✔
707
                }
708
                return _.merge(this.config, _.cloneDeep(data));
724✔
709
        }
710

711
        /**
712
         * Internal: Builds app `config` object by looping through plugins, configs, ENV, Progress args, Docker secrets. , and finally
713
         * config overrides (in that order). These items are hoisted to the Spawnpoint `config` object.
714
         * This is step 5 of 8 to startup Spawnpoint
715
         * @param  {String} [cwd] Path to load config files from.
716
         * @param  {Boolean} ignoreExtra When true will skip plugins, ENV and Docker secrets. Allows for recursive usage.
717
         * @return {this}
718
         * @private
719
         */
720
        loadConfig(cwd = '', ignoreExtra = false) {
128✔
721
                cwd = cwd || this.cwd;
76✔
722

723
                if (!ignoreExtra) {
76✔
724
                        // load plugin defaults
725
                        _.each(this.plugins, (plugin) => {
64✔
726
                                if (plugin.config) {
12!
727
                                        // ensure sideloaded plugins retain original config
728
                                        if (plugin.original_namespace) {
×
729
                                                plugin.config[plugin.namespace] = plugin.config[plugin.original_namespace];
×
730
                                                delete plugin.config[plugin.original_namespace];
×
731
                                        }
732
                                        this.registerConfig(plugin.config);
×
733
                                }
734
                                this.loadConfig(plugin.dir, true);
12✔
735
                        });
736
                }
737

738
                // load local json files
739
                _.each(this.recursiveList(cwd + this.config.configs, '.json'), (file) => {
76✔
740
                        // prevent loading base config and codes
741
                        if (!file.includes(this.configFile) && !file.includes(this.config.codes)) {
850✔
742
                                if (!this.config[path.parse(file).name]) {
660✔
743
                                        this.config[path.parse(file).name] = {};
166✔
744
                                }
745
                                this.registerConfig(path.parse(file).name, require(file));
660✔
746
                        }
747
                });
748

749
                if (!ignoreExtra) {
76✔
750
                        // handle process flags
751
                        this.args = minimist(process.argv.slice(2));
64✔
752
                        _.each(this.args, (value, key) => this.registerConfig(key, value, 'args'));
64✔
753
                        this.argv = _.clone(this.args._) || [];
64!
754

755
                        // handle environment variables
756
                        _.each(process.env, (value, key) => {
64✔
757
                                key = key.replace(/__/g, '.'); // replace double underscores to dots, to allow object notation in environment vars
9,344✔
758
                                try {
9,344✔
759
                                        value = JSON.parse(value);
9,344✔
760
                                } catch {
761
                                        // do nothing
762
                                }
763
                                return this.registerConfig(key, value, 'env');
9,344✔
764
                        });
765

766
                        if (this.config.secrets) {
64!
767
                                // handle docker secrets
768
                                _.each(this.recursiveList(this.config.secrets, false), (file) => {
64✔
769
                                        let key; let
770
                                                value;
771
                                        try {
×
772
                                                key = path.basename(file);
×
773
                                                value = fs.readFileSync(file, 'utf8');
×
774
                                                value = JSON.parse(value); // if it fails it will revert to above value
×
775
                                        } catch {
776
                                                // do nothing
777
                                        }
778
                                        if (!value || !key) { return; }
×
779
                                        return this.registerConfig(key, value, 'secrets');
×
780
                                });
781
                        }
782
                } else {
783
                        this.debug('Ignoring config extra loading');
12✔
784
                }
785
                this.emit('app.setup.loadConfig');
76✔
786

787
                if (this.config.configOverride) {
76!
788
                        // allow dev-config.json in root directory to override config vars
789
                        let access = null;
×
790
                        try {
×
791
                                access = require(path.join(this.cwd, this.config.configOverride));
×
792
                        } catch {
793
                                // do nothing
794
                        }
795
                        if (access) {
×
796
                                this.debug('Overriding config with custom overrides');
×
797
                                _.each(access, (value, key) => this.registerConfig(key, value, 'config-hoist'));
×
798
                                // Emit an event to allow plugins to know that the config has been overridden
799
                                this.emit('app.setup.configOverridden');
×
800
                        }
801
                }
802
                return this;
76✔
803
        }
804

805
        /**
806
         * Internal: Loads the internal Spawnpoint codes.
807
         * This is step 2 of 8 to startup Spawnpoint
808
         * @return {this}
809
         * @private
810
         */
811
        initCodes() {
812
                this.codes = {};
64✔
813
                _.each(this.recursiveList(path.join(__dirname, '../codes'), '.json'), (file) => {
64✔
814
                        _.merge(this.codes, require(file));
320✔
815
                });
816
                this.emit('app.setup.initCodes');
64✔
817
                return this;
64✔
818
        }
819

820
        /**
821
         * Internal: Loads the application codes from a folder
822
         * This is step 6 of 8 to startup Spawnpoint
823
         * @param {String} [cwd] Folder to load paths from.
824
         * @param  {Boolean} ignoreExtra When true will skip plugins. Allows for recursive usage.
825
         * @return {this}
826
         * @private
827
         */
828
        loadCodes(cwd = '', ignoreExtra = false) {
124✔
829
                cwd = cwd || path.join(this.cwd, this.config.codes);
70✔
830

831
                if (!ignoreExtra) {
70✔
832
                        // load plugin defaults
833
                        _.each(this.plugins, (plugin) => {
62✔
834
                                if (plugin.codes) {
8!
835
                                        this.registerCodes(plugin.codes);
×
836
                                }
837
                                this.loadCodes(plugin.dir + '/codes', true);
8✔
838
                        });
839
                }
840

841
                // handle local files
842
                let list = null;
70✔
843
                try {
70✔
844
                        list = this.recursiveList(cwd, ['.json']);
70✔
845
                } catch {
846
                        this.debug('No codes folder found (%s), skipping', this.config.codes);
×
847
                }
848
                if (list) {
70!
849
                        _.each(list, (file) => {
70✔
850
                                this.registerCodes(require(file));
124✔
851
                        });
852
                }
853
                this.emit('app.setup.loadCodes');
70✔
854
                return this;
70✔
855
        }
856

857
        /**
858
         * Internal: Hoists new codes into Spawnpoint `codes` object.
859
         * @param  {Object} codes Codes to inject at key as the code computer readable and value at the human readable message.
860
         * @return {this}
861
         * @private
862
         */
863
        registerCodes(codes) {
864
                _.merge(this.codes, _.cloneDeep(codes));
128✔
865
                return this;
128✔
866
        }
867

868
        /**
869
         * Internal: Starts the Spawnpoint application lifecycle registery. This ensures the application starts up correctly and shuts down gracefully.
870
         * This is step 3 of 8 to startup Spawnpoint
871
         * @return {this}
872
         * @private
873
         */
874
        initRegistry() {
875
                this.register = [];
120✔
876

877
                this.on('app.ready', () => {
120✔
878
                        this.status.running = true;
68✔
879
                        // only handle uncaught exceptions when ready
880
                        if (!this.config.catchExceptions) { return; }
68✔
881
                        process.on('uncaughtException', (err) => {
4✔
882
                                this.error(err.message || err).debug(err.stack || '(no stack trace)');
2!
883
                                // close the app if we have not completed startup
884
                                if (!this.status.running) {
2!
885
                                        this.emit('app.stop', true);
2✔
886
                                }
887
                        });
888
                });
889

890
                // app registry is used to track graceful halting
891
                this.on('app.register', (item) => {
120✔
892
                        if (!this.register.includes(item)) {
22✔
893
                                this.log('Plugin registered: %s', item);
20✔
894
                                this.register.push(item);
20✔
895
                        }
896
                });
897
                this.on('app.deregister', (item) => {
120✔
898
                        const i = this.register.indexOf(item);
14✔
899
                        if (i !== -1) {
14✔
900
                                this.register.splice(i, 1);
12✔
901
                                this.warn('De-registered: %s', item);
12✔
902
                        }
903
                        if (!this.status.running && this.register.length === 0) {
14✔
904
                                this.emit('app.exit', true);
2✔
905
                        }
906
                });
907
                this.on('app.stop', () => {
120✔
908
                        if (this.status.stopping) {
44✔
909
                                this.status.stopAttempts++;
26✔
910
                                if (this.status.stopAttempts === 1) {
26✔
911
                                        this.warn('%s will be closed in %sms if it does not shut down gracefully.', this.config.name, this.config.stopTimeout);
12✔
912
                                        setTimeout(() => {
12✔
913
                                                this.error('%s took too long to close. Killing process.', this.config.name);
12✔
914
                                                this.emit('app.exit');
12✔
915
                                        }, this.config.stopTimeout);
916
                                }
917
                                if (this.status.stopAttempts < this.config.stopAttempts) {
26✔
918
                                        return this.warn('%s already stopping. Attempt %s more times to kill process', this.config.name, this.config.stopAttempts - this.status.stopAttempts);
16✔
919
                                }
920
                                this.error('Forcefully killing %s', this.config.name);
10✔
921
                                return this.emit('app.exit');
10✔
922
                        }
923

924
                        this.status.running = false;
18✔
925
                        this.status.stopping = true;
18✔
926
                        this.info('Stopping %s gracefully', this.config.name);
18✔
927
                        this.emit('app.close');
18✔
928
                        if (this.register.length === 0) {
18✔
929
                                return this.emit('app.exit', true);
2✔
930
                        }
931
                });
932
                this.on('app.exit', (graceful) => {
120✔
933
                        /* eslint-disable no-process-exit */
934
                        if (!graceful) {
4✔
935
                                return process.exit(1);
2✔
936
                        }
937
                        this.info('%s gracefully closed.', this.config.name);
2✔
938
                        process.exit();
2✔
939
                });
940

941
                if (this.config.signals) {
120✔
942
                        // gracefully handle ctrl+c
943
                        _.each(this.config.signals.close, (event) => {
6✔
944
                                process.on(event, () => {
2✔
945
                                        this.emit('app.stop');
4✔
946
                                });
947
                        });
948

949
                        // set debug mode on SIGUSR1
950
                        _.each(this.config.signals.debug, (event) => {
6✔
951
                                process.on(event, () => {
2✔
952
                                        this.config.debug = !this.config.debug;
2✔
953
                                });
954
                        });
955
                }
956

957
                this.emit('app.setup.initRegistry');
120✔
958
                return this;
120✔
959
        }
960

961
        /**
962
         * Internal: Starts the Spawnpoint errorCode & failCode tracking. Disabled by default unless `config.trackErrors` is enabled due to a larger
963
         * memory footprint required.
964
         * This is step 7 of 8 to startup Spawnpoint
965
         * @return {this}
966
         * @private
967
         */
968
        initLimitListeners() {
969
                const self = this;
62✔
970
                if (!this.config.trackErrors) { return this; }
62✔
971
                const issues = {
10✔
972
                        errorCode: {},
973
                        failCode: {},
974
                };
975
                _.each(['errorCode', 'failCode'], function(type) {
10✔
976
                        function limitToErrors(error) {
977
                                if (!self.limitMaps[type] || !self.limitMaps[type][error.code]) {
22✔
978
                                        return; // no issue being tracked
2✔
979
                                }
980

981
                                const limits = self.limitMaps[type][error.code];
20✔
982

983

984
                                const defaultIssues = {
20✔
985
                                        occurrences: 0, // long count, track
986
                                        balance: 0, // time-based balance
987
                                        dateFirst: Math.floor(Date.now() / 1000),
988
                                        dateLast: null,
989
                                        datesTriggered: [],
990
                                        triggered: false, // track if we've triggered the current balance
991
                                };
992

993
                                if (!issues[type][error.code]) {
20✔
994
                                        issues[type][error.code] = {};
6✔
995
                                        issues[type][error.code].Global = _.pick(defaultIssues, ['occurrences', 'dateFirst', 'dateLast']);
6✔
996
                                }
997

998
                                issues[type][error.code].Global.occurrences++;
20✔
999
                                issues[type][error.code].Global.dateLast = Math.floor(Date.now() / 1000);
20✔
1000

1001
                                for (const limit of limits) {
20✔
1002
                                        // new issue
1003
                                        if (!issues[type][error.code][limit.uuid]) {
20✔
1004
                                                issues[type][error.code][limit.uuid] = _.pick(defaultIssues, ['balance', 'triggered', 'datesTriggered']);
6✔
1005
                                        }
1006
                                        issues[type][error.code][limit.uuid].balance++;
20✔
1007
                                        if (limit.time) {
20✔
1008
                                                setTimeout(function() {
10✔
1009
                                                        issues[type][error.code][limit.uuid].balance--;
10✔
1010
                                                        if (issues[type][error.code][limit.uuid].balance <= 0) {
10✔
1011
                                                                issues[type][error.code][limit.uuid].balance = 0;
6✔
1012
                                                                issues[type][error.code][limit.uuid].triggered = false;
6✔
1013
                                                        }
1014
                                                }, limit.time);
1015
                                        }
1016
                                        if (!issues[type][error.code][limit.uuid].triggered && issues[type][error.code][limit.uuid].balance >= limit.threshold) {
20✔
1017
                                                issues[type][error.code][limit.uuid].triggered = true;
10✔
1018
                                                limit.callback(_.merge(_.clone(issues[type][error.code][limit.uuid]), _.clone(issues[type][error.code].Global)));
10✔
1019
                                                issues[type][error.code][limit.uuid].datesTriggered.push(issues[type][error.code].Global.dateLast); // add after callback, to avoid double dates
10✔
1020
                                        } else if (issues[type][error.code][limit.uuid].triggered && limit.reset >= 0) {
10✔
1021
                                                issues[type][error.code][limit.uuid].triggered = false;
2✔
1022
                                                issues[type][error.code][limit.uuid].balance = limit.reset;
2✔
1023
                                        }
1024
                                }
1025
                        }
1026
                        self.on(type, limitToErrors);
20✔
1027
                });
1028
                self.emit('app.setup.initLimitListeners');
10✔
1029
                return this;
10✔
1030
        }
1031

1032
        /**
1033
         * Internal: Loads all plugins defined on `config.plugins` array. These plugins must be installed via NPM.
1034
         * This is step 4 of 8 to startup Spawnpoint
1035
         * @return {this}
1036
         * @private
1037
         */
1038
        loadPlugins() {
1039
                this.config.plugins = _.map(this.config.plugins, (plugin) => {
66✔
1040
                        if (typeof(plugin) === 'string') {
16✔
1041
                                plugin = {
2✔
1042
                                        plugin: plugin,
1043
                                        name: null,
1044
                                        namespace: null,
1045
                                };
1046
                        }
1047
                        const pluginFile = require(plugin.plugin);
16✔
1048
                        // remove node modules cache to allow reuse of plugins under new namespaces
1049
                        delete require.cache[require.resolve(plugin.plugin)];
16✔
1050

1051
                        if (plugin.namespace) {
16✔
1052
                                pluginFile.original_namespace = pluginFile.namespace;
12✔
1053
                                plugin.original_namespace = pluginFile.namespace;
12✔
1054
                                pluginFile.namespace = plugin.namespace;
12✔
1055
                                pluginFile.name = plugin.name;
12✔
1056
                                this.info('Sideloading [%s] as plugin: [%s]', plugin.namespace, plugin.name);
12✔
1057
                        } else {
1058
                                plugin.namespace = pluginFile.namespace;
4✔
1059
                                plugin.name = pluginFile.name;
4✔
1060
                                this.info('Loading plugin: [%s]', plugin.name);
4✔
1061
                        }
1062
                        this.plugins[plugin.namespace] = pluginFile;
16✔
1063
                        return plugin;
16✔
1064
                });
1065
                this.emit('app.setup.loadPlugins');
66✔
1066
                return this;
66✔
1067
        }
1068

1069
        /**
1070
         * Internal: Sets up the error mapping to automatically match custom Error types to Spawnpoint codes.
1071
         * This is step 8 of 8 to startup Spawnpoint
1072
         * @return {this}
1073
         * @private
1074
         */
1075
        loadErrorMap() {
1076
                _.each(this.plugins, (plugin) => {
66✔
1077
                        if (plugin.errors) {
12✔
1078
                                this.registerErrors(plugin.errors);
2✔
1079
                        }
1080
                });
1081
                this.emit('app.setup.loadErrorMap');
66✔
1082
                return this;
66✔
1083
        }
1084

1085
        /**
1086
         * Internal: Called to register a Spawnpoint plugin. See Plugins docs for more details on how plugins work.
1087
         * @param  {Object} opts Plugin options `object`
1088
         * @param  {String} opts.name Plugin Name
1089
         * @param  {String} opts.namespace Application namespace used by the plugin
1090
         * @param  {String} opts.dir Folder where the plugin and it's config/codes can be found. (usually `__dir`)
1091
         * @param  {Object} opts.codes Custom codes to register to Spawnpoint
1092
         * @param  {Object} opts.config Custom config to register to Spawnpoint
1093
         * @param  {Function} opts.exports Plugin function to execute with (app, [callback]) context. Callback is only defined when `opts.callback` is true
1094
         * @param  {Boolean} [opts.callback] When true, will set the opts.exports to have a required callback function to be called once this plugin is in a `ready` state.
1095
         * @return {Object} Returns Spawnpoint plugin reference. Used internally to load plugins.
1096
         * @private
1097
         */
1098
        static registerPlugin(opts) {
1099
                assert(opts.name, 'Plugin is missing required `name` option.');
18✔
1100
                assert(opts.namespace, 'Plugin is missing required `namespace` option.');
18✔
1101
                assert(opts.exports, 'Plugin is missing required `exports` function.');
18✔
1102
                return _.merge(opts, {
18✔
1103
                        codes: this.codes || null,
36✔
1104
                        config: this.config || null,
36✔
1105
                });
1106
        }
1107

1108
        /**
1109
         * Internal: Forces the JSON (require) handler to allow comments in JSON files. This allow documentation in JSON config files.
1110
         * @return {this}
1111
         * @private
1112
         */
1113
        setupJSONHandler() {
1114
                require(path.join(__dirname, '/json-handler.js'));
64✔
1115
                return this;
64✔
1116
        }
1117
}
1118

1119
module.exports = spawnpoint;
18✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc