Merge branch 'v1.3-dev' as v1.3.11
[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 /**
31 * Return an array containing x if x is not an array.
32 * @param {*} x
33 */
34 const ensureArray = (x) => {
35 if (x === undefined) {
36 return [];
37 }
38 if (!Array.isArray(x)) {
39 return Array(x);
40 }
41 return x;
42 };
43
44
45 /**
46 * Recursively freeze an object.
47 * @param {Object} o
48 * @returns {Object}
49 */
50 const freezeDeep = (o) => {
51 Object.freeze(o);
52 Object.getOwnPropertyNames(o).forEach((prop) => {
53 if (Object.hasOwnProperty.call(o, prop)
54 && ['object', 'function'].includes(typeof o[prop])
55 && !Object.isFrozen(o[prop])) {
56 return freezeDeep(o[prop]);
57 }
58 });
59 return o;
60 };
61
62
63 /**
64 * Pick out useful axios response fields.
65 * @param {*} res
66 * @returns
67 */
68 const axiosResponseLogData = (res) => {
69 const data = common.pick(res, [
70 'status',
71 'statusText',
72 'headers',
73 'elapsedTimeMs',
74 'data',
75 ]);
76 if (data.data) {
77 data.data = logTruncate(data.data, 100);
78 }
79 return data;
80 };
81
82
83 /**
84 * Fallback values, if not configured.
85 * @returns {Object}
86 */
87 const topicLeaseDefaults = () => {
88 return Object.freeze({
89 leaseSecondsPreferred: 86400 * 10,
90 leaseSecondsMin: 86400 * 1,
91 leaseSecondsMax: 86400 * 365,
92 });
93 };
94
95
96 /**
97 * Pick from a range, constrained, with some fuzziness.
98 * @param {Number} attempt
99 * @param {Number[]} delays
100 * @param {Number} jitter
101 * @returns {Number}
102 */
103 const attemptRetrySeconds = (attempt, retryBackoffSeconds = [60, 120, 360, 1440, 7200, 43200, 86400], jitter = 0.618) => {
104 const maxAttempt = retryBackoffSeconds.length - 1;
105 if (typeof attempt !== 'number' || attempt < 0) {
106 attempt = 0;
107 } else if (attempt > maxAttempt) {
108 attempt = maxAttempt;
109 }
110 // eslint-disable-next-line security/detect-object-injection
111 let seconds = retryBackoffSeconds[attempt];
112 seconds += Math.floor(Math.random() * seconds * jitter);
113 return seconds;
114 };
115
116
117 /**
118 * Return array items split as an array of arrays of no more than per items each.
119 * @param {Array} array
120 * @param {Number} per
121 */
122 const arrayChunk = (array, per = 1) => {
123 const nChunks = Math.ceil(array.length / per);
124 return Array.from(Array(nChunks), (_, i) => array.slice(i * per, (i + 1) * per));
125 };
126
127
128 /**
129 * Be paranoid about blowing the stack when pushing to an array.
130 * @param {Array} dst
131 * @param {Array} src
132 */
133 const stackSafePush = (dst, src) => {
134 const jsEngineMaxArguments = 2**16; // Current as of Node 12
135 arrayChunk(src, jsEngineMaxArguments).forEach((items) => {
136 Array.prototype.push.apply(dst, items);
137 });
138 };
139
140
141 /**
142 * Limit length of string to keep logs sane
143 * @param {String} str
144 * @param {Number} len
145 * @returns {String}
146 */
147 const logTruncate = (str, len) => {
148 if (typeof str !== 'string' || str.toString().length <= len) {
149 return str;
150 }
151 return str.toString().slice(0, len) + `... (${str.toString().length} bytes)`;
152 };
153
154 module.exports = {
155 ...common,
156 arrayChunk,
157 attemptRetrySeconds,
158 axiosResponseLogData,
159 ensureArray,
160 freezeDeep,
161 logTruncate,
162 randomBytesAsync,
163 stackSafePush,
164 topicLeaseDefaults,
165 validHash,
166 };