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

refactor: make library backwards compatible with Next.js < 15.1 #310

Merged
merged 3 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading