update dependencies, devDependencies, copyright
[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 got response fields.
65 * @param {*} res
66 * @returns
67 */
68 const gotResponseLogData = (res) => {
69 const data = common.pick(res, [
70 'statusCode',
71 'statusMessage',
72 'headers',
73 'body',
74 'error',
75 ]);
76 if (typeof res.body === 'string') {
77 data.body = logTruncate(data.body, 100);
78 } else if (res.body instanceof Buffer) {
79 data.body = `<Buffer ${res.body.byteLength} bytes>`;
80 }
81 if (res?.timings?.phases?.total) {
82 data.elapsedTimeMs = res.timings.phases.total;
83 }
84 if (res?.redirectUrls?.length) {
85 data.redirectUrls = res.redirectUrls;
86 }
87 if (res?.retryCount) {
88 data.retryCount = res.retryCount;
89 }
90 return data;
91 };
92
93
94 /**
95 * Fallback values, if not configured.
96 * @returns {Object}
97 */
98 const topicLeaseDefaults = () => {
99 return Object.freeze({
100 leaseSecondsPreferred: 86400 * 10,
101 leaseSecondsMin: 86400 * 1,
102 leaseSecondsMax: 86400 * 365,
103 });
104 };
105
106
107 /**
108 * Pick from a range, constrained, with some fuzziness.
109 * @param {Number} attempt
110 * @param {Number[]} delays
111 * @param {Number} jitter
112 * @returns {Number}
113 */
114 const attemptRetrySeconds = (attempt, retryBackoffSeconds = [60, 120, 360, 1440, 7200, 43200, 86400], jitter = 0.618) => {
115 const maxAttempt = retryBackoffSeconds.length - 1;
116 if (typeof attempt !== 'number' || attempt < 0) {
117 attempt = 0;
118 } else if (attempt > maxAttempt) {
119 attempt = maxAttempt;
120 }
121 // eslint-disable-next-line security/detect-object-injection
122 let seconds = retryBackoffSeconds[attempt];
123 seconds += Math.floor(Math.random() * seconds * jitter);
124 return seconds;
125 };
126
127
128 /**
129 * Return array items split as an array of arrays of no more than per items each.
130 * @param {Array} array
131 * @param {Number} per
132 */
133 const arrayChunk = (array, per = 1) => {
134 const nChunks = Math.ceil(array.length / per);
135 return Array.from(Array(nChunks), (_, i) => array.slice(i * per, (i + 1) * per));
136 };
137
138
139 /**
140 * Be paranoid about blowing the stack when pushing to an array.
141 * @param {Array} dst
142 * @param {Array} src
143 */
144 const stackSafePush = (dst, src) => {
145 const jsEngineMaxArguments = 2**16; // Current as of Node 12
146 arrayChunk(src, jsEngineMaxArguments).forEach((items) => {
147 Array.prototype.push.apply(dst, items);
148 });
149 };
150
151
152 /**
153 * Limit length of string to keep logs sane
154 * @param {String} str
155 * @param {Number} len
156 * @returns {String}
157 */
158 const logTruncate = (str, len) => {
159 if (typeof str !== 'string' || str.toString().length <= len) {
160 return str;
161 }
162 return str.toString().slice(0, len) + `... (${str.toString().length} bytes)`;
163 };
164
165 module.exports = {
166 ...common,
167 arrayChunk,
168 attemptRetrySeconds,
169 gotResponseLogData,
170 ensureArray,
171 freezeDeep,
172 logTruncate,
173 randomBytesAsync,
174 stackSafePush,
175 topicLeaseDefaults,
176 validHash,
177 };