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

nodecraft / spawnpoint / 18501847947

14 Oct 2025 03:32PM UTC coverage: 90.703% (-0.2%) from 90.93%
18501847947

push

github

web-flow
Merge pull request #98 from nodecraft/dependabot/npm_and_yarn/examples/framework-express/multi-6bc014718a

271 of 319 branches covered (84.95%)

Branch coverage included in aggregate %.

529 of 563 relevant lines covered (93.96%)

609.72 hits per line

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

88.66
/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
                                                        let required = require(file);
4✔
154
                                                        // handle require esm modules
155
                                                        if (required.__esModule) {
4!
156
                                                                required = required.default;
×
157
                                                        }
158
                                                        required(this, modelCallback);
4✔
159
                                                } catch (err) {
160
                                                        error = err;
2✔
161
                                                }
162
                                                if (error) {
4✔
163
                                                        return acb(error);
2✔
164
                                                }
165
                                        }, callback);
166
                                });
167
                        }
168
                        jobs.push((callback) => {
6✔
169
                                _.each(list, (file) => {
6✔
170
                                        this.debug('Loading', file);
6✔
171
                                        let error;
172
                                        try {
6✔
173
                                                let required = require(file);
6✔
174
                                                // handle require esm modules
175
                                                if (required.__esModule) {
6!
176
                                                        required = required.default;
×
177
                                                }
178
                                                required(this);
6✔
179
                                                this.debug('Successfully loaded', file);
4✔
180
                                        } catch (err) {
181
                                                error = err;
2✔
182
                                        }
183
                                        if (error) {
6✔
184
                                                return console.error(`Failed to load [${file}]`, error);
2✔
185
                                        }
186
                                });
187
                                return callback();
6✔
188
                        });
189
                });
190
                process.nextTick(() => {
62✔
191
                        async.series(jobs, (err) => {
62✔
192
                                if (err) {
62✔
193
                                        this.error('Failed to start up').debug(err);
2✔
194
                                        this.emit('app.exit');
2✔
195
                                        return callback(err);
2✔
196
                                }
197
                                this.log('%s is ready.', this.config.name);
60✔
198
                                this.emit('app.ready');
60✔
199
                                return callback();
60✔
200
                        });
201
                });
202
                this.emit('app.setup.done');
62✔
203
                return this;
62✔
204
        }
205

206
        /**
207
         * Recursively list files in a directory by an optional file extension.
208
         * NOTE: This is an event blocking sync method.
209
         * @param  {String} dir  Directory to list files from.
210
         * @param  {Array|string} [exts] Optional list of file extensions to return. Defaults to .js files. Set to a falsy value to disable this filter.
211
         * @return {Array} Absolute/full path of filenames found.
212
         */
213
        recursiveList(dir, exts = ['.js']) {
14✔
214
                assert(typeof(dir) === 'string', '`dir` must be a string');
306✔
215
                if (typeof(exts) === 'string') {
296✔
216
                        exts = [exts];
158✔
217
                }
218
                const list = [];
296✔
219
                let stat;
220
                try {
296✔
221
                        stat = fs.statSync(dir);
296✔
222
                } catch {
223
                        stat = false;
72✔
224
                }
225
                if (!stat || !stat.isDirectory()) {
296✔
226
                        return list;
72✔
227
                }
228
                // ensure proper trailing slash
229
                dir = String(dir + '/').replaceAll('/', '/');
224✔
230

231
                // Use withFileTypes to avoid a stat for every entry (significantly faster)
232
                const stack = [dir];
224✔
233
                while (stack.length > 0) {
224✔
234
                        const current = stack.pop();
322✔
235
                        let entries;
236
                        try {
322✔
237
                                entries = fs.readdirSync(current, { withFileTypes: true });
322✔
238
                        } catch {
239
                                continue;
×
240
                        }
241
                        for (const dirent of entries) {
322✔
242
                                const full = current + dirent.name;
1,562✔
243
                                if (dirent.isDirectory()) {
1,562✔
244
                                        if (exts && exts.includes('/')) {
98✔
245
                                                list.push(full);
28✔
246
                                        }
247
                                        stack.push(full + '/');
98✔
248
                                } else if (!exts || exts.includes(path.extname(dirent.name))) {
1,464✔
249
                                        list.push(full);
1,320✔
250
                                }
251
                        }
252
                }
253
                list.sort(); // windows won't sort this like unix will
224✔
254
                return list;
224✔
255
        }
256

257
        /**
258
         * Utility: Create random string.
259
         * @param  {Number} [length] How long of a random string to create.
260
         * @param  {String} [hashMethod] Which crypto hash method to use.
261
         * @return {String} Random string of characters.
262
         */
263
        random(length = 16) {
20,000✔
264
                length = Number.parseInt(length);
20,016✔
265
                assert(!Number.isNaN(length), '`length` must be a number');
20,016✔
266
                if (Number.isNaN(length) || length < 1) {
20,004✔
267
                        length = 16; // TODO: throw an error in an update
4✔
268
                }
269
                return nanoid(length);
20,004✔
270
        }
