Merge branch 'v1.1-dev' as v1.1.1
[websub-hub] / src / common.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * Assorted utility functions.
6 */
7
8 const { common } = require('@squeep/api-dingus');
9
10 const { randomBytes, getHashes } = require('crypto');
11 const { promisify } = require('util');
12
13
14 /**
15 * Wrap this in a promise, as crypto.randomBytes is capable of blocking.
16 */
17 const randomBytesAsync = promisify(randomBytes);
18
19
20 /**
21 * The HMAC hashes we are willing to support.
22 * @param {String} algorithm
23 * @returns {Boolean}
24 */
25 const validHash = (algorithm) => getHashes()
26 .filter((h) => h.match(/^sha[0-9]+$/))
27 .includes(algorithm);
28
29 /**
30 * Recursively freeze an object.
31 * @param {Object} o
32 * @returns {Object}
33 */
34 const freezeDeep = (o) => {
35 Object.freeze(o);
36 Object.getOwnPropertyNames(o).forEach((prop) => {
37 if (Object.hasOwnProperty.call(o, prop)
38 && ['object', 'function'].includes(typeof o[prop])
39 && !Object.isFrozen(o[prop])) {
40 return freezeDeep(o[prop]);
41 }
42 });
43 return o;
44 }
45
46
47 /**
48 * Pick out useful axios response fields.
49 * @param {*} res
50 * @returns
51 */
52 const axiosResponseLogData = (res) => {
53 const data = common.pick(res, [
54 'status',
55 'statusText',
56 'headers',
57 'elapsedTimeMs',
58 'data',
59 ]);
60 if (data.data) {
61 data.data = logTruncate(data.data, 100);
62 }
63 return data;
64 };
65
66
67 /**
68 * Fallback values, if not configured.
69 * @returns {Object}
70 */
71 const topicLeaseDefaults = () => {
72 return Object.freeze({
73 leaseSecondsPreferred: 86400 * 10,
74 leaseSecondsMin: 86400 * 1,
75 leaseSecondsMax: 86400 * 365,
76 });
77 };
78
79
80 /**
81 * Pick from a range, constrained, with some fuzziness.
82 * @param {Number} attempt
83 * @param {Number[]} delays
84 * @param {Number} jitter
85 * @returns {Number}
86 */
87 const attemptRetrySeconds = (attempt, retryBackoffSeconds = [60, 120, 360, 1440, 7200, 43200, 86400], jitter = 0.618) => {
88 const maxAttempt = retryBackoffSeconds.length - 1;
89 if (typeof attempt !== 'number' || attempt < 0) {
90 attempt = 0;
91 } else if (attempt > maxAttempt) {
92 attempt = maxAttempt;
93 }
94 // eslint-disable-next-line security/detect-object-injection
95 let seconds = retryBackoffSeconds[attempt];
96 seconds += Math.floor(Math.random() * seconds * jitter);
97 return seconds;
98 }
99
100
101 /**
102 * Return array items split as an array of arrays of no more than per items each.
103 * @param {Array} array
104 * @param {Number} per
105 */
106 const arrayChunk = (array, per = 1) => {
107 const nChunks = Math.ceil(array.length / per);
108 return Array.from(Array(nChunks), (_, i) => array.slice(i * per, (i + 1) * per));
109 }
110
111
112 /**
113 * Be paranoid about blowing the stack when pushing to an array.
114 * @param {Array} dst
115 * @param {Array} src
116 */
117 const stackSafePush = (dst, src) => {
118 const jsEngineMaxArguments = 2**16; // Current as of Node 12
119 arrayChunk(src, jsEngineMaxArguments).forEach((items) => {
120 Array.prototype.push.apply(dst, items);
121 });
122 }
123
124
125 /**
126 * Limit length of string to keep logs sane
127 * @param {String} str
128 * @param {Number} len
129 * @returns {String}
130 */
131 const logTruncate = (str, len) => {
132 if (typeof str !== 'string' || str.toString().length <= len) {
133 return str;
134 }
135 return str.toString().slice(0, len) + `... (${str.toString().length} bytes)`;
136 };
137
138 module.exports = {
139 ...common,
140 arrayChunk,
141 attemptRetrySeconds,
142 axiosResponseLogData,
143 freezeDeep,
144 logTruncate,
145 randomBytesAsync,
146 stackSafePush,
147 topicLeaseDefaults,
148 validHash,
149 };