ee-core 2.0.3 → 2.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/bin/tools.js +4 -0
  2. package/config/config.default.js +9 -0
  3. package/const/channel.js +10 -1
  4. package/core/lib/ee.js +1 -1
  5. package/core/lib/loader/file_loader.js +2 -2
  6. package/core/lib/loader/mixin/config.js +1 -1
  7. package/core/lib/utils/index.js +1 -1
  8. package/ee/eeApp.js +8 -7
  9. package/exception/index.js +40 -12
  10. package/httpclient/index.js +4 -12
  11. package/index.js +1 -1
  12. package/jobs/child/app.js +11 -2
  13. package/jobs/child/forkProcess.js +81 -6
  14. package/jobs/child/index.js +41 -45
  15. package/jobs/child-pool/index.js +205 -0
  16. package/jobs/index.js +3 -1
  17. package/jobs/load-balancer/algorithm/index.js +12 -0
  18. package/jobs/load-balancer/algorithm/minimumConnection.js +19 -0
  19. package/jobs/load-balancer/algorithm/polling.js +12 -0
  20. package/jobs/load-balancer/algorithm/random.js +10 -0
  21. package/jobs/load-balancer/algorithm/specify.js +15 -0
  22. package/jobs/load-balancer/algorithm/weights.js +22 -0
  23. package/jobs/load-balancer/algorithm/weightsMinimumConnection.js +30 -0
  24. package/jobs/load-balancer/algorithm/weightsPolling.js +23 -0
  25. package/jobs/load-balancer/algorithm/weightsRandom.js +17 -0
  26. package/jobs/load-balancer/consts.js +10 -0
  27. package/jobs/load-balancer/index.js +202 -0
  28. package/jobs/load-balancer/scheduler.js +32 -0
  29. package/loader/index.js +22 -2
  30. package/message/childMessage.js +23 -0
  31. package/package.json +1 -6
  32. package/ps/index.js +44 -0
  33. package/tools/encrypt.js +105 -45
  34. package/utils/co.js +237 -0
  35. package/utils/depd/index.js +538 -0
  36. package/utils/depd/lib/browser/index.js +77 -0
  37. package/utils/extend.js +73 -0
  38. package/utils/get-port/index.d.ts +64 -0
  39. package/utils/get-port/index.js +109 -0
  40. package/utils/helper.js +25 -1
  41. package/utils/index.js +46 -0
  42. package/utils/ip.js +261 -0
  43. package/utils/is.js +2 -1
  44. package/utils/time/index.js +20 -0
  45. package/utils/time/ms.js +162 -0
  46. package/jobs/childPool/app.js +0 -62
  47. package/jobs/childPool/forkProcess.js +0 -81
  48. package/jobs/childPool/index.js +0 -71
  49. package/jobs/childPool/pool.js +0 -67
  50. /package/{oldUtils → old-utils}/index.js +0 -0
