next.js/packages/next/src/server/patch-error-inspect.ts
patch-error-inspect.ts568 lines19.2 KB
import { findSourceMap as nativeFindSourceMap } from 'module'
import * as path from 'path'
import * as url from 'url'
import type * as util from 'util'
import { SourceMapConsumer as SyncSourceMapConsumer } from 'next/dist/compiled/source-map'
import {
  type ModernSourceMapPayload,
  devirtualizeReactServerURL,
  findApplicableSourceMapPayload,
  ignoreListAnonymousStackFramesIfSandwiched as ignoreListAnonymousStackFramesIfSandwichedGeneric,
  sourceMapIgnoreListsEverything,
} from './lib/source-maps'
import { parseStack, type StackFrame } from './lib/parse-stack'
import type { IgnorableStackFrame } from '../next-devtools/server/shared'
import { workUnitAsyncStorage } from './app-render/work-unit-async-storage.external'
import { dim, italic } from '../lib/picocolors'

type FindSourceMapPayload = (
  sourceURL: string
) => ModernSourceMapPayload | undefined
// Find a source map using the bundler's API.
// This is only a fallback for when Node.js fails to due to bugs e.g. https://github.com/nodejs/node/issues/52102
// TODO: Remove once all supported Node.js versions are fixed.
// TODO(veil): Set from Webpack as well
let bundlerFindSourceMapPayload: FindSourceMapPayload = () => undefined

export function setBundlerFindSourceMapImplementation(
  findSourceMapImplementation: FindSourceMapPayload
): void {
  bundlerFindSourceMapPayload = findSourceMapImplementation
}

// Code frame renderer - injected by dev/build to avoid hard dependency on native bindings
type CodeFrameRenderer = (
  frame: IgnorableStackFrame,
  source: string | null,
  colors: boolean
) => string | null

let codeFrameRenderer: CodeFrameRenderer | undefined

export function setCodeFrameRenderer(renderer: CodeFrameRenderer): void {
  codeFrameRenderer = renderer
}

function getOriginalCodeFrame(
  frame: IgnorableStackFrame,
  source: string | null,
  colors: boolean = process.stdout.isTTY
): string | null {
  if (!codeFrameRenderer) {
    // No renderer available - gracefully degrade
    return null
  }
  return codeFrameRenderer(frame, source, colors)
}

type SourceMapCache = Map<
  string,
  null | { map: SyncSourceMapConsumer; payload: ModernSourceMapPayload }
>

function frameToString(
  methodName: string | null,
  sourceURL: string | null,
  line1: number | null,
  column1: number | null
): string {
  let sourceLocation = line1 !== null ? `:${line1}` : ''
  if (column1 !== null && sourceLocation !== '') {
    sourceLocation += `:${column1}`
  }

  let fileLocation: string | null
  if (
    sourceURL !== null &&
    sourceURL.startsWith('file://') &&
    URL.canParse(sourceURL)
  ) {
    // If not relative to CWD, the path is ambiguous to IDEs and clicking will prompt to select the file first.
    // In a multi-app repo, this leads to potentially larger file names but will make clicking snappy.
    // There's no tradeoff for the cases where `dir` in `next dev [dir]` is omitted
    // since relative to cwd is both the shortest and snappiest.
    fileLocation = path.relative(process.cwd(), url.fileURLToPath(sourceURL))
  } else if (sourceURL !== null && sourceURL.startsWith('/')) {
    fileLocation = path.relative(process.cwd(), sourceURL)
  } else {
    fileLocation = sourceURL
  }

  return methodName
    ? `    at ${methodName} (${fileLocation}${sourceLocation})`
    : `    at ${fileLocation}${sourceLocation}`
}

function computeErrorName(error: Error): string {
  // TODO: Node.js seems to use a different algorithm
  // class ReadonlyRequestCookiesError extends Error {}` would read `ReadonlyRequestCookiesError: [...]`
  // in the stack i.e. seems like under certain conditions it favors the constructor name.
  return error.name || 'Error'
}