271

272
        /**
273
         * Utility: get random element from `collection`.
274
         * This is a copy of the lodash _.sample method.
275
         * @param  {Array|Object} items The collection to sample.
276
         * @return {*} Returns the random element.
277
         */
278
        sample(items) {
279
                return _.sample(items);
12✔
280
        }
281

282
        /**
283
         * Utility: Creates new `roundRobin` class with collection.
284
         * @param  {Array|Object} items The collection to sample.
285
         * @return {roundRobin} Returns new instance of `roundRobin` class.
286
         */
287
        roundRobin(items) {
288
                return new this._roundRobin(items);
24✔
289
        }
290

291
        /**
292
         * Utility: get random element from `collection` in an async lock.
293
         * @param  {Array|Object} items The collection to sample.
294
         * @return {roundRobin} Returns new instance of `roundRobin` class.
295
         */
296
        getAndLock(items) {
297
                return new this._getAndLock(items);
24✔
298
        }
299

300
        /**
301
         * Utility: omit keys from an object. Similar to Lodash omit, but much faster.
302
         * @param  {Object} items The source object.
303
         * @param  {Array} keysToOmit Keys to omit from the object.
304
         * @return {Object} Returns object with requested keys removed.
305
         */
306
        omit(obj, keysToOmit = []) {
×
307
                return helpers.omit(obj, keysToOmit);
×
308
        }
309

310
        /**
311
         * Checks if the current application runtime is running as a root user/group.
312
         * @return {Boolean} When true: the application is running as a root user/group.
313
         */
314
        isRoot() {
315
                if (this.isSecure() === true) {
2!
316
                        return false;
2✔
317
                }
318
                return true;
×
319
        }
320

321
        /**
322
         * Checks if the current application runtime is running as a specific `uid` and/or `gid`.
323
         * @param  {Number}  [uid] Unix `uid` to check against.
324
         * @param  {Number}  [gid] Unix `gid` to check against. When not set will match `uid`.
325
         * @return {Boolean} When true: the application is running as the user/group.
326
         */
327
        isSecure(uid, gid) {
328
                // TODO: Fix testing on non UNIX (windows)?
329
                if (typeof(process.getuid) !== 'function' || typeof(process.getgid) !== 'function') {
4!
330
                        return true; // TODO: throw error
×
331
                }
332

333
                if (uid && !gid) {
4!
334
                        gid = uid;
×
335
                }
336
                const checks = {
4✔
337
                        uid: process.getuid(),
338
                        gid: process.getgid(),
339
                        groups: String(child_process.execSync('groups')),
340
                };
341
                if (checks.uid === 0 || checks.gid === 0) {
4!
342
                        return this.errorCode('usercheck.is_root', { checks: checks });
×
343
                }
344
                if (checks.groups.includes('root')) {
4!
345
                        return this.errorCode('usercheck.is_root_group', { checks: checks });
×
346
                }
347
                if (uid && gid && (uid !== checks.uid || gid !== checks.gid)) {
4!
348
                        return this.errorCode('usercheck.incorrect_user', { checks: checks });
×
349
                }
350
                return true;
4✔
351
        }
352

353
        /**
354
         * Helper method that requires a file and hoists the current spawnpoint application reference.
355
         * @param  {String} filePath File path to require.
356
         */
357
        require(filePath) {
358
                if (!filePath.startsWith(this.cwd)) {
2!
359
                        filePath = path.join(this.cwd, filePath);
2✔
360
                }
361
                return require(filePath)(this);
2✔
362
        }
363

364
        /**
365
         * Builds a Spawnpoint code object. Codes are used to create a link between a human readable message
366
         * and a computer readable string. Example: `file.not_found` -> "The requested file was not found."
367
         * @param {String} code computer readable string code.
368
         * @param {Object} [data] Object to extend the code Object with
369
         * @return {Object} Code Object with a `message` with the computer readable message and the `code` matching the input code.
370
         */
371
        code(code, data = {}) {
108✔
372
                assert(code && typeof(code) === 'string', '`code` must be an string.');
120✔
373
                assert(typeof(data) === 'object', '`data` must be an object.');
90✔
374
                if (!this.codes[code]) {
90✔
375
                        throw new Error('No return code found with code: ' + code); // TODO: convert this to an errorCode
6✔
376
                }
377
                return _.defaults(data, {
84✔
378
                        code: code,
379
                        message: this.codes[code],
380
                });
381
        }
382

383
        /**
384
         * Spawnpoint code that wraps a Javascript `Error` as a hard application error.
385
         * @param {String} code computer readable string code.
386
         * @param {Object} [data] Object to extend the code Object with
387
         * @return {Object} Error Code Object with a `message` with the computer readable message and the `code` matching the input code.
388
         */
389
        errorCode(code, data) {
390
                const getCode = this.code(code, data);
72✔
391
                this.emit('errorCode', getCode);
60✔
392
                return new this._errorCode(getCode);
60✔
393
        }
394

