diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 2cde8766fda2ee..c6a10982602d9c 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -22,6 +22,11 @@ const safeGet = async (key: string): Promise => { const middleware = async (req: NextRequest): Promise> => { const url = req.nextUrl; const requestHeaders = new Headers(req.headers); + const response = NextResponse.next(); + + if (response.headers.get("x-pages-router-error") === "true") { + return NextResponse.rewrite(new URL("/_not-found", req.url)); + } requestHeaders.set("x-url", req.url); diff --git a/apps/web/pages/_error.tsx b/apps/web/pages/_error.tsx new file mode 100644 index 00000000000000..76089811d6006a --- /dev/null +++ b/apps/web/pages/_error.tsx @@ -0,0 +1,115 @@ +/** + * Typescript class based component for custom-error + * @link https://nextjs.org/docs/advanced-features/custom-error-page + */ +import type { NextPage, NextPageContext } from "next"; +import type { ErrorProps } from "next/error"; +import NextError from "next/error"; +import React from "react"; + +import { getErrorFromUnknown } from "@calcom/lib/errors"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { redactError } from "@calcom/lib/redactError"; + +import { ErrorPage } from "@components/error/error-page"; + +// Adds HttpException to the list of possible error types. +type AugmentedError = (NonNullable & HttpError) | null; +type CustomErrorProps = { + err?: AugmentedError; + message?: string; + hasGetInitialPropsRun?: boolean; +} & Omit; + +type AugmentedNextPageContext = Omit & { + err: AugmentedError; +}; + +const log = logger.getSubLogger({ prefix: ["[error]"] }); + +const CustomError: NextPage = (props) => { + const { statusCode, err, message, hasGetInitialPropsRun } = props; + + if (!hasGetInitialPropsRun && err) { + // getInitialProps is not called in case of + // https://github.com/vercel/next.js/issues/8592. As a workaround, we pass + // err via _app.tsx so it can be captured + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const e = getErrorFromUnknown(err); + // can be captured here + // e.g. Sentry.captureException(e); + } + return ; +}; + +/** + * Partially adapted from the example in + * https://github.com/vercel/next.js/tree/canary/examples/with-sentry + */ +CustomError.getInitialProps = async (ctx: AugmentedNextPageContext) => { + const { res, err, asPath } = ctx; + const errorInitialProps = (await NextError.getInitialProps({ + res, + err, + } as NextPageContext)) as CustomErrorProps; + + // Workaround for https://github.com/vercel/next.js/issues/8592, mark when + // getInitialProps has run + errorInitialProps.hasGetInitialPropsRun = true; + + // If a HttpError message, let's override defaults + if (err instanceof HttpError) { + const redactedError = redactError(err); + errorInitialProps.statusCode = err.statusCode; + errorInitialProps.title = redactedError.name; + errorInitialProps.message = redactedError.message; + errorInitialProps.err = { + ...redactedError, + url: err.url, + statusCode: err.statusCode, + cause: err.cause, + method: err.method, + }; + } + + if (res) { + // Running on the server, the response object is available. + // + // Next.js will pass an err on the server if a page's `getInitialProps` + // threw or returned a Promise that rejected + + // Overrides http status code if present in errorInitialProps + res.statusCode = errorInitialProps.statusCode; + + log.debug(`server side logged this: ${err?.toString() ?? JSON.stringify(err)}`); + log.info("return props, ", errorInitialProps); + + res.setHeader("x-pages-router-error", "true"); + + return errorInitialProps; + } else { + // Running on the client (browser). + // + // Next.js will provide an err if: + // + // - a page's `getInitialProps` threw or returned a Promise that rejected + // - an exception was thrown somewhere in the React lifecycle (render, + // componentDidMount, etc) that was caught by Next.js's React Error + // Boundary. Read more about what types of exceptions are caught by Error + // Boundaries: https://reactjs.org/docs/error-boundaries.html + if (err) { + log.info("client side logged this", err); + return errorInitialProps; + } + } + + // If this point is reached, getInitialProps was called without any + // information about what the error might be. This is unexpected and may + // indicate a bug introduced in Next.js + new Error(`_error.tsx getInitialProps missing data at path: ${asPath}`); + + return errorInitialProps; +}; + +export default CustomError; diff --git a/apps/web/playwright/app-router-not-found.e2e.ts b/apps/web/playwright/app-router-not-found.e2e.ts new file mode 100644 index 00000000000000..c886b0535259b2 --- /dev/null +++ b/apps/web/playwright/app-router-not-found.e2e.ts @@ -0,0 +1,15 @@ +import { expectPageToBeNotFound } from "playwright/lib/testUtils"; + +import { test } from "./lib/fixtures"; + +test.describe("App Router - error handling", () => { + test("404s must render App Router's not-found page regardless of whether pages router or app router is used", async ({ + page, + }) => { + await expectPageToBeNotFound({ page, url: "/123491234" }); + await expectPageToBeNotFound({ page, url: "/team/123491234" }); + await expectPageToBeNotFound({ page, url: "/org/123491234" }); + await expectPageToBeNotFound({ page, url: "/insights/123491234" }); + await expectPageToBeNotFound({ page, url: "/login/123491234" }); + }); +});