Skip to content

Commit

Permalink
feat: support generic server error (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
TheEdoRan committed Apr 3, 2024
1 parent 2701376 commit 3d677bd
Show file tree
Hide file tree
Showing 11 changed files with 161 additions and 130 deletions.
7 changes: 5 additions & 2 deletions packages/example-app/src/lib/safe-action.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { randomUUID } from "crypto";
import { DEFAULT_SERVER_ERROR, createSafeActionClient } from "next-safe-action";
import {
DEFAULT_SERVER_ERROR_MESSAGE,
createSafeActionClient,
} from "next-safe-action";

export class ActionError extends Error {}

Expand All @@ -20,7 +23,7 @@ export const action = createSafeActionClient({
}

// Otherwise return default error message.
return DEFAULT_SERVER_ERROR;
return DEFAULT_SERVER_ERROR_MESSAGE;
},
}).use(async ({ next, metadata }) => {
// Here we use a logging middleware.
Expand Down
32 changes: 16 additions & 16 deletions packages/next-safe-action/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ const DEFAULT_RESULT = {
fetchError: undefined,
serverError: undefined,
validationErrors: undefined,
} satisfies HookResult<any, any>;
} satisfies HookResult<any, any, any>;

const getActionStatus = <const S extends Schema, const Data>(
const getActionStatus = <const ServerError, const S extends Schema, const Data>(
isExecuting: boolean,
result: HookResult<S, Data>
result: HookResult<ServerError, S, Data>
): HookActionStatus => {
if (isExecuting) {
return "executing";
Expand All @@ -37,12 +37,12 @@ const getActionStatus = <const S extends Schema, const Data>(
return "idle";
};

const useActionCallbacks = <const S extends Schema, const Data>(
result: HookResult<S, Data>,
const useActionCallbacks = <const ServerError, const S extends Schema, const Data>(
result: HookResult<ServerError, S, Data>,
input: InferIn<S>,
status: HookActionStatus,
reset: () => void,
cb?: HookCallbacks<S, Data>
cb?: HookCallbacks<ServerError, S, Data>
) => {
const onExecuteRef = React.useRef(cb?.onExecute);
const onSuccessRef = React.useRef(cb?.onSuccess);
Expand Down Expand Up @@ -85,16 +85,16 @@ const useActionCallbacks = <const S extends Schema, const Data>(
*
* {@link https://next-safe-action.dev/docs/usage/client-components/hooks/useaction See an example}
*/
export const useAction = <const S extends Schema, const Data>(
safeActionFn: SafeActionFn<S, Data>,
callbacks?: HookCallbacks<S, Data>
export const useAction = <const ServerError, const S extends Schema, const Data>(
safeActionFn: SafeActionFn<ServerError, S, Data>,
callbacks?: HookCallbacks<ServerError, S, Data>
) => {
const [, startTransition] = React.useTransition();
const [result, setResult] = React.useState<HookResult<S, Data>>(DEFAULT_RESULT);
const [result, setResult] = React.useState<HookResult<ServerError, S, Data>>(DEFAULT_RESULT);
const [input, setInput] = React.useState<InferIn<S>>();
const [isExecuting, setIsExecuting] = React.useState(false);

const status = getActionStatus<S, Data>(isExecuting, result);
const status = getActionStatus<ServerError, S, Data>(isExecuting, result);

const execute = React.useCallback(
(input: InferIn<S>) => {
Expand Down Expand Up @@ -144,14 +144,14 @@ export const useAction = <const S extends Schema, const Data>(
*
* {@link https://next-safe-action.dev/docs/usage/client-components/hooks/useoptimisticaction See an example}
*/
export const useOptimisticAction = <const S extends Schema, const Data>(
safeActionFn: SafeActionFn<S, Data>,
export const useOptimisticAction = <const ServerError, const S extends Schema, const Data>(
safeActionFn: SafeActionFn<ServerError, S, Data>,
initialOptimisticData: Data,
reducer: (state: Data, input: InferIn<S>) => Data,
callbacks?: HookCallbacks<S, Data>
callbacks?: HookCallbacks<ServerError, S, Data>
) => {
const [, startTransition] = React.useTransition();
const [result, setResult] = React.useState<HookResult<S, Data>>(DEFAULT_RESULT);
const [result, setResult] = React.useState<HookResult<ServerError, S, Data>>(DEFAULT_RESULT);
const [input, setInput] = React.useState<InferIn<S>>();
const [isExecuting, setIsExecuting] = React.useState(false);

Expand All @@ -160,7 +160,7 @@ export const useOptimisticAction = <const S extends Schema, const Data>(
reducer
);

const status = getActionStatus<S, Data>(isExecuting, result);
const status = getActionStatus<ServerError, S, Data>(isExecuting, result);

const execute = React.useCallback(
(input: InferIn<S>) => {
Expand Down
12 changes: 8 additions & 4 deletions packages/next-safe-action/src/hooks.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,27 @@ import type { MaybePromise } from "./utils";
/**
* Type of `result` object returned by `useAction` and `useOptimisticAction` hooks.
*/
export type HookResult<S extends Schema, Data> = SafeActionResult<S, Data> & {
export type HookResult<ServerError, S extends Schema, Data> = SafeActionResult<
ServerError,
S,
Data
> & {
fetchError?: string;
};

/**
* Type of hooks callbacks. These are executed when action is in a specific state.
*/
export type HookCallbacks<S extends Schema, Data> = {
export type HookCallbacks<ServerError, S extends Schema, Data> = {
onExecute?: (input: InferIn<S>) => MaybePromise<void>;
onSuccess?: (data: Data, input: InferIn<S>, reset: () => void) => MaybePromise<void>;
onError?: (
error: Omit<HookResult<S, Data>, "data">,
error: Omit<HookResult<ServerError, S, Data>, "data">,
input: InferIn<S>,
reset: () => void
) => MaybePromise<void>;
onSettled?: (
result: HookResult<S, Data>,
result: HookResult<ServerError, S, Data>,
input: InferIn<S>,
reset: () => void
) => MaybePromise<void>;
Expand Down
56 changes: 32 additions & 24 deletions packages/next-safe-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,29 @@ import type {
SafeActionResult,
ServerCodeFn,
} from "./index.types";
import { DEFAULT_SERVER_ERROR, isError } from "./utils";
import { DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils";
import {
ServerValidationError,
buildValidationErrors,
returnValidationErrors,
} from "./validation-errors";
import type { ValidationErrors } from "./validation-errors.types";

class SafeActionClient<const Ctx = null> {
private readonly handleServerErrorLog: NonNullable<SafeActionClientOpts["handleServerErrorLog"]>;
class SafeActionClient<const ServerError, const Ctx = null> {
private readonly handleServerErrorLog: NonNullable<
SafeActionClientOpts<ServerError>["handleServerErrorLog"]
>;
private readonly handleReturnedServerError: NonNullable<
SafeActionClientOpts["handleReturnedServerError"]
SafeActionClientOpts<ServerError>["handleReturnedServerError"]
>;

private middlewareFns: MiddlewareFn<any, any, any>[];
private middlewareFns: MiddlewareFn<ServerError, any, any, any>[];
private _metadata: ActionMetadata = {};

constructor(
opts: { middlewareFns: MiddlewareFn<any, any, any>[] } & Required<SafeActionClientOpts>
opts: { middlewareFns: MiddlewareFn<ServerError, any, any, any>[] } & Required<
SafeActionClientOpts<ServerError>
>
) {
this.middlewareFns = opts.middlewareFns;
this.handleServerErrorLog = opts.handleServerErrorLog;
Expand All @@ -42,7 +46,7 @@ class SafeActionClient<const Ctx = null> {
* @returns {SafeActionClient}
*/
public clone() {
return new SafeActionClient<Ctx>({
return new SafeActionClient<ServerError, Ctx>({
handleReturnedServerError: this.handleReturnedServerError,
handleServerErrorLog: this.handleServerErrorLog,
middlewareFns: [...this.middlewareFns], // copy the middleware stack so we don't mutate it
Expand All @@ -55,11 +59,11 @@ class SafeActionClient<const Ctx = null> {
* @returns SafeActionClient
*/
public use<const ClientInput, const NextCtx>(
middlewareFn: MiddlewareFn<ClientInput, Ctx, NextCtx>
middlewareFn: MiddlewareFn<ServerError, ClientInput, Ctx, NextCtx>
) {
this.middlewareFns.push(middlewareFn);

return new SafeActionClient<NextCtx>({
return new SafeActionClient<ServerError, NextCtx>({
middlewareFns: this.middlewareFns,
handleReturnedServerError: this.handleReturnedServerError,
handleServerErrorLog: this.handleServerErrorLog,
Expand Down Expand Up @@ -93,11 +97,13 @@ class SafeActionClient<const Ctx = null> {
* @param serverCodeFn A function that executes the server code.
* @returns {SafeActionFn}
*/
define<const Data = null>(serverCodeFn: ServerCodeFn<S, Data, Ctx>): SafeActionFn<S, Data> {
define<const Data = null>(
serverCodeFn: ServerCodeFn<S, Data, Ctx>
): SafeActionFn<ServerError, S, Data> {
return async (clientInput: unknown) => {
let prevCtx: any = null;
let frameworkError: Error | undefined = undefined;
const middlewareResult: MiddlewareResult<any> = { success: false };
const middlewareResult: MiddlewareResult<ServerError, any> = { success: false };

// Execute the middleware stack.
const executeMiddlewareChain = async (idx = 0) => {
Expand Down Expand Up @@ -149,16 +155,14 @@ class SafeActionClient<const Ctx = null> {
return;
}

if (!isError(e)) {
console.warn("Could not handle server error. Not an instance of Error: ", e);
middlewareResult.serverError = DEFAULT_SERVER_ERROR;
return;
}
// If error is not an instance of Error, wrap it in an Error object with
// the default message.
const error = isError(e) ? e : new Error(DEFAULT_SERVER_ERROR_MESSAGE);

await Promise.resolve(classThis.handleServerErrorLog(e));
await Promise.resolve(classThis.handleServerErrorLog(error));

middlewareResult.serverError = await Promise.resolve(
classThis.handleReturnedServerError(e)
classThis.handleReturnedServerError(error)
);
}
};
Expand All @@ -170,7 +174,7 @@ class SafeActionClient<const Ctx = null> {
throw frameworkError;
}

const actionResult: SafeActionResult<S, Data> = {};
const actionResult: SafeActionResult<ServerError, S, Data> = {};

if (typeof middlewareResult.data !== "undefined") {
actionResult.data = middlewareResult.data as Data;
Expand Down Expand Up @@ -199,7 +203,9 @@ class SafeActionClient<const Ctx = null> {
*
* {@link https://next-safe-action.dev/docs/getting-started See an example}
*/
export const createSafeActionClient = (createOpts?: SafeActionClientOpts) => {
export const createSafeActionClient = <const ServerError = string>(
createOpts?: SafeActionClientOpts<ServerError>
) => {
// If server log function is not provided, default to `console.error` for logging
// server error messages.
const handleServerErrorLog =
Expand All @@ -211,17 +217,19 @@ export const createSafeActionClient = (createOpts?: SafeActionClientOpts) => {
// If `handleReturnedServerError` is provided, use it to handle server error
// messages returned on the client.
// Otherwise mask the error and use a generic message.
const handleReturnedServerError = (e: Error) =>
createOpts?.handleReturnedServerError?.(e) || DEFAULT_SERVER_ERROR;
const handleReturnedServerError = ((e: Error) =>
createOpts?.handleReturnedServerError?.(e) || DEFAULT_SERVER_ERROR_MESSAGE) as NonNullable<
SafeActionClientOpts<ServerError>["handleReturnedServerError"]
>;

return new SafeActionClient({
return new SafeActionClient<ServerError, null>({
middlewareFns: [async ({ next }) => next({ ctx: null })],
handleServerErrorLog,
handleReturnedServerError,
});
};

export { DEFAULT_SERVER_ERROR, returnValidationErrors, type ValidationErrors };
export { DEFAULT_SERVER_ERROR_MESSAGE, returnValidationErrors, type ValidationErrors };

export type {
ActionMetadata,
Expand Down
51 changes: 28 additions & 23 deletions packages/next-safe-action/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,28 @@ import type { ValidationErrors } from "./validation-errors.types";
/**
* Type of options when creating a new safe action client.
*/
export type SafeActionClientOpts = {
export type SafeActionClientOpts<ServerError> = {
handleServerErrorLog?: (e: Error) => MaybePromise<void>;
handleReturnedServerError?: (e: Error) => MaybePromise<string>;
handleReturnedServerError?: (e: Error) => MaybePromise<ServerError>;
};

/**
* Type of the result of a safe action.
*/
// eslint-disable-next-line
export type SafeActionResult<ServerError, S extends Schema, Data, NextCtx = unknown> = {
data?: Data;
serverError?: ServerError;
validationErrors?: ValidationErrors<S>;
};

/**
* Type of the function called from components with typesafe input data.
*/
export type SafeActionFn<ServerError, S extends Schema, Data> = (
input: InferIn<S>
) => Promise<SafeActionResult<ServerError, S, Data>>;

/**
* Type of meta options to be passed when defining a new safe action.
*/
Expand All @@ -21,7 +38,12 @@ export type ActionMetadata = {
* 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<NextCtx> = SafeActionResult<any, unknown, NextCtx> & {
export type MiddlewareResult<ServerError, NextCtx> = SafeActionResult<
ServerError,
any,
unknown,
NextCtx
> & {
parsedInput?: unknown;
ctx?: unknown;
success: boolean;
Expand All @@ -30,15 +52,15 @@ export type MiddlewareResult<NextCtx> = SafeActionResult<any, unknown, NextCtx>
/**
* Type of the middleware function passed to a safe action client.
*/
export type MiddlewareFn<ClientInput, Ctx, NextCtx> = {
export type MiddlewareFn<ServerError, ClientInput, Ctx, NextCtx> = {
(opts: {
clientInput: ClientInput;
ctx: Ctx;
metadata: ActionMetadata;
next: {
<const NC>(opts: { ctx: NC }): Promise<MiddlewareResult<NC>>;
<const NC>(opts: { ctx: NC }): Promise<MiddlewareResult<ServerError, NC>>;
};
}): Promise<MiddlewareResult<NextCtx>>;
}): Promise<MiddlewareResult<ServerError, NextCtx>>;
};

/**
Expand All @@ -48,20 +70,3 @@ export type ServerCodeFn<S extends Schema, Data, Context> = (
parsedInput: Infer<S>,
utils: { ctx: Context; metadata: ActionMetadata }
) => Promise<Data>;

/**
* Type of the result of a safe action.
*/
// eslint-disable-next-line
export type SafeActionResult<S extends Schema, Data, NextCtx = unknown> = {
data?: Data;
serverError?: string;
validationErrors?: ValidationErrors<S>;
};

/**
* Type of the function called from components with typesafe input data.
*/
export type SafeActionFn<S extends Schema, Data> = (
input: InferIn<S>
) => Promise<SafeActionResult<S, Data>>;
2 changes: 1 addition & 1 deletion packages/next-safe-action/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const DEFAULT_SERVER_ERROR = "Something went wrong while executing the operation.";
export const DEFAULT_SERVER_ERROR_MESSAGE = "Something went wrong while executing the operation.";

export const isError = (error: unknown): error is Error => error instanceof Error;

Expand Down
2 changes: 1 addition & 1 deletion website/docs/safe-action-client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ Here's a reference of all the available optional functions:

| Function name | Purpose |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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). |
| `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` instead of the default string. 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). |
Loading

0 comments on commit 3d677bd

Please sign in to comment.