Skip to content

Commit

Permalink
refactor: make library backwards compatible with Next.js < 15.1 (#310)
Browse files Browse the repository at this point in the history
Code in this PR bundles internal framework error handling (rethrown
navigation errors) to make next-safe-action compatible with both newer
and older Next.js versions.
  • Loading branch information
TheEdoRan authored Dec 13, 2024
1 parent 86f00f4 commit a8e07f7
Show file tree
Hide file tree
Showing 11 changed files with 208 additions and 52 deletions.
6 changes: 3 additions & 3 deletions packages/next-safe-action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 2 additions & 4 deletions packages/next-safe-action/src/action-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions packages/next-safe-action/src/next/errors/bailout-to-csr.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions packages/next-safe-action/src/next/errors/dynamic-usage.ts
Original file line number Diff line number Diff line change
@@ -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);
36 changes: 36 additions & 0 deletions packages/next-safe-action/src/next/errors/http-access-fallback.ts
Original file line number Diff line number Diff line change
@@ -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);
}
28 changes: 28 additions & 0 deletions packages/next-safe-action/src/next/errors/index.ts
Original file line number Diff line number Diff line change
@@ -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";
12 changes: 12 additions & 0 deletions packages/next-safe-action/src/next/errors/postpone.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
46 changes: 46 additions & 0 deletions packages/next-safe-action/src/next/errors/redirect.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
13 changes: 13 additions & 0 deletions packages/next-safe-action/src/next/errors/router.ts
Original file line number Diff line number Diff line change
@@ -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);
}
30 changes: 0 additions & 30 deletions packages/next-safe-action/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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.";

/**
Expand All @@ -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 };
16 changes: 1 addition & 15 deletions website/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,14 @@ 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
- TypeScript >= 5
- 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
Expand Down

0 comments on commit a8e07f7

Please sign in to comment.