395
        /**
396
         * Spawnpoint code that wraps a Javascript `Error`, as a soft error.
397
         * @param {String} code computer readable string code.
398
         * @param {Object} [data] Object to extend the code Object with
399
         * @return {Object} Error Code Object with a `message` with the computer readable message and the `code` matching the input code.
400
         */
401
        failCode(code, data) {
402
                const getCode = this.code(code, data);
30✔
403
                this.emit('failCode', getCode);
18✔
404
                return new this._failCode(getCode);
18✔
405
        }
406

407
        /**
408
         * Error Monitoring, when enabled. This allows you to track how often an error occurs and issue a callback once that threadhold is met.
409
         * @param  {String} code Spawnpoint code to match against
410
         * @param  {Number} threshold Number of occurrences required to trigger callback.
411
         * @param  {Object} options Extra limit options
412
         * @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.
413
         * @param  {Callback} callback Triggered when threshold is met.
414
         * @return {this}
415
         */
416
        registerLimit(code, threshold, options, callback) {
417
                if (!callback && options) {
8✔
418
                        callback = options;
2✔
419
                        options = {};
2✔
420
                }
421
                const opts = _.defaults(options, {
8✔
422
                        callback: callback,
423
                        threshold: threshold,
424
                        error: 'errorCode', // or failCode
425
                        index: null, // 'object.to.path' of unique index to track by
426
                        reset: 1, // reset balance counter to this on a subsequent callback. Give it a negative number to disable this.
427
                        time: null,
428
                });
429

430
                opts.uuid = _.uniqueId();
8✔
431

432
                if (!this.limitMaps[opts.error]) {
8!
433
                        this.limitMaps[opts.error] = {};
8✔
434
                }
435
                if (!this.limitMaps[opts.error][code]) {
8!
436
                        this.limitMaps[opts.error][code] = [];
8✔
437
                }
438
                this.limitMaps[opts.error][code].push(opts);
8✔
439
                return this;
8✔
440
        }
441

442
        /**
443
         * Console.log wrapper that only triggers with when `config.debug` is enabled.
444
         * @params {*} [args..] Arguments to be passed to logging.
445
         * @return {this}
446
         */
447
        debug() {
448
                if (this.config.debug) {
1,434✔
449
                        Reflect.apply(console.log, this, arguments);
2✔
450
                }
451
                return this;
1,434✔
452
        }
453

454
        /**
455
         * Console.log wrapper that adds an INFO tag and timestamp to the log.
456
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
457
         * @return {this}
458
         */
459
        info() {
460
                helpers.log({
36✔
461
                        logs: this.logs,
462
                        config: this.config.log,
463
                        type: helpers.tag('INFO', kleur.green),
464
                        line: kleur.white(Reflect.apply(format, this, arguments)),
465
                });
466
                return this;
36✔
467
        }
468

469
        /**
470
         * Console.log wrapper that adds an LOG tag and timestamp to the log.
471
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
472
         * @return {this}
473
         */
474
        log() {
475
                helpers.log({
92✔
476
                        logs: this.logs,
477
                        config: this.config.log,
478
                        type: helpers.tag('LOG', kleur.cyan),
479
                        line: kleur.white(Reflect.apply(format, this, arguments)),
480
                });
481
                return this;
92✔
482
        }
483

484
        /**
485
         * Console.error` wrapper that adds an WARN tag and timestamp to the log. This prints to STDERR.
486
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
487
         * @return {this}
488
         */
489
        warn() {
490
                helpers.log({
42✔
491
                        logs: this.logs,
492
                        config: this.config.log,
493
                        type: helpers.tag('WARN', kleur.yellow),
494
                        line: kleur.yellow(Reflect.apply(format, this, arguments)),
495
                }, 'error');
496
                return this;
42✔
497
        }
498

499
        /**
500
         * Console.error` wrapper that adds an ERROR tag and timestamp to the log. This prints to STDERR.
501
         * @params {String|Object|Array|Number} [args..] Arguments to be passed to logging.
502
         * @return {this}
503
         */
504
        error() {
505
                helpers.log({
28✔
506
                        logs: this.logs,
507
                        config: this.config.log,
508
                        type: helpers.tag('ERROR', kleur.red.bold),
509
                        line: kleur.red(Reflect.apply(format, this, arguments)),
510
                }, 'error');
511
                return this;
28✔
512
        }
513

514
        /**
515
         * Registers multiple custom Errors to a specific errorCode. This helps wrap errors into a singular errorCode system.
516
         * @param {String} code The errorCode human readable Spawnpoint code.
517
         * @param {Error} error Instance of the error to map to..
518
         * @return {this}
519
         */
520
        registerError(code, error) {
521
                this.errorMaps[code] = error;
28✔
522
                return this;
28✔
523
        }
524

525
        /**
526
         * Registers multiple custom Errors to a specific errorCode, using the `registerError` method.
527
         * @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.
528
         * @return {this}
529
         */
530
        registerErrors(errors) {
531
                _.each(errors, (error, code) => {
2✔
532
                        this.registerError(code, error);
2✔
533
                });
534
                return this;
2✔
535
        }
536

