Skip to content

Commit

Permalink
feat(nextjs): app router support (#1071)
Browse files Browse the repository at this point in the history
  • Loading branch information
subzero10 authored Jun 2, 2023
1 parent 78acabd commit e3752f4
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 17 deletions.
14 changes: 9 additions & 5 deletions packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ This version is considered suitable for preview.

The following limitations are known to exist and will be tackled in future releases:

- [Issue link](https://github.com/honeybadger-io/honeybadger-js/issues/1055): A custom `_error.js` component is used to report uncaught exceptions to Honeybadger.
- [Issue link](https://github.com/honeybadger-io/honeybadger-js/issues/1055): A custom error component is used to report uncaught exceptions to Honeybadger.
This is necessary because Next.js does not provide a way to hook into the error handler.
This is not a catch-all errors solution. There are some caveats to this approach, as reported [here](https://nextjs.org/docs/advanced-features/custom-error-page#caveats).
This is not a catch-all errors solution.
If you are using the _Pages Router_, there are some caveats to this approach, as reported [here](https://nextjs.org/docs/advanced-features/custom-error-page#caveats).
This is a limitation of Next.js, not Honeybadger's Next.js integration.
Errors thrown in middlewares or API routes will not be reported to Honeybadger, since when they reach _error.js, the response status code is 404 and no error information is available.
Additionally, there is an open [issue](https://github.com/vercel/next.js/issues/45535) about 404 being reported with Next.js apps deployed on Vercel, when they should be reported as 500.
- [Issue link](https://github.com/honeybadger-io/honeybadger-js/issues/1056):Source maps for the [Edge runtime](https://vercel.com/docs/concepts/functions/edge-functions/edge-runtime) are not supported yet.
Errors thrown in middlewares or API routes will not be reported to Honeybadger, since when they reach the error component, the response status code is 404 and no error information is available.
Additionally, there is an open [issue](https://github.com/vercel/next.js/issues/45535) about 404 being reported with Next.js apps deployed on Vercel, when they should be reported as 500.
If you are using the _App Router_, these limitations do not apply, because errors thrown in middlewares or API routes do not reach the custom error component
but are caught by the global `window.onerror` handler. However, some other server errors (i.e. from data fetching methods) will be reported with minimal information,
since Next.js will send a [generic error message](https://nextjs.org/docs/app/building-your-application/routing/error-handling#handling-server-errors) to this component for better security.
- [Issue link](https://github.com/honeybadger-io/honeybadger-js/issues/1056): Source maps for the [Edge runtime](https://vercel.com/docs/concepts/functions/edge-functions/edge-runtime) are not supported yet.

## Example app

Expand Down
98 changes: 86 additions & 12 deletions packages/nextjs/src/copy-config-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,76 @@ const fs = require('fs')

const debug = process.env.HONEYBADGER_DEBUG === 'true'

async function copyErrorJs() {
const targetPath = path.join('pages', '_error.js')
const errorJsAlreadyExists = fs.existsSync(targetPath)
if (errorJsAlreadyExists) {
// Don't overwrite an existing _error.js file.
// Create a _error.js.bak file instead.
const backupPath = path.join('pages', '_error.js.bak')
function usesTypescript() {
return fs.existsSync('tsconfig.json')
}

function usesPagesRouter() {
return fs.existsSync('pages')
}

function usesAppRouter() {
return fs.existsSync('app')
}

function getTargetPath(isAppRouter = false, isGlobalErrorComponent = false) {
if (!isAppRouter && isGlobalErrorComponent) {
throw new Error('invalid arguments: isGlobalErrorComponent can only be true when isAppRouter is true')
}

const extension = usesTypescript() ? 'tsx' : 'js'
const srcFolder = isAppRouter ? 'app' : 'pages'

let fileName = ''
if (isAppRouter) {
fileName = isGlobalErrorComponent ? 'global-error' : 'error'
}
else {
fileName = '_error'
}

return path.join(srcFolder, fileName + '.' + extension)
}

function getTemplate(isAppRouter = false, isGlobalErrorComponent = false) {
if (!isAppRouter && isGlobalErrorComponent) {
throw new Error('invalid arguments: isGlobalErrorComponent can only be true when isAppRouter is true')
}

const extension = isGlobalErrorComponent ? 'tsx' : 'js'
const templateName = isAppRouter ? '_error_app_router' : '_error'

return path.resolve(__dirname, '../templates', templateName + '.' + extension)
}

async function copyErrorJs(isAppRouter = false) {
const sourcePath = getTemplate(isAppRouter)
const targetPath = getTargetPath(isAppRouter)

return copyFileWithBackup(sourcePath, targetPath)
}

function copyGlobalErrorJs() {
const sourcePath = getTemplate(true, true)
const targetPath = getTargetPath(true, true)

return copyFileWithBackup(sourcePath, targetPath)
}

async function copyFileWithBackup(sourcePath, targetPath) {
const fileAlreadyExists = fs.existsSync(targetPath)
if (fileAlreadyExists) {
// Don't overwrite an existing file without creating a backup first
const backupPath = targetPath + '.bak'
if (debug) {
console.debug('backing up', targetPath, 'to', backupPath)
}
await fs.promises.copyFile(targetPath, backupPath)
}

const sourcePath = path.resolve(__dirname, '../templates/_error.js')
if (debug) {
console.debug('copying', sourcePath, 'to', targetPath)
}

return fs.promises.copyFile(sourcePath, targetPath)
}
Expand All @@ -26,15 +85,30 @@ async function copyConfigFiles() {
}

const templateDir = path.resolve(__dirname, '../templates')
const files = await fs.promises.readdir(templateDir)
const copyPromises = files.map((file) => {
const configFiles = [
'honeybadger.browser.config.js',
'honeybadger.edge.config.js',
'honeybadger.server.config.js',
]

const copyPromises = configFiles.map((file) => {
if (debug) {
console.debug('copying', file)
}
return fs.promises.copyFile(path.join(templateDir, file), file)
})

if (usesPagesRouter()) {
copyPromises.push(copyErrorJs(false))
}

if (usesAppRouter()) {
copyPromises.push(copyErrorJs(true))
copyPromises.push(copyGlobalErrorJs())
}

return file === '_error.js' ? copyErrorJs() : fs.promises.copyFile(path.join(templateDir, file), file)
});
await Promise.all(copyPromises);

console.log('Done copying config files.')
}

Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/templates/_error_app_router.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client'; // Error components must be Client Components

// eslint-disable-next-line import/no-unresolved
import { useEffect } from 'react'
import { Honeybadger } from '@honeybadger-io/react'

/**
* error.[js|tsx]: https://nextjs.org/docs/app/building-your-application/routing/error-handling
* global-error.[js|tsx]: https://nextjs.org/docs/app/building-your-application/routing/error-handling#handling-errors-in-layouts
*
* This component is called when:
* - on the server, when data fetching methods throw or reject
* - on the client, when getInitialProps throws or rejects
* - on the client, when a React lifecycle method (render, componentDidMount, etc) throws or rejects
* and was caught by the built-in Next.js error boundary
*/
export default function Error({
error,
reset,
}) {
useEffect(() => {
Honeybadger.notify(error)
}, [error])

return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
)
}
41 changes: 41 additions & 0 deletions packages/nextjs/templates/_error_app_router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client'; // Error components must be Client Components

// eslint-disable-next-line import/no-unresolved
import { useEffect } from 'react'
import { Honeybadger } from '@honeybadger-io/react'

/**
* error.[js|tsx]: https://nextjs.org/docs/app/building-your-application/routing/error-handling
* global-error.[js|tsx]: https://nextjs.org/docs/app/building-your-application/routing/error-handling#handling-errors-in-layouts
*
* This component is called when:
* - on the server, when data fetching methods throw or reject
* - on the client, when getInitialProps throws or rejects
* - on the client, when a React lifecycle method (render, componentDidMount, etc) throws or rejects
* and was caught by the built-in Next.js error boundary
*/
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
Honeybadger.notify(error)
}, [error])

return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
)
}

0 comments on commit e3752f4

Please sign in to comment.