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

nodecraft / spawnpoint / 6615486559

23 Oct 2023 03:29PM UTC coverage: 92.005% (+0.2%) from 91.766%
6615486559

push

github

web-flow
Merge pull request #84 from nodecraft/chore/updates

258 of 297 branches covered (0.0%)

Branch coverage included in aggregate %.

139 of 139 new or added lines in 5 files covered. (100.0%)

513 of 541 relevant lines covered (94.82%)

1991.98 hits per line

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

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

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

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

18
// Define private helper functions
19
const helpers = require('./helpers.js');
54✔
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') {
360✔
35
                // init EventEmitter
36
                super();
564✔
37

38
                if(typeof(configFile) !== 'string') {
564✔
39
                        throw new TypeError('`configFile` must be a path string to a Spawnpoint config file.');
24✔
40
                }
41
                if(!configFile.endsWith('.json') && !configFile.endsWith('.js')) {
540✔
42
                        configFile = configFile + '.json';
84✔
43
                }
44
                this.configFile = configFile;
540✔
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 = {
540✔
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();
540✔
58

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

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

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

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

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

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

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

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

83

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

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

96
                return this;
540✔
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 = () => {}) {
72✔
106
                // force .json parsing with comments :)
107
                this.setupJSONHandler();
192✔
108

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

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

126
                _.each(this.plugins, (plugin) => {
186✔
127
                        if(plugin.callback) {
24✔
128
                                return jobs.push(cb => plugin.exports(this, cb));
12✔
129
                        }
130
                        jobs.push((cb) => {
12✔
131
                                plugin.exports(this);
12✔
132
                                return cb();
12✔
133
                        });
134
                });
135
                // load framework files
136
                _.each(this.config.autoload, (jobDetails) => {
186✔
137
                        this.log('Autoloading %s', jobDetails.name || jobDetails.folder);
30✔
138
                        const list = this.recursiveList(format('%s/%s', this.cwd, jobDetails.folder), jobDetails.extension || '.js');
30✔
139
                        if(jobDetails.callback) {
30✔
140
                                return jobs.push((callback) => {
12✔
141
                                        async.eachSeries(list, (file, acb) => {
12✔
142
                                                const modelCallback = (err) => {
12✔
143
                                                        if(err) {
6!
144
                                                                this.error('Failed to load', file);
×
145
                                                                return acb(err);
×
146
                                                        }
147
                                                        this.debug('Successfully loaded', file);
6✔
148
                                                        return acb();
6✔
149
                                                };
150
                                                this.debug('Loading', file);
12✔
151
                                                let error;
152
                                                try{
12✔
153
                                                        require(file)(this, modelCallback);
12✔
154
                                                }catch(err) {
155
                                                        error = err;
6✔
156
                                                }
157
                                                if(error) {
12✔
158
                                                        return acb(error);
6✔
159
                                                }
160
                                        }, callback);
161
                                });
162
                        }
163
                        jobs.push((callback) => {
18✔
164
                                _.each(list, (file) => {
18✔
165
                                        this.debug('Loading', file);
18✔
166
                                        let error;
167
                                        try{
18✔
168
                                                require(file)(this);
18✔
169
                                                this.debug('Successfully loaded', file);
12✔
170
                                        }catch(err) {
171
                                                error = err;
6✔
172
                                        }
173
                                        if(error) {
18✔
174
                                                return console.error(`Failed to load [${file}]`, error);
6✔
175
                                        }
176
                                });
177
                                return callback();
18✔
178
                        });
179
                });
180
                process.nextTick(() => {
186✔
181
                        async.series(jobs, (err) => {
186✔
182
                                if(err) {
186✔
183
                                        this.error('Failed to start up').debug(err);
6✔
184
                                        this.emit('app.exit');
6✔
185
                                        return callback(err);
6✔
186
                                }
187
                                this.log('%s is ready.', this.config.name);
180✔
188
                                this.emit('app.ready');
180✔
189
                                return callback();
180✔
190
                        });
191
                });
192
                this.emit('app.setup.done');
186✔
193
                return this;
186✔
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']) {
42✔
204
                assert(typeof(dir) === 'string', '`dir` must be a string');
1,128✔
205
                if(typeof(exts) === 'string') {
1,098✔
206
                        exts = [exts];
474✔
207
                }
208
                const parent = this;
1,098✔
209
                let stat; let
210
                        list = [];
1,098✔
211
                try{
1,098✔
212
                        stat = fs.statSync(dir);
1,098✔
213
                }catch{
214
                        stat = false;
216✔
215
                }
216
                if(!stat || !stat.isDirectory()) {
1,098✔
217
                        return list;
216✔
218
                }
219
                dir = String(dir + '/').replace(/\//g, '/'); // ensure proper trailing slash
882✔
220
                _.each(fs.readdirSync(dir), (file) => {
882✔
221
                        const isDir = fs.statSync(dir + file).isDirectory();
4,416✔
222
                        if(isDir && exts && exts.includes('/')) {
4,416✔
223
                                list.push(dir + file);
48✔
224
                        }else if(isDir) {
4,368✔
225
                                const recursive = parent.recursiveList(dir + file, exts);
210✔
226
                                if(Array.isArray(recursive) && recursive.length > 0) {
210✔
227
                                        list = [...list, ...recursive]; // add results
198✔
228
                                }
229
                        }else if(!exts || exts.includes(path.extname(file))) {
4,158✔
230
                                list.push(dir + file);
3,960✔
231
                        }
232
                });
233
                list.sort(); // windows won't sort this like unix will
882✔
234
                return list;
882✔
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) {
60,000✔
244
                length = Number.parseInt(length);
60,048✔
245
                assert(!Number.isNaN(length), '`length` must be a number');
60,048✔
246
                if(Number.isNaN(length) || length < 1) {
60,012✔
247
                        length = 16; // TODO: throw an error in an update
12✔
248
                }
249
                return nanoid(length);
60,012✔
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);
36✔
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);
72✔
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);
72✔
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) {
6!
296
                        return false;
6✔
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') {
12✔
310
                        return true; // TODO: throw error
4✔
311
                }