537
        /**
538
         * 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
539
         * error handling so that you don't have to have the server reply with a generic error.
540
         * @param  {Error} error Error to check for mapped error.
541
         * @return {errorCode|false} Returns Spawnpoint mapped code, errorCode, or failCode or false when no mapped error was found.
542
         */
543
        maskErrorToCode(error, type = 'code') {
2✔
544
                const validTypes = ['errorCode', 'failCode', 'code'];
8✔
545
                let returnedError = false;
8✔
546
                if (!validTypes.includes(type)) {
8✔
547
                        throw new Error('Invalid `type` provided. Valid types:' + validTypes.join(',')); // TODO: convert to errorCode
2✔
548
                }
549
                _.each(this.errorMaps, (currentError, code) => {
6✔
550
                        if (!returnedError && error instanceof currentError) {
12✔
551
                                returnedError = this[type](code, error);
6✔
552
                        }
553
                });
554
                return returnedError;
6✔
555
        }
556

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

622
                // setup all of the required functions mounted on the `config` object
623

624
                /**
625
                 * Helper method to safely get a nested config item.
626
                 * @param  {String} path The path of the property to get.
627
                 * @param  {*} [defaultValue=false] The value returned for undefined resolved values.
628
                 * @return {*} Returns the resolved value.
629
                 */
630
                this.config.get = function(path, defaultValue) {
76✔
631
                        return _.get(self.config, path, defaultValue);
10✔
632
                };
633

634
                /**
635
                 * Helper method to safely check if a nested config item exists
636
                 * @param  {String} path The path to check.
637
                 * @memberOf config
638
                 * @namespace config.has
639
                 * @return {*} Returns `true` if path exists, else `false`.
640
                 */
641
                this.config.has = function(path) {
76✔
642
                        return _.has(self.config, path);
2✔
643
                };
644

645
                /**
646
                 * Helper method to get a random element from a Spawnpoint `config` item `collection`.
647
                 * @param  {path} path The path to return items from.
648
                 * @memberOf config
649
                 * @namespace config.getRandom
650
                 * @return {*} Returns random element from the collection.
651
                 */
652
                this.config.getRandom = function(path) {
76✔
653
                        const items = self.config.get(path);
4✔
654
                        if (!items) {
4✔
655
                                throw self.errorCode('spawnpoint.config.sample_not_collection'); // TODO: choose better name
2✔
656
                        }
657
                        return _.sample(items);
2✔
658
                };
659

660
                const rrKeys = {};
76✔
661
                /**
662
                 * Helper method to get get random element from Spawnpoint `config` item `Array` with Round Robin ordering
663
                 * This ensures no single item is returned more than it's siblings.
664
                 * @param  {path} path The path to return items from.
665
                 * @memberOf config
666
                 * @namespace config.getRoundRobin
667
                 * @return {*} Returns random element from the collection.
668
                 */
669
                this.config.getRoundRobin = function(path) {
76✔
670
                        if (!rrKeys[path]) {
10✔
671
                                const items = self.config.get(path);
2✔
672
                                rrKeys[path] = self.roundRobin(items);
2✔
673
                        }
674
                        return rrKeys[path].next();
10✔
675
                };
676

677
                const lockedKeys = {};
76✔
678
                /**
679
                 * Helper method to get get random element from Spawnpoint `config` item `Array` with async locking queue.
680
                 * This ensures no item is used at the same time as another async operation.
681
                 * @param  {path} path The path to return items from.
682
                 * @memberOf config
683
                 * @namespace config.getAndLock
684
                 * @return {*} Returns random element from the collection.
685
                 */
686
                this.config.getAndLock = function(path, timeout, callback) {
76✔
687
                        if (!lockedKeys[path]) {
150✔
688
                                const items = self.config.get(path);
2✔
689
                                lockedKeys[path] = self.getAndLock(items);
2✔
690
                        }
691
                        return lockedKeys[path].next(timeout, callback);
150✔
692
                };
693

694
                this.emit('app.setup.initConfig');
76✔
695
                return this;
76✔
696
        }
697

698
        /**
699
         * Internal: Registers Spawnpoint `config` by merging or setting values to the `config` object.
700
         * @param  {String} name `config` top level key
701
         * @param  {*} config value or object of the config
702
         * @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.
703
         * @return {Object} returns `this.config` new value
704
         * @private
705
         */
706
        registerConfig(name, config, allowListCheck = '') {
660✔
707
                let data = {};
9,556✔
708

709
                if (allowListCheck && this.configBlocklist[allowListCheck]) {
9,556✔
710
                        if (this.configBlocklist[allowListCheck].list.includes(name)) { return this.debug('ignoring blocklist', name); }
8,896✔
711
                        let found = false;
8,520✔
712
                        _.each(this.configBlocklist[allowListCheck].patterns, (pattern) => {
8,520✔
713
                                if (!found && pattern.test(name)) {
25,368✔
714
                                        found = true;
1,024✔
715
                                }
716
                        });
717
                        if (found) { return this.debug('ignoring blocklist pattern', name); }
8,520✔
718
                        if (this.config.debug) {
7,496!
719
                                this.log('Setting %s ENV variable [%s]', allowListCheck, name);
×
720
                        }
721
                }
722
                if (name && !config) {
8,156✔
723
                        data = name;
384✔
724
                } else {
725
                        data[name] = config;
7,772✔
726
                }
727
                if (allowListCheck === 'env' || allowListCheck === 'secrets' || allowListCheck === 'config-hoist') {
8,156✔
728
                        return _.set(this.config, name, config);
7,432✔
729
                }
730
                // eslint-disable-next-line unicorn/prefer-structured-clone
731
                return _.merge(this.config, _.cloneDeep(data));
724✔
732
        }
733

734
        /**
735
         * Fast, low-overhead parser for ENV/secrets values.
736
         * - true/false/null -> boolean/null
737
         * - numbers (int/float) -> number
738
         * - JSON-like (starts with { or [) -> JSON.parse (try once)
739
         * Otherwise returns the original string.
740
         * @private
741
         */
742
        _parseEnvValue(value, allowJson = true) {
8,832✔
743
                if (typeof value !== 'string') { return value; }
8,832!
744
                const lower = value.toLowerCase();
8,832✔
745
                if (lower === 'true') { return true; }
8,832✔
746
                if (lower === 'false') { return false; }
8,576!
747
                if (lower === 'null') { return null; }
8,576!
748
                if (/^-?\d+(\.\d+)?$/.test(value)) {
8,576✔
749
                        const num = Number(value);
1,152✔
750
                        if (!Number.isNaN(num)) { return num; }
1,152!
751
                }
752
                if (
7,424✔
753
                        allowJson &&
28,928!
754
                        value.length > 1 &&
755
                        ((value[0] === '{' && value.endsWith('}')) ||
756
                                (value[0] === '[' && value.endsWith(']')))
757
                ) {
758
                        try {
64✔
759
                                return JSON.parse(value);
64✔
760
                        } catch {
761
                                // keep original string on parse failure
762
                        }
763
                }
764
                return value;
7,360✔
765
        }
766
        /**
767
         * Internal: Builds app `config` object by looping through plugins, configs, ENV, Progress args, Docker secrets. , and finally
768
         * config overrides (in that order). These items are hoisted to the Spawnpoint `config` object.
769
         * This is step 5 of 8 to startup Spawnpoint
770
         * @param  {String} [cwd] Path to load config files from.
771
         * @param  {Boolean} ignoreExtra When true will skip plugins, ENV and Docker secrets. Allows for recursive usage.
772
         * @return {this}
773
         * @private
774
         */
775
        loadConfig(cwd = '', ignoreExtra = false) {
128✔
776
                cwd = cwd || this.cwd;
76✔
777

778
                if (!ignoreExtra) {
76✔
779
                        // load plugin defaults
780
                        _.each(this.plugins, (plugin) => {
64✔
781
                                if (plugin.config) {
12!
782
                                        // ensure sideloaded plugins retain original config
783
                                        if (plugin.original_namespace) {
×
784
                                                plugin.config[plugin.namespace] = plugin.config[plugin.original_namespace];
×
785
                                                delete plugin.config[plugin.original_namespace];
×
786
                                        }
787
                                        this.registerConfig(plugin.config);
×
788
                                }
789
                                this.loadConfig(plugin.dir, true);
12✔
790
                        });
791
                }
792

793
                // load local json files
794
                _.each(this.recursiveList(cwd + this.config.configs, '.json'), (file) => {
76✔
795
                        // prevent loading base config and codes
796
                        if (!file.includes(this.configFile) && !file.includes(this.config.codes)) {
850✔
797
                                if (!this.config[path.parse(file).name]) {
660✔
798
                                        this.config[path.parse(file).name] = {};
166✔
799
                                }
800
                                this.registerConfig(path.parse(file).name, require(file));
660✔
801
                        }
802
                });
803

804
                if (!ignoreExtra) {
76✔
805
                        // handle process flags
806
                        this.args = minimist(process.argv.slice(2));
64✔
807
                        _.each(this.args, (value, key) => this.registerConfig(key, value, 'args'));
64✔
808
                        this.argv = _.clone(this.args._) || [];
64!
809

810
                        // handle environment variables
811
                        _.each(process.env, (value, key) => {
64✔
812
                                key = key.replaceAll('__', '.'); // replace double underscores to dots, to allow object notation in environment vars
8,832✔
813
                                value = this._parseEnvValue(value);
8,832✔
814
                                return this.registerConfig(key, value, 'env');
8,832✔
815
                        });
816

817
                        if (this.config.secrets) {
64!
818
                                // handle docker secrets
819
                                _.each(this.recursiveList(this.config.secrets, false), (file) => {
64✔
820
                                        let key;
821
                                        let value;
822
                                        try {
×
823
                                                key = path.basename(file);
×
824
                                                value = fs.readFileSync(file, 'utf8');
×
825
                                                value = this._parseEnvValue(value, true); // if it fails it will revert to above value
×
826
                                        } catch {
827
                                                // do nothing
828
                                        }
829
                                        if (!value || !key) { return; }
×
830
                                        return this.registerConfig(key, value, 'secrets');
×
831
                                });
832
                        }
833
                } else {
834
                        this.debug('Ignoring config extra loading');
12✔
835
                }
836
                this.emit('app.setup.loadConfig');
76✔
837

838
                if (this.config.configOverride) {
76!
839
                        // allow dev-config.json in root directory to override config vars
840
                        let access = null;
×
841
                        try {
×
842
                                access = require(path.join(this.cwd, this.config.configOverride));
×
843
                        } catch {
844
                                // do nothing
845
                        }
846
                        if (access) {
×
847
                                this.debug('Overriding config with custom overrides');
×
848
                                _.each(access, (value, key) => this.registerConfig(key, value, 'config-hoist'));
×
849
                                // Emit an event to allow plugins to know that the config has been overridden
850
                                this.emit('app.setup.configOverridden');
×
851
                        }
852
                }
853
                return this;
76✔
854
        }
