diff --git a/packages/next-safe-action/package.json b/packages/next-safe-action/package.json index 3f435c7..e68b267 100644 --- a/packages/next-safe-action/package.json +++ b/packages/next-safe-action/package.json @@ -91,9 +91,9 @@ }, "peerDependencies": { "@sinclair/typebox": ">= 0.33.3", - "next": ">= 15.1.0", - "react": ">= 19.0.0", - "react-dom": ">= 19.0.0", + "next": ">= 14.0.0", + "react": ">= 18.2.0", + "react-dom": ">= 18.2.0", "valibot": ">= 0.36.0", "yup": ">= 1.0.0", "zod": ">= 3.0.0" diff --git a/packages/next-safe-action/src/action-builder.ts b/packages/next-safe-action/src/action-builder.ts index 88c8b77..71dd369 100644 --- a/packages/next-safe-action/src/action-builder.ts +++ b/packages/next-safe-action/src/action-builder.ts @@ -13,15 +13,13 @@ import type { StateServerCodeFn, } from "./index.types"; import { - DEFAULT_SERVER_ERROR_MESSAGE, - isError, isForbiddenError, isFrameworkError, isNotFoundError, isRedirectError, isUnauthorizedError, - winningBoolean, -} from "./utils"; +} from "./next/errors"; +import { DEFAULT_SERVER_ERROR_MESSAGE, isError, winningBoolean } from "./utils"; import { ActionMetadataValidationError, ActionOutputDataValidationError, diff --git a/packages/next-safe-action/src/next/errors/bailout-to-csr.ts b/packages/next-safe-action/src/next/errors/bailout-to-csr.ts new file mode 100644 index 0000000..5867524 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/bailout-to-csr.ts @@ -0,0 +1,22 @@ +// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts + +// This has to be a shared module which is shared between client component error boundary and dynamic component +const BAILOUT_TO_CSR = "BAILOUT_TO_CLIENT_SIDE_RENDERING"; + +/** An error that should be thrown when we want to bail out to client-side rendering. */ +class BailoutToCSRError extends Error { + public readonly digest = BAILOUT_TO_CSR; + + constructor(public readonly reason: string) { + super(`Bail out to client-side rendering: ${reason}`); + } +} + +/** Checks if a passed argument is an error that is thrown if we want to bail out to client-side rendering. */ +export function isBailoutToCSRError(err: unknown): err is BailoutToCSRError { + if (typeof err !== "object" || err === null || !("digest" in err)) { + return false; + } + + return err.digest === BAILOUT_TO_CSR; +} diff --git a/packages/next-safe-action/src/next/errors/dynamic-usage.ts b/packages/next-safe-action/src/next/errors/dynamic-usage.ts new file mode 100644 index 0000000..6544342 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/dynamic-usage.ts @@ -0,0 +1,45 @@ +// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/export/helpers/is-dynamic-usage-error.ts + +import { isBailoutToCSRError } from "./bailout-to-csr"; +import { isNextRouterError } from "./router"; + +const DYNAMIC_ERROR_CODE = "DYNAMIC_SERVER_USAGE"; + +class DynamicServerError extends Error { + digest: typeof DYNAMIC_ERROR_CODE = DYNAMIC_ERROR_CODE; + + constructor(public readonly description: string) { + super(`Dynamic server usage: ${description}`); + } +} + +function isDynamicServerError(err: unknown): err is DynamicServerError { + if (typeof err !== "object" || err === null || !("digest" in err) || typeof err.digest !== "string") { + return false; + } + + return err.digest === DYNAMIC_ERROR_CODE; +} + +function isDynamicPostponeReason(reason: string) { + return ( + reason.includes("needs to bail out of prerendering at this point because it used") && + reason.includes("Learn more: https://nextjs.org/docs/messages/ppr-caught-error") + ); +} + +function isDynamicPostpone(err: unknown) { + if ( + typeof err === "object" && + err !== null && + // eslint-disable-next-line + typeof (err as any).message === "string" + ) { + // eslint-disable-next-line + return isDynamicPostponeReason((err as any).message); + } + return false; +} + +export const isDynamicUsageError = (err: unknown) => + isDynamicServerError(err) || isBailoutToCSRError(err) || isNextRouterError(err) || isDynamicPostpone(err); diff --git a/packages/next-safe-action/src/next/errors/http-access-fallback.ts b/packages/next-safe-action/src/next/errors/http-access-fallback.ts new file mode 100644 index 0000000..b22dcf1 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/http-access-fallback.ts @@ -0,0 +1,36 @@ +// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts + +const HTTPAccessErrorStatus = { + NOT_FOUND: 404, + FORBIDDEN: 403, + UNAUTHORIZED: 401, +}; + +const ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus)); + +const HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK"; + +export type HTTPAccessFallbackError = Error & { + digest: `${typeof HTTP_ERROR_FALLBACK_ERROR_CODE};${string}`; +}; + +/** + * Checks an error to determine if it's an error generated by + * the HTTP navigation APIs `notFound()`, `forbidden()` or `unauthorized()`. + * + * @param error the error that may reference a HTTP access error + * @returns true if the error is a HTTP access error + */ +export function isHTTPAccessFallbackError(error: unknown): error is HTTPAccessFallbackError { + if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") { + return false; + } + const [prefix, httpStatus] = error.digest.split(";"); + + return prefix === HTTP_ERROR_FALLBACK_ERROR_CODE && ALLOWED_CODES.has(Number(httpStatus)); +} + +export function getAccessFallbackHTTPStatus(error: HTTPAccessFallbackError): number { + const httpStatus = error.digest.split(";")[1]; + return Number(httpStatus); +} diff --git a/packages/next-safe-action/src/next/errors/index.ts b/packages/next-safe-action/src/next/errors/index.ts new file mode 100644 index 0000000..f3a1064 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/index.ts @@ -0,0 +1,28 @@ +import { isBailoutToCSRError } from "./bailout-to-csr"; +import { isDynamicUsageError } from "./dynamic-usage"; +import { + getAccessFallbackHTTPStatus, + isHTTPAccessFallbackError, + type HTTPAccessFallbackError, +} from "./http-access-fallback"; +import { isPostpone } from "./postpone"; +import { isNextRouterError } from "./router"; + +export function isNotFoundError(error: unknown): error is HTTPAccessFallbackError { + return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 404; +} + +export function isForbiddenError(error: unknown): error is HTTPAccessFallbackError { + return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 403; +} + +export function isUnauthorizedError(error: unknown): error is HTTPAccessFallbackError { + return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 401; +} + +// Next.js error handling +export function isFrameworkError(error: unknown): error is Error { + return isNextRouterError(error) || isBailoutToCSRError(error) || isDynamicUsageError(error) || isPostpone(error); +} + +export { isRedirectError } from "./redirect"; diff --git a/packages/next-safe-action/src/next/errors/postpone.ts b/packages/next-safe-action/src/next/errors/postpone.ts new file mode 100644 index 0000000..f087873 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/postpone.ts @@ -0,0 +1,12 @@ +// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/is-postpone.ts + +const REACT_POSTPONE_TYPE: symbol = Symbol.for("react.postpone"); + +export function isPostpone(error: any): boolean { + return ( + typeof error === "object" && + error !== null && + // eslint-disable-next-line + error.$$typeof === REACT_POSTPONE_TYPE + ); +} diff --git a/packages/next-safe-action/src/next/errors/redirect.ts b/packages/next-safe-action/src/next/errors/redirect.ts new file mode 100644 index 0000000..9ecf3d7 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/redirect.ts @@ -0,0 +1,46 @@ +// Comes from: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/redirect-error.ts + +enum RedirectStatusCode { + SeeOther = 303, + TemporaryRedirect = 307, + PermanentRedirect = 308, +} + +const REDIRECT_ERROR_CODE = "NEXT_REDIRECT"; + +enum RedirectType { + push = "push", + replace = "replace", +} + +export type RedirectError = Error & { + digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${string};${RedirectStatusCode};`; +}; + +/** + * Checks an error to determine if it's an error generated by the + * `redirect(url)` helper. + * + * @param error the error that may reference a redirect error + * @returns true if the error is a redirect error + */ +export function isRedirectError(error: unknown): error is RedirectError { + if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") { + return false; + } + + const digest = error.digest.split(";"); + const [errorCode, type] = digest; + const destination = digest.slice(2, -2).join(";"); + const status = digest.at(-2); + + const statusCode = Number(status); + + return ( + errorCode === REDIRECT_ERROR_CODE && + (type === "replace" || type === "push") && + typeof destination === "string" && + !isNaN(statusCode) && + statusCode in RedirectStatusCode + ); +} diff --git a/packages/next-safe-action/src/next/errors/router.ts b/packages/next-safe-action/src/next/errors/router.ts new file mode 100644 index 0000000..c6dad49 --- /dev/null +++ b/packages/next-safe-action/src/next/errors/router.ts @@ -0,0 +1,13 @@ +// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/is-next-router-error.ts + +import { isHTTPAccessFallbackError, type HTTPAccessFallbackError } from "./http-access-fallback"; +import { isRedirectError, type RedirectError } from "./redirect"; + +/** + * Returns true if the error is a navigation signal error. These errors are + * thrown by user code to perform navigation operations and interrupt the React + * render. + */ +export function isNextRouterError(error: unknown): error is RedirectError | HTTPAccessFallbackError { + return isRedirectError(error) || isHTTPAccessFallbackError(error); +} diff --git a/packages/next-safe-action/src/utils.ts b/packages/next-safe-action/src/utils.ts index f58b00a..64485b1 100644 --- a/packages/next-safe-action/src/utils.ts +++ b/packages/next-safe-action/src/utils.ts @@ -1,14 +1,3 @@ -import type { HTTPAccessFallbackError } from "next/dist/client/components/http-access-fallback/http-access-fallback.js"; -import { - getAccessFallbackHTTPStatus, - isHTTPAccessFallbackError, -} from "next/dist/client/components/http-access-fallback/http-access-fallback.js"; -import { isNextRouterError } from "next/dist/client/components/is-next-router-error.js"; -import { isRedirectError } from "next/dist/client/components/redirect-error.js"; -import { isDynamicUsageError } from "next/dist/export/helpers/is-dynamic-usage-error.js"; -import { isPostpone } from "next/dist/server/lib/router-utils/is-postpone.js"; -import { isBailoutToCSRError } from "next/dist/shared/lib/lazy-dynamic/bailout-to-csr.js"; - export const DEFAULT_SERVER_ERROR_MESSAGE = "Something went wrong while executing the operation."; /** @@ -23,22 +12,3 @@ export const isError = (error: unknown): error is Error => error instanceof Erro export const winningBoolean = (...args: (boolean | undefined | null)[]) => { return args.reduce((acc, v) => (typeof v === "boolean" ? v : acc), false) as boolean; }; - -// Next.js error handling -export function isFrameworkError(error: unknown): error is Error { - return isNextRouterError(error) || isBailoutToCSRError(error) || isDynamicUsageError(error) || isPostpone(error); -} - -export function isNotFoundError(error: unknown): error is HTTPAccessFallbackError { - return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 404; -} - -export function isForbiddenError(error: unknown): error is HTTPAccessFallbackError { - return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 403; -} - -export function isUnauthorizedError(error: unknown): error is HTTPAccessFallbackError { - return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 401; -} - -export { isRedirectError }; diff --git a/website/docs/getting-started.mdx b/website/docs/getting-started.mdx index a063ea2..cb48639 100644 --- a/website/docs/getting-started.mdx +++ b/website/docs/getting-started.mdx @@ -8,15 +8,7 @@ import TabItem from '@theme/TabItem'; # Getting started -:::info Requirements (>= 7.10.0) - -- Next.js 15.1 -- React >= 19 -- TypeScript >= 5 -- A supported validation library: Zod, Valibot, Yup, TypeBox -::: - -:::info Old requirements (\<= 7.9.9) +:::info Requirements - Next.js >= 14 (>= 15 for [`useStateAction`](/docs/execute-actions/hooks/usestateaction) hook) - React >= 18.2.0 @@ -24,12 +16,6 @@ import TabItem from '@theme/TabItem'; - A supported validation library: Zod, Valibot, Yup, TypeBox ::: - - -:::warning -Next.js >= 15.1 and React 19 are required for using next-safe-action >= 7.10.0. This is due to internal error handling framework changes. So, please upgrade to the latest version to use this library with Next.js 15.0.5 or later. -::: - **next-safe-action** provides a typesafe Server Actions implementation for Next.js App Router applications. ## Installation