diff --git a/benchmarks/instantiate.js b/benchmarks/instantiate.js index 0d40d7d..361ab68 100644 --- a/benchmarks/instantiate.js +++ b/benchmarks/instantiate.js @@ -3,14 +3,19 @@ const benchmark = require('benchmark') const createError = require('..') +Error.stackTraceLimit = 0 + const FastifyError = createError('CODE', 'Not available') const FastifyError1 = createError('CODE', 'Not %s available') const FastifyError2 = createError('CODE', 'Not %s available %s') +const q = 'q' +const qq = 'qq' +const ss = 'ss' new benchmark.Suite() .add('instantiate Error', function () { new Error() }, { minSamples: 100 }) // eslint-disable-line no-new .add('instantiate FastifyError', function () { new FastifyError() }, { minSamples: 100 }) // eslint-disable-line no-new - .add('instantiate FastifyError arg 1', function () { new FastifyError1('q') }, { minSamples: 100 }) // eslint-disable-line no-new - .add('instantiate FastifyError arg 2', function () { new FastifyError2('qq', 'ss') }, { minSamples: 100 }) // eslint-disable-line no-new + .add('instantiate FastifyError arg 1', function () { new FastifyError1(q) }, { minSamples: 100 }) // eslint-disable-line no-new + .add('instantiate FastifyError arg 2', function () { new FastifyError2(qq, ss) }, { minSamples: 100 }) // eslint-disable-line no-new .on('cycle', function onCycle (event) { console.log(String(event.target)) }) .run({ async: false }) diff --git a/index.js b/index.js index 13a4172..0d87ea6 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ 'use strict' -const { inherits, format } = require('util') +const { inherits } = require('util') +const formatFn = require('./lib/formatFn') function createError (code, message, statusCode = 500, Base = Error) { if (!code) throw new Error('Fastify error code must not be empty') @@ -9,15 +10,16 @@ function createError (code, message, statusCode = 500, Base = Error) { code = code.toUpperCase() !statusCode && (statusCode = undefined) - function FastifyError (...args) { + const format = formatFn(message) + + function FastifyError () { if (!new.target) { - return new FastifyError(...args) + return new FastifyError(...arguments) } this.code = code this.name = 'FastifyError' this.statusCode = statusCode - - this.message = format(message, ...args) + this.message = format(arguments) Error.stackTraceLimit !== 0 && Error.captureStackTrace(this, FastifyError) } diff --git a/lib/countSpecifiers.js b/lib/countSpecifiers.js new file mode 100644 index 0000000..84cf1c4 --- /dev/null +++ b/lib/countSpecifiers.js @@ -0,0 +1,17 @@ +'use strict' + +const countSpecifiersRE = /%[sdifjoOc%]/g + +function countSpecifiers (message) { + let result = 0 + message.replace(countSpecifiersRE, function (x) { + if (x !== '%%') { + result++ + } + return x + }) + + return result +} + +module.exports = countSpecifiers diff --git a/lib/formatFn.js b/lib/formatFn.js new file mode 100644 index 0000000..da53579 --- /dev/null +++ b/lib/formatFn.js @@ -0,0 +1,125 @@ +'use strict' + +const specifiersRE = /(%[sdifjoOc%])/g +const inspect = require('util').inspect +const countSpecifiers = require('./countSpecifiers') + +function formatFn (message) { + const specifiersAmount = countSpecifiers(message) + + let fnBody = ` + function stringify(value) { + const stackTraceLimit = Error.stackTraceLimit + stackTraceLimit !== 0 && (Error.stackTraceLimit = 0) + try { + return JSON.stringify(value) + } catch (_e) { + return '[Circular]' + } finally { + stackTraceLimit !== 0 && (Error.stackTraceLimit = stackTraceLimit) + } + } + + const oOptions = { showHidden: true, showProxy: true } + + function rest (args) { + let result = '' + let i = ${specifiersAmount} + const argsLength = args.length + while (i < args.length) { + result += ' ' + args[i++] + } + return result + } + + return function format (args) { + + const argsLen = args.length + + return ""` + + let argNum = 0 + const messageParts = message.split(specifiersRE) + + for (const messagePart of messageParts) { + switch (messagePart) { + case '': + break + case '%%': + fnBody += ' + (argsLen === 0 ? "%%" : "%")' + break + case '%d': + fnBody += ` + ( + ${argNum} >= argsLen && '%d' || + args[${argNum}] + )` + argNum++ + break + case '%i': + fnBody += ` + ( + ${argNum} >= argsLen && '%i' || + typeof args[${argNum}] === 'number' && ( + args[${argNum}] === Infinity && 'NaN' || + args[${argNum}] === -Infinity && 'NaN' || + args[${argNum}] !== args[${argNum}] && 'NaN' || + '' + Math.trunc(args[${argNum}]) + ) || + typeof args[${argNum}] === 'bigint' && args[${argNum}] + 'n' || + parseInt(args[${argNum}], 10) + )` + argNum++ + break + case '%f': + fnBody += ` + ( + ${argNum} >= argsLen && '%f' || + parseFloat(args[${argNum}]) + )` + argNum++ + break + case '%s': + fnBody += ` + ( + ${argNum} >= argsLen && '%s' || + ( + (typeof args[${argNum}] === 'bigint' && args[${argNum}].toString() + 'n') || + (typeof args[${argNum}] === 'number' && args[${argNum}] === 0 && 1 / args[${argNum}] === -Infinity && '-0') || + args[${argNum}] + ) + )` + argNum++ + break + case '%o': + fnBody += ` + ( + ${argNum} >= argsLen && '%o' || + inspect(args[${argNum}], oOptions) + )` + argNum++ + break + case '%O': + fnBody += ` + ( + ${argNum} >= argsLen && '%O' || + inspect(args[${argNum}]) + )` + argNum++ + break + case '%j': + fnBody += ` + ( + ${argNum} >= argsLen && '%j' || + stringify(args[${argNum}]) + )` + argNum++ + break + case '%c': + break + default: + fnBody += '+ ' + JSON.stringify(messagePart) + } + } + + fnBody += '+ rest(args)' + + fnBody += '}' + + return new Function('inspect', fnBody)(inspect) // eslint-disable-line no-new-func +} + +module.exports = formatFn diff --git a/test/lib/countSpecifiers.test.js b/test/lib/countSpecifiers.test.js new file mode 100644 index 0000000..c0f0984 --- /dev/null +++ b/test/lib/countSpecifiers.test.js @@ -0,0 +1,28 @@ +'use strict' + +const test = require('tap').test +const countSpecifiers = require('../../lib/countSpecifiers') + +const testCases = [ + ['no specifier', 0], + ['a string %s', 1], + ['a number %d', 1], + ['an integer %i', 1], + ['a float %f', 1], + ['as json %j', 1], + ['as object %o', 1], + ['as object %O', 1], + ['as css %c', 1], + ['not a specifier %%', 0], + ['mixed %s %%%s', 2] +] +test('countSpecifiers', t => { + t.plan(testCases.length) + + for (const [testCase, expected] of testCases) { + t.test(testCase, t => { + t.plan(1) + t.equal(countSpecifiers(testCase), expected) + }) + } +}) diff --git a/test/lib/formtFn.test.js b/test/lib/formtFn.test.js new file mode 100644 index 0000000..b2d3685 --- /dev/null +++ b/test/lib/formtFn.test.js @@ -0,0 +1,52 @@ +'use strict' + +const test = require('tap').test +const formatFn = require('../../lib/formatFn') +const format = require('util').format + +const circular = {} +circular.circular = circular + +const testCases = [ + ['%%', [], '%%'], + ['%% %s', [], '%% %s'], + ['%% %d', [2], '% 2'], + ['no specifier', [], 'no specifier'], + ['string %s', [0], 'string 0'], + ['string %s', [-0], 'string -0'], + ['string %s', [0n], 'string 0n'], + ['string %s', [Infinity], 'string Infinity'], + ['string %s', [-Infinity], 'string -Infinity'], + ['string %s', [-NaN], 'string NaN'], + ['string %s', [undefined], 'string undefined'], + ['%s', [{ toString: () => 'Foo' }], 'Foo'], + ['integer %i', [0n], 'integer 0n'], + ['integer %i', [Infinity], 'integer NaN'], + ['integer %i', [-Infinity], 'integer NaN'], + ['integer %i', [NaN], 'integer NaN'], + ['string %s', ['yes'], 'string yes'], + ['float %f', [0], 'float 0'], + ['float %f', [-0], 'float 0'], + ['float %f', [0.0000001], 'float 1e-7'], + ['float %f', [0.000001], 'float 0.000001'], + ['float %f', ['a'], 'float NaN'], + ['float %f', [{}], 'float NaN'], + ['json %j', [{}], 'json {}'], + ['json %j', [circular], 'json [Circular]'], + ['%s:%s', ['foo'], 'foo:%s'], + ['%s:%c', ['foo', 'bar'], 'foo:'], + ['%o', [{}], '{}'], + ['%O', [{}], '{}'], + ['1', [2, 3], '1 2 3'] +] +test('formatFn', t => { + t.plan(testCases.length) + + for (const [testCase, args, expected] of testCases) { + t.test(testCase, t => { + t.plan(2) + t.equal(formatFn(testCase)(args), expected) + t.equal(formatFn(testCase)(args), format(testCase, ...args)) + }) + } +})