@@ -0,0 +1,64 @@
1
+ /// <reference types="node"/>
2
+ import {ListenOptions} from 'net';
3
+
4
+ declare namespace getPort {
5
+ interface Options extends Omit<ListenOptions, 'port'> {
6
+ /**
7
+ A preferred port or an iterable of preferred ports to use.
8
+ */
9
+ readonly port?: number | Iterable<number>;
10
+
11
+ /**
12
+ The host on which port resolution should be performed. Can be either an IPv4 or IPv6 address.
13
+ */
14
+ readonly host?: string;
15
+ }
16
+ }
17
+
18
+ declare const getPort: {
19
+ /**
20
+ Get an available TCP port number.
21
+
22
+ @returns Port number.
23
+
24
+ @example
25
+ ```
26
+ import getPort = require('get-port');
27
+
28
+ (async () => {
29
+ console.log(await getPort());
30
+ //=> 51402
31
+
32
+ // Pass in a preferred port
33
+ console.log(await getPort({port: 3000}));
34
+ // Will use 3000 if available, otherwise fall back to a random port
35
+
36
+ // Pass in an array of preferred ports
37
+ console.log(await getPort({port: [3000, 3001, 3002]}));
38
+ // Will use any element in the preferred ports array if available, otherwise fall back to a random port
39
+ })();
40
+ ```
41
+ */
42
+ (options?: getPort.Options): Promise<number>;
43
+
44
+ /**
45
+ Make a range of ports `from`...`to`.
46
+
47
+ @param from - First port of the range. Must be in the range `1024`...`65535`.
48
+ @param to - Last port of the range. Must be in the range `1024`...`65535` and must be greater than `from`.
49
+ @returns The ports in the range.
50
+
51
+ @example
52
+ ```
53
+ import getPort = require('get-port');
54
+
55
+ (async () => {
56
+ console.log(await getPort({port: getPort.makeRange(3000, 3100)}));
57
+ // Will use any port from 3000 to 3100, otherwise fall back to a random port
58
+ })();
59
+ ```
60
+ */
61
+ makeRange(from: number, to: number): Iterable<number>;
62
+ };
63
+
64
+ export = getPort;
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ const net = require('net');
3
+
4
+ class Locked extends Error {
5
+ constructor(port) {
6
+ super(`${port} is locked`);
7
+ }
8
+ }
9
+
10
+ const lockedPorts = {
11
+ old: new Set(),
12
+ young: new Set()
13
+ };
14
+
15
+ // On this interval, the old locked ports are discarded,
16
+ // the young locked ports are moved to old locked ports,
17
+ // and a new young set for locked ports are created.
18
+ const releaseOldLockedPortsIntervalMs = 1000 * 15;
19
+
20
+ // Lazily create interval on first use
21
+ let interval;
22
+
23
+ const getAvailablePort = options => new Promise((resolve, reject) => {
24
+ const server = net.createServer();
25
+ server.unref();
26
+ server.on('error', reject);
27
+ server.listen(options, () => {
28
+ const {port} = server.address();
29
+ server.close(() => {
30
+ resolve(port);
31
+ });
32
+ });
33
+ });
34
+
35
+ const portCheckSequence = function * (ports) {
36
+ if (ports) {
37
+ yield * ports;
38
+ }
39
+
40
+ yield 0; // Fall back to 0 if anything else failed
41
+ };
42
+
43
+ module.exports = async options => {
44
+ let ports;
45
+
46
+ if (options) {
47
+ ports = typeof options.port === 'number' ? [options.port] : options.port;
48
+ }
49
+
50
+ if (interval === undefined) {
51
+ interval = setInterval(() => {
52
+ lockedPorts.old = lockedPorts.young;
53
+ lockedPorts.young = new Set();
54
+ }, releaseOldLockedPortsIntervalMs);
55
+
56
+ // Does not exist in some environments (Electron, Jest jsdom env, browser, etc).
57
+ if (interval.unref) {
58
+ interval.unref();
59
+ }
60
+ }
61
+
62
+ for (const port of portCheckSequence(ports)) {
63
+ try {
64
+ let availablePort = await getAvailablePort({...options, port}); // eslint-disable-line no-await-in-loop
65
+ while (lockedPorts.old.has(availablePort) || lockedPorts.young.has(availablePort)) {
66
+ if (port !== 0) {
67
+ throw new Locked(port);
68
+ }
69
+
70
+ availablePort = await getAvailablePort({...options, port}); // eslint-disable-line no-await-in-loop
71
+ }
72
+
73
+ lockedPorts.young.add(availablePort);
74
+ return availablePort;
75
+ } catch (error) {
76
+ if (!['EADDRINUSE', 'EACCES'].includes(error.code) && !(error instanceof Locked)) {
77
+ throw error;
78
+ }
79
+ }
80
+ }
81
+
82
+ throw new Error('No available ports found');
83
+ };
84
+
85
+ module.exports.makeRange = (from, to) => {
86
+ if (!Number.isInteger(from) || !Number.isInteger(to)) {
87
+ throw new TypeError('`from` and `to` must be integer numbers');
88
+ }
89
+
90
+ if (from < 1024 || from > 65535) {
91
+ throw new RangeError('`from` must be between 1024 and 65535');
92
+ }
93
+
94
+ if (to < 1024 || to > 65536) {
95
+ throw new RangeError('`to` must be between 1024 and 65536');
96
+ }
97
+
98
+ if (to < from) {
99
+ throw new RangeError('`to` must be greater than or equal to `from`');
100
+ }
101
+
102
+ const generator = function * (from, to) {
103
+ for (let port = from; port <= to; port++) {
104
+ yield port;
105
+ }
106
+ };
107
+
108
+ return generator(from, to);
109
+ };
package/utils/helper.js CHANGED
@@ -2,7 +2,7 @@ const fs = require('fs');
2
2
  const mkdirp = require('mkdirp');
3
3
  const convert = require('koa-convert');
4
4
  const is = require('is-type-of');
5
- const co = require('co');
5
+ const co = require('./co');
6
6
 
7
7
  /**
8
8
  * fnDebounce
@@ -114,3 +114,27 @@ exports.callFn = async function (fn, args, ctx) {
114
114
  exports.middleware = function (fn) {
115
115
  return is.generatorFunction(fn) ? convert(fn) : fn;
116
116
  }
117
+
118
+ /**
119
+ * 序列化对象
120
+ */
121
+ exports.stringify = function(obj, ignore = []) {
122
+ const result = {};
123
+ Object.keys(obj).forEach(key => {
124
+ if (!ignore.includes(key)) {
125
+ result[key] = obj[key];
126
+ }
127
+ });
128
+ return JSON.stringify(result);
129
+ }
130
+
131
+ /**
132
+ * 是否有效值
133
+ */
134
+ exports.validValue = function(value) {
135
+ return (
136
+ value !== undefined &&
137
+ value !== null &&
138
+ value !== ''
139
+ );
140
+ }
package/utils/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ "use strict";
2
+
3
+ const os = require("os");
1
4
  const path = require('path');
2
5
  const Ps = require('../ps');
3
6
  const UtilsJson = require('./json');
@@ -11,5 +14,48 @@ exports.getPackage = function() {
11
14
  return json;
12
15
  };
13
16
 
17
+ /**
18
+ * Get the first proper MAC address
19
+ * @param iface If provided, restrict MAC address fetching to this interface
20
+ */
21
+ exports.getMAC = function(iface) {
22
+ const zeroRegex = /(?:[0]{1,2}[:-]){5}[0]{1,2}/;
23
+ const list = os.networkInterfaces();
24
+ if (iface) {
25
+ const parts = list[iface];
26
+ if (!parts) {
27
+ throw new Error(`interface ${iface} was not found`);
28
+ }
29
+ for (const part of parts) {
30
+ if (zeroRegex.test(part.mac) === false) {
31
+ return part.mac;
32
+ }
33
+ }
34
+ throw new Error(`interface ${iface} had no valid mac addresses`);
35
+ }
36
+ else {
37
+ for (const [key, parts] of Object.entries(list)) {
38
+ // for some reason beyond me, this is needed to satisfy typescript
39
+ // fix https://github.com/bevry/getmac/issues/100
40
+ if (!parts)
41
+ continue;
42
+ for (const part of parts) {
43
+ if (zeroRegex.test(part.mac) === false) {
44
+ return part.mac;
45
+ }
46
+ }
47
+ }
48
+ }
49
+ throw new Error('failed to get the MAC address');
50
+ }
51
+
52
+ /**
53
+ * Check if the input is a valid MAC address
54
+ */
55
+ exports.isMAC = function(macAddress) {
56
+ const macRegex = /(?:[a-z0-9]{1,2}[:-]){5}[a-z0-9]{1,2}/i;
57
+ return macRegex.test(macAddress);
58
+ }
59
+
14
60
 
15
61
 
package/utils/ip.js ADDED
@@ -0,0 +1,261 @@
1
+ const { promisify } = require('util');
2
+ const dgram = require('dgram');
3
+ const { isIPv6, isIPv4 } = require('net');
4
+ const dns = require('dns-socket');
5
+ const HttpClient = require('../httpclient');
6
+
7
+ const emptyIP = '';
8
+ const defaults = {
9
+ timeout: 3000,
10
+ type: 'http', // 'http' | 'dns' | 'all'
11
+ };
12
+
13
+ const dnsServers = [
14
+ {
15
+ v4: {
16
+ servers: [
17
+ '208.67.222.222',
18
+ '208.67.220.220',
19
+ '208.67.222.220',
20
+ '208.67.220.222',
21
+ ],
22
+ name: 'myip.opendns.com',
23
+ type: 'A',
24
+ },
25
+ v6: {
26
+ servers: [
27
+ '2620:0:ccc::2',
28
+ '2620:0:ccd::2',
29
+ ],
30
+ name: 'myip.opendns.com',
31
+ type: 'AAAA',
32
+ },
33
+ },
34
+ ];
35
+
36
+ const type = {
37
+ v4: {
38
+ dnsServers: dnsServers.map(({v4: {servers, ...question}}) => ({
39
+ servers, question,
40
+ })),
41
+ httpsUrls: [
42
+ 'https://icanhazip.com/',
43
+ 'https://api.ipify.org/',
44
+ ],
45
+ },
46
+ v6: {
47
+ dnsServers: dnsServers.map(({v6: {servers, ...question}}) => ({
48
+ servers, question,
49
+ })),
50
+ httpsUrls: [
51
+ 'https://icanhazip.com/',
52
+ 'https://api6.ipify.org/',
53
+ ],
54
+ },
55
+ };
56
+
57
+ const queryDns = (version, options) => {
58
+ const data = type[version];
59
+ const socket = dns({
60
+ retries: 0,
61
+ maxQueries: 1,
62
+ socket: dgram.createSocket(version === 'v6' ? 'udp6' : 'udp4'),
63
+ timeout: options.timeout,
64
+ });
65
+
66
+ const socketQuery = promisify(socket.query.bind(socket));
67
+ const promise = (async () => {
68
+ for (const dnsServerInfo of data.dnsServers) {
69
+ const {servers, question} = dnsServerInfo;
70
+ for (const server of servers) {
71
+ if (socket.destroyed) {
72
+ return emptyIP;
73
+ }
74
+
75
+ try {
76
+ const {name, type, transform} = question;
77
+ const dnsResponse = await socketQuery({questions: [{name, type}]}, 53, server);
78
+
79
+ const {
80
+ answers: {
81
+ 0: {
82
+ data,
83
+ },
84
+ },
85
+ } = dnsResponse;
86
+ const response = (typeof data === 'string' ? data : data.toString()).trim();
87
+ const ip = (transform && version === 'v6') ? transform(response) : response;
88
+ const method = version === 'v6' ? isIPv6 : isIPv4;
89
+
90
+ if (ip && method(ip)) {
91
+ socket.destroy();
92
+ return ip;
93
+ }
94
+ } catch (error) {
95
+ // Log.coreLogger.error('[ee-core] [utils/ip] queryDns error:', error);
96
+ }
97
+ }
98
+ }
99
+
100
+ socket.destroy();
101
+ return emptyIP;
102
+ })();
103
+
104
+ promise.cancel = () => {
105
+ socket.destroy();
106
+ };
107
+
108
+ return promise;
109
+ };
110
+
111
+ const queryHttps = (version, options) => {
112
+ let cancel;
113
+ const hc = new HttpClient();
114
+
115
+ const promise = (async () => {
116
+ const requestOptions = {
117
+ method: 'GET',
118
+ timeout: options.timeout,
119
+ dataType: 'text',
120
+ };
121
+
122
+ const urls = [
123
+ ...type[version].httpsUrls,
124
+ ...(options.fallbackUrls ?? []),
125
+ ];
126
+
127
+ for (const url of urls) {
128
+ try {
129
+ const gotPromise = hc.request(url, requestOptions);
130
+ gotPromise.cancel = () => {
131
+ // todo
132
+ }
133
+ cancel = gotPromise.cancel;
134
+
135
+ const response = await gotPromise;
136
+ let result = response.status == 200 ? response.data : '';
137
+ const ip = result.trim();
138
+
139
+ const method = version === 'v6' ? isIPv6 : isIPv4;
140
+
141
+ if (ip && method(ip)) {
142
+ return ip;
143
+ }
144
+ } catch (error) {
145
+ //Log.coreLogger.error('[ee-core] [utils/ip] queryHttps error:', error);
146
+ }
147
+ }
148
+
149
+ return emptyIP;
150
+ })();
151
+
152
+ promise.cancel = function () {
153
+ return cancel.apply(this);
154
+ };
155
+
156
+ return promise;
157
+ };
158
+
159
+ const queryAll = (version, options) => {
160
+ let cancel;
161
+ const promise = (async () => {
162
+ let response;
163
+ const dnsPromise = queryDns(version, options);
164
+ cancel = dnsPromise.cancel;
165
+ try {
166
+ response = await dnsPromise;
167
+ } catch {
168
+ const httpsPromise = queryHttps(version, options);
169
+ cancel = httpsPromise.cancel;
170
+ response = await httpsPromise;
171
+ }
172
+
173
+ return response;
174
+ })();
175
+
176
+ promise.cancel = cancel;
177
+
178
+ return promise;
179
+ };
180
+
181
+ /**
182
+ * 查询 public ipv4
183
+ */
184
+ function publicIpv4(options) {
185
+ options = {
186
+ ...defaults,
187
+ ...options,
188
+ };
189
+
190
+ if (options.type == 'http') {
191
+ return queryHttps('v4', options);
192
+ }
193
+
194
+ if (options.type == 'dns') {
195
+ return queryDns('v4', options);
196
+ }
197
+
198
+ return queryAll('v4', options);
199
+ }
200
+
201
+ /**
202
+ * 查询public ipv6
203
+ */
204
+ function publicIpv6(options) {
205
+ options = {
206
+ ...defaults,
207
+ ...options,
208
+ };
209
+
210
+ if (options.type == 'http') {
211
+ return queryHttps('v6', options);
212
+ }
213
+
214
+ if (options.type == 'dns') {
215
+ return queryDns('v6', options);
216
+ }
217
+
218
+ return queryAll('v6', options);
219
+ }
220
+
221
+ /**
222
+ * todo 未来趋势是ipv6优先,以后再放开
223
+ */
224
+ const publicIp = createPublicIp(publicIpv4, publicIpv6);
225
+ function createPublicIp(publicIpv4, publicIpv6) {
226
+ return function publicIp(options) {
227
+ const ipv4Promise = publicIpv4(options);
228
+ const ipv6Promise = publicIpv6(options);
229
+
230
+ const promise = (async () => {
231
+ try {
232
+ const ipv6 = await ipv6Promise;
233
+ ipv4Promise.cancel();
234
+ return ipv6;
235
+ } catch (ipv6Error) {
236
+ //Log.coreLogger.error('[ee-core] [utils/ip] publicIp ipv6Error:', ipv6Error);
237
+ try {
238
+ return await ipv4Promise;
239
+ } catch (ipv4Error) {
240
+ //Log.coreLogger.error('[ee-core] [utils/ip] publicIp ipv4Error:', ipv4Error);
241
+ }
242
+ }
243
+ })();
244
+
245
+ promise.cancel = () => {
246
+ ipv4Promise.cancel();
247
+ ipv6Promise.cancel();
248
+ };
249
+
250
+ return promise;
251
+ };
252
+ }
253
+
254
+ const IP = {
255
+ //publicIp,
256
+ publicIpv4,
257
+ publicIpv6
258
+ }
259
+
260
+ module.exports = IP;
261
+
package/utils/is.js CHANGED
@@ -139,7 +139,8 @@ const IS = {
139
139
  _osxRelease () {
140
140
  const actual = release().split('.')
141
141
  return `10.${actual[0] - 4}.${actual[1]}`
142
- }
142
+ },
143
+
143
144
  }
144
145
 
145
146
  module.exports = IS;
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const util = require('util');
4
+ const ms = require('./ms');
5
+
6
+ function humanizeToMs (t) {
7
+ if (typeof t === 'number') return t;
8
+ var r = ms(t);
9
+ if (r === undefined) {
10
+ var err = new Error(util.format('humanize-ms(%j) result undefined', t));
11
+ console.warn(err.stack);
12
+ }
13
+ return r;
14
+ }
15
+
16
+ const TIME = {
17
+ humanizeToMs,
18
+ ms
19
+ }
20
+ module.exports = TIME;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Helpers.
3
+ */
4
+
5
+ var s = 1000;
6
+ var m = s * 60;
7
+ var h = m * 60;
8
+ var d = h * 24;
9
+ var w = d * 7;
10
+ var y = d * 365.25;
11
+
12
+ /**
13
+ * Parse or format the given `val`.
14
+ *
15
+ * Options:
16
+ *
17
+ * - `long` verbose formatting [false]
18
+ *
19
+ * @param {String|Number} val
20
+ * @param {Object} [options]
21
+ * @throws {Error} throw an error if val is not a non-empty string or a number
22
+ * @return {String|Number}
23
+ * @api public
24
+ */
25
+
26
+ module.exports = function(val, options) {
27
+ options = options || {};
28
+ var type = typeof val;
29
+ if (type === 'string' && val.length > 0) {
30
+ return parse(val);
31
+ } else if (type === 'number' && isFinite(val)) {
32
+ return options.long ? fmtLong(val) : fmtShort(val);
33
+ }
34
+ throw new Error(
35
+ 'val is not a non-empty string or a valid number. val=' +
36
+ JSON.stringify(val)
37
+ );
38
+ };
39
+
40
+ /**
41
+ * Parse the given `str` and return milliseconds.
42
+ *
43
+ * @param {String} str
44
+ * @return {Number}
45
+ * @api private
46
+ */
47
+
48
+ function parse(str) {
49
+ str = String(str);
50
+ if (str.length > 100) {
51
+ return;
52
+ }
53
+ var match = /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(
54
+ str
55
+ );
56
+ if (!match) {
57
+ return;
58
+ }
59
+ var n = parseFloat(match[1]);
60
+ var type = (match[2] || 'ms').toLowerCase();
61
+ switch (type) {
62
+ case 'years':
63
+ case 'year':
64
+ case 'yrs':
65
+ case 'yr':
66
+ case 'y':
67
+ return n * y;
68
+ case 'weeks':
69
+ case 'week':
70
+ case 'w':
71
+ return n * w;
72
+ case 'days':
73
+ case 'day':
74
+ case 'd':
75
+ return n * d;
76
+ case 'hours':
77
+ case 'hour':
78
+ case 'hrs':
79
+ case 'hr':
80
+ case 'h':
81
+ return n * h;
82
+ case 'minutes':
83
+ case 'minute':
84
+ case 'mins':
85
+ case 'min':
86
+ case 'm':
87
+ return n * m;
88
+ case 'seconds':
89
+ case 'second':
90
+ case 'secs':
91
+ case 'sec':
92
+ case 's':
93
+ return n * s;
94
+ case 'milliseconds':
95
+ case 'millisecond':
96
+ case 'msecs':
97
+ case 'msec':
98
+ case 'ms':
99
+ return n;
100
+ default:
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Short format for `ms`.
107
+ *
108
+ * @param {Number} ms
109
+ * @return {String}
110
+ * @api private
111
+ */
112
+
113
+ function fmtShort(ms) {
114
+ var msAbs = Math.abs(ms);
115
+ if (msAbs >= d) {
116
+ return Math.round(ms / d) + 'd';
117
+ }
118
+ if (msAbs >= h) {
119
+ return Math.round(ms / h) + 'h';
120
+ }
121
+ if (msAbs >= m) {
122
+ return Math.round(ms / m) + 'm';
123
+ }
124
+ if (msAbs >= s) {
125
+ return Math.round(ms / s) + 's';
126
+ }
127
+ return ms + 'ms';
128
+ }
129
+
130
+ /**
131
+ * Long format for `ms`.
132
+ *
133
+ * @param {Number} ms
134
+ * @return {String}
135
+ * @api private
136
+ */
137
+
138
+ function fmtLong(ms) {
139
+ var msAbs = Math.abs(ms);
140
+ if (msAbs >= d) {
141
+ return plural(ms, msAbs, d, 'day');
142
+ }
143
+ if (msAbs >= h) {
144
+ return plural(ms, msAbs, h, 'hour');
145
+ }
146
+ if (msAbs >= m) {
147
+ return plural(ms, msAbs, m, 'minute');
148
+ }
149
+ if (msAbs >= s) {
150
+ return plural(ms, msAbs, s, 'second');
151
+ }
152
+ return ms + ' ms';
153
+ }
154
+
155
+ /**
156
+ * Pluralization helper.
157
+ */
158
+
159
+ function plural(ms, msAbs, n, name) {
160
+ var isPlural = msAbs >= n * 1.5;
161
+ return Math.round(ms / n) + ' ' + name + (isPlural ? 's' : '');
162
+ }