function prepareUnsourcemappedStackTrace(
  error: Error,
  structuredStackTrace: any[]
): string {
  const name = computeErrorName(error)
  const message = error.message || ''
  let stack = name + ': ' + message
  for (let i = 0; i < structuredStackTrace.length; i++) {
    stack += '\n    at ' + structuredStackTrace[i].toString()
  }
  return stack
}

function shouldIgnoreListGeneratedFrame(file: string): boolean {
  return file.startsWith('node:') || file.includes('node_modules')
}

function shouldIgnoreListOriginalFrame(file: string): boolean {
  return file.includes('node_modules')
}

interface SourcemappableStackFrame extends StackFrame {
  file: NonNullable<StackFrame['file']>
}

interface SourceMappedFrame {
  stack: IgnorableStackFrame
  // DEV only
  code: string | null
}

function createUnsourcemappedFrame(
  frame: SourcemappableStackFrame
): SourceMappedFrame {
  return {
    stack: {
      file: frame.file,
      line1: frame.line1,
      column1: frame.column1,
      methodName: frame.methodName,
      arguments: frame.arguments,
      ignored: shouldIgnoreListGeneratedFrame(frame.file),
    },
    code: null,
  }
}

function ignoreListAnonymousStackFramesIfSandwiched(
  sourceMappedFrames: Array<{
    stack: IgnorableStackFrame
    code: string | null
  }>
) {
  return ignoreListAnonymousStackFramesIfSandwichedGeneric(
    sourceMappedFrames,
    (frame) => frame.stack.file === '<anonymous>',
    (frame) => frame.stack.ignored,
    (frame) => frame.stack.methodName,
    (frame) => {
      frame.stack.ignored = true
    }
  )
}

/**
 * @param frame
 * @param sourceMapCache
 * @returns The original frame if not sourcemapped.
 */
