From dd28cf9439f927978c560dd583a1383d572e3601 Mon Sep 17 00:00:00 2001 From: eps1lon Date: Thu, 17 Oct 2024 20:32:35 +0200 Subject: [PATCH] Sourcemap terminal errors by default in `next dev` --- .../next/src/server/lib/error-inspection.ts | 26 ++ .../error-inspect.tsx | 206 ++++++++++++++ packages/next/src/server/node-environment.ts | 3 + .../next/src/server/patch-error-inspect.ts | 260 ++++++++++++++++++ .../e2e/app-dir/hello-world/app/Component.tsx | 7 + test/e2e/app-dir/hello-world/app/page.tsx | 5 +- test/e2e/app-dir/hello-world/next.config.js | 6 +- 7 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 packages/next/src/server/lib/error-inspection.ts create mode 100644 packages/next/src/server/node-environment-extensions/error-inspect.tsx create mode 100644 packages/next/src/server/patch-error-inspect.ts create mode 100644 test/e2e/app-dir/hello-world/app/Component.tsx diff --git a/packages/next/src/server/lib/error-inspection.ts b/packages/next/src/server/lib/error-inspection.ts new file mode 100644 index 0000000000000..4bd0ece596bc0 --- /dev/null +++ b/packages/next/src/server/lib/error-inspection.ts @@ -0,0 +1,26 @@ +import type * as NodeUtil from 'node:util' + +export function setupErrorInspection() { + // TODO: Edge runtime? + + const inspectSymbol = Symbol.for('nodejs.util.inspect.custom') + + class SourceMappedError extends Error { + // Disable the one we're setting on the Error prototype to avoid recursion. + static [inspectSymbol] = undefined + } + + const originalInit = Error.prototype[inspectSymbol] + // @ts-expect-error + Error.prototype[inspectSymbol as any] = function ( + depth: number, + inspectOptions: NodeUtil.InspectOptions, + inspect: typeof NodeUtil.inspect + ) { + // Create a new Error object with the source mapping apply and then use native + // Node.js formatting on the result. + const newError = new SourceMappedError(this.message) + newError.stack = parseAndSourceMap(String(this.stack)) + return inspect(newError, inspectOptions) + } +} diff --git a/packages/next/src/server/node-environment-extensions/error-inspect.tsx b/packages/next/src/server/node-environment-extensions/error-inspect.tsx new file mode 100644 index 0000000000000..a2ca350115c4f --- /dev/null +++ b/packages/next/src/server/node-environment-extensions/error-inspect.tsx @@ -0,0 +1,206 @@ +import * as fs from 'fs' +import * as path from 'path' +import url from 'url' +import * as util from 'util' +import { + SourceMapConsumer as SyncSourceMapConsumer, + type RawSourceMap, +} from 'next/dist/compiled/source-map' +import dataUriToBuffer from 'next/dist/compiled/data-uri-to-buffer' +import { type StackFrame } from 'next/dist/compiled/stacktrace-parser' +import { parseStack } from '../../client/components/react-dev-overlay/server/middleware' +import { getSourceMapUrl } from '../../client/components/react-dev-overlay/internal/helpers/get-source-map-url' + +// TODO: Implement for Edge runtime +const inspectSymbol = Symbol.for('nodejs.util.inspect.custom') + +// This matches either of these V8 formats. +// at name (filename:0:0) +// at filename:0:0 +// at async filename:0:0 +const frameRegExp = + /^ {3} at (?:(.+) \((.+):(\d+):(\d+)\)|(?:async )?(.+):(\d+):(\d+))$/ + +function getSourceMapFromFile(filename: string): RawSourceMap | null { + filename = filename.startsWith('file://') + ? url.fileURLToPath(filename) + : filename + + let fileContents: string + + try { + fileContents = fs.readFileSync(filename, 'utf-8') + } catch (error: unknown) { + if ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null + } + throw error + } + + const sourceUrl = getSourceMapUrl(fileContents) + + if (sourceUrl === null) { + return null + } + + if (sourceUrl.startsWith('data:')) { + let buffer: dataUriToBuffer.MimeBuffer + + try { + buffer = dataUriToBuffer(sourceUrl) + } catch (error) { + console.error( + `Failed to parse source map URL for ${filename}.`, + util.inspect(error, { customInspect: false }) + ) + return null + } + + if (buffer.type !== 'application/json') { + console.error( + `Unknown source map type for ${filename}: ${buffer.typeFull}.` + ) + } + + try { + return JSON.parse(buffer.toString()) + } catch (error) { + console.error( + `Failed to parse source map for ${filename}.`, + util.inspect(error, { customInspect: false }) + ) + } + } + + const sourceMapFilename = path.resolve(path.dirname(filename), sourceUrl) + + try { + const sourceMapContents = fs.readFileSync(sourceMapFilename, 'utf-8') + + return JSON.parse(sourceMapContents.toString()) + } catch (error) { + console.error( + `Failed to parse source map ${sourceMapFilename}.`, + util.inspect(error, { customInspect: false }) + ) + return null + } +} + +function frameToString(frame: StackFrame): string { + return frame.methodName + ? ` at ${frame.methodName} (${frame.file}:${frame.lineNumber}:${frame.column})` + : ` at ${frame.file}:${frame.lineNumber}:${frame.column}` +} + +function parseAndSourceMap(error: Error): string { + const stack = String(error.stack) + let unparsedStack = stack + + let idx = unparsedStack.indexOf('react-stack-bottom-frame') + if (idx !== -1) { + idx = unparsedStack.lastIndexOf('\n', idx) + } + if (idx !== -1) { + // Cut off everything after the bottom frame since it'll be internals. + unparsedStack = unparsedStack.slice(0, idx) + } + + const unsourcemappedStack = parseStack(unparsedStack) + const sourcemapConsumers = new Map() + + const sourceMappedStack = unsourcemappedStack.map((frame) => { + if (frame.file === null) { + return frame + } + + let sourcemap = sourcemapConsumers.get(frame.file) + if (sourcemap === undefined) { + const rawSourcemap = getSourceMapFromFile(frame.file) + if (rawSourcemap === null) { + return frame + } + sourcemap = new SyncSourceMapConsumer(rawSourcemap) + sourcemapConsumers.set(frame.file, sourcemap) + } + + const sourcePosition = sourcemap.originalPositionFor({ + column: frame.column ?? 0, + line: frame.lineNumber ?? 1, + }) + + if (sourcePosition.source === null) { + return frame + } + + // TODO: Respect sourcemaps's ignoreList + const sourceContent: string | null = + sourcemap.sourceContentFor( + sourcePosition.source, + /* returnNullOnMissing */ true + ) ?? null + + console.log({ sourceContent, sourcePosition }) + + const originalFrame: StackFrame = { + methodName: + sourcePosition.name || + // default is not a valid identifier in JS so webpack uses a custom variable when it's an unnamed default export + // Resolve it back to `default` for the method name if the source position didn't have the method. + frame.methodName + ?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default') + ?.replace('__webpack_exports__.', ''), + column: sourcePosition.column, + file: sourceContent + ? // TODO: + // ? path.relative(rootDirectory, filePath) + sourcePosition.source + : sourcePosition.source, + lineNumber: sourcePosition.line, + // TODO: c&p from async createOriginalStackFrame but why not frame.arguments? + arguments: [], + } + + return originalFrame + }) + + return `${error.message}\n${sourceMappedStack.map(frameToString).join('\n')}` +} + +// @ts-expect-error +Error.prototype[inspectSymbol] = function ( + // can be ignored since also in inspectOptions + depth: number, + inspectOptions: util.InspectOptions, + inspect: typeof util.inspect +): string { + // Create a new Error object with the source mapping apply and then use native + // Node.js formatting on the result. + const newError = + this.cause !== undefined + ? // Setting an undefined `cause` would print `[cause]: undefined` + new Error(this.message, { cause: this.cause }) + : new Error(this.message) + + // TODO: Ensure `class MyError extends Error {}` prints `MyError` as the name + newError.stack = parseAndSourceMap(this) + + 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) + } finally { + ;(newError as any)[inspectSymbol] = originalCustomInspect + } +} diff --git a/packages/next/src/server/node-environment.ts b/packages/next/src/server/node-environment.ts index 83d97ac3a1571..337526e4e9f4a 100644 --- a/packages/next/src/server/node-environment.ts +++ b/packages/next/src/server/node-environment.ts @@ -1,6 +1,9 @@ // This file should be imported before any others. It sets up the environment // for later imports to work properly. +// Improve Error first so that errors from other extensions are improved. +import './node-environment-extensions/error-inspect' + import './node-environment-baseline' import './node-environment-extensions/random' import './node-environment-extensions/date' diff --git a/packages/next/src/server/patch-error-inspect.ts b/packages/next/src/server/patch-error-inspect.ts new file mode 100644 index 0000000000000..59b3007bf88f6 --- /dev/null +++ b/packages/next/src/server/patch-error-inspect.ts @@ -0,0 +1,260 @@ +import * as fs from 'fs' +import * as path from 'path' +import url from 'url' +import * as util from 'util' +import type webpack from 'webpack' +import { + SourceMapConsumer as SyncSourceMapConsumer, + type RawSourceMap, +} from 'next/dist/compiled/source-map' +import dataUriToBuffer from 'next/dist/compiled/data-uri-to-buffer' +import { type StackFrame } from 'next/dist/compiled/stacktrace-parser' +import { parseStack } from '../client/components/react-dev-overlay/server/middleware' +import { getSourceMapUrl } from '../client/components/react-dev-overlay/internal/helpers/get-source-map-url' + +let currentCompilation: webpack.Compilation | null = null +export function setCurrentCompilation(compilation: webpack.Compilation): void { + currentCompilation = compilation +} + +// TODO: Implement for Edge runtime +const inspectSymbol = Symbol.for('nodejs.util.inspect.custom') + +function getSourceMapFromFile(filename: string): RawSourceMap | null { + filename = filename.startsWith('file://') + ? url.fileURLToPath(filename) + : filename + + let fileContents: string + + try { + fileContents = fs.readFileSync(filename, 'utf-8') + } catch (error: unknown) { + if ( + error !== null && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null + } + throw error + } + + const sourceUrl = getSourceMapUrl(fileContents) + + if (sourceUrl === null) { + return null + } + + if (sourceUrl.startsWith('data:')) { + let buffer: dataUriToBuffer.MimeBuffer + + try { + buffer = dataUriToBuffer(sourceUrl) + } catch (error) { + console.error( + `Failed to parse source map URL for ${filename}.`, + util.inspect(error, { customInspect: false }) + ) + return null + } + + if (buffer.type !== 'application/json') { + console.error( + `Unknown source map type for ${filename}: ${buffer.typeFull}.` + ) + } + + try { + return JSON.parse(buffer.toString()) + } catch (error) { + console.error( + `Failed to parse source map for ${filename}.`, + util.inspect(error, { customInspect: false }) + ) + } + } + + const sourceMapFilename = path.resolve(path.dirname(filename), sourceUrl) + + try { + const sourceMapContents = fs.readFileSync(sourceMapFilename, 'utf-8') + + return JSON.parse(sourceMapContents.toString()) + } catch (error) { + console.error( + `Failed to parse source map ${sourceMapFilename}.`, + util.inspect(error, { customInspect: false }) + ) + return null + } +} + +function getModuleById( + id: string | undefined, + compilation: webpack.Compilation +) { + const { chunkGraph, modules } = compilation + + return [...modules].find((module) => chunkGraph.getModuleId(module) === id) +} + +function getSourceMapFromCompilation( + id: string, + compilation: webpack.Compilation +): RawSourceMap | null { + try { + const module = getModuleById(id, compilation) + + if (!module) { + return null + } + + // @ts-expect-error The types for `CodeGenerationResults.get` require a + // runtime to be passed as second argument, but apparently it also works + // without it. + const codeGenerationResult = compilation.codeGenerationResults.get(module) + const source = codeGenerationResult?.sources.get('javascript') + + return source !== undefined + ? // source-map@0.8 uses `string` in `version` instead of number + (source.map() as unknown as RawSourceMap) + : null + } catch (error) { + console.error( + `Failed to lookup module by ID ("${id}"):`, + util.inspect(error, { customInspect: false }) + ) + return null + } +} + +function frameToString(frame: StackFrame): string { + return frame.methodName + ? ` at ${frame.methodName} (${frame.file}:${frame.lineNumber}:${frame.column})` + : ` at ${frame.file}:${frame.lineNumber}:${frame.column}` +} + +function parseAndSourceMap( + error: Error, + compilation: webpack.Compilation | null +): string { + const stack = String(error.stack) + let unparsedStack = stack + + let idx = unparsedStack.indexOf('react-stack-bottom-frame') + if (idx !== -1) { + idx = unparsedStack.lastIndexOf('\n', idx) + } + if (idx !== -1) { + // Cut off everything after the bottom frame since it'll be internals. + unparsedStack = unparsedStack.slice(0, idx) + } + + const unsourcemappedStack = parseStack(unparsedStack) + const sourcemapConsumers = new Map() + + const sourceMappedStack = unsourcemappedStack.map((frame) => { + if (frame.file === null) { + return frame + } + + let sourcemap = sourcemapConsumers.get(frame.file) + if (sourcemap === undefined) { + const moduleId = frame.file.replace( + /^(webpack-internal:\/\/\/|file:\/\/)/, + '' + ) + const modulePath = frame.file.replace( + /^(webpack-internal:\/\/\/|file:\/\/)(\(.*\)\/)?/, + '' + ) + const rawSourcemap = + compilation === null || + frame.file.startsWith(path.sep) || + frame.file.startsWith('file:') + ? getSourceMapFromFile(frame.file) + : getSourceMapFromCompilation(moduleId, compilation) + if (rawSourcemap === null) { + return frame + } + sourcemap = new SyncSourceMapConsumer(rawSourcemap) + sourcemapConsumers.set(frame.file, sourcemap) + } + + const sourcePosition = sourcemap.originalPositionFor({ + column: frame.column ?? 0, + line: frame.lineNumber ?? 1, + }) + + if (sourcePosition.source === null) { + return frame + } + + // TODO: Respect sourcemaps's ignoreList + const sourceContent: string | null = + sourcemap.sourceContentFor( + sourcePosition.source, + /* returnNullOnMissing */ true + ) ?? null + + const originalFrame: StackFrame = { + methodName: + sourcePosition.name || + // default is not a valid identifier in JS so webpack uses a custom variable when it's an unnamed default export + // Resolve it back to `default` for the method name if the source position didn't have the method. + frame.methodName + ?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default') + ?.replace('__webpack_exports__.', ''), + column: sourcePosition.column, + file: sourceContent + ? // TODO: + // ? path.relative(rootDirectory, filePath) + sourcePosition.source + : sourcePosition.source, + lineNumber: sourcePosition.line, + // TODO: c&p from async createOriginalStackFrame but why not frame.arguments? + arguments: [], + } + + return originalFrame + }) + + return `${error.message}\n${sourceMappedStack.map(frameToString).join('\n')}` +} + +export function patchErrorInspect(): void { + // @ts-expect-error + Error.prototype[inspectSymbol] = function ( + // can be ignored since also in inspectOptions + depth: number, + inspectOptions: util.InspectOptions, + inspect: typeof util.inspect + ): string { + // Create a new Error object with the source mapping apply and then use native + // Node.js formatting on the result. + const newError = + this.cause !== undefined + ? // Setting an undefined `cause` would print `[cause]: undefined` + new Error(this.message, { cause: this.cause }) + : new Error(this.message) + + // TODO: Ensure `class MyError extends Error {}` prints `MyError` as the name + newError.stack = parseAndSourceMap(this, currentCompilation) + + 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) + } finally { + ;(newError as any)[inspectSymbol] = originalCustomInspect + } + } +} diff --git a/test/e2e/app-dir/hello-world/app/Component.tsx b/test/e2e/app-dir/hello-world/app/Component.tsx new file mode 100644 index 0000000000000..43721c0c38bb4 --- /dev/null +++ b/test/e2e/app-dir/hello-world/app/Component.tsx @@ -0,0 +1,7 @@ +export function logError() { + console.error(new Error('test')) +} + +export function Component() { + return null +} diff --git a/test/e2e/app-dir/hello-world/app/page.tsx b/test/e2e/app-dir/hello-world/app/page.tsx index ff7159d9149fe..651b54eebaccd 100644 --- a/test/e2e/app-dir/hello-world/app/page.tsx +++ b/test/e2e/app-dir/hello-world/app/page.tsx @@ -1,3 +1,6 @@ +import { Component, logError } from './Component' + export default function Page() { - return

hello world

+ logError() + return } diff --git a/test/e2e/app-dir/hello-world/next.config.js b/test/e2e/app-dir/hello-world/next.config.js index 807126e4cf0bf..70e8c4e01e19b 100644 --- a/test/e2e/app-dir/hello-world/next.config.js +++ b/test/e2e/app-dir/hello-world/next.config.js @@ -1,6 +1,10 @@ /** * @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + experimental: { + serverSourceMaps: true, + }, +} module.exports = nextConfig