update dependencies, fixes to support new authentication features
[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 potential sha* algorithm
23 * @returns {boolean} is supported
24 */
25 const validHash = (algorithm) => getHashes()
26 .filter((h) => h.match(/^sha\d+$/))
27 .includes(algorithm);
28
29
30 /**
31 * Return an array containing x if x is not an array.
32 * @param {*} x possibly an array
33 * @returns {Array} x or [x]
34 */
35 const ensureArray = (x) => {
36 if (x === undefined) {
37 return [];
38 }
39 if (!Array.isArray(x)) {
40 return Array(x);
41 }
42 return x;
43 };
44
45
46 /**
47 * Recursively freeze an object.
48 * @param {object} o object
49 * @returns {object} frozen object
50 */
51 const freezeDeep = (o) => {
52 Object.freeze(o);
53 Object.getOwnPropertyNames(o).forEach((prop) => {
54 if (Object.hasOwn(o, prop)
55 && ['object', 'function'].includes(typeof o[prop])
56 && !Object.isFrozen(o[prop])) {
57 return freezeDeep(o[prop]);
58 }
59 });
60 return o;
61 };
62
63
64 /**
65 * Pick out useful got response fields.
66 * @param {*} res response
67 * @returns {object} winnowed response
68 */
69 const gotResponseLogData = (res) => {
70 const data = common.pick(res, [
71 'statusCode',
72 'statusMessage',
73 'headers',
74 'body',
75 'error',
76 ]);
77 if (typeof res.body === 'string') {
78 data.body = logTruncate(data.body, 100);
79 } else if (res.body instanceof Buffer) {
80 data.body = `<Buffer ${res.body.byteLength} bytes>`;
81 }
82 if (res?.timings?.phases?.total) {
83 data.elapsedTimeMs = res.timings.phases.total;
84 }
85 if (res?.redirectUrls?.length) {
86 data.redirectUrls = res.redirectUrls;
87 }
88 if (res?.retryCount) {
89 data.retryCount = res.retryCount;
90 }
91 return data;
92 };
93
94
95 /**
96 * Fallback values, if not configured.
97 * @returns {object} object
98 */
99 const topicLeaseDefaults = () => {
100 return Object.freeze({
101 leaseSecondsPreferred: 86400 * 10,
102 leaseSecondsMin: 86400 * 1,
103 leaseSecondsMax: 86400 * 365,
104 });
105 };
106
107
108 /**
109 * Pick from a range, constrained, with some fuzziness.
110 * @param {number} attempt attempt number
111 * @param {number[]=} retryBackoffSeconds array of indexed delays
112 * @param {number=} jitter vary backoff by up to this fraction additional
113 * @returns {number} seconds to delay retry
114 */
115 const attemptRetrySeconds = (attempt, retryBackoffSeconds = [60, 120, 360, 1440, 7200, 43200, 86400], jitter = 0.618) => {
116 const maxAttempt = retryBackoffSeconds.length - 1;
117 if (typeof attempt !== 'number' || attempt < 0) {
118 attempt = 0;
119 } else if (attempt > maxAttempt) {
120 attempt = maxAttempt;
121 }
122
123 let seconds = retryBackoffSeconds[attempt];
124 seconds += Math.floor(Math.random() * seconds * jitter);
125 return seconds;
126 };
127
128
129 /**
130 * Return array items split as an array of arrays of no more than per items each.
131 * @param {Array} array items
132 * @param {number} per chunk size
133 * @returns {Array[]} array of chunks
134 */
135 const arrayChunk = (array, per = 1) => {
136 const nChunks = Math.ceil(array.length / per);
137 return Array.from(Array(nChunks), (_, i) => array.slice(i * per, (i + 1) * per));
138 };
139
140
141 /**
142 * Be paranoid about blowing the stack when pushing to an array.
143 * @param {Array} dst destination array
144 * @param {Array} src source array
145 */
146 const stackSafePush = (dst, src) => {
147 const jsEngineMaxArguments = 2**16; // Current as of Node 12
148 arrayChunk(src, jsEngineMaxArguments).forEach((items) => {
149 Array.prototype.push.apply(dst, items);
150 });
151 };
152
153
154 /**
155 * Limit length of string to keep logs sane
156 * @param {string} str string
157 * @param {number} len max length
158 * @returns {string} truncated string
159 */
160 const logTruncate = (str, len) => {
161 if (typeof str !== 'string' || str.toString().length <= len) {
162 return str;
163 }
164 return str.toString().slice(0, len) + `... (${str.toString().length} bytes)`;
165 };
166
167 const nop = () => undefined;
168
169 module.exports = {
170 ...common,
171 arrayChunk,
172 attemptRetrySeconds,
173 gotResponseLogData,
174 ensureArray,
175 freezeDeep,
176 logTruncate,
177 nop,
178 randomBytesAsync,
179 stackSafePush,
180 topicLeaseDefaults,
181 validHash,
182 };