function getSourcemappedFrameIfPossible(
  frame: SourcemappableStackFrame,
  sourceMapCache: SourceMapCache,
  inspectOptions: util.InspectOptions
): {
  stack: IgnorableStackFrame
  code: string | null
} {
  const sourceMapCacheEntry = sourceMapCache.get(frame.file)
  let sourceMapConsumer: SyncSourceMapConsumer
  let sourceMapPayload: ModernSourceMapPayload
  if (sourceMapCacheEntry === undefined) {
    let sourceURL = frame.file
    // e.g. "/Users/foo/APP/.next/server/chunks/ssr/[root-of-the-server]__2934a0._.js"
    // or "C:\Users\foo\APP\.next\server\chunks\ssr\[root-of-the-server]__2934a0._.js"
    // will be keyed by Node.js as "file:///APP/.next/server/chunks/ssr/[root-of-the-server]__2934a0._.js".
    // This is likely caused by `callsite.toString()` in `Error.prepareStackTrace converting file URLs to paths.
    //
    // But frame.file might also be "webpack-internal:///(rsc)/./app/bad-sourcemap/page.js" or
    // "<anonymous>" or "node:internal/process/task_queues" here
    if (path.isAbsolute(frame.file)) {
      sourceURL = url.pathToFileURL(frame.file).toString()
    }
    let maybeSourceMapPayload: ModernSourceMapPayload | undefined
    try {
      const sourceMap = nativeFindSourceMap(sourceURL)
      maybeSourceMapPayload = sourceMap?.payload
    } catch (cause) {
      // We should not log an actual error instance here because that will re-enter
      // this codepath during error inspection and could lead to infinite recursion.
      console.error(
        `${sourceURL}: Invalid source map. Only conformant source maps can be used to find the original code. Cause: ${cause}`
      )
      // If loading fails once, it'll fail every time.
      // So set the cache to avoid duplicate errors.
      sourceMapCache.set(frame.file, null)
      // Don't even fall back to the bundler because it might be not as strict
      // with regards to parsing and then we fail later once we consume the
      // source map payload.
      // This essentially avoids a redundant error where we fail here and then
      // later on consumption because the bundler just handed back an invalid
      // source map.
      return createUnsourcemappedFrame(frame)
    }
    if (maybeSourceMapPayload === undefined) {
      maybeSourceMapPayload = bundlerFindSourceMapPayload(sourceURL)
    }

    if (maybeSourceMapPayload === undefined) {
      return createUnsourcemappedFrame(frame)
    }
    sourceMapPayload = maybeSourceMapPayload
    try {
      // Pass the source map URL as the second parameter so that the consumer
      // can resolve relative paths in the source map's `sources` array. This is
      // a guess! Turbopack places .map files as siblings to the chunks so this
      // is sufficient to compute relative paths but is actually wrong (the
      // chunk and sourcemap have different content hashes). We are using the
      // node API to read the sourcemap and it doesn't give us access to the
      // URI. Devirtualize `about://React/Server/file:///path/to/chunk.js?4` to
      // `file:///path/to/chunk.js` so that relative `sources` in the source map
      // resolve against the real chunk URL, not the virtual one.
      const sourceMapURL = devirtualizeReactServerURL(sourceURL) + '.map'
      sourceMapConsumer = new SyncSourceMapConsumer(
        sourceMapPayload,
        // @ts-expect-error: our typings don't include this parameter but it is here.
        sourceMapURL
      )
    } catch (cause) {
      // We should not log an actual error instance here because that will re-enter
      // this codepath during error inspection and could lead to infinite recursion.
      console.error(
        `${sourceURL}: Invalid source map. Only conformant source maps can be used to find the original code. Cause: ${cause}`
      )
      // If creating the consumer fails once, it'll fail every time.
      // So set the cache to avoid duplicate errors.
      sourceMapCache.set(frame.file, null)
      return createUnsourcemappedFrame(frame)
    }
    sourceMapCache.set(frame.file, {
      map: sourceMapConsumer,
      payload: sourceMapPayload,
    })
  } else if (sourceMapCacheEntry === null) {
    // We failed earlier getting the payload or consumer.
    // Just return an unsourcemapped frame.
    // Errors will already be logged.
    return createUnsourcemappedFrame(frame)
  } else {
    sourceMapConsumer = sourceMapCacheEntry.map
    sourceMapPayload = sourceMapCacheEntry.payload
  }

  const sourcePosition = sourceMapConsumer.originalPositionFor({
    column: (frame.column1 ?? 1) - 1,
    line: frame.line1 ?? 1,
  })

  const applicableSourceMap = findApplicableSourceMapPayload(
    (frame.line1 ?? 1) - 1,
    (frame.column1 ?? 1) - 1,
    sourceMapPayload
  )
  let ignored =
    applicableSourceMap !== undefined &&
    sourceMapIgnoreListsEverything(applicableSourceMap)
  if (sourcePosition.source === null) {
    return {
      stack: {
        arguments: frame.arguments,
        file: frame.file,
        line1: frame.line1,
        column1: frame.column1,
        methodName: frame.methodName,
        ignored: ignored || shouldIgnoreListGeneratedFrame(frame.file),
      },
      code: null,
    }
  }

  // TODO(veil): Upstream a method to sourcemap consumer that immediately says if a frame is ignored or not.
  if (applicableSourceMap === undefined) {
    console.error('No applicable source map found in sections for frame', frame)
  } else if (!ignored && shouldIgnoreListOriginalFrame(sourcePosition.source)) {
    // Externals may be libraries that don't ship ignoreLists.
    // This is really taking control away from libraries.
    // They should still ship `ignoreList` so that attached debuggers ignore-list their frames.
    // TODO: Maybe only ignore library sourcemaps if `ignoreList` is absent?
    // Though keep in mind that Turbopack omits empty `ignoreList`.
    // So if we establish this convention, we should communicate it to the ecosystem.
    ignored = true
  } else if (!ignored) {
    // TODO: O(n^2). Consider moving `ignoreList` into a Set
    const sourceIndex = applicableSourceMap.sources.indexOf(
      sourcePosition.source
    )
    ignored = applicableSourceMap.ignoreList?.includes(sourceIndex) ?? false
  }

  const originalFrame: IgnorableStackFrame = {
    // We ignore the sourcemapped name since it won't be the correct name.
    // The callsite will point to the column of the variable name instead of the
    // name of the enclosing function.
    // TODO(NDX-531): Spy on prepareStackTrace to get the enclosing line number for method name mapping.
    methodName: frame.methodName
      ?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default')
      ?.replace('__webpack_exports__.', ''),
    file: sourcePosition.source,
    line1: sourcePosition.line,
    column1: sourcePosition.column + 1,
    // TODO: c&p from async createOriginalStackFrame but why not frame.arguments?
    arguments: [],
    ignored,
  }

  /** undefined = not yet computed */
  let codeFrame: string | null | undefined

  return {
    stack: originalFrame,
    get code() {
      if (codeFrame === undefined) {
        const sourceContent: string | null =
          sourceMapConsumer.sourceContentFor(
            sourcePosition.source,
            /* returnNullOnMissing */ true
          ) ?? null
        codeFrame = getOriginalCodeFrame(
          originalFrame,
          sourceContent,
          inspectOptions.colors
        )
      }
      return codeFrame
    },
  }
}

