Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure dev server side errors are correct #28520

Merged
merged 8 commits into from Aug 27, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/next/client/dev/on-demand-entries-client.js
Expand Up @@ -9,7 +9,11 @@ export default async ({ assetPrefix }) => {
)
})

setupPing(assetPrefix, () => Router.pathname, currentPage)
setupPing(
assetPrefix,
() => Router.query.__NEXT_PAGE || Router.pathname,
currentPage
)

// prevent HMR connection from being closed when running tests
if (!process.env.__NEXT_TEST_MODE) {
Expand Down
5 changes: 4 additions & 1 deletion packages/next/client/dev/on-demand-entries-utils.js
Expand Up @@ -29,7 +29,10 @@ export function setupPing(assetPrefix, pathnameFn, retry) {
if (event.data.indexOf('{') === -1) return
try {
const payload = JSON.parse(event.data)
if (payload.invalid) {
// don't attempt fetching the page if we're already showing
// the dev overlay as this can cause the error to be triggered
// repeatedly
if (payload.invalid && !self.__NEXT_DATA__.err) {
// Payload can be invalid even if the page does not exist.
// So, we need to make sure it exists before reloading.
fetch(location.href, {
Expand Down
6 changes: 6 additions & 0 deletions packages/next/client/next-dev.js
Expand Up @@ -59,6 +59,12 @@ initNext({ webpackHMR })
} else if (event.data.indexOf('serverOnlyChanges') !== -1) {
const { pages } = JSON.parse(event.data)

// Make sure to reload when the dev-overlay is showing for an
// API route
if (pages.includes(router.query.__NEXT_PAGE)) {
return window.location.reload()
}

if (!router.clc && pages.includes(router.pathname)) {
console.log('Refreshing page data due to server-side change')

Expand Down
11 changes: 10 additions & 1 deletion packages/next/server/api-utils.ts
Expand Up @@ -25,7 +25,9 @@ export async function apiResolver(
query: any,
resolverModule: any,
apiContext: __ApiPreviewProps,
propagateError: boolean
propagateError: boolean,
dev?: boolean,
page?: string
): Promise<void> {
const apiReq = req as NextApiRequest
const apiRes = res as NextApiResponse
Expand Down Expand Up @@ -117,6 +119,13 @@ export async function apiResolver(
if (err instanceof ApiError) {
sendError(apiRes, err.statusCode, err.message)
} else {
if (dev) {
if (err) {
err.page = page
}
throw err
}

console.error(err)
if (propagateError) {
throw err
Expand Down
2 changes: 1 addition & 1 deletion packages/next/server/dev/hot-reloader.ts
Expand Up @@ -134,7 +134,7 @@ export default class HotReloader {
private webpackHotMiddleware: (NextHandleFunction & any) | null
private config: NextConfigComplete
private stats: webpack.Stats | null
private serverStats: webpack.Stats | null
public serverStats: webpack.Stats | null
private clientError: Error | null = null
private serverError: Error | null = null
private serverPrevDocumentHash: string | null
Expand Down
71 changes: 70 additions & 1 deletion packages/next/server/dev/next-dev-server.ts
@@ -1,5 +1,6 @@
import crypto from 'crypto'
import fs from 'fs'
import chalk from 'chalk'
import { IncomingMessage, ServerResponse } from 'http'
import { Worker } from 'jest-worker'
import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator'
Expand Down Expand Up @@ -47,6 +48,11 @@ import {
loadDefaultErrorComponents,
} from '../load-components'
import { DecodeError } from '../../shared/lib/utils'
import { parseStack } from '@next/react-dev-overlay/lib/internal/helpers/parseStack'
import {
createOriginalStackFrame,
getSourceById,
} from '@next/react-dev-overlay/lib/middleware'

// Load ReactDevOverlay only when needed
let ReactDevOverlayImpl: React.FunctionComponent
Expand Down Expand Up @@ -431,8 +437,71 @@ export default class DevServer extends Server {
// if they should match against the basePath or not
parsedUrl.pathname = originalPathname
}
try {
return await super.run(req, res, parsedUrl)
} catch (err) {
res.statusCode = 500
try {
let usedOriginalStack = false
try {
const frames = parseStack(err.stack)
const frame = frames[0]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we should remap all frames

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is showing the other frames helpful usually? We collapse all but the first in the dev overlay


if (frame.lineNumber && frame?.file) {
const compilation = this.hotReloader?.serverStats?.compilation
const moduleId = frame.file!.replace(
/^(webpack-internal:\/\/\/|file:\/\/)/,
''
)

const source = await getSourceById(
!!frame.file?.startsWith(sep) ||
!!frame.file?.startsWith('file:'),
moduleId,
compilation,
this.hotReloader!.isWebpack5
)

const originalFrame = await createOriginalStackFrame({
line: frame.lineNumber!,
column: frame.column,
source,
frame,
modulePath: moduleId,
rootDirectory: this.dir,
})

if (originalFrame) {
usedOriginalStack = true
const { originalCodeFrame, originalStackFrame } = originalFrame
const { file, lineNumber, column, methodName } =
originalStackFrame
console.error(
chalk.red('error') +
' - ' +
`${file} (${lineNumber}:${column}) @ ${methodName}`
)
console.error(`${chalk.red(err.name)}: ${err.message}`)
console.error(originalCodeFrame)
}
}
} catch (_) {
// failed to load original source map, should we
// log this even though it's most likely unactionable
// for the user?
}

return super.run(req, res, parsedUrl)
if (!usedOriginalStack) {
console.error(err)
}
return await this.renderError(err, req, res, pathname!, {
__NEXT_PAGE: err?.page || pathname,
})
} catch (internalErr) {
console.error(internalErr)
res.end('Internal Server Error')
}
}
}

// override production loading of routes-manifest
Expand Down
13 changes: 10 additions & 3 deletions packages/next/server/next-server.ts
Expand Up @@ -487,7 +487,7 @@ export default class Server {
try {
return await this.run(req, res, parsedUrl)
} catch (err) {
if (this.minimalMode) {
if (this.minimalMode || this.renderOpts.dev) {
throw err
}
this.logError(err)
Expand Down Expand Up @@ -1125,7 +1125,9 @@ export default class Server {
query,
pageModule,
this.renderOpts.previewProps,
this.minimalMode
this.minimalMode,
this.renderOpts.dev,
page
)
return true
}
Expand Down Expand Up @@ -1857,6 +1859,7 @@ export default class Server {
ctx: RequestContext
): Promise<ResponsePayload | null> {
const { res, query, pathname } = ctx
let page = pathname
const bubbleNoFallback = !!query._nextBubbleNoFallback
delete query._nextBubbleNoFallback

Expand Down Expand Up @@ -1888,6 +1891,7 @@ export default class Server {
)
if (dynamicRouteResult) {
try {
page = dynamicRoute.page
return await this.renderToResponseWithComponents(
{
...ctx,
Expand Down Expand Up @@ -1929,7 +1933,10 @@ export default class Server {
)

if (!isWrappedError) {
if (this.minimalMode) {
if (this.minimalMode || this.renderOpts.dev) {
if (err) {
err.page = page
}
throw err
}
this.logError(err)
Expand Down
16 changes: 1 addition & 15 deletions packages/next/server/render.tsx
Expand Up @@ -405,7 +405,6 @@ export async function renderToHTML(
buildManifest,
fontManifest,
reactLoadableManifest,
ErrorDebug,
getStaticProps,
getStaticPaths,
getServerSideProps,
Expand Down Expand Up @@ -941,10 +940,7 @@ export async function renderToHTML(
;(renderOpts as any).pageData = props
}
} catch (dataFetchError) {
if (isDataReq || !dev || !dataFetchError) throw dataFetchError
ctx.err = dataFetchError
renderOpts.err = dataFetchError
console.error(dataFetchError)
throw dataFetchError
}

if (
Expand Down Expand Up @@ -1059,16 +1055,6 @@ export async function renderToHTML(
const renderPage: RenderPage = (
options: ComponentsEnhancer = {}
): RenderPageResult | Promise<RenderPageResult> => {
if (ctx.err && ErrorDebug) {
const htmlOrPromise = renderToString(<ErrorDebug error={ctx.err} />)
return typeof htmlOrPromise === 'string'
? { html: htmlOrPromise, head }
: htmlOrPromise.then((html) => ({
html,
head,
}))
}

if (dev && (props.router || props.Component)) {
throw new Error(
`'router' and 'Component' can not be returned in getInitialProps from _app.js https://nextjs.org/docs/messages/cant-override-next-props`
Expand Down
83 changes: 43 additions & 40 deletions packages/react-dev-overlay/src/middleware.ts
Expand Up @@ -177,52 +177,50 @@ export async function createOriginalStackFrame({
}
}

function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
async function getSourceById(
isServerSide: boolean,
isFile: boolean,
id: string
): Promise<Source> {
if (isFile) {
const fileContent: string | null = await fs
.readFile(id, 'utf-8')
.catch(() => null)

if (fileContent == null) {
return null
}

const map = getRawSourceMap(fileContent)
if (map == null) {
return null
}
export async function getSourceById(
isFile: boolean,
id: string,
compilation: any,
isWebpack5: boolean
): Promise<Source> {
if (isFile) {
const fileContent: string | null = await fs
.readFile(id, 'utf-8')
.catch(() => null)

if (fileContent == null) {
return null
}

return {
map() {
return map
},
}
const map = getRawSourceMap(fileContent)
if (map == null) {
return null
}

try {
const compilation = isServerSide
? options.serverStats()?.compilation
: options.stats()?.compilation
if (compilation == null) {
return null
}
return {
map() {
return map
},
}
}

const module = [...compilation.modules].find(
(searchModule) =>
getModuleId(compilation, searchModule, options.isWebpack5) === id
)
return getModuleSource(compilation, module, options.isWebpack5)
} catch (err) {
console.error(`Failed to lookup module by ID ("${id}"):`, err)
try {
if (compilation == null) {
return null
}

const module = [...compilation.modules].find(
(searchModule) =>
getModuleId(compilation, searchModule, isWebpack5) === id
)
return getModuleSource(compilation, module, isWebpack5)
} catch (err) {
console.error(`Failed to lookup module by ID ("${id}"):`, err)
return null
}
}

function getOverlayMiddleware(options: OverlayMiddlewareOptions) {
return async function (
req: IncomingMessage,
res: ServerResponse,
Expand Down Expand Up @@ -254,10 +252,15 @@ function getOverlayMiddleware(options: OverlayMiddlewareOptions) {

let source: Source
try {
const compilation = isServerSide
? options.serverStats()?.compilation
: options.stats()?.compilation

source = await getSourceById(
isServerSide,
frame.file.startsWith('file:'),
moduleId
moduleId,
compilation,
!!options.isWebpack5
)
} catch (err) {
console.log('Failed to get source map:', err)
Expand Down
14 changes: 12 additions & 2 deletions test/integration/api-support/test/index.test.js
Expand Up @@ -109,14 +109,24 @@ function runTests(dev = false) {
const res = await fetchViaHTTP(appPort, '/api/user-error', null, {})
const text = await res.text()
expect(res.status).toBe(500)
expect(text).toBe('Internal Server Error')

if (dev) {
expect(text).toContain('User error')
} else {
expect(text).toBe('Internal Server Error')
}
})

it('should throw Internal Server Error (async)', async () => {
const res = await fetchViaHTTP(appPort, '/api/user-error-async', null, {})
const text = await res.text()
expect(res.status).toBe(500)
expect(text).toBe('Internal Server Error')

if (dev) {
expect(text).toContain('User error')
} else {
expect(text).toBe('Internal Server Error')
}
})

it('should parse JSON body', async () => {
Expand Down
@@ -0,0 +1,3 @@
export default function handler(req, res) {
res.status(200).json({ slug: req.query.slug })
}
3 changes: 3 additions & 0 deletions test/integration/server-side-dev-errors/pages/api/hello.js
@@ -0,0 +1,3 @@
export default function handler(req, res) {
res.status(200).json({ hello: 'world' })
}