diff --git a/README.md b/README.md index b8fab34..4db0407 100644 --- a/README.md +++ b/README.md @@ -48,25 +48,33 @@ Usage: nettime [options] Options: - -V, --version output the version number - -0, --http1.0 use HTTP 1.0 - --http1.1 use HTTP 1.1 (default) - --http2 use HTTP 2.0 - -c, --connect-timeout maximum time to wait for a connection - -d, --data data to be sent using the POST verb - -f, --format set output format: text, json, raw - -H, --header
send specific HTTP header - -i, --include include response headers in the output - -I, --head use HEAD verb to get document info only - -k, --insecure ignore certificate errors - -o, --output write the received data to a file - -t, --time-unit set time unit: ms, s+ns - -u, --user credentials for Basic Authentication - -X, --request specify HTTP verb to use for the request - -h, --help output usage information + -V, --version output the version number + -0, --http1.0 use HTTP 1.0 + --http1.1 use HTTP 1.1 (default) + --http2 use HTTP 2.0 + -c, --connect-timeout maximum time to wait for a connection + -d, --data data to be sent using the POST verb + -f, --format set output format: text, json, raw + -H, --header
send specific HTTP header + -i, --include include response headers in the output + -I, --head use HEAD verb to get document info only + -k, --insecure ignore certificate errors + -o, --output write the received data to a file + -t, --time-unit set time unit: ms, s+ns + -u, --user credentials for Basic Authentication + -X, --request specify HTTP verb to use for the request + -C, --request-count count of requests to make (default: 1) + -D, --request-delay delay between two requests + -A, --average-timings print an average of multiple request timings + -h, --help output usage information The default output format is "text" and time unit "ms". Other options are compatible with curl. Timings are printed to the standard output. + +Examples: + + $ nettime -f json https://www.github.com + $ nettime --http2 -C 3 -A https://www.google.com ``` ## Programmatic usage @@ -109,6 +117,9 @@ The input object can contain: * `outputFile`: file path to write the received data to. * `rejectUnauthorized`: boolean to refuse finishing the HTTPS request, is set to `true` (the default), if validation of the web site certificate fails; setting it to `false` makes the request ignore certificate errors. * `returnResponse`: boolean for including property `response` (`Buffer`) with the received data in the promised result object. +* `requestCount`: integer for making multiple requests instead of one. +* `requestDelay`: integer to introduce a delay (in milliseconds ) between each two requests. The default is 100. +* `timeout`: intere to set the maximum time (in milliseconds) a single request should take before aborting it. The result object contains: @@ -134,21 +145,30 @@ The result object contains: } ``` +If the option `requestCount` is greater than `1`, the result objects will be returned in an array of the same length as teh `requestCount` value. + *Note*: The `time-unit` parameter affects not only the "text" output format of the command line script, but also the "json" one. If set to "ms", timing values will be printed in milliseconds. If set to "s+ns", timings will be printed as arrays in [process.hrtime]'s format. Calling the `nettime` function programmatically will always return the timings as arrays in [process.hrtime]'s format. ### Helper functions -The following static methods are exposed on the `nettime` function to help dealing with [process.hrtime]'s timing format: +The following functions are exposed as named exports from the `nettime/lib/timings` module to help dealing with [process.hrtime]'s timing format and timings from multiple requests: * `getDuration(start, end)`: computes the difference between two timings. Expects two arrays in [process.hrtime]'s format and returns the result as an array in the same [process.hrtime]'s format. * `getMilliseconds(timing)`: converts the timing to milliseconds. Expects an array in [process.hrtime]'s format and returns the result as an integer. +* `computeAverageDurations(multipleTimings)`: computes average durations from an array of event timings. The array is supposed to contain objects with the same keys as the `timings` object from the `nettime` response. The returned object will contain the same keys pointing to event durations in [process.hrtime]'s format. +* `createTimingsFromDurations(timings, startTime)`: reconstructs event timings from event durations. The `timings` object is supposed to contain the same keys as the `timings` object from the `nettime` response, but pointing to event durations in [process.hrtime]'s format. The returned object will contain the same keys, but pointing to event times in [process.hrtime]'s format. The `startTime` parameter can shoft the event times. The default is no shift - `[0, 0]`. These methods can be required separately too: ```js -const { getDuration, getMilliseconds } = require('nettime/lib/timings') +const { + getDuration, getMilliseconds, + computeAverageDurations, createTimingsFromDurations +} = require('nettime/lib/timings') ``` +Methods `getDuration` and `getMilliseconds` are accessible also as static methods of the `nettime` function exported from the main `nettime` module. + ## Contributing In lieu of a formal styleguide, take care to maintain the existing coding style. Add unit tests for any new or changed functionality. Lint and test your code using Grunt. diff --git a/bin/nettime b/bin/nettime index fcac079..512eded 100755 --- a/bin/nettime +++ b/bin/nettime @@ -3,6 +3,9 @@ const commander = require('commander') const nettime = require('..') const { version, description } = require('../package.json') +const { + computeAverageDurations, createTimingsFromDurations +} = require('../lib/timings') const printTimings = require('../lib/printer') const readlineSync = require('readline-sync') @@ -13,7 +16,7 @@ commander .option('-0, --http1.0', 'use HTTP 1.0') .option('--http1.1', 'use HTTP 1.1 (default)') .option('--http2', 'use HTTP 2') - .option('-c, --connect-timeout ', 'maximum time to wait for a connection', parseInt) + .option('-c, --connect-timeout ', 'maximum time to wait for a connection', totInteger) .option('-d, --data ', 'data to be sent using the POST verb') .option('-f, --format ', 'set output format: text, json, raw') .option('-H, --header
', 'send specific HTTP header', collect, []) @@ -24,6 +27,9 @@ commander .option('-t, --time-unit ', 'set time unit: ms, s+ns') .option('-u, --user ', 'credentials for Basic Authentication') .option('-X, --request ', 'specify HTTP verb to use for the request') + .option('-C, --request-count ', 'count of requests to make', totInteger, 1) + .option('-D, --request-delay ', 'delay between two requests', totInteger, 100) + .option('-A, --average-timings', 'print an average of multiple request timings') .on('--help', () => { console.log() console.log(' The default output format is "text" and time unit "ms". Other options') @@ -31,8 +37,9 @@ commander console.log() console.log(' Examples:') console.log() - console.log(' $ nettime https://www.google.com') - console.log(' $ nettime -f json https://www.github.com') + console.log(' $ nettime https://www.github.com') + console.log(' $ nettime -f json https://www.gitlab.com') + console.log(' $ nettime --http2 -C 3 -A https://www.google.com') }) .parse(process.argv) @@ -98,7 +105,7 @@ if (credentials) { const { connectTimeout: timeout, data, head, include: includeHeaders, insecure, - output: outputFile, request + output: outputFile, request, requestCount, requestDelay, averageTimings } = commander const httpVersion = commander.http2 ? '2.0' : commander['http1.0'] ? '1.0' : '1.1' const method = request || (head ? 'HEAD' : data ? 'POST' : 'GET') @@ -116,9 +123,27 @@ nettime({ includeHeaders, outputFile, rejectUnauthorized, - timeout + timeout, + requestCount, + requestDelay }) - .then(result => console.log(formatter(result))) + .then(results => { + if (requestCount > 1) { + if (averageTimings) { + const result = computeAverageTimings(results) + results = [result] + } + } else { + results = [results] + } + return results + }) + .then(results => { + for (const result of results) { + console.log(formatter(result)) + console.log() + } + }) .catch(({ message }) => { console.error(message) process.exitCode = 1 @@ -129,9 +154,39 @@ function collect (value, result) { return result } +function totInteger (value) { + return parseInt(value) +} + function convertToMilliseconds (timings) { const getMilliseconds = nettime.getMilliseconds for (const timing in timings) { timings[timing] = getMilliseconds(timings[timing]) } } + +function computeAverageTimings (results) { + checkStatusCodes() + const timings = results.map(({ timings }) => timings) + const averageDurations = computeAverageDurations(timings) + return createAverageResult(results[0], averageDurations) + + function checkStatusCodes () { + let firstStatusCode + for (const { statusCode } of results) { + if (firstStatusCode === undefined) { + firstStatusCode = statusCode + } else { + if (firstStatusCode !== statusCode) { + throw new Error(`Status code of the first request was ${firstStatusCode}, but ${statusCode} was received later.`) + } + } + } + } + + function createAverageResult (firstResult, averageDurations) { + const { httpVersion, statusCode, statusMessage } = firstResult + const timings = createTimingsFromDurations(averageDurations) + return { timings, httpVersion, statusCode, statusMessage } + } +} diff --git a/lib/nettime.js b/lib/nettime.js index bd379a4..d6f25a5 100644 --- a/lib/nettime.js +++ b/lib/nettime.js @@ -5,7 +5,28 @@ const { EOL } = require('os') const { URL } = require('url') const { getDuration, getMilliseconds } = require('./timings') -function nettime (options) { +async function nettime (options) { + const { requestCount, requestDelay } = options + if (requestCount > 1) { + const results = [] + for (let i = 0; i < requestCount; ++i) { + const result = await makeSingleRequest(options) + results.push(result) + if (requestDelay) { + await wait(requestDelay) + } + options.appendToOutput = true + } + return results + } + return makeSingleRequest(options) +} + +function wait (milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)) +} + +function makeSingleRequest (options) { return new Promise((resolve, reject) => { const timings = {} const outputFile = options.outputFile @@ -34,8 +55,9 @@ function nettime (options) { if (includeHeaders && response) { prependOutputHeader() } + const flag = options.appendToOutput ? 'a' : 'w' return new Promise(resolve => - writeFile(outputFile, data, error => { + writeFile(outputFile, data, { flag }, error => { if (error) { if (options.failOnOutputFileError === false) { console.error(error.message) diff --git a/lib/printer.js b/lib/printer.js index bb901c8..283f091 100644 --- a/lib/printer.js +++ b/lib/printer.js @@ -1,30 +1,20 @@ -const { getDuration } = require('./timings') +const { events, getDuration } = require('./timings') const { sprintf } = require('sprintf-js') -const labels = { - socketOpen: 'Socket Open', - dnsLookup: 'DNS Lookup', - tcpConnection: 'TCP Connection', - tlsHandshake: 'TLS Handshake', - firstByte: 'First Byte', - contentTransfer: 'Content Transfer', - socketClose: 'Socket Close' -} - function printMilliseconds (timings) { let lastTiming = [0, 0] const output = [ 'Phase Finished Duration', '-----------------------------------' ] - for (const part in labels) { - const timing = timings[part] + for (const event in events) { + const timing = timings[event] if (timing) { const timeFraction = Math.round(timing[1] / 1e6) const duration = getDuration(lastTiming, timing) const durationFraction = Math.round(duration[1] / 1e6) output.push(sprintf('%-17s %3d.%03ds %3d.%03ds', - labels[part], timing[0], timeFraction, duration[0], durationFraction)) + events[event], timing[0], timeFraction, duration[0], durationFraction)) lastTiming = timing } } @@ -38,14 +28,14 @@ function printNanoseconds (timings) { 'Phase Finished Duration', '-----------------------------------------------' ] - for (const part in labels) { - const timing = timings[part] + for (const event in events) { + const timing = timings[event] if (timing) { const timeFraction = Math.round(timing[1] / 1000) / 1000 const duration = getDuration(lastTiming, timing) const durationFraction = Math.round(duration[1] / 1000) / 1000 output.push(sprintf('%-17s %3ds %7.3fms %3ds %7.3fms', - labels[part], timing[0], timeFraction, duration[0], durationFraction)) + events[event], timing[0], timeFraction, duration[0], durationFraction)) lastTiming = timing } } diff --git a/lib/timings.js b/lib/timings.js index 2ea81f5..d6a879c 100644 --- a/lib/timings.js +++ b/lib/timings.js @@ -1,3 +1,13 @@ +const events = { + socketOpen: 'Socket Open', + dnsLookup: 'DNS Lookup', + tcpConnection: 'TCP Connection', + tlsHandshake: 'TLS Handshake', + firstByte: 'First Byte', + contentTransfer: 'Content Transfer', + socketClose: 'Socket Close' +} + function getDuration (start, end) { let seconds = end[0] - start[0] let nanoseconds = end[1] - start[1] @@ -12,4 +22,90 @@ function getMilliseconds ([seconds, nanoseconds]) { return seconds * 1000 + Math.round(nanoseconds / 1000) / 1000 } -module.exports = { getDuration, getMilliseconds } +function computeAverageDurations (timings) { + const timingCount = timings.length + const durations = createEventDurations() + computeEventDurations() + checkSkippedEvents() + computeEventDurationAverages() + return durations + + function createEventDurations () { + const durations = {} + for (const event in events) { + durations[event] = [] + } + return durations + } + + function computeEventDurations () { + for (const timing of timings) { + let lastTime = [0, 0] + for (const event in events) { + const time = timing[event] + if (time) { + const duration = getDuration(lastTime, time) + durations[event].push(duration) + lastTime = time + } else { + durations[event].push(undefined) + } + } + } + } + + function checkSkippedEvents () { + for (const event in events) { + const durationValues = durations[event] + if (durationValues[0] === undefined) { + for (let i = 0; i < timingCount; ++i) { + if (durationValues[i] !== undefined) { + throw new Error(`Unexpected event ${event} timing of the request ${i}.`) + } + } + } else { + for (let i = 0; i < timingCount; ++i) { + if (durationValues[i] === undefined) { + throw new Error(`Expected event ${event} timing of the request ${i}.`) + } + } + } + } + } + + function computeEventDurationAverages () { + for (const event in events) { + durations[event] = durations[event].reduce((result, duration) => { + if (duration) { + return [result[0] + duration[0], result[1] + duration[1]] + } + return undefined + }, [0, 0]) + } + for (const event in events) { + const duration = durations[event] + if (duration) { + const [seconds, nanoseconds] = duration + durations[event] = [seconds / timingCount, nanoseconds / timingCount] + } + } + } +} + +function createTimingsFromDurations (durations, startTime) { + const timings = {} + let lastTiming = startTime || [0, 0] + for (const event in events) { + const duration = durations[event] + if (duration) { + const seconds = lastTiming[0] + duration[0] + const nanoseconds = lastTiming[1] + duration[1] + lastTiming = timings[event] = [seconds, nanoseconds] + } + } + return timings +} + +module.exports = { + events, getDuration, getMilliseconds, computeAverageDurations, createTimingsFromDurations +} diff --git a/tests/nettime.js b/tests/nettime.js index 4818270..beab740 100644 --- a/tests/nettime.js +++ b/tests/nettime.js @@ -24,7 +24,8 @@ function createServer (protocol, port, options) { return new Promise((resolve, reject) => { const creator = protocol.createSecureServer || protocol.createServer const server = options ? creator(options, serve) : protocol.createServer(serve) - server.on('error', reject) + server + .on('error', reject) .listen(port, ipAddress, () => { servers.push(server) resolve() @@ -205,7 +206,7 @@ test.test('test a full URL', test => { .then(test.end) }) -test.test('test a full URL withotu password', test => { +test.test('test a full URL without password', test => { return makeRequest('http', 'user@localhost', insecurePort, '?search#hash') .then(result => { test.equal(result.statusCode, 404) @@ -215,6 +216,24 @@ test.test('test a full URL withotu password', test => { .then(test.end) }) +test.test('test two requests', test => { + return nettime({ + url: `http://localhost:${insecurePort}`, + requestCount: 2, + requestDelay: 1 + }) + .then(results => { + test.ok(Array.isArray(results)) + for (const result of results) { + checkRequest({}, result) + test.equal(result.statusCode, 204) + test.equal(result.statusMessage, 'No Content') + } + }) + .catch(test.threw) + .then(test.end) +}) + test.test('test with a hostname', test => { return makeRequest('http', 'localhost', insecurePort) .then(result => { diff --git a/tests/timings.js b/tests/timings.js index a5cc161..7b2bde6 100644 --- a/tests/timings.js +++ b/tests/timings.js @@ -1,5 +1,23 @@ const test = require('tap') -const { getDuration, getMilliseconds } = require('../lib/timings') +const { + events, getDuration, getMilliseconds, computeAverageDurations, createTimingsFromDurations +} = require('../lib/timings') + +const eventCopy = { + socketOpen: 'Socket Open', + dnsLookup: 'DNS Lookup', + tcpConnection: 'TCP Connection', + tlsHandshake: 'TLS Handshake', + firstByte: 'First Byte', + contentTransfer: 'Content Transfer', + socketClose: 'Socket Close' +} + +test.test('test timing event names', test => { + test.deepEqual(Object.keys(events), Object.keys(eventCopy)) + test.deepEqual(Object.values(events), Object.values(eventCopy)) + test.end() +}) test.test('test getting duration', test => { test.deepEqual(getDuration([0, 100], [0, 200]), [0, 100]) @@ -13,3 +31,87 @@ test.test('test getting milliseconds', test => { test.deepEqual(getMilliseconds([1, 1000]), 1000.001) test.end() }) + +test.test('test computing average durations', test => { + const input = [ + { + socketOpen: [0, 100], + tcpConnection: [0, 300] + }, + { + socketOpen: [0, 100], + tcpConnection: [1, 100] + } + ] + const expected = { + socketOpen: [0, 100], + dnsLookup: undefined, + tcpConnection: [0.5, 100], + tlsHandshake: undefined, + firstByte: undefined, + contentTransfer: undefined, + socketClose: undefined + } + const actual = computeAverageDurations(input) + test.deepEqual(actual, expected) + test.end() +}) + +test.test('test checking unexpected defined event timings', test => { + const input = [ + { + socketOpen: [0, 100], + tcpConnection: [0, 300] + }, + { + socketOpen: [0, 100], + dnsLookup: [0, 500], + tcpConnection: [1, 100] + } + ] + try { + computeAverageDurations(input) + test.fail() + } catch (error) { + } + test.end() +}) + +test.test('test checking unexpected undefined event timings', test => { + const input = [ + { + socketOpen: [0, 100], + dnsLookup: [0, 500], + tcpConnection: [0, 300] + }, + { + socketOpen: [0, 100], + tcpConnection: [1, 100] + } + ] + try { + computeAverageDurations(input) + test.fail() + } catch (error) { + } + test.end() +}) + +test.test('test creating timings from durations', test => { + const input = { + socketOpen: [0, 100], + dnsLookup: undefined, + tcpConnection: [0.5, 100], + tlsHandshake: undefined, + firstByte: undefined, + contentTransfer: undefined, + socketClose: undefined + } + const expected = { + socketOpen: [0, 100], + tcpConnection: [0.5, 200] + } + const actual = createTimingsFromDurations(input) + test.deepEqual(actual, expected) + test.end() +})