function parseAndSourceMap(
  error: Error,
  inspectOptions: util.InspectOptions
): string {
  const showIgnoreListed = process.env.__NEXT_SHOW_IGNORE_LISTED === 'true'
  // We overwrote Error.prepareStackTrace earlier so error.stack is not sourcemapped.
  let unparsedStack = String(error.stack)
  // We could just read it from `error.stack`.
  // This works around cases where a 3rd party `Error.prepareStackTrace` implementation
  // doesn't implement the name computation correctly.
  const errorName = computeErrorName(error)

  let idx = unparsedStack.indexOf('react_stack_bottom_frame')
  if (idx !== -1) {
    idx = unparsedStack.lastIndexOf('\n', idx)
  } else {
    idx = unparsedStack.indexOf('react-stack-bottom-frame')
    if (idx !== -1) {
      idx = unparsedStack.lastIndexOf('\n', idx)
    }
  }
  if (idx !== -1 && !showIgnoreListed) {
    // Cut off everything after the bottom frame since it'll be React internals.
    unparsedStack = unparsedStack.slice(0, idx)
  }

  const unsourcemappedStack = parseStack(unparsedStack)
  const sourceMapCache: SourceMapCache = new Map()

  const sourceMappedFrames: Array<{
    stack: IgnorableStackFrame
    code: string | null
  }> = []
  let sourceFrame: null | string = null
  for (const frame of unsourcemappedStack) {
    if (frame.file === null) {
      sourceMappedFrames.push({
        code: null,
        stack: {
          file: frame.file,
          line1: frame.line1,
          column1: frame.column1,
          methodName: frame.methodName,
          arguments: frame.arguments,
          ignored: false,
        },
      })
    } else {
      const sourcemappedFrame = getSourcemappedFrameIfPossible(
        // We narrowed this earlier by bailing if `frame.file` is null.
        frame as SourcemappableStackFrame,
        sourceMapCache,
        inspectOptions
      )
      sourceMappedFrames.push(sourcemappedFrame)

      // We can determine the sourceframe here.
      // anonymous frames won't have a sourceframe so we don't need to scan
      // all stacks again to check if they are sandwiched between ignored frames.
      if (
        sourceFrame === null &&
        // TODO: Is this the right choice?
        !sourcemappedFrame.stack.ignored &&
        sourcemappedFrame.code !== null
      ) {
        sourceFrame = sourcemappedFrame.code
      }
    }
  }

  ignoreListAnonymousStackFramesIfSandwiched(sourceMappedFrames)

  let sourceMappedStack = ''
  for (let i = 0; i < sourceMappedFrames.length; i++) {
    const frame = sourceMappedFrames[i]

    if (!frame.stack.ignored) {
      sourceMappedStack +=
        '\n' +
        frameToString(
          frame.stack.methodName,
          frame.stack.file,
          frame.stack.line1,
          frame.stack.column1
        )
    } else if (showIgnoreListed) {
      sourceMappedStack +=
        '\n' +
        dim(
          frameToString(
            frame.stack.methodName,
            frame.stack.file,
            frame.stack.line1,
            frame.stack.column1
          )
        )
    }
  }

  if (sourceMappedStack === '' && sourceMappedFrames.length > 0) {
    // The `at` marker is important so that Node.js doesn't add square brackets
    // around the stringified error i.e. this results in
    // Error: message
    //   at <ignore-listed frames>
    // instead of
    // [Error: message
    //   at <ignore-listed frames>]
    sourceMappedStack = '\n    at ' + italic('ignore-listed frames')
  }

  return (
    errorName +
    ': ' +
    error.message +
    sourceMappedStack +
    (sourceFrame !== null ? '\n' + sourceFrame : '')
  )
}