855

856
        /**
857
         * Internal: Loads the internal Spawnpoint codes.
858
         * This is step 2 of 8 to startup Spawnpoint
859
         * @return {this}
860
         * @private
861
         */
862
        initCodes() {
863
                this.codes = {};
64✔
864
                _.each(this.recursiveList(path.join(__dirname, '../codes'), '.json'), (file) => {
64✔
865
                        _.merge(this.codes, require(file));
320✔
866
                });
867
                this.emit('app.setup.initCodes');
64✔
868
                return this;
64✔
869
        }
870

871
        /**
872
         * Internal: Loads the application codes from a folder
873
         * This is step 6 of 8 to startup Spawnpoint
874
         * @param {String} [cwd] Folder to load paths from.
875
         * @param  {Boolean} ignoreExtra When true will skip plugins. Allows for recursive usage.
876
         * @return {this}
877
         * @private
878
         */
879
        loadCodes(cwd = '', ignoreExtra = false) {
124✔
880
                cwd = cwd || path.join(this.cwd, this.config.codes);
70✔
881

882
                if (!ignoreExtra) {
70✔
883
                        // load plugin defaults
884
                        _.each(this.plugins, (plugin) => {
62✔
885
                                if (plugin.codes) {
8!
886
                                        this.registerCodes(plugin.codes);
×
887
                                }
888
                                this.loadCodes(plugin.dir + '/codes', true);
8✔
889
                        });
890
                }
891

892
                // handle local files
893
                let list = null;
70✔
894
                try {
70✔
895
                        list = this.recursiveList(cwd, ['.json']);
70✔
896
                } catch {
897
                        this.debug('No codes folder found (%s), skipping', this.config.codes);
×
898
                }
899
                if (list) {
70!
900
                        _.each(list, (file) => {
70✔
901
                                this.registerCodes(require(file));
124✔
902
                        });
903
                }
904
                this.emit('app.setup.loadCodes');
70✔
905
                return this;
70✔
906
        }
907

908
        /**
909
         * Internal: Hoists new codes into Spawnpoint `codes` object.
910
         * @param  {Object} codes Codes to inject at key as the code computer readable and value at the human readable message.
911
         * @return {this}
912
         * @private
913
         */
914
        registerCodes(codes) {
915
                _.merge(this.codes, structuredClone(codes));
128✔
916
                return this;
128✔
917
        }
918

919
        /**
920
         * Internal: Starts the Spawnpoint application lifecycle registery. This ensures the application starts up correctly and shuts down gracefully.
921
         * This is step 3 of 8 to startup Spawnpoint
922
         * @return {this}
923
         * @private
924
         */