312

313
                if(uid && !gid) {
8!
314
                        gid = uid;
×
315
                }
316
                const checks = {
8✔
317
                        uid: process.getuid(),
318
                        gid: process.getgid(),
319
                        groups: String(child_process.execSync('groups')),
320
                };
321
                if(checks.uid === 0 || checks.gid === 0) {
8!
322
                        return this.errorCode('usercheck.is_root', {checks: checks});
×
323
                }
324
                if(checks.groups.includes('root')) {
8!
325
                        return this.errorCode('usercheck.is_root_group', {checks: checks});
×
326
                }
327
                if(uid && gid && (uid !== checks.uid || gid !== checks.gid)) {
8!
328
                        return this.errorCode('usercheck.incorrect_user', {checks: checks});
×
329
                }
330
                return true;
8✔
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)) {
6!
339
                        filePath = path.join(this.cwd, filePath);
6✔
340
                }
341
                return require(filePath)(this);
6✔
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 = {}) {
324✔
352
                assert(code && typeof(code) === 'string', '`code` must be an string.');
360✔
353
                assert(typeof(data) === 'object', '`data` must be an object.');
270✔
354
                if(!this.codes[code]) {
270✔
355
                        throw new Error('No return code found with code: ' + code); // TODO: convert this to an errorCode
18✔
356
                }
357
                return _.defaults(data, {
252✔
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);
216✔
371
                this.emit('errorCode', getCode);
180✔
372
                return new this._errorCode(getCode);
180✔
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);
90✔
383
                this.emit('failCode', getCode);
54✔
384
                return new this._failCode(getCode);
54✔
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) {
24✔
398
                        callback = options;
6✔
399
                        options = {};
6✔
400
                }
401
                const opts = _.defaults(options, {
24✔
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();
24✔
411

412
                if(!this.limitMaps[opts.error]) {
24!
413
                        this.limitMaps[opts.error] = {};
24✔
414
                }
415
                if(!this.limitMaps[opts.error][code]) {
24!
416
                        this.limitMaps[opts.error][code] = [];
24✔
417
                }
418
                this.limitMaps[opts.error][code].push(opts);
24✔
419
                return this;
24✔
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) {
6,518✔
429
                        Reflect.apply(console.log, this, arguments);
6✔
430
                }
431
                return this;
6,518✔
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({
108✔
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;
108✔
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({
24,740✔
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;
24,740✔
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({
126✔
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;
126✔
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({
84✔
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;
84✔
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;
84✔
502
                return this;
84✔
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) => {
6✔
512
                        this.registerError(code, error);
6✔
513
                });
514
                return this;
6✔
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') {
6✔
524
                const validTypes = ['errorCode', 'failCode', 'code'];
24✔
525
                let returnedError = false;
24✔
526
                if(!validTypes.includes(type)) {
24✔
527
                        throw new Error('Invalid `type` provided. Valid types:' + validTypes.join(',')); // TODO: convert to errorCode
6✔
528
                }
529
                _.each(this.errorMaps, (currentError, code) => {
18✔
530
                        if(!returnedError && error instanceof currentError) {
36✔
531
                                returnedError = this[type](code, error);
18✔
532
                        }
533
                });
534
                return returnedError;
18✔
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) {
222✔
545
                const self = this;
228✔
546
                if(configFile) {
228✔
547
                        this.configFile = configFile;
6✔
548
                }
549
                // reset config variable for reloading
550
                this.config = _.defaults(require(path.join(this.cwd, this.configFile)), {
228✔
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) {
228✔
573
                        this.config.configOverride = 'dev-config.json';
6✔
574
                }
575
                if(this.config.resetConfigBlockListDefaults) {
228✔
576
                        this.configBlocklist = {
6✔
577
                                env: {list: [], patterns: []},
578
                                secrets: {list: [], patterns: []},
579
                                args: {list: [], patterns: []},
580
                        };
581
                }
582
                if(this.config.configBlocklist) {
228✔
583
                        _.merge(this.configBlocklist, this.config.configBlocklist);
6✔
584
                }
585
                _.each(this.configBlocklist, (items) => {
228✔
586
                        items.patterns = _.map(items.patterns, pattern => new RegExp(pattern));
684✔
587
                });
588
                let packageData = {};
228✔
589
                try{
228✔
590
                        packageData = require(path.join(this.cwd, '/package.json'));
228✔
591
                }catch{
592
                        // do nothing
593
                }
594
                // allow package.json version & name to set app.config vars
595
                if(packageData.version) {
228!
596
                        this.config.version = this.config.version || packageData.version;
×
597
                }
598
                if(packageData.name) {
228!
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) {
228✔
611
                        return _.get(self.config, path, defaultValue);
30✔
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) {
228✔
622
                        return _.has(self.config, path);
6✔
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) {
228✔
633
                        const items = self.config.get(path);
12✔
634
                        if(!items) {
12✔
635
                                throw self.errorCode('spawnpoint.config.sample_not_collection'); // TODO: choose better name
6✔
636
                        }
637
                        return _.sample(items);
6✔
638
                };
639

640
                const rrKeys = {};
228✔
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) {
228✔
650
                        if(!rrKeys[path]) {
30✔
651
                                const items = self.config.get(path);
6✔
652
                                rrKeys[path] = self.roundRobin(items);
6✔
653
                        }
654
                        return rrKeys[path].next();
30✔
655
                };
656

657
                const lockedKeys = {};
228✔
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) {
228✔
667
                        if(!lockedKeys[path]) {
450✔
668
                                const items = self.config.get(path);
6✔
669
                                lockedKeys[path] = self.getAndLock(items);
6✔
670
                        }
671
                        return lockedKeys[path].next(timeout, callback);
450✔
672
                };
673

674
                this.emit('app.setup.initConfig');
228✔
675
                return this;
228✔
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 = '') {
1,980✔
687
                let data = {};
32,860✔
688

689
                if(allowListCheck && this.configBlocklist[allowListCheck]) {
32,860✔
690
                        if(this.configBlocklist[allowListCheck].list.includes(name)) { return this.debug('ignoring blocklist', name); }
30,880✔
691
                        let found = false;
27,632✔
692
                        _.each(this.configBlocklist[allowListCheck].patterns, (pattern) => {
27,632✔
693
                                if(!found && pattern.test(name)) {
82,320✔
694
                                        found = true;
3,168✔
695
                                }
696
                        });
697
                        if(found) { return this.debug('ignoring blocklist pattern', name); }
27,632✔
698
                        this.log('Setting %s ENV variable [%s]', allowListCheck, name);
24,464✔
699
                }
700
                if(name && !config) {
26,444✔
701
                        data = name;
1,280✔
702
                }else{
703
                        data[name] = config;
25,164✔
704
                }
705
                if(allowListCheck === 'env' || allowListCheck === 'secrets' || allowListCheck === 'config-hoist') {
26,444✔
706
                        return _.set(this.config, name, config);
24,272✔
707
                }
708
                return _.merge(this.config, _.cloneDeep(data));
2,172✔
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) {
384✔
721
                cwd = cwd || this.cwd;
228✔
722

723
                if(!ignoreExtra) {
228✔
724
                        // load plugin defaults
725
                        _.each(this.plugins, (plugin) => {
192✔
726
                                if(plugin.config) {
36!
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);
36✔
735
                        });
736
                }
737

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

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

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

766
                        if(this.config.secrets) {
192!
767
                                // handle docker secrets
768
                                _.each(this.recursiveList(this.config.secrets, false), (file) => {
192✔
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');
36✔
784
                }
785
                this.emit('app.setup.loadConfig');
228✔
786

787
                if(this.config.configOverride) {
228!
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
                        }
799
                }
800
                return this;
228✔
801
        }
802

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

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

829
                if(!ignoreExtra) {
210✔
830
                        // load plugin defaults
831
                        _.each(this.plugins, (plugin) => {
186✔
832
                                if(plugin.codes) {
24!
833
                                        this.registerCodes(plugin.codes);
×
834
                                }
835
                                this.loadCodes(plugin.dir + '/codes', true);
24✔
836
                        });
837
                }
838

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

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

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

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

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

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

939
                if(this.config.signals) {
360✔
940
                        // gracefully handle ctrl+c
941
                        _.each(this.config.signals.close, (event) => {
18✔
942
                                process.on(event, () => {
6✔
943
                                        this.emit('app.stop');
12✔
944
                                });
945
                        });
946

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

955
                this.emit('app.setup.initRegistry');
360✔
956
                return this;
360✔
957
        }
958

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

979
                                const limits = self.limitMaps[type][error.code];
60✔
980

981

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

991
                                if(!issues[type][error.code]) {
60✔
992
                                        issues[type][error.code] = {};
18✔
993
                                        issues[type][error.code].Global = _.pick(defaultIssues, ['occurrences', 'dateFirst', 'dateLast']);
18✔
994
                                }
995

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

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

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

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

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

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

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

1117
module.exports = spawnpoint;
54✔
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