function sourceMapError(
  this: void,
  error: Error,
  inspectOptions: util.InspectOptions
): Error {
  // Setting an undefined `cause` would print `[cause]: undefined`
  const options = error.cause !== undefined ? { cause: error.cause } : undefined

  // Create a new Error object with the source mapping applied and then use native
  // Node.js formatting on the result.
  const newError =
    error instanceof AggregateError
      ? // Preserve AggregateError's `errors` instance property
        new AggregateError(error.errors, error.message, options)
      : new Error(error.message, options)

  // TODO: Ensure `class MyError extends Error {}` prints `MyError` as the name
  newError.stack = parseAndSourceMap(error, inspectOptions)

  for (const key in error) {
    if (!Object.prototype.hasOwnProperty.call(newError, key)) {
      // @ts-expect-error -- We're copying all enumerable properties.
      // So they definitely exist on `this` and obviously have no type on `newError` (yet)
      newError[key] = error[key]
    }
  }

  return newError
}

export function patchErrorInspectNodeJS(
  errorConstructor: ErrorConstructor
): void {
  const inspectSymbol = Symbol.for('nodejs.util.inspect.custom')

  errorConstructor.prepareStackTrace = prepareUnsourcemappedStackTrace

  // @ts-expect-error -- TODO upstream types
  errorConstructor.prototype[inspectSymbol] = function (
    depth: number,
    inspectOptions: util.InspectOptions,
    inspect: typeof util.inspect
  ): string {
    // avoid false-positive dynamic i/o warnings e.g. due to usage of `Math.random` in `source-map`.
    return workUnitAsyncStorage.exit(() => {
      const newError = sourceMapError(this, inspectOptions)

      const originalCustomInspect = (newError as any)[inspectSymbol]
      // Prevent infinite recursion.
      // { customInspect: false } would result in `error.cause` not using our inspect.
      Object.defineProperty(newError, inspectSymbol, {
        value: undefined,
        enumerable: false,
        writable: true,
      })
      try {
        return inspect(newError, {
          ...inspectOptions,
          depth,
        })
      } finally {
        ;(newError as any)[inspectSymbol] = originalCustomInspect
      }
    })
  }
}

export function patchErrorInspectEdgeLite(
  errorConstructor: ErrorConstructor
): void {
  const inspectSymbol = Symbol.for('edge-runtime.inspect.custom')

  errorConstructor.prepareStackTrace = prepareUnsourcemappedStackTrace

  // @ts-expect-error -- TODO upstream types
  errorConstructor.prototype[inspectSymbol] = function ({
    format,
  }: {
    format: (...args: unknown[]) => string
  }): string {
    // avoid false-positive dynamic i/o warnings e.g. due to usage of `Math.random` in `source-map`.
    return workUnitAsyncStorage.exit(() => {
      const newError = sourceMapError(this, {})

      const originalCustomInspect = (newError as any)[inspectSymbol]
      // Prevent infinite recursion.
      Object.defineProperty(newError, inspectSymbol, {
        value: undefined,
        enumerable: false,
        writable: true,
      })
      try {
        return format(newError)
      } finally {
        ;(newError as any)[inspectSymbol] = originalCustomInspect
      }
    })
  }
}
Quest for Codev2.0.0
/
SIGN IN