925
        initRegistry() {
926
                this.register = [];
120✔
927

928
                this.on('app.ready', () => {
120✔
929
                        this.status.running = true;
68✔
930
                        // only handle uncaught exceptions when ready
931
                        if (!this.config.catchExceptions) { return; }
68✔
932
                        process.on('uncaughtException', (err) => {
4✔
933
                                this.error(err.message || err).debug(err.stack || '(no stack trace)');
2!
934
                                // close the app if we have not completed startup
935
                                if (!this.status.running) {
2!
936
                                        this.emit('app.stop', true);
2✔
937
                                }
938
                        });
939
                });
940

941
                // app registry is used to track graceful halting
942
                this.on('app.register', (item) => {
120✔
943
                        if (!this.register.includes(item)) {
22✔
944
                                this.log('Plugin registered: %s', item);
20✔
945
                                this.register.push(item);
20✔
946
                        }
947
                });
948
                this.on('app.deregister', (item) => {
120✔
949
                        const i = this.register.indexOf(item);
14✔
950
                        if (i !== -1) {
14✔
951
                                this.register.splice(i, 1);
12✔
952
                                this.warn('De-registered: %s', item);
12✔
953
                        }
954
                        if (!this.status.running && this.register.length === 0) {
14✔
955
                                this.emit('app.exit', true);
2✔
956
                        }
957
                });
958
                this.on('app.stop', () => {
120✔
959
                        if (this.status.stopping) {
44✔
960
                                this.status.stopAttempts++;
26✔
961
                                if (this.status.stopAttempts === 1) {
26✔
962
                                        this.warn('%s will be closed in %sms if it does not shut down gracefully.', this.config.name, this.config.stopTimeout);
12✔
963
                                        setTimeout(() => {
12✔
964
                                                this.error('%s took too long to close. Killing process.', this.config.name);
12✔
965
                                                this.emit('app.exit');
12✔
966
                                        }, this.config.stopTimeout);
967
                                }
968
                                if (this.status.stopAttempts < this.config.stopAttempts) {
26✔
969
                                        return this.warn('%s already stopping. Attempt %s more times to kill process', this.config.name, this.config.stopAttempts - this.status.stopAttempts);
16✔
970
                                }
971
                                this.error('Forcefully killing %s', this.config.name);
10✔
972
                                return this.emit('app.exit');
10✔
973
                        }
974

975
                        this.status.running = false;
18✔
976
                        this.status.stopping = true;
18✔
977
                        this.info('Stopping %s gracefully', this.config.name);
18✔
978
                        this.emit('app.close');
18✔
979
                        if (this.register.length === 0) {
18✔
980
                                return this.emit('app.exit', true);
2✔
981
                        }
982
                });
983
                this.on('app.exit', (graceful) => {
120✔
984
                        if (!graceful) {
4✔
985
                                /* eslint-disable n/no-process-exit */
986
                                return process.exit(1);
2✔
987
                        }
988
                        this.info('%s gracefully closed.', this.config.name);
2✔
989
                        process.exit();
2✔
990
                });
991

992
                if (this.config.signals) {
120✔
993
                        // gracefully handle ctrl+c
994
                        _.each(this.config.signals.close, (event) => {
6✔
995
                                process.on(event, () => {
2✔
996
                                        this.emit('app.stop');
4✔
997
                                });
998
                        });
999

1000
                        // set debug mode on SIGUSR1
1001
                        _.each(this.config.signals.debug, (event) => {
6✔
1002
                                process.on(event, () => {
2✔
1003
                                        this.config.debug = !this.config.debug;
2✔
1004
                                });
1005
                        });
1006
                }
1007

1008
                this.emit('app.setup.initRegistry');
120✔
1009
                return this;
120✔
1010
        }
1011

1012
        /**
1013
         * Internal: Starts the Spawnpoint errorCode & failCode tracking. Disabled by default unless `config.trackErrors` is enabled due to a larger
1014
         * memory footprint required.
1015
         * This is step 7 of 8 to startup Spawnpoint
1016
         * @return {this}
1017
         * @private
1018
         */
1019
        initLimitListeners() {
1020
                const self = this;
62✔
1021
                if (!this.config.trackErrors) { return this; }
62✔
1022
                const issues = {
10✔
1023
                        errorCode: {},
1024
                        failCode: {},
1025
                };
1026
                _.each(['errorCode', 'failCode'], function(type) {
10✔
1027
                        function limitToErrors(error) {
1028
                                if (!self.limitMaps[type] || !self.limitMaps[type][error.code]) {
22✔
1029
                                        return; // no issue being tracked
2✔
1030
                                }
1031

1032
                                const limits = self.limitMaps[type][error.code];
20✔
1033

1034

1035
                                const defaultIssues = {
20✔
1036
                                        occurrences: 0, // long count, track
1037
                                        balance: 0, // time-based balance
1038
                                        dateFirst: Math.floor(Date.now() / 1000),
1039
                                        dateLast: null,
1040
                                        datesTriggered: [],
1041
                                        triggered: false, // track if we've triggered the current balance
1042
                                };
1043

1044
                                if (!issues[type][error.code]) {
20✔
1045
                                        issues[type][error.code] = {};
6✔
1046
                                        issues[type][error.code].Global = _.pick(defaultIssues, ['occurrences', 'dateFirst', 'dateLast']);
6✔
1047
                                }
1048

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

1052
                                for (const limit of limits) {
20✔
1053
                                        // new issue
1054
                                        if (!issues[type][error.code][limit.uuid]) {
20✔
1055
                                                issues[type][error.code][limit.uuid] = _.pick(defaultIssues, ['balance', 'triggered', 'datesTriggered']);
6✔
1056
                                        }
1057
                                        issues[type][error.code][limit.uuid].balance++;
20✔
1058
                                        if (limit.time) {
20✔
1059
                                                setTimeout(function() {
10✔
1060
                                                        issues[type][error.code][limit.uuid].balance--;
10✔
1061
                                                        if (issues[type][error.code][limit.uuid].balance <= 0) {
10✔
1062
                                                                issues[type][error.code][limit.uuid].balance = 0;
6✔
1063
                                                                issues[type][error.code][limit.uuid].triggered = false;
6✔
1064
                                                        }
1065
                                                }, limit.time);
1066
                                        }
1067
                                        if (!issues[type][error.code][limit.uuid].triggered && issues[type][error.code][limit.uuid].balance >= limit.threshold) {
20✔
1068
                                                issues[type][error.code][limit.uuid].triggered = true;
10✔
1069
                                                limit.callback(_.merge(_.clone(issues[type][error.code][limit.uuid]), _.clone(issues[type][error.code].Global)));
10✔
1070
                                                issues[type][error.code][limit.uuid].datesTriggered.push(issues[type][error.code].Global.dateLast); // add after callback, to avoid double dates
10✔
1071
                                        } else if (issues[type][error.code][limit.uuid].triggered && limit.reset >= 0) {
10✔
1072
                                                issues[type][error.code][limit.uuid].triggered = false;
2✔
1073
                                                issues[type][error.code][limit.uuid].balance = limit.reset;
2✔
1074
                                        }
1075
                                }
1076
                        }
1077
                        self.on(type, limitToErrors);
20✔
1078
                });
1079
                self.emit('app.setup.initLimitListeners');
10✔
1080
                return this;
10✔
1081
        }
1082

1083
        /**
1084
         * Internal: Loads all plugins defined on `config.plugins` array. These plugins must be installed via NPM.
1085
         * This is step 4 of 8 to startup Spawnpoint
1086
         * @return {this}
1087
         * @private
1088
         */
1089
        loadPlugins() {
1090
                this.config.plugins = _.map(this.config.plugins, (plugin) => {
66✔
1091
                        if (typeof(plugin) === 'string') {
16✔
1092
                                plugin = {
2✔
1093
                                        plugin: plugin,
1094
                                        name: null,
1095
                                        namespace: null,
1096
                                };
1097
                        }
1098
                        const pluginFile = require(plugin.plugin);
16✔
1099

1100
                        if (plugin.namespace) {
16✔
1101
                                // remove node modules cache to allow reuse of plugins under new namespaces
1102
                                delete require.cache[require.resolve(plugin.plugin)];
12✔
1103
                                pluginFile.original_namespace = pluginFile.namespace;
12✔
1104
                                plugin.original_namespace = pluginFile.namespace;
12✔
1105
                                pluginFile.namespace = plugin.namespace;
12✔
1106
                                pluginFile.name = plugin.name;
12✔
1107
                                this.info('Sideloading [%s] as plugin: [%s]', plugin.namespace, plugin.name);
12✔
1108
                        } else {
1109
                                plugin.namespace = pluginFile.namespace;
4✔
1110
                                plugin.name = pluginFile.name;
4✔
1111
                                this.info('Loading plugin: [%s]', plugin.name);
4✔
1112
                        }
1113
                        this.plugins[plugin.namespace] = pluginFile;
16✔
1114
                        return plugin;
16✔
1115
                });
1116
                this.emit('app.setup.loadPlugins');
66✔
1117
                return this;
66✔
1118
        }
1119

1120
        /**
1121
         * Internal: Sets up the error mapping to automatically match custom Error types to Spawnpoint codes.
1122
         * This is step 8 of 8 to startup Spawnpoint
1123
         * @return {this}
1124
         * @private
1125
         */
1126
        loadErrorMap() {
1127
                _.each(this.plugins, (plugin) => {
66✔
1128
                        if (plugin.errors) {
12✔
1129
                                this.registerErrors(plugin.errors);
2✔
1130
                        }
1131
                });
1132
                this.emit('app.setup.loadErrorMap');
66✔
1133
                return this;
66✔
1134
        }
1135

1136
        /**
1137
         * Internal: Called to register a Spawnpoint plugin. See Plugins docs for more details on how plugins work.
1138
         * @param  {Object} opts Plugin options `object`
1139
         * @param  {String} opts.name Plugin Name
1140
         * @param  {String} opts.namespace Application namespace used by the plugin
1141
         * @param  {String} opts.dir Folder where the plugin and it's config/codes can be found. (usually `__dir`)
1142
         * @param  {Object} opts.codes Custom codes to register to Spawnpoint
1143
         * @param  {Object} opts.config Custom config to register to Spawnpoint
1144
         * @param  {Function} opts.exports Plugin function to execute with (app, [callback]) context. Callback is only defined when `opts.callback` is true
1145
         * @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.
1146
         * @return {Object} Returns Spawnpoint plugin reference. Used internally to load plugins.
1147
         * @private
1148
         */
1149
        static registerPlugin(opts) {
1150
                assert(opts.name, 'Plugin is missing required `name` option.');
14✔
1151
                assert(opts.namespace, 'Plugin is missing required `namespace` option.');
14✔
1152
                assert(opts.exports, 'Plugin is missing required `exports` function.');
14✔
1153
                return _.merge(opts, {
14✔
1154
                        codes: this.codes || null,
28✔
1155
                        config: this.config || null,
28✔
1156
                });
1157
        }
1158

1159
        /**
1160
         * Internal: Forces the JSON (require) handler to allow comments in JSON files. This allow documentation in JSON config files.
1161
         * @return {this}
1162
         * @private
1163
         */
1164
        setupJSONHandler() {
1165
                require(path.join(__dirname, '/json-handler.js'));
64✔
1166
                return this;
64✔
1167
        }
1168
}
1169

1170
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