From 714554dca1201b074aab6ce35e38bd4e4685a616 Mon Sep 17 00:00:00 2001 From: Edoardo Ranghieri Date: Wed, 3 Apr 2024 02:07:29 +0200 Subject: [PATCH] feat: support middleware chaining (#89) See #88 --- README.md | 2 +- package-lock.json | 3 +- .../src/app/(examples)/direct/login-action.ts | 39 ++- .../app/(examples)/hook/deleteuser-action.ts | 23 +- .../(examples)/nested-schema/shop-action.ts | 15 +- .../optimistic-hook/addlikes-action.ts | 25 +- .../react-hook-form/buyproduct-action.ts | 17 +- .../(examples)/server-form/signup-action.ts | 15 +- .../with-context/edituser-action.ts | 59 ++-- packages/example-app/src/lib/safe-action.ts | 93 +++-- packages/next-safe-action/.eslintrc.js | 1 + packages/next-safe-action/src/hooks.ts | 54 +-- packages/next-safe-action/src/hooks.types.ts | 33 ++ packages/next-safe-action/src/index.ts | 322 +++++++++++------- packages/next-safe-action/src/index.types.ts | 67 ++++ packages/next-safe-action/src/utils.ts | 60 +--- .../next-safe-action/src/validation-errors.ts | 71 ++++ .../src/validation-errors.types.ts | 19 ++ website/docs/contributing.md | 2 +- .../docs/examples/extending-base-client.md | 57 ++++ website/docs/getting-started.md | 34 +- website/docs/introduction.md | 6 +- .../defining-multiple-clients.md | 41 --- website/docs/safe-action-client/index.md | 7 +- ...-handling.md => initialization-options.md} | 16 +- .../safe-action-client/instance-methods.md | 58 ++++ .../safe-action-client/using-a-middleware.md | 109 ------ website/docs/types.md | 76 ++++- website/docs/usage/action-result-object.md | 4 +- .../client-components/direct-execution.md | 4 +- .../client-components/hooks/useaction.md | 12 +- .../hooks/useoptimisticaction.md | 28 +- website/docs/usage/client-components/index.md | 2 +- .../docs/usage/custom-validation-errors.md | 28 +- website/docs/usage/forms.md | 13 +- website/docs/usage/middleware.md | 195 +++++++++++ website/src/components/landing/features.tsx | 4 +- 37 files changed, 1037 insertions(+), 577 deletions(-) create mode 100644 packages/next-safe-action/src/hooks.types.ts create mode 100644 packages/next-safe-action/src/index.types.ts create mode 100644 packages/next-safe-action/src/validation-errors.ts create mode 100644 packages/next-safe-action/src/validation-errors.types.ts create mode 100644 website/docs/examples/extending-base-client.md delete mode 100644 website/docs/safe-action-client/defining-multiple-clients.md rename website/docs/safe-action-client/{custom-server-error-handling.md => initialization-options.md} (86%) create mode 100644 website/docs/safe-action-client/instance-methods.md delete mode 100644 website/docs/safe-action-client/using-a-middleware.md create mode 100644 website/docs/usage/middleware.md diff --git a/README.md b/README.md index 742c79d7..46b83ea3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ https://github.com/TheEdoRan/next-safe-action/assets/1337629/7ebc398e-6c7d-49b2- - ✅ Pretty simple - ✅ End-to-end type safety -- ✅ Context based clients (with middlewares) +- ✅ Powerful middleware system - ✅ Input validation using multiple validation libraries - ✅ Advanced server error handling - ✅ Optimistic updates diff --git a/package-lock.json b/package-lock.json index d0c756c2..2d3b8cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2117,7 +2117,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "devOptional": true }, "node_modules/@types/json5": { "version": "0.0.29", @@ -12682,7 +12682,6 @@ "version": "5.4.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/example-app/src/app/(examples)/direct/login-action.ts b/packages/example-app/src/app/(examples)/direct/login-action.ts index 9ae6a188..9a6ca284 100644 --- a/packages/example-app/src/app/(examples)/direct/login-action.ts +++ b/packages/example-app/src/app/(examples)/direct/login-action.ts @@ -4,29 +4,32 @@ import { action } from "@/lib/safe-action"; import { returnValidationErrors } from "next-safe-action"; import { z } from "zod"; -const input = z.object({ +const schema = z.object({ username: z.string().min(3).max(10), password: z.string().min(8).max(100), }); -export const loginUser = action(input, async ({ username, password }, ctx) => { - if (username === "johndoe") { - returnValidationErrors(input, { +export const loginUser = action + .metadata({ actionName: "loginUser" }) + .schema(schema) + .define(async ({ username, password }, ctx) => { + if (username === "johndoe") { + returnValidationErrors(schema, { + username: { + _errors: ["user_suspended"], + }, + }); + } + + if (username === "user" && password === "password") { + return { + success: true, + }; + } + + returnValidationErrors(schema, { username: { - _errors: ["user_suspended"], + _errors: ["incorrect_credentials"], }, }); - } - - if (username === "user" && password === "password") { - return { - success: true, - }; - } - - returnValidationErrors(input, { - username: { - _errors: ["incorrect_credentials"], - }, }); -}); diff --git a/packages/example-app/src/app/(examples)/hook/deleteuser-action.ts b/packages/example-app/src/app/(examples)/hook/deleteuser-action.ts index 50871b9b..bac2cdd5 100644 --- a/packages/example-app/src/app/(examples)/hook/deleteuser-action.ts +++ b/packages/example-app/src/app/(examples)/hook/deleteuser-action.ts @@ -3,18 +3,21 @@ import { ActionError, action } from "@/lib/safe-action"; import { z } from "zod"; -const input = z.object({ +const schema = z.object({ userId: z.string().min(1).max(10), }); -export const deleteUser = action(input, async ({ userId }) => { - await new Promise((res) => setTimeout(res, 1000)); +export const deleteUser = action + .metadata({ actionName: "deleteUser" }) + .schema(schema) + .define(async ({ userId }) => { + await new Promise((res) => setTimeout(res, 1000)); - if (Math.random() > 0.5) { - throw new ActionError("Could not delete user!"); - } + if (Math.random() > 0.5) { + throw new ActionError("Could not delete user!"); + } - return { - deletedUserId: userId, - }; -}); + return { + deletedUserId: userId, + }; + }); diff --git a/packages/example-app/src/app/(examples)/nested-schema/shop-action.ts b/packages/example-app/src/app/(examples)/nested-schema/shop-action.ts index 0bd185cf..ff464547 100644 --- a/packages/example-app/src/app/(examples)/nested-schema/shop-action.ts +++ b/packages/example-app/src/app/(examples)/nested-schema/shop-action.ts @@ -3,7 +3,7 @@ import { action } from "@/lib/safe-action"; import { z } from "zod"; -const input = z +const schema = z .object({ user: z.object({ id: z.string().uuid(), @@ -68,8 +68,11 @@ const input = z } }); -export const buyProduct = action(input, async () => { - return { - success: true, - }; -}); +export const buyProduct = action + .metadata({ actionName: "buyProduct" }) + .schema(schema) + .define(async () => { + return { + success: true, + }; + }); diff --git a/packages/example-app/src/app/(examples)/optimistic-hook/addlikes-action.ts b/packages/example-app/src/app/(examples)/optimistic-hook/addlikes-action.ts index 2f645139..c218b7cd 100644 --- a/packages/example-app/src/app/(examples)/optimistic-hook/addlikes-action.ts +++ b/packages/example-app/src/app/(examples)/optimistic-hook/addlikes-action.ts @@ -13,20 +13,23 @@ const incrementLikes = (by: number) => { return likes; }; -const input = z.object({ +const schema = z.object({ incrementBy: z.number(), }); -export const addLikes = action(input, async ({ incrementBy }) => { - await new Promise((res) => setTimeout(res, 2000)); +export const addLikes = action + .metadata({ actionName: "addLikes" }) + .schema(schema) + .define(async ({ incrementBy }) => { + await new Promise((res) => setTimeout(res, 2000)); - const likesCount = incrementLikes(incrementBy); + const likesCount = incrementLikes(incrementBy); - // This Next.js function revalidates the provided path. - // More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath - revalidatePath("/optimistic-hook"); + // This Next.js function revalidates the provided path. + // More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath + revalidatePath("/optimistic-hook"); - return { - likesCount, - }; -}); + return { + likesCount, + }; + }); diff --git a/packages/example-app/src/app/(examples)/react-hook-form/buyproduct-action.ts b/packages/example-app/src/app/(examples)/react-hook-form/buyproduct-action.ts index 75b8ad31..a4ce6ec0 100644 --- a/packages/example-app/src/app/(examples)/react-hook-form/buyproduct-action.ts +++ b/packages/example-app/src/app/(examples)/react-hook-form/buyproduct-action.ts @@ -4,10 +4,13 @@ import { action } from "@/lib/safe-action"; import { randomUUID } from "crypto"; import { schema } from "./validation"; -export const buyProduct = action(schema, async ({ productId }) => { - return { - productId, - transactionId: randomUUID(), - transactionTimestamp: Date.now(), - }; -}); +export const buyProduct = action + .metadata({ actionName: "buyProduct" }) + .schema(schema) + .define(async ({ productId }) => { + return { + productId, + transactionId: randomUUID(), + transactionTimestamp: Date.now(), + }; + }); diff --git a/packages/example-app/src/app/(examples)/server-form/signup-action.ts b/packages/example-app/src/app/(examples)/server-form/signup-action.ts index dfc324ac..bdfd0b5d 100644 --- a/packages/example-app/src/app/(examples)/server-form/signup-action.ts +++ b/packages/example-app/src/app/(examples)/server-form/signup-action.ts @@ -9,9 +9,12 @@ const schema = zfd.formData({ password: zfd.text(z.string().min(8)), }); -export const signup = action(schema, async ({ email, password }) => { - console.log("Email:", email, "Password:", password); - return { - success: true, - }; -}); +export const signup = action + .metadata({ actionName: "signup" }) + .schema(schema) + .define(async ({ email, password }) => { + console.log("Email:", email, "Password:", password); + return { + success: true, + }; + }); diff --git a/packages/example-app/src/app/(examples)/with-context/edituser-action.ts b/packages/example-app/src/app/(examples)/with-context/edituser-action.ts index cd9fc7f8..5829171e 100644 --- a/packages/example-app/src/app/(examples)/with-context/edituser-action.ts +++ b/packages/example-app/src/app/(examples)/with-context/edituser-action.ts @@ -3,41 +3,44 @@ import { authAction } from "@/lib/safe-action"; import { maxLength, minLength, object, string } from "valibot"; -const input = object({ +const schema = object({ fullName: string([minLength(3, "Too short"), maxLength(20, "Too long")]), age: string([minLength(2, "Too young"), maxLength(3, "Too old")]), }); -export const editUser = authAction( - input, - // Here you have access to `userId`, which comes from `buildContext` - // return object in src/lib/safe-action.ts. - // \\\\\ - async ({ fullName, age }, { userId }) => { - if (fullName.toLowerCase() === "john doe") { - return { - error: { - cause: "forbidden_name", - }, - }; - } +export const editUser = authAction + .metadata({ actionName: "editUser" }) + .schema(schema) + .define( + // Here you have access to `userId`, and `sessionId which comes from middleware functions + // defined before. + // \\\\\\\\\\\\\\\\\\ + async ({ fullName, age }, { ctx: { userId, sessionId } }) => { + if (fullName.toLowerCase() === "john doe") { + return { + error: { + cause: "forbidden_name", + }, + }; + } + + const intAge = parseInt(age); - const intAge = parseInt(age); + if (Number.isNaN(intAge)) { + return { + error: { + reason: "invalid_age", // different key in `error`, will be correctly inferred + }, + }; + } - if (Number.isNaN(intAge)) { return { - error: { - reason: "invalid_age", // different key in `error`, will be correctly inferred + success: { + newFullName: fullName, + newAge: intAge, + userId, + sessionId, }, }; } - - return { - success: { - newFullName: fullName, - newAge: intAge, - userId, - }, - }; - } -); + ); diff --git a/packages/example-app/src/lib/safe-action.ts b/packages/example-app/src/lib/safe-action.ts index 91900a7c..9f134e0d 100644 --- a/packages/example-app/src/lib/safe-action.ts +++ b/packages/example-app/src/lib/safe-action.ts @@ -3,18 +3,8 @@ import { DEFAULT_SERVER_ERROR, createSafeActionClient } from "next-safe-action"; export class ActionError extends Error {} -const handleReturnedServerError = (e: Error) => { - // If the error is an instance of `ActionError`, unmask the message. - if (e instanceof ActionError) { - return e.message; - } - - // Otherwise return default error message. - return DEFAULT_SERVER_ERROR; -}; - export const action = createSafeActionClient({ - // You can provide a custom log Promise, otherwise the lib will use `console.error` + // You can provide a custom logging function, otherwise the lib will use `console.error` // as the default logging system. If you want to disable server errors logging, // just pass an empty Promise. handleServerErrorLog: (e) => { @@ -23,23 +13,78 @@ export const action = createSafeActionClient({ e.message ); }, - handleReturnedServerError, + handleReturnedServerError: (e) => { + // If the error is an instance of `ActionError`, unmask the message. + if (e instanceof ActionError) { + return e.message; + } + + // Otherwise return default error message. + return DEFAULT_SERVER_ERROR; + }, +}).use(async ({ next, metadata }) => { + // Here we use a logging middleware. + const start = Date.now(); + + // Here we await the next middleware. + const result = await next({ ctx: null }); + + const end = Date.now(); + + // Log the execution time of the action. + console.log( + "LOGGING MIDDLEWARE: this action took", + end - start, + "ms to execute" + ); + + // Log the result + console.log("LOGGING MIDDLEWARE: result ->", result); + + // Log metadata + console.log("LOGGING MIDDLEWARE: metadata ->", metadata); + + // And then return the result of the awaited next middleware. + return result; }); -export const authAction = createSafeActionClient({ - // You can provide a middleware function. In this case, context is used - // for (fake) auth purposes. - middleware(parsedInput) { +async function getSessionId() { + return randomUUID(); +} + +export const authAction = action + // Clone the base client to extend this one with additional middleware functions. + .clone() + // In this case, context is used for (fake) auth purposes. + .use(async ({ next }) => { const userId = randomUUID(); + console.log("HELLO FROM FIRST AUTH ACTION MIDDLEWARE, USER ID:", userId); + + return next({ + ctx: { + userId, + }, + }); + }) + // Here we get `userId` from the previous context, and it's all type safe. + .use(async ({ ctx, next }) => { + // Emulate a slow server. + await new Promise((res) => + setTimeout(res, Math.max(Math.random() * 2000, 500)) + ); + + const sessionId = await getSessionId(); + console.log( - "HELLO FROM ACTION MIDDLEWARE, USER ID:", - userId, - "PARSED INPUT:", - parsedInput + "HELLO FROM SECOND AUTH ACTION MIDDLEWARE, SESSION ID:", + sessionId ); - return { userId }; - }, - handleReturnedServerError, -}); + return next({ + ctx: { + ...ctx, // here we spread the previous context to extend it + sessionId, // with session id + }, + }); + }); diff --git a/packages/next-safe-action/.eslintrc.js b/packages/next-safe-action/.eslintrc.js index bb41b894..33a6d377 100644 --- a/packages/next-safe-action/.eslintrc.js +++ b/packages/next-safe-action/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { "@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-this-alias": "off", "no-mixed-spaces-and-tabs": "off", "react-hooks/exhaustive-deps": "warn", }, diff --git a/packages/next-safe-action/src/hooks.ts b/packages/next-safe-action/src/hooks.ts index f73b5a2c..1c766e3f 100644 --- a/packages/next-safe-action/src/hooks.ts +++ b/packages/next-safe-action/src/hooks.ts @@ -5,42 +5,10 @@ import { isNotFoundError } from "next/dist/client/components/not-found.js"; import { isRedirectError } from "next/dist/client/components/redirect.js"; import * as React from "react"; import {} from "react/experimental"; -import type { SafeAction } from "."; -import type { MaybePromise } from "./utils"; +import type { HookActionStatus, HookCallbacks, HookResult } from "./hooks.types"; +import type { SafeActionFn } from "./index.types"; import { isError } from "./utils"; -// TYPES - -/** - * Type of `result` object returned by `useAction` and `useOptimisticAction` hooks. - */ -export type HookResult = Awaited>> & { - fetchError?: string; -}; - -/** - * Type of hooks callbacks. These are executed when action is in a specific state. - */ -export type HookCallbacks = { - onExecute?: (input: InferIn) => MaybePromise; - onSuccess?: (data: Data, input: InferIn, reset: () => void) => MaybePromise; - onError?: ( - error: Omit, "data">, - input: InferIn, - reset: () => void - ) => MaybePromise; - onSettled?: ( - result: HookResult, - input: InferIn, - reset: () => void - ) => MaybePromise; -}; - -/** - * Type of the action status returned by `useAction` and `useOptimisticAction` hooks. - */ -export type HookActionStatus = "idle" | "executing" | "hasSucceeded" | "hasErrored"; - // UTILS const DEFAULT_RESULT = { @@ -112,13 +80,13 @@ const useActionCallbacks = ( /** * Use the action from a Client Component via hook. - * @param safeAction The typesafe action. + * @param safeActionFn The typesafe action. * @param callbacks Optional callbacks executed based on the action status. * * {@link https://next-safe-action.dev/docs/usage/client-components/hooks/useaction See an example} */ export const useAction = ( - safeAction: SafeAction, + safeActionFn: SafeActionFn, callbacks?: HookCallbacks ) => { const [, startTransition] = React.useTransition(); @@ -134,7 +102,7 @@ export const useAction = ( setIsExecuting(true); return startTransition(() => { - return safeAction(input) + return safeActionFn(input) .then((res) => setResult(res ?? DEFAULT_RESULT)) .catch((e) => { if (isRedirectError(e) || isNotFoundError(e)) { @@ -148,7 +116,7 @@ export const useAction = ( }); }); }, - [safeAction] + [safeActionFn] ); const reset = React.useCallback(() => { @@ -169,7 +137,7 @@ export const useAction = ( * Use the action from a Client Component via hook, with optimistic data update. * * **NOTE: This hook uses an experimental React feature.** - * @param safeAction The typesafe action. + * @param safeActionFn The typesafe action. * @param initialOptimisticData Initial optimistic data. * @param reducer Optimistic state reducer. * @param callbacks Optional callbacks executed based on the action status. @@ -177,7 +145,7 @@ export const useAction = ( * {@link https://next-safe-action.dev/docs/usage/client-components/hooks/useoptimisticaction See an example} */ export const useOptimisticAction = ( - safeAction: SafeAction, + safeActionFn: SafeActionFn, initialOptimisticData: Data, reducer: (state: Data, input: InferIn) => Data, callbacks?: HookCallbacks @@ -201,7 +169,7 @@ export const useOptimisticAction = ( return startTransition(() => { setOptimisticState(input); - return safeAction(input) + return safeActionFn(input) .then((res) => setResult(res ?? DEFAULT_RESULT)) .catch((e) => { if (isRedirectError(e) || isNotFoundError(e)) { @@ -215,7 +183,7 @@ export const useOptimisticAction = ( }); }); }, - [setOptimisticState, safeAction] + [setOptimisticState, safeActionFn] ); const reset = React.useCallback(() => { @@ -232,3 +200,5 @@ export const useOptimisticAction = ( status, }; }; + +export type { HookActionStatus, HookCallbacks, HookResult }; diff --git a/packages/next-safe-action/src/hooks.types.ts b/packages/next-safe-action/src/hooks.types.ts new file mode 100644 index 00000000..4e83cb3c --- /dev/null +++ b/packages/next-safe-action/src/hooks.types.ts @@ -0,0 +1,33 @@ +import type { InferIn, Schema } from "@typeschema/main"; +import type { SafeActionResult } from "./index.types"; +import type { MaybePromise } from "./utils"; + +/** + * Type of `result` object returned by `useAction` and `useOptimisticAction` hooks. + */ +export type HookResult = SafeActionResult & { + fetchError?: string; +}; + +/** + * Type of hooks callbacks. These are executed when action is in a specific state. + */ +export type HookCallbacks = { + onExecute?: (input: InferIn) => MaybePromise; + onSuccess?: (data: Data, input: InferIn, reset: () => void) => MaybePromise; + onError?: ( + error: Omit, "data">, + input: InferIn, + reset: () => void + ) => MaybePromise; + onSettled?: ( + result: HookResult, + input: InferIn, + reset: () => void + ) => MaybePromise; +}; + +/** + * Type of the action status returned by `useAction` and `useOptimisticAction` hooks. + */ +export type HookActionStatus = "idle" | "executing" | "hasSucceeded" | "hasErrored"; diff --git a/packages/next-safe-action/src/index.ts b/packages/next-safe-action/src/index.ts index 09894cb2..b70f0878 100644 --- a/packages/next-safe-action/src/index.ts +++ b/packages/next-safe-action/src/index.ts @@ -1,48 +1,196 @@ -import type { Infer, InferIn, Schema } from "@typeschema/main"; +import type { Schema } from "@typeschema/main"; import { validate } from "@typeschema/main"; import { isNotFoundError } from "next/dist/client/components/not-found.js"; import { isRedirectError } from "next/dist/client/components/redirect.js"; -import type { ErrorList, Extend, MaybePromise, SchemaErrors } from "./utils"; -import { buildValidationErrors, isError } from "./utils"; +import type { + ActionMetadata, + MiddlewareFn, + MiddlewareResult, + SafeActionClientOpts, + SafeActionFn, + SafeActionResult, + ServerCodeFn, +} from "./index.types"; +import { DEFAULT_SERVER_ERROR, isError } from "./utils"; +import { + ServerValidationError, + buildValidationErrors, + returnValidationErrors, +} from "./validation-errors"; +import type { ValidationErrors } from "./validation-errors.types"; + +class SafeActionClient { + private readonly handleServerErrorLog: NonNullable; + private readonly handleReturnedServerError: NonNullable< + SafeActionClientOpts["handleReturnedServerError"] + >; + + private middlewareFns: MiddlewareFn[]; + private _metadata: ActionMetadata = {}; + + constructor( + opts: { middlewareFns: MiddlewareFn[] } & Required + ) { + this.middlewareFns = opts.middlewareFns; + this.handleServerErrorLog = opts.handleServerErrorLog; + this.handleReturnedServerError = opts.handleReturnedServerError; + } -// TYPES + /** + * Clone the safe action client keeping the same middleware and initialization functions. + * This is used to extend the base client with additional middleware functions. + * @returns {SafeActionClient} + */ + public clone() { + return new SafeActionClient({ + handleReturnedServerError: this.handleReturnedServerError, + handleServerErrorLog: this.handleServerErrorLog, + middlewareFns: [...this.middlewareFns], // copy the middleware stack so we don't mutate it + }); + } -/** - * Type of options when creating a new safe action client. - */ -export type SafeClientOpts = { - handleServerErrorLog?: (e: Error) => MaybePromise; - handleReturnedServerError?: (e: Error) => MaybePromise; - middleware?: (parsedInput: any, data?: MiddlewareData) => MaybePromise; -}; + /** + * Use a middleware function. + * @param middlewareFn Middleware function + * @returns SafeActionClient + */ + public use( + middlewareFn: MiddlewareFn + ) { + this.middlewareFns.push(middlewareFn); + + return new SafeActionClient({ + middlewareFns: this.middlewareFns, + handleReturnedServerError: this.handleReturnedServerError, + handleServerErrorLog: this.handleServerErrorLog, + }); + } -/** - * Type of the function called from Client Components with typesafe input data. - */ -export type SafeAction = (input: InferIn) => Promise<{ - data?: Data; - serverError?: string; - validationErrors?: ValidationErrors; -}>; + /** + * Set metadata for the action that will be defined afterwards. + * @param data Metadata for the action + * @returns {Function} Define a new action + */ + public metadata(data: ActionMetadata) { + this._metadata = data; -/** - * Type of the function that executes server code when defining a new safe action. - */ -export type ServerCodeFn = ( - parsedInput: Infer, - ctx: Context -) => Promise; + return { + schema: this.schema.bind(this), + }; + } -/** - * Type of the returned object when input validation fails. - */ -export type ValidationErrors = Extend>>; + /** + * Pass an input schema to define safe action arguments. + * @param schema An input schema supported by [TypeSchema](https://typeschema.com/#coverage). + * @returns {Function} The `define` function, which is used to define a new safe action. + */ + public schema(schema: S) { + const classThis = this; + + return { + /** + * Define a new safe action. + * @param serverCodeFn A function that executes the server code. + * @returns {SafeActionFn} + */ + define(serverCodeFn: ServerCodeFn): SafeActionFn { + return async (clientInput: unknown) => { + let prevCtx: any = null; + let frameworkError: Error | undefined = undefined; + const middlewareResult: MiddlewareResult = { success: false }; + + // Execute the middleware stack. + const executeMiddlewareChain = async (idx = 0) => { + const currentFn = classThis.middlewareFns[idx]; + + middlewareResult.ctx = prevCtx; + + try { + if (currentFn) { + await currentFn({ + clientInput, // pass raw client input + ctx: prevCtx, + metadata: classThis._metadata, + next: async ({ ctx }) => { + prevCtx = ctx; + await executeMiddlewareChain(idx + 1); + return middlewareResult; + }, + }); + } else { + const parsedInput = await validate(schema, clientInput); + + if (!parsedInput.success) { + middlewareResult.validationErrors = buildValidationErrors(parsedInput.issues); + return; + } + + const data = + (await serverCodeFn(parsedInput.data, { + ctx: prevCtx, + metadata: classThis._metadata, + })) ?? null; + middlewareResult.success = true; + middlewareResult.data = data; + middlewareResult.parsedInput = parsedInput.data; + } + } catch (e: unknown) { + // next/navigation functions work by throwing an error that will be + // processed internally by Next.js. + if (isRedirectError(e) || isNotFoundError(e)) { + middlewareResult.success = true; + frameworkError = e; + return; + } + + // If error is ServerValidationError, return validationErrors as if schema validation would fail. + if (e instanceof ServerValidationError) { + middlewareResult.validationErrors = e.validationErrors; + return; + } + + if (!isError(e)) { + console.warn("Could not handle server error. Not an instance of Error: ", e); + middlewareResult.serverError = DEFAULT_SERVER_ERROR; + return; + } + + await Promise.resolve(classThis.handleServerErrorLog(e)); + + middlewareResult.serverError = await Promise.resolve( + classThis.handleReturnedServerError(e) + ); + } + }; + + await executeMiddlewareChain(); -// UTILS + // If an internal framework error occurred, throw it, so it will be processed by Next.js. + if (frameworkError) { + throw frameworkError; + } -export const DEFAULT_SERVER_ERROR = "Something went wrong while executing the operation"; + const actionResult: SafeActionResult = {}; -// SAFE ACTION CLIENT + if (typeof middlewareResult.data !== "undefined") { + actionResult.data = middlewareResult.data as Data; + } + + if (typeof middlewareResult.validationErrors !== "undefined") { + actionResult.validationErrors = + middlewareResult.validationErrors as ValidationErrors; + } + + if (typeof middlewareResult.serverError !== "undefined") { + actionResult.serverError = middlewareResult.serverError; + } + + return actionResult; + }; + }, + }; + } +} /** * Initialize a new action client. @@ -51,9 +199,7 @@ export const DEFAULT_SERVER_ERROR = "Something went wrong while executing the op * * {@link https://next-safe-action.dev/docs/getting-started See an example} */ -export const createSafeActionClient = ( - createOpts?: SafeClientOpts -) => { +export const createSafeActionClient = (createOpts?: SafeActionClientOpts) => { // If server log function is not provided, default to `console.error` for logging // server error messages. const handleServerErrorLog = @@ -68,93 +214,21 @@ export const createSafeActionClient = ( const handleReturnedServerError = (e: Error) => createOpts?.handleReturnedServerError?.(e) || DEFAULT_SERVER_ERROR; - // `actionBuilder` is the server function that creates a new action. - // It expects an input schema and a `serverCode` function, so the action - // knows what to do on the server when called by the client. - // It returns a function callable by the client. - const actionBuilder = ( - schema: S, - serverCode: ServerCodeFn, - utils?: { - middlewareData?: MiddlewareData; - } - ): SafeAction => { - // This is the function called by client. If `input` fails the schema - // parsing, the function will return a `validationErrors` object, containing - // all the invalid fields provided. - return async (clientInput) => { - try { - const parsedInput = await validate(schema, clientInput); - - // If schema validation fails. - if (!parsedInput.success) { - return { - validationErrors: buildValidationErrors(parsedInput.issues), - }; - } - - // Get the context if `middleware` is provided. - const ctx = (await Promise.resolve( - createOpts?.middleware?.(parsedInput.data, utils?.middlewareData) - )) as Context; - - // Get `result.data` from the server code function. If it doesn't return - // anything, `data` will be `null`. - const data = ((await serverCode(parsedInput.data, ctx)) ?? null) as Data; - - return { data }; - } catch (e: unknown) { - // next/navigation functions work by throwing an error that will be - // processed internally by Next.js. So, in this case we need to rethrow it. - if (isRedirectError(e) || isNotFoundError(e)) { - throw e; - } - - // If error is ServerValidationError, return validationErrors as if schema validation would fail. - if (e instanceof ServerValidationError) { - return { validationErrors: e.validationErrors as ValidationErrors }; - } - - // If error cannot be handled, warn the user and return a generic message. - if (!isError(e)) { - console.warn("Could not handle server error. Not an instance of Error: ", e); - return { serverError: DEFAULT_SERVER_ERROR }; - } - - await Promise.resolve(handleServerErrorLog(e)); - - return { - serverError: await Promise.resolve(handleReturnedServerError(e)), - }; - } - }; - }; - - return actionBuilder; + return new SafeActionClient({ + middlewareFns: [async ({ next }) => next({ ctx: null })], + handleServerErrorLog, + handleReturnedServerError, + }); }; -// VALIDATION ERRORS - -// This class is internally used to throw validation errors in action's server code function, using -// `returnValidationErrors`. -class ServerValidationError extends Error { - public validationErrors: ValidationErrors; - constructor(validationErrors: ValidationErrors) { - super("Server Validation Error"); - this.validationErrors = validationErrors; - } -} +export { DEFAULT_SERVER_ERROR, returnValidationErrors, type ValidationErrors }; -/** - * Return custom validation errors to the client from the action's server code function. - * Code declared after this function invocation will not be executed. - * @param schema Input schema - * @param validationErrors Validation errors object - * @throws {ServerValidationError} - */ -export function returnValidationErrors( - schema: S, - validationErrors: ValidationErrors -): never { - throw new ServerValidationError(validationErrors); -} +export type { + ActionMetadata, + MiddlewareFn, + MiddlewareResult, + SafeActionClientOpts, + SafeActionFn, + SafeActionResult, + ServerCodeFn, +}; diff --git a/packages/next-safe-action/src/index.types.ts b/packages/next-safe-action/src/index.types.ts new file mode 100644 index 00000000..66902b85 --- /dev/null +++ b/packages/next-safe-action/src/index.types.ts @@ -0,0 +1,67 @@ +import type { Infer, InferIn, Schema } from "@typeschema/main"; +import type { MaybePromise } from "./utils"; +import type { ValidationErrors } from "./validation-errors.types"; + +/** + * Type of options when creating a new safe action client. + */ +export type SafeActionClientOpts = { + handleServerErrorLog?: (e: Error) => MaybePromise; + handleReturnedServerError?: (e: Error) => MaybePromise; +}; + +/** + * Type of meta options to be passed when defining a new safe action. + */ +export type ActionMetadata = { + actionName?: string; +}; + +/** + * Type of the result of a middleware function. It extends the result of a safe action with + * `parsedInput` and `ctx` optional properties. + */ +export type MiddlewareResult = SafeActionResult & { + parsedInput?: unknown; + ctx?: unknown; + success: boolean; +}; + +/** + * Type of the middleware function passed to a safe action client. + */ +export type MiddlewareFn = { + (opts: { + clientInput: ClientInput; + ctx: Ctx; + metadata: ActionMetadata; + next: { + (opts: { ctx: NC }): Promise>; + }; + }): Promise>; +}; + +/** + * Type of the function that executes server code when defining a new safe action. + */ +export type ServerCodeFn = ( + parsedInput: Infer, + utils: { ctx: Context; metadata: ActionMetadata } +) => Promise; + +/** + * Type of the result of a safe action. + */ +// eslint-disable-next-line +export type SafeActionResult = { + data?: Data; + serverError?: string; + validationErrors?: ValidationErrors; +}; + +/** + * Type of the function called from components with typesafe input data. + */ +export type SafeActionFn = ( + input: InferIn +) => Promise>; diff --git a/packages/next-safe-action/src/utils.ts b/packages/next-safe-action/src/utils.ts index 89c741ab..6aae2adf 100644 --- a/packages/next-safe-action/src/utils.ts +++ b/packages/next-safe-action/src/utils.ts @@ -1,6 +1,4 @@ -import type { ValidationIssue } from "@typeschema/core"; -import type { Schema } from "@typeschema/main"; -import type { ValidationErrors } from "."; +export const DEFAULT_SERVER_ERROR = "Something went wrong while executing the operation."; export const isError = (error: unknown): error is Error => error instanceof Error; @@ -8,59 +6,3 @@ export const isError = (error: unknown): error is Error => error instanceof Erro // Returns type or promise of type. export type MaybePromise = Promise | T; - -// Extends an object without printing "&". -export type Extend = S extends infer U ? { [K in keyof U]: U[K] } : never; - -// VALIDATION ERRORS - -// Object with an optional list of validation errors. -export type ErrorList = { _errors?: string[] } & {}; - -// Creates nested schema validation errors type using recursion. -export type SchemaErrors = { - [K in keyof S]?: S[K] extends object | null | undefined - ? Extend> - : ErrorList; -} & {}; - -// This function is used to build the validation errors object from a list of validation issues. -export const buildValidationErrors = (issues: ValidationIssue[]) => { - const ve: any = {}; - - for (const issue of issues) { - const { path, message } = issue; - - // When path is undefined or empty, set root errors. - if (!path || path.length === 0) { - ve._errors = ve._errors ? [...ve._errors, message] : [message]; - continue; - } - - // Reference to errors object. - let ref = ve; - - // Set object for the path, if it doesn't exist. - for (let i = 0; i < path.length - 1; i++) { - const k = path[i]!; - - if (!ref[k]) { - ref[k] = {}; - } - - ref = ref[k]; - } - - // Key is always the last element of the path. - const key = path[path.length - 1]!; - - // Set error for the current path. If `_errors` array exists, add the message to it. - ref[key] = ( - ref[key]?._errors - ? { ...structuredClone(ref[key]), _errors: [...ref[key]._errors, message] } - : { ...structuredClone(ref[key]), _errors: [message] } - ) satisfies ErrorList; - } - - return ve as ValidationErrors; -}; diff --git a/packages/next-safe-action/src/validation-errors.ts b/packages/next-safe-action/src/validation-errors.ts new file mode 100644 index 00000000..788ede02 --- /dev/null +++ b/packages/next-safe-action/src/validation-errors.ts @@ -0,0 +1,71 @@ +import type { ValidationIssue } from "@typeschema/core"; +import type { Schema } from "@typeschema/main"; +import type { ErrorList, ValidationErrors } from "./validation-errors.types"; + +// This function is used internally to build the validation errors object from a list of validation issues. +export const buildValidationErrors = (issues: ValidationIssue[]) => { + const ve: any = {}; + + for (const issue of issues) { + const { path, message } = issue; + + // When path is undefined or empty, set root errors. + if (!path || path.length === 0) { + ve._errors = ve._errors ? [...ve._errors, message] : [message]; + continue; + } + + // Reference to errors object. + let ref = ve; + + // Set object for the path, if it doesn't exist. + for (let i = 0; i < path.length - 1; i++) { + const k = path[i]!; + + if (!ref[k]) { + ref[k] = {}; + } + + ref = ref[k]; + } + + // Key is always the last element of the path. + const key = path[path.length - 1]!; + + // Set error for the current path. If `_errors` array exists, add the message to it. + ref[key] = ( + ref[key]?._errors + ? { + ...structuredClone(ref[key]), + _errors: [...ref[key]._errors, message], + } + : { ...structuredClone(ref[key]), _errors: [message] } + ) satisfies ErrorList; + } + + return ve as ValidationErrors; +}; + +// This class is internally used to throw validation errors in action's server code function, using +// `returnValidationErrors`. +export class ServerValidationError extends Error { + public validationErrors: ValidationErrors; + constructor(validationErrors: ValidationErrors) { + super("Server Validation Error"); + this.validationErrors = validationErrors; + } +} + +/** + * Return custom validation errors to the client from the action's server code function. + * Code declared after this function invocation will not be executed. + * @param schema Input schema + * @param validationErrors Validation errors object + * @throws {ServerValidationError} + */ +export function returnValidationErrors( + schema: S, + validationErrors: ValidationErrors +): never { + throw new ServerValidationError(validationErrors); +} diff --git a/packages/next-safe-action/src/validation-errors.types.ts b/packages/next-safe-action/src/validation-errors.types.ts new file mode 100644 index 00000000..097c5311 --- /dev/null +++ b/packages/next-safe-action/src/validation-errors.types.ts @@ -0,0 +1,19 @@ +import type { Infer, Schema } from "@typeschema/main"; + +// Extends an object without printing "&". +type Extend = S extends infer U ? { [K in keyof U]: U[K] } : never; + +// Object with an optional list of validation errors. +export type ErrorList = { _errors?: string[] } & {}; + +// Creates nested schema validation errors type using recursion. +type SchemaErrors = { + [K in keyof S]?: S[K] extends object | null | undefined + ? Extend> + : ErrorList; +} & {}; + +/** + * Type of the returned object when input validation fails. + */ +export type ValidationErrors = Extend>>; diff --git a/website/docs/contributing.md b/website/docs/contributing.md index 01f1d82a..f7983778 100644 --- a/website/docs/contributing.md +++ b/website/docs/contributing.md @@ -11,4 +11,4 @@ Have you found bugs or just want to ask a question? Please open a [GitHub issue] ### Donations -If you find this project useful, please consider making a [donation](https://www.paypal.com/donate/?hosted_button_id=ES9JRPSC66XKW). This is absolutely not required, but is very much appreciated, since it will help to cover the time and resources required to maintain this project. Thank you! \ No newline at end of file +If you find this project useful, please consider making a [donation](https://github.com/sponsors/TheEdoRan). This is absolutely not required, but is very much appreciated, since it will help to cover the time and resources required to maintain this project. Thank you! \ No newline at end of file diff --git a/website/docs/examples/extending-base-client.md b/website/docs/examples/extending-base-client.md new file mode 100644 index 00000000..243e116c --- /dev/null +++ b/website/docs/examples/extending-base-client.md @@ -0,0 +1,57 @@ +--- +sidebar_position: 3 +description: Learn how to use both a basic and an authorization client at the same time in your project. +--- + + +# Extending a base client + +A common and recommended pattern with this library is to extend the base safe action client, to cover different use cases that you might want and/or need in your applicaton. + +The most simple case that comes to mind is to define a base client for unauthenticated actions, and then extend it to create a client for authenticated actions, thanks to an authorization middleware: + +```typescript title="src/lib/safe-action.ts" +import { createSafeActionClient } from "next-safe-action"; +import { cookies } from "next/headers"; +import { getUserIdFromSessionId } from "./db"; + +// This is our base client. +// Here we define a middleware that logs the result of the action execution. +export const actionClient = createSafeActionClient().use(async ({ next }) => { + const result = await next({ ctx: null }); + console.log("LOGGING MIDDLEWARE: result ->", result); + return result; +}); + +// This client extends the base one and ensures that the user is authenticated before running +// action server code function. Note that by extending the base client, you don't need to +// redeclare the logging middleware, is will simply be inherited by the new client. +export const authActionClient = actionClient + // Clone the base client to extend this one with additional middleware functions. + .clone() + // In this case, context is used for (fake) auth purposes. + .use(async ({ next }) => { + const session = cookies().get("session")?.value; + + // If the session is not found, we throw an error and stop execution here. + if (!session) { + throw new Error("Session not found!"); + } + + // In the real world, you would check if the session is valid by querying a database. + // We'll keep it very simple here. + const userId = await getUserIdFromSessionId(session); + + // If the session is not valid, we throw an error and stop execution here. + if (!userId) { + throw new Error("Session is not valid!"); + } + + // Here we return the context object for the next middleware in the chain/server code function. + return next({ + ctx: { + userId, + }, + }); + }); +``` \ No newline at end of file diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index e3c367d9..28c37f45 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -29,6 +29,8 @@ For Next.js >= 14, assuming you want to use Zod as your validation library, use npm i next-safe-action zod @typeschema/zod ``` +Find the adapter for your validation library of choice in the [TypeSchema documentation](https://typeschema.com/#coverage). + ## Usage ### 1. Instantiate a new client @@ -38,21 +40,21 @@ npm i next-safe-action zod @typeschema/zod ```typescript title="src/lib/safe-action.ts" import { createSafeActionClient } from "next-safe-action"; -export const action = createSafeActionClient(); +export const actionClient = createSafeActionClient(); ``` -This is a basic client, without any options. If you want to explore the full set of options, check out the [safe action client](/docs/safe-action-client) section. +This is a basic client, without any options or middleware functions. If you want to explore the full set of options, check out the [safe action client](/docs/safe-action-client) section. ### 2. Define a new action -This is how a safe action is created. Providing a validation input schema to the function, we're sure that data that comes in is type safe and validated. -The second argument of this function is an async function that receives the parsed input, and defines what happens on the server when the action is called from client. In short, this is your server code. It never runs on the client: +This is how a safe action is created. Providing a validation input schema to the function via `schema()`, we're sure that data that comes in is type safe and validated. +The `define()` method lets you define what happens on the server when the action is called from client, via an async function that receives the parsed input and context as arguments. In short, this is your _server code_. **It never runs on the client**: ```typescript title="src/app/login-action.ts" "use server"; // don't forget to add this! import { z } from "zod"; -import { action } from "@/lib/safe-action"; +import { actionClient } from "@/lib/safe-action"; // This schema is used to validate input from client. const schema = z.object({ @@ -60,22 +62,24 @@ const schema = z.object({ password: z.string().min(8).max(100), }); -export const loginUser = action(schema, async ({ username, password }) => { - if (username === "johndoe" && password === "123456") { - return { - success: "Successfully logged in", - }; - } - - return { failure: "Incorrect credentials" }; -}); +export const loginUser = actionClient + .schema(schema) + .define(async ({ username, password }) => { + if (username === "johndoe" && password === "123456") { + return { + success: "Successfully logged in", + }; + } + + return { failure: "Incorrect credentials" }; + }); ``` `action` returns a new function that can be called from the client. ### 3. Import and execute the action -In this example, we're **directly** calling the Server Actions from a Client Component. The action is passed as a prop to the component, and we can infer its type by simply using `typeof`: +In this example, we're **directly** calling the Server Action from a Client Component: ```tsx title="src/app/login.tsx" "use client"; // this is a Client Component diff --git a/website/docs/introduction.md b/website/docs/introduction.md index 79cfa43b..a990a73b 100644 --- a/website/docs/introduction.md +++ b/website/docs/introduction.md @@ -1,11 +1,11 @@ --- sidebar_position: 1 -description: next-safe-action is a library that takes full advantage of the latest and greatest Next.js, React and TypeScript features, using validation libraries of your choice, to let you define typesafe Server Actions and execute them inside Client Components. +description: next-safe-action is a library that takes full advantage of the latest and greatest Next.js, React and TypeScript features, using validation libraries of your choice, to let you define type safe Server Actions and execute them inside React Components. --- # Introduction -**next-safe-action** is a library that takes full advantage of the latest and greatest Next.js, React and TypeScript features, using validation libraries of your choice, to let you define **typesafe** Server Actions and execute them inside Client Components. +**next-safe-action** is a library that takes full advantage of the latest and greatest Next.js, React and TypeScript features, using validation libraries of your choice, to let you define **type safe** Server Actions and execute React Components. ## How does it work? @@ -19,7 +19,7 @@ Your browser does not support the video tag. ## Features - ✅ Pretty simple - ✅ End-to-end type safety -- ✅ Context based clients (with middlewares) +- ✅ Powerful middleware system - ✅ Input validation using multiple validation libraries - ✅ Advanced server error handling - ✅ Optimistic updates diff --git a/website/docs/safe-action-client/defining-multiple-clients.md b/website/docs/safe-action-client/defining-multiple-clients.md deleted file mode 100644 index 23b6ef97..00000000 --- a/website/docs/safe-action-client/defining-multiple-clients.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -sidebar_position: 3 -description: Learn how to define multiple clients. ---- - -# Defining multiple clients - -A common and recommended pattern with this library is to define multiple safe action clients, to cover different use cases that you might want and/or need in your applicaton. - -The most simple case that comes to mind is to define a client for unauthenticated actions, and one for authenticated actions, but you can define as many clients as you want: - -```typescript src=src/lib/safe-action.ts -import { createSafeActionClient } from "next-safe-action"; -import { cookies } from "next/headers"; -import { getUserIdFromSessionId } from "./db"; - -// This is our base client. -export const action = createSafeActionClient(); - -// This client ensures that the user is authenticated before running action server code. -export const authAction = createSafeActionClient({ - // Can also be a non async function. - async middleware() { - const session = cookies().get("session")?.value; - - if (!session) { - throw new Error("Session not found!"); - } - - // In the real world, you would check if the session is valid by querying a database. - // We'll keep it very simple here. - const userId = await getUserIdFromSessionId(session); - - if (!userId) { - throw new Error("Session is not valid!"); - } - - return { userId }; - }, -}); -``` \ No newline at end of file diff --git a/website/docs/safe-action-client/index.md b/website/docs/safe-action-client/index.md index 228380fe..9b4a06ca 100644 --- a/website/docs/safe-action-client/index.md +++ b/website/docs/safe-action-client/index.md @@ -5,7 +5,7 @@ description: Safe action client is the instance that you can use to create types # Safe action client -The safe action client instance is created by the `createSafeActionClient()` function. The instance is used to create safe actions, as you have already seen in previous sections of the documentation. You can create multiple clients too, for different purposes, as explained [in this section](/docs/safe-action-client/defining-multiple-clients). +The safe action client instance is created by the `createSafeActionClient()` function. The instance is used to create safe actions, as you have already seen in previous sections of the documentation. You can create multiple clients too, for different purposes, as explained [in this section](/docs/examples/extending-base-client). You can also provide functions to the client, to customize the behavior for every action you then create with it. We will explore them in detail in the following sections. @@ -13,6 +13,5 @@ Here's a reference of all the available optional functions: | Function name | Purpose | |------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `middleware?` | Performs custom logic before action server code is executed, but after input from the client is validated. More information [here](/docs/safe-action-client/using-a-middleware). | -| `handleReturnedServerError?` | When an error occurs on the server after executing the action on the client, it lets you define custom logic to returns a custom `serverError` message instead of the default one. More information [here](/docs/safe-action-client/custom-server-error-handling#handlereturnedservererror). | -| `handleServerErrorLog?` | When an error occurs on the server after executing the action on the client, it lets you define custom logic to log the error on the server. By default the error is logged via `console.error`. More information [here](/docs/safe-action-client/custom-server-error-handling#handleservererrorlog). | \ No newline at end of file +| `handleReturnedServerError?` | When an error occurs on the server after executing the action on the client, it lets you define custom logic to returns a custom `serverError` message instead of the default one. More information [here](/docs/safe-action-client/initialization-options#handlereturnedservererror). | +| `handleServerErrorLog?` | When an error occurs on the server after executing the action on the client, it lets you define custom logic to log the error on the server. By default the error is logged via `console.error`. More information [here](/docs/safe-action-client/initialization-options#handleservererrorlog). | \ No newline at end of file diff --git a/website/docs/safe-action-client/custom-server-error-handling.md b/website/docs/safe-action-client/initialization-options.md similarity index 86% rename from website/docs/safe-action-client/custom-server-error-handling.md rename to website/docs/safe-action-client/initialization-options.md index 023a7564..ce4fef75 100644 --- a/website/docs/safe-action-client/custom-server-error-handling.md +++ b/website/docs/safe-action-client/initialization-options.md @@ -1,18 +1,18 @@ --- -sidebar_position: 2 -description: Learn how to customize server error handling. +sidebar_position: 1 +description: You can initialize a safe action client with these options. --- -# Custom server error handling +# Initialization options -### `handleReturnedServerError?` +## `handleReturnedServerError?` You can provide this optional function to the safe action client. It is used to customize the server error message returned to the client, if one occurs during action's server execution. This includes errors thrown by the action server code, and errors thrown by the middleware. Here's a simple example, changing the message for every error thrown on the server: ```typescript title=src/lib/safe-action.ts -export const action = createSafeActionClient({ +export const actionClient = createSafeActionClient({ // Can also be an async function. handleReturnedServerError(e) { return "Oh no, something went wrong!"; @@ -29,7 +29,7 @@ import { DEFAULT_SERVER_ERROR } from "next-safe-action"; class MyCustomError extends Error {} -export const action = createSafeActionClient({ +export const actionClient = createSafeActionClient({ // Can also be an async function. handleReturnedServerError(e) { // In this case, we can use the 'MyCustomError` class to unmask errors @@ -44,14 +44,14 @@ export const action = createSafeActionClient({ }); ``` -### `handleServerErrorLog?` +## `handleServerErrorLog?` You can provide this optional function to the safe action client. This is used to define how errors should be logged when one occurs while the server is executing an action. This includes errors thrown by the action server code, and errors thrown by the middleware. Here you get as argument the **original error object**, not a message customized by `handleReturnedServerError`, if provided. Here's a simple example, logging error to the console while also reporting it to an error handling system: ```typescript title=src/lib/safe-action.ts -export const action = createSafeActionClient({ +export const actionClient = createSafeActionClient({ // Can also be an async function. handleServerErrorLog(e) { // We can, for example, also send the error to a dedicated logging system. diff --git a/website/docs/safe-action-client/instance-methods.md b/website/docs/safe-action-client/instance-methods.md new file mode 100644 index 00000000..1c25610c --- /dev/null +++ b/website/docs/safe-action-client/instance-methods.md @@ -0,0 +1,58 @@ +--- +sidebar_position: 2 +description: List of methods of the safe action client. +--- + +# Instance methods + +`createSafeActionClient` creates an instance of the safe action client, which has the following methods: + +## `clone` + +```typescript +actionClient.clone() => new SafeActionClient() +``` + +`clone` returns a new instance of the safe action client with the same initialization options and middleware functions as the original one. It is used to extend a base client with additional middleware functions. If you don't use `clone` when creating a new client, the middleware function list of the original one will be mutated and extended with the new ones, which is not desirable. + +## `use` + +```typescript +use(middlewareFn: MiddlewareFn) => new SafeActionClient() +``` + +`use` accepts a middleware function of type [`MiddlewareFn`](/docs/types#middlewarefn) as argument and returns a new instance of the safe action client with that middleware function added to the stack, that will be executed after the last one, if any. Check out how to `use` middleware in [the related section](/docs/usage/middleware) of the usage guide. + +## `metadata` + +```typescript +metadata(data: ActionMetadata) => { schema() } +``` + +`metadata` expects an object of type [`ActionMetadata`](/docs/types#actionmetadata) that lets you specify useful data about the safe action you're defining, and it returns the [`schema`](#schema) method, since metadata is action specific and not shared with other actions. As of now, the only data you can pass in is the `actionName`, but that could be extended in the future. You can then access it in the `middlewareFn` passed to [`use`](#use) and in [`serverCodeFn`](#servercodefn) passed to [`define`](#define). + +## `schema` + +```typescript +schema(schema: S) => { define() } +``` + +`schema` accepts an input schema of type `Schema` (from TypeSchema), which is used to define the arguments that the safe action will receive, and returns the [`define`](#define) method, which allows you to define a new action using that input schema. + +## `define` + +```typescript +define(serverCodeFn: ServerCodeFn) => SafeActionFn +``` + +`define` is the final method in the list. It accepts a [`serverCodeFn`](#servercodefn) of type [`ServerCodeFn`](/docs/types#servercodefn) and returns a new safe action function of type [`SafeActionFn`](/docs/types#safeactionfn), which can be called from your components. + +When the action is executed, all middleware functions in the chain will be called at runtime, in the order they were defined. + +### `serverCodeFn` + +```typescript +serverCodeFn = (parsedInput: Infer, utils: { ctx: Context; metadata: ActionMetadata }) => Promise; +``` + +`serverCodeFn` is the async function that will be executed on the **server side** when the action is invoked. If input validation fails, or execution gets halted in a middleware function, the server code function will not be called. diff --git a/website/docs/safe-action-client/using-a-middleware.md b/website/docs/safe-action-client/using-a-middleware.md deleted file mode 100644 index de4d9955..00000000 --- a/website/docs/safe-action-client/using-a-middleware.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -sidebar_position: 1 -description: Learn how to use a middleware in your client to perform custom logic before action server code is executed. ---- - -# Using a middleware - -You can provide a middleware to the safe action client to perform custom logic before action server code is executed, but after input from the client is validated. - -So, let's say, you want to be sure that the user is authenticated before executing an action. In this case, you would create an `authAction` client and check if the user session is valid: - -```typescript title="src/lib/safe-action.ts" -import { createSafeActionClient } from "next-safe-action"; -import { cookies } from "next/headers"; -import { getUserIdFromSessionId } from "./db"; - -export const authAction = createSafeActionClient({ - // If you need to access validated input, you can use `parsedInput` how you want - // in your middleware. Please note that `parsedInput` is typed any, as it - // comes from an action, while middleware is an (optional) instance function. - // Can also be a non async function. - async middleware(parsedInput) { - const session = cookies().get("session")?.value; - - if (!session) { - throw new Error("Session not found!"); - } - - // In the real world, you would check if the session is valid by querying a database. - // We'll keep it very simple here. - const userId = await getUserIdFromSessionId(session); - - if (!userId) { - throw new Error("Session is not valid!"); - } - - return { userId }; - }, -}); -``` - -As you can see, you can use the `cookies()` and `headers()` functions from `next/headers` to get cookie values and request headers. You can also delete/manipulate cookies in the middleware (since it is part of a Server Action execution), and safely throw an error, that will be caught by the safe action client and returned to the client as a `serverError` result. - -Middleware can also be used to return a context, that will be passed as the second argument of the action server code function. This is very useful if you want, for example, find out which user executed the action. Here's an example reusing the `authAction` client defined above: - -```typescript title="src/app/send-message-action.ts" -import { authAction } from "@/lib/safe-action"; -import { z } from "zod"; -import { createMessage } from "./db"; - -const schema = z.object({ - text: z.string(), -}); - -// This comes from middleware return object (context). -// \\ -const sendMessage = authAction(schema, async ({ text }, { userId }) => { - // Fake db call, this function creates a new message in the database, we know - // the user id thanks to the context injected by the middleware function. - const messageId = createMessage(userId, text); - return { messageId }; -}); -``` - -If the user session is not valid this server code is never executed. So in this case, we're sure the user is authenticated. - -## Passing data to middleware from actions - -You can pass data to your middleware from actions. This is useful, for example, if you want to restrict action execution to certain user roles or permissions. We'll redefine the `authAction` client to pass a user role to the middleware. - -```typescript title="src/lib/safe-action.ts" -type MiddlewareData = { - userRole: "admin" | "user"; -} - -export const authAction = createSafeActionClient({ - // Second argument is always optional and you can give a type to it. Doing so, you'll get inference - // when passing data from actions. - async middleware(parsedInput, data?: MiddlewareData) { - // ... - - // Restrict actions execution to admins. - if (data?.userRole !== "admin") { - throw new Error("Only admins can execute this action!"); - } - - // ... - }, -}); -``` - -And then, you can pass the data to the middleware as the last argument of the action, after defining your server code function, in an object called `utils`, using an optional property named `middlewareData`, which has the same type as the second argument of the middleware function. - -```typescript title="src/app/actions.ts" -"use server"; - -import { z } from "zod"; -import { authAction } from "@/lib/safe-action"; - -const schema = z.object({ - username: z.string(), -}) - -export const deleteUser = action(schema, async ({ username }, { userId }) => { - // Action server code here... - }, - { middlewareData: { userRole: "admin" } } // type safe data -); -``` \ No newline at end of file diff --git a/website/docs/types.md b/website/docs/types.md index 6f2b7398..5425c617 100644 --- a/website/docs/types.md +++ b/website/docs/types.md @@ -7,28 +7,54 @@ description: List of next-safe-action types. ## / -### `SafeClientOpts` +### `SafeActionClientOpts` Type of options when creating a new safe action client. ```typescript -export type SafeClientOpts = { +export type SafeActionClientOpts = { handleServerErrorLog?: (e: Error) => MaybePromise; handleReturnedServerError?: (e: Error) => MaybePromise; - middleware?: (parsedInput: any, data?: MiddlewareData) => MaybePromise; }; ``` -### `SafeAction` +### `ActionMetadata` -Type of the function called from Client Components with typesafe input data. +Type of meta options to be passed when defining a new safe action. ```typescript -type SafeAction = (input: InferIn) => Promise<{ - data?: Data; - serverError?: string; - validationErrors?: Partial | "_root", string[]>>; -}>; +export type ActionMetadata = { + actionName?: string; +}; +``` + +### `MiddlewareResult` + +Type of the result of a middleware function. It extends the result of a safe action with `parsedInput` and `ctx` optional properties. + +```typescript +export type MiddlewareResult = SafeActionResult & { + parsedInput?: unknown; + ctx?: unknown; + success: boolean; +}; +``` + +### `MiddlewareFn` + +Type of the middleware function passed to a safe action client. + +```typescript +export type MiddlewareFn = { + (opts: { + clientInput: ClientInput; + ctx: Ctx; + metadata: ActionMetadata; + next: { + (opts: { ctx: NC }): Promise>; + }; + }): Promise>; +}; ``` ### `ServerCodeFn` @@ -36,9 +62,9 @@ type SafeAction = (input: InferIn) => Promise<{ Type of the function that executes server code when defining a new safe action. ```typescript -type ServerCodeFn = ( +export type ServerCodeFn = ( parsedInput: Infer, - ctx: Context + utils: { ctx: Context; metadata: ActionMetadata } ) => Promise; ``` @@ -50,6 +76,28 @@ Type of the returned object when input validation fails. export type ValidationErrors = Extend>>; ``` +### `SafeActionResult` + +Type of the result of a safe action. + +```typescript +export type SafeActionResult = { + data?: Data; + serverError?: string; + validationErrors?: ValidationErrors; +}; +``` + +### `SafeActionFn` + +Type of the function called from components with typesafe input data. + +```typescript +export type SafeActionFn = ( + input: InferIn +) => Promise>; +``` + ## /hooks ### `HookResult` @@ -59,7 +107,7 @@ Type of `result` object returned by `useAction` and `useOptimisticAction` hooks. If a server-client communication error occurs, `fetchError` will be set to the error message. ```typescript -type HookResult = Awaited>> & { +type HookResult = SafeActionResult & { fetchError?: string; }; ``` @@ -137,4 +185,4 @@ export type SchemaErrors = { ## TypeSchema library -`Infer`, `InferIn`, `Schema` types come from [TypeSchema](https://typeschema.com/#types) library. +`Infer`, `InferIn`, `Schema` types come from [TypeSchema](https://typeschema.com) library. diff --git a/website/docs/usage/action-result-object.md b/website/docs/usage/action-result-object.md index 673e1380..d4293417 100644 --- a/website/docs/usage/action-result-object.md +++ b/website/docs/usage/action-result-object.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 4 description: Action result object is the result of an action execution. --- @@ -12,4 +12,4 @@ Here's how action result object is structured (all keys are optional): |--------------------|--------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `data?` | Execution is successful. | What you returned in action's server code. | | `validationErrors?` | Input data doesn't pass validation. | An object whose keys are the names of the fields that failed validation. Each key's value is either an `ErrorList` or a nested key with an `ErrorList` inside.
`ErrorList` is defined as: `{ errors?: string[] }`.
It follows the same structure as [Zod's `format` function](https://zod.dev/ERROR_HANDLING?id=formatting-errors). -| `serverError?` | An error occurs during action's server code execution. | A `string` that by default is "Something went wrong while executing the operation" for every server error that occurs, but this is [configurable](/docs/safe-action-client/custom-server-error-handling#handlereturnedservererror) when instantiating a new client. | \ No newline at end of file +| `serverError?` | An error occurs during action's server code execution. | A `string` that by default is "Something went wrong while executing the operation" for every server error that occurs, but this is [configurable](/docs/safe-action-client/initialization-options#handlereturnedservererror) when instantiating a new client. | \ No newline at end of file diff --git a/website/docs/usage/client-components/direct-execution.md b/website/docs/usage/client-components/direct-execution.md index 3562e01f..c385d95a 100644 --- a/website/docs/usage/client-components/direct-execution.md +++ b/website/docs/usage/client-components/direct-execution.md @@ -5,7 +5,7 @@ description: You can execute safe actions by directrly calling them inside Clien # 1. Direct execution -The first way to execute Server Actions inside Client Components is by passing the action from a Server Component to a Client Component and directly calling it in a function. This method is the most simple one, but in some cases it could be all you need, for example if you just need the action result inside an `onClick` or `onSubmit` handler, without overcomplicating things: +The first way to execute Server Actions inside Client Components is by importing it and directly calling it in a function. This method is the simplest one, but in some cases it could be all you need, for example if you just need the action result inside an `onClick` or `onSubmit` handlers, without overcomplicating things: ```tsx export default function Login({ loginUser }: Props) { @@ -27,4 +27,4 @@ export default function Login({ loginUser }: Props) { Every action you execute returns an object with the same structure. This is described in the [action result object](/docs/usage/action-result-object) section. -Explore a working example [here](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/example-app/src/app). \ No newline at end of file +Explore a working example [here](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/example-app/src/app/(examples)/direct). \ No newline at end of file diff --git a/website/docs/usage/client-components/hooks/useaction.md b/website/docs/usage/client-components/hooks/useaction.md index 35821418..5aa97eca 100644 --- a/website/docs/usage/client-components/hooks/useaction.md +++ b/website/docs/usage/client-components/hooks/useaction.md @@ -21,9 +21,11 @@ const schema = z.object({ name: z.string(), }); -export const greetUser = action(schema, async ({ name }) => { - return { message: `Hello ${name}!` }; -}); +export const greetUser = actionClient + .schema(schema) + .define(async ({ name }) => { + return { message: `Hello ${name}!` }; + }); ``` 2. In your Client Component, you can use it like this: @@ -59,7 +61,7 @@ As you can see, here we display a greet message after the action is performed, i | Name | Type | Purpose | |--------------|--------------------------------------------|--------------------------------------------------------------------------------------------------| -| `safeAction` | [SafeAction](/docs/types#safeaction) | This is the action that will be called when you use `execute` from hook's return object. | +| `safeActionFn` | [SafeActionFn](/docs/types#safeactionfn) | This is the action that will be called when you use `execute` from hook's return object. | | `callbacks?` | [HookCallbacks](/docs/types#hookcallbacks) | Optional callbacks. More information about them [here](/docs/usage/client-components/hooks/callbacks). | ### `useAction` return object @@ -73,4 +75,4 @@ As you can see, here we display a greet message after the action is performed, i | `status` | [`HookActionStatus`](/docs/types#hookresult) | The action current status. | | `reset` | `() => void` | You can programmatically reset the `result` object with this function. | -Explore a working example [here](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/example-app/src/app/hook). \ No newline at end of file +Explore a working example [here](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/example-app/src/app/(examples)/hook). \ No newline at end of file diff --git a/website/docs/usage/client-components/hooks/useoptimisticaction.md b/website/docs/usage/client-components/hooks/useoptimisticaction.md index 9e160adc..85c6fdfc 100644 --- a/website/docs/usage/client-components/hooks/useoptimisticaction.md +++ b/website/docs/usage/client-components/hooks/useoptimisticaction.md @@ -29,18 +29,20 @@ const schema = z.object({ let likes = 42; export const getLikes = () => likes; -export const addLikes = action(schema, async ({ amount }) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); +export const addLikes = actionClient + .schema(schema) + .define(async ({ amount }) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); - // Mutate data in fake db. This would be a database call in the real world. - likes += amount; + // Mutate data in fake db. This would be a database call in the real world. + likes += amount; - // We use this function to revalidate server state. - // More information about it here: - // https://nextjs.org/docs/app/api-reference/functions/revalidatePath - revalidatePath("/"); + // We use this function to revalidate server state. + // More information about it here: + // https://nextjs.org/docs/app/api-reference/functions/revalidatePath + revalidatePath("/"); - return { numOfLikes: likes }; + return { numOfLikes: likes }; }); ``` @@ -102,8 +104,8 @@ export default function AddLikes({ numOfLikes }: Props) { | Name | Type | Purpose | |------------------|-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `safeAction` | [`SafeAction`](/docs/types#safeaction) | This is the action that will be called when you use `execute` from hook's return object. | -| `initialOptimisticData` | `Data` (return type of the `safeAction` you passed as first argument) | An initializer for the optimistic state. Usually this value comes from the parent Server Component. | +| `safeActionFn` | [`SafeActionFn`](/docs/types#safeactionfn) | This is the action that will be called when you use `execute` from hook's return object. | +| `initialOptimisticData` | `Data` (return type of the `safeActionFn` you passed as first argument) | An initializer for the optimistic state. Usually this value comes from the parent Server Component. | | `reducer` | `(state: Data, input: InferIn) => Data` | When you call the action via `execute`, this function determines how the optimistic update is performed. Basically, here you define what happens **immediately** after `execute` is called, and before the actual result comes back from the server. | | `callbacks?` | [`HookCallbacks`](/docs/types#hookcallbacks) | Optional callbacks. More information about them [here](/docs/usage/client-components/hooks/callbacks). | @@ -118,6 +120,6 @@ export default function AddLikes({ numOfLikes }: Props) { | `result` | [`HookResult`](/docs/types#hookresult) | When the action gets called via `execute`, this is the result object. | | `status` | [`HookActionStatus`](/docs/types#hookresult) | The action current status. | | `reset` | `() => void` | You can programmatically reset the `result` object with this function. | -| `optimisticData` | `Data` (return type of the `safeAction` you passed as first argument) | This is the data that gets updated immediately after `execute` is called, with the behavior you defined in the `reducer` function hook argument. The initial state is what you provided to the hook via `initialOptimisticData` argument. | +| `optimisticData` | `Data` (return type of the `safeActionFn` you passed as first argument) | This is the data that gets updated immediately after `execute` is called, with the behavior you defined in the `reducer` function hook argument. The initial state is what you provided to the hook via `initialOptimisticData` argument. | -Explore a working example [here](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/example-app/src/app/optimistic-hook). \ No newline at end of file +Explore a working example [here](https://github.com/TheEdoRan/next-safe-action/tree/main/packages/example-app/src/app/(examples)/optimistic-hook). \ No newline at end of file diff --git a/website/docs/usage/client-components/index.md b/website/docs/usage/client-components/index.md index 68b6a62c..23e50db2 100644 --- a/website/docs/usage/client-components/index.md +++ b/website/docs/usage/client-components/index.md @@ -6,4 +6,4 @@ sidebar_label: Client Components # Usage with Client Components -There are three different ways to execute Server Actions from Client Components. First one is the "direct way", the most simple one, but the least powerful too. The other two are by using `useAction` and `useOptimisticAction` hooks, which we will cover in the next sections. \ No newline at end of file +There are three different ways to execute Server Actions from Client Components. First one is the "direct way", the simplest one, but the least powerful too. The other two are by using `useAction` and `useOptimisticAction` hooks, which we will cover in the next sections. \ No newline at end of file diff --git a/website/docs/usage/custom-validation-errors.md b/website/docs/usage/custom-validation-errors.md index e57b1e24..10449b05 100644 --- a/website/docs/usage/custom-validation-errors.md +++ b/website/docs/usage/custom-validation-errors.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 description: Set custom validation errors in schema or in action's server code function. --- @@ -39,18 +39,20 @@ import { returnValidationErrors } from "next-safe-action"; import { action } from "@/lib/safe-action"; // Here we're using the same schema declared above. -const signupAction = action(schema, async ({email}) => { - // Assume this is a database call. - if (!isEmailAvailable(email)) { - returnValidationErrors(schema, { - email: { - _errors: ["Email already registered"], - }, - }); - } - - ... -}); +const signupAction = actionClient + .schema(schema) + .define(async ({ email }) => { + // Assume this is a database call. + if (!isEmailAvailable(email)) { + returnValidationErrors(schema, { + email: { + _errors: ["Email already registered"], + }, + }); + } + + ... + }); ``` Note that: diff --git a/website/docs/usage/forms.md b/website/docs/usage/forms.md index e11a093c..ace1db02 100644 --- a/website/docs/usage/forms.md +++ b/website/docs/usage/forms.md @@ -26,14 +26,15 @@ const schema = zfd.formData({ password: zfd.text(z.string().min(8)), }); -export const signup = action(schema, async ({ email, password }) => { - console.log("Email:", email, "Password:", password); - - // Do something useful here. -}); +export const signup = action + .schema(schema) + .define(async ({ email, password }) => { + console.log("Email:", email, "Password:", password); + // Do something useful here. + }); ``` -2. Import it in a Server Component and use it as a form action. +2. Import it in a Server Component and use it as a Form Action. ```tsx title=src/app/signup/page.tsx import { signup } from "./signup-action"; diff --git a/website/docs/usage/middleware.md b/website/docs/usage/middleware.md new file mode 100644 index 00000000..e419fd18 --- /dev/null +++ b/website/docs/usage/middleware.md @@ -0,0 +1,195 @@ +--- +sidebar_position: 3 +description: Learn how to use middleware functions in your actions. +--- + +# Middleware + +next-safe-action, since version 7, ships with a composable and powerful middleware system, which allows you to create functions for almost every kind of use case you can imagine (authorization, logging, role based access, etc.). It works very similarly to the [tRPC implementation](https://trpc.io/docs/server/middlewares), with some minor differences. + +Middleware functions are defined using [`use`](/docs/safe-action-client/instance-methods#use) method in your action clients, via the `middlewareFn` argument. + +## Usage + +You can chain multiple middleware functions, that will be executed in the order they were defined. You can also await the next middleware function(s) in the stack (useful, for instance, for logging), and then return the result of the execution. Chaining functions is very useful when you want to dynamically extend the context and/or halt execution based on your use case. + +### Instance level middleware + +Instance level is the right place when you want to share middleware behavior for all the actions you're going to define; for example when you need to log the result of actions execution, or verify if the user intending to execute the operation is authorized to do so, and if not, halt the execution at that point, by throwing an error. + +Here we'll use a logging middleware in the base client and then extend it with an authorization middleware in `authActionClient`. We'll also define a safe action called `editProfile`, that will use `authActionClient` as its client. Note that the `handleReturnedServerError` function passed to the base client will also be used for `authActionClient`: + +```typescript title="src/lib/safe-action.ts" +import { createSafeActionClient } from "next-safe-action"; +import { cookies } from "next/headers"; +import { getUserIdFromSessionId } from "./db"; + +class ActionError extends Error {} + +// Base client. +const actionClient = createSafeActionClient({ + handleReturnedServerError(e) { + if (e instanceof ActionError) { + return e.message; + } + + return DEFAULT_SERVER_ERROR; + }, +// Define logging middleware. +}).use(async ({ next, clientInput, metadata }) => { + console.log("LOGGING MIDDLEWARE"); + + // Here we await the action execution. + const result = await next({ ctx: null }); + + console.log("Result ->", result); + console.log("Client input ->", clientInput); + console.log("Metadata ->", metadata); + + // And then return the result of the awaited action. + return result; +}); + +// Auth client defined by extending the base one. +// Note that the same initialization options and middleware functions of the base client +// will also be used for this one. +const authActionClient = actionClient + // Clone the base client so it doesn't get mutated. + .clone() + // Define authorization middleware. + .use(async ({ next }) => { + const session = cookies().get("session")?.value; + + if (!session) { + throw new Error("Session not found!"); + } + + const userId = await getUserIdFromSessionId(session); + + if (!userId) { + throw new Error("Session is not valid!"); + } + + // Return the next middleware with `userId` value in the context + return next({ ctx: { userId } }); + }); +``` + +Here we import `authActionClient` in the action's file: + +```typescript title="src/app/edituser-action.ts" +"use server"; + +import { authActionClient } from "@/lib/safe-action"; +import { z } from "zod"; + +const editProfile = authActionClient + // We can pass the action name inside `metadata()`. + .metadata({ actionName: "editProfile" }) + // Here we pass the input schema. + .schema(z.object({ newUsername: z.string() })) + // Here we get `userId` from the middleware defined in `authActionClient`. + .define(async ({ newUsername }, { ctx: { userId } }) => { + await saveNewUsernameInDb(userId, newUsername); + + return { + updated: true, + }; + }); +``` + +Calling `editProfile` will produce this console output, thanks to the two middleware functions passed to the clients above: + +``` +LOGGING MIDDLEWARE +Result -> { + success: true, + ctx: { userId: 'e473de7f-d1e4-49c1-b4fe-85eb50048b99' }, + data: { updated: true }, + parsedInput: { newUsername: 'johndoe' } +} +Client input -> { newUsername: 'johndoe' } +Metadata -> { actionName: 'editProfile' } +``` + +Note that `userId` in `ctx` comes from the `authActionClient` middleware, and console output comes from the logging middleware defined in the based client. + +### Action level middleware + +Server Action level is the right place for middleware checks that only specific actions need to make. For instance, when you want to restrict the execution to specific user roles. + +In this example we'll use the same `authActionClient` defined above to define a `deleteUser` action that chains a middleware function which restricts the execution of this operation to just admins: + +```typescript title="src/app/deleteuser-action.ts" +"use server"; + +import { authActionClient } from "@/lib/safe-action"; +import { z } from "zod"; + +const deleteUser = authActionClient + .use(async ({ next, ctx }) => { + // `userId` comes from the context set in the previous middleware function. + const userRole = await getUserRole(ctx.userId); + + if (userRole !== "admin") { + throw new ActionError("Only admins can delete users."); + } + + // Here we pass the same untouched context (`userId`) to the next function, since we don't need + // to add data to the context here. + return next({ ctx }); + }) + .metadata({ actionName: "deleteUser" }) + .schema(z.void()) + .define(async (_, { ctx: { userId } }) => { + // Here we're sure that the user that is performing this operation is an admin. + await deleteUserFromDb(userId); + }); +``` + +This is the console output when an admin executes this action: + +``` +LOGGING MIDDLEWARE +Result -> { + success: true, + ctx: { userId: '9af18417-524e-4f04-9621-b5934b09f2c9' }, + data: null, + parsedInput: undefined +} +Client input -> undefined +Metadata -> { actionName: 'deleteUser' } +``` + +If a regular user tries to do the same, the execution will be stopped at the last middleware function, defined at the action level, that checks the user role. This is the console output in this case: + +``` +LOGGING MIDDLEWARE +Action error: Only admins can delete users. +Result -> { + success: false, + ctx: { userId: '0a1fa8a8-d323-47c0-bbde-eadbfcdd2587' }, + serverError: 'Only admins can delete users.' +} +Client input -> undefined +Metadata -> { actionName: 'deleteUser' } +``` + +Note that the second line comes from the default `handleServerErrorLog` function of the safe action client(s). + +--- + +## `middlewareFn` arguments + +`middlewareFn` has the following arguments: + +| Name | Type | Purpose | +|---------------|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `clientInput` | `ClientInput` (generic) | The raw input (not parsed) passed from client. | +| `ctx` | `Ctx` (generic) | Type safe context value from previous middleware function(s). | +| `metadata` | [`ActionMetadata`](/docs/types/#actionmetadata) | Metadata for the safe action execution. | +| `next` | `(opts: { ctx: NC }): Promise>` | Function that will execute the next function in the middleware stack or the server code function. It expects, as argument, the next `ctx` value for the next function in the chain. | + +## `middlewareFn` return value + +`middlewareFn` returns a Promise of a [`MiddlewareResult`](/docs/types#middlewareresult) object. It extends the result of a safe action with `success` property, and `parsedInput` and `ctx` optional properties. This is the exact return type of the `next` function, so you must always return it (or its result) to continue executing the middleware chain. \ No newline at end of file diff --git a/website/src/components/landing/features.tsx b/website/src/components/landing/features.tsx index 6104649b..57b53b62 100644 --- a/website/src/components/landing/features.tsx +++ b/website/src/components/landing/features.tsx @@ -13,9 +13,9 @@ const features: { title: string; description: string }[] = [ "With next-safe-action you get full type safety between server and client code.", }, { - title: "Context-based clients (with middlewares)", + title: "Powerful middleware system", description: - "Powerful context-based clients with custom logic execution, thanks to middlewares.", + "Manage authorization, log and halt execution, and much more with a composable middleware system.", }, { title: "Input validation using multiple validation libraries",