diff --git a/src/error/normalize/main.js b/src/error/normalize/main.js index 622840ea..962e4201 100644 --- a/src/error/normalize/main.js +++ b/src/error/normalize/main.js @@ -1,3 +1,5 @@ +import { setFullStack, getStackHeader, fixStack } from './stack.js' + // Ensure we are using an Error instance export const normalizeError = function (error) { if (!(error instanceof Error)) { @@ -12,6 +14,8 @@ export const normalizeError = function (error) { return error } +// If an exception is not an Error instance, create one. +// `String()` might throw due to `value.toString()`, so we handle it. const createError = function (value) { try { const error = new Error(String(value)) @@ -22,6 +26,7 @@ const createError = function (value) { } } +// Ensure `error.name` is a string const normalizeName = function (error) { if (!isDefinedString(error.name)) { setErrorProperty( @@ -34,12 +39,14 @@ const normalizeName = function (error) { } } +// Ensure `error.message` is a string const normalizeMessage = function (error) { if (!isDefinedString(error.message)) { error.message = '' } } +// Ensure `error.stack` exists and looks normal const normalizeStack = function (error) { if (!isDefinedString(error.stack)) { setFullStack(error) @@ -52,76 +59,18 @@ const normalizeStack = function (error) { } } -const setFullStack = function (error) { - error.stack = `${getStackHeader(error)}\n${getStackTrace()}` -} - -const getStackHeader = function (error) { - return `${error.name}: ${error.message}` -} - -const fixStack = function (error, header) { - const lines = error.stack.split('\n') - const index = lines.findIndex(isStackLine) - const linesA = index === -1 ? [getStackTrace()] : lines.slice(index) - error.stack = [header, ...linesA].join('\n') -} - -const isStackLine = function (line) { - return STACK_LINE_REGEXP.test(line) -} - -const STACK_LINE_REGEXP = /^\s*at\s/u - -const getStackTrace = function () { - // eslint-disable-next-line unicorn/error-message - const lines = new Error('').stack.split('\n') - const index = findInternalIndex(lines) - return lines.slice(index).join('\n') -} - -// Remove stack lines due to this library itself. -// `index` should never be `-1`, but we include this as a failsafe. -// The stack trace might be truncated, e.g. due to `Error.stackTraceLimit` in -// Node.js, leading to the whole stack to be removed. In that case, we keep the -// last stack line. -const findInternalIndex = function (lines) { - const index = findLastIndex(lines, isInternalStackLine) - - if (index === -1) { - return 1 - } - - return lines.length - 1 === index ? index : index + 1 -} - -// TODO: use `Array.findLastIndex()` after dropping support for Node <18 -const findLastIndex = function (array, condition) { - // eslint-disable-next-line fp/no-loops, fp/no-mutation, fp/no-let - for (let index = array.length - 1; index >= 0; index -= 1) { - // eslint-disable-next-line max-depth - if (condition(array[index])) { - return index - } - } - - return -1 -} - -const isInternalStackLine = function (line) { - return line.includes(`at ${normalizeError.name}`) -} - const isDefinedString = function (value) { return typeof value === 'string' && value !== '' } +// Recurse over `error.cause` const normalizeCause = function (error) { if (error.cause !== undefined) { error.cause = normalizeError(error.cause) } } +// Recurse over `error.errors` const normalizeAggregate = function (error) { if (hasAggregate(error)) { error.errors = error.errors.map(normalizeError) @@ -139,6 +88,7 @@ const isErrorInstance = function (error) { return error instanceof Error } +// Error properties are non-enumerable const setErrorProperty = function (error, propName, value) { // eslint-disable-next-line fp/no-mutating-methods Object.defineProperty(error, 'name', { diff --git a/src/error/normalize/stack.js b/src/error/normalize/stack.js new file mode 100644 index 00000000..cb288824 --- /dev/null +++ b/src/error/normalize/stack.js @@ -0,0 +1,69 @@ +// Ensure `error.stack` reflects `error.name` and `error.message` +// Also create stack trace if missing. +export const setFullStack = function (error) { + error.stack = `${getStackHeader(error)}\n${getStackTrace()}` +} + +// Expected first line of `error.stack` +export const getStackHeader = function (error) { + return `${error.name}: ${error.message}` +} + +// Ensure `error.stack` reflects `error.name` and `error.message` +export const fixStack = function (error, header) { + const lines = error.stack.split('\n') + const index = lines.findIndex(isStackLine) + const linesA = index === -1 ? [getStackTrace()] : lines.slice(index) + error.stack = [header, ...linesA].join('\n') +} + +const isStackLine = function (line) { + return STACK_LINE_REGEXP.test(line) +} + +const STACK_LINE_REGEXP = /^\s*at\s/u + +// Generate a new stack trace +const getStackTrace = function () { + // eslint-disable-next-line unicorn/error-message + const lines = new Error('').stack.split('\n') + const index = findInternalIndex(lines) + return lines.slice(index).join('\n') +} + +// Remove stack lines due to this library itself. +// `index` should never be `-1`, but we include this as a failsafe. +// The stack trace might be truncated, e.g. due to `Error.stackTraceLimit` in +// Node.js, leading to the whole stack to be removed. In that case, we keep the +// last stack line. +const findInternalIndex = function (lines) { + const index = findLastIndex(lines, isInternalStackLine) + + if (index === -1) { + return 1 + } + + return lines.length - 1 === index ? index : index + 1 +} + +// TODO: use `Array.findLastIndex()` after dropping support for Node <18 +const findLastIndex = function (array, condition) { + // eslint-disable-next-line fp/no-loops, fp/no-mutation, fp/no-let + for (let index = array.length - 1; index >= 0; index -= 1) { + // eslint-disable-next-line max-depth + if (condition(array[index])) { + return index + } + } + + return -1 +} + +const isInternalStackLine = function (line) { + return line.includes(NORMALIZE_ERROR_LINE) +} + +// How the top-level function appears in a stack trace. +// Since stack traces are implementation-specific, we must be very conservative +// and careful. Also, we must ensure transpilation does not change this. +const NORMALIZE_ERROR_LINE = 'at normalizeError'