diff --git a/index.js b/index.js index 257cd7c..73a27e5 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ 'use strict' -const { format } = require('node:util') +const formatGenerator = require('./lib/format-generator') +const countSpecifiers = require('./lib/count-specifiers') function toString () { return `${this.name} [${this.code}]: ${this.message}` @@ -13,6 +14,10 @@ function createError (code, message, statusCode = 500, Base = Error) { code = code.toUpperCase() !statusCode && (statusCode = undefined) + const specifiersAmount = countSpecifiers(message) + const format = formatGenerator(message) + const withOptionsParameterLength = specifiersAmount + 1 + function FastifyError (...args) { if (!new.target) { return new FastifyError(...args) @@ -22,12 +27,15 @@ function createError (code, message, statusCode = 500, Base = Error) { this.name = 'FastifyError' this.statusCode = statusCode - const lastElement = args.length - 1 - if (lastElement !== -1 && args[lastElement] && typeof args[lastElement] === 'object' && 'cause' in args[lastElement]) { - this.cause = args.pop().cause + if ( + args.length === withOptionsParameterLength && + typeof args[specifiersAmount] === 'object' && + 'cause' in args[specifiersAmount] + ) { + this.cause = args[specifiersAmount].cause } - this.message = format(message, ...args) + this.message = format(args) Error.stackTraceLimit !== 0 && Error.captureStackTrace(this, FastifyError) } diff --git a/lib/count-specifiers.js b/lib/count-specifiers.js new file mode 100644 index 0000000..84cf1c4 --- /dev/null +++ b/lib/count-specifiers.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/format-generator.js b/lib/format-generator.js new file mode 100644 index 0000000..4167e2e --- /dev/null +++ b/lib/format-generator.js @@ -0,0 +1,127 @@ +'use strict' + +const specifiersRE = /(%[sdifjoOc%])/g + +let globalInspect + +function lazyInspect () { + if (globalInspect === undefined) { globalInspect = require('util').inspect } + return globalInspect +} + +function formatGenerator (message) { + let inspect + + let fnBody = '' + + if (message.includes('%j')) { + 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) + } + }` + } + if (message.includes('%o')) { + inspect = lazyInspect() + fnBody += ` + const oOptions = { showHidden: true, showProxy: true } + ` + } else if (message.includes('%O')) { + inspect = lazyInspect() + } + + fnBody += ` + 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 += '}' + + return new Function('inspect', fnBody)(inspect) // eslint-disable-line no-new-func +} + +module.exports = formatGenerator diff --git a/test/count-specifiers.test.js b/test/count-specifiers.test.js new file mode 100644 index 0000000..f7e3bdd --- /dev/null +++ b/test/count-specifiers.test.js @@ -0,0 +1,25 @@ +'use strict' + +const { test } = require('node:test') +const countSpecifiers = require('../lib/count-specifiers') + +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.assert.strictEqual(countSpecifiers(testCase), expected) + } +}) diff --git a/test/format-generator.test.js b/test/format-generator.test.js new file mode 100644 index 0000000..7afaf21 --- /dev/null +++ b/test/format-generator.test.js @@ -0,0 +1,49 @@ +'use strict' + +const format = require('util').format +const { test } = require('node:test') +const formatGenerator = require('../lib/format-generator') + +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', [{}], '{}'] +] + +test('formatGenerator', t => { + t.plan(testCases.length * 2) + + for (const [testCase, args, expected] of testCases) { + t.assert.strictEqual(formatGenerator(testCase)(args), expected) + t.assert.strictEqual(formatGenerator(testCase)(args), format(testCase, ...args)) + } +})