Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: TheEdoRan/next-safe-action
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v7.5.0
Choose a base ref
...
head repository: TheEdoRan/next-safe-action
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v7.6.0
Choose a head ref
  • 1 commit
  • 24 files changed
  • 1 contributor

Commits on Aug 13, 2024

  1. feat(middleware): support creation of standalone functions (#229)

    Code in this PR requires context to be an object, it extends it by default, and enables creation of standalone middleware functions via built-in `experimental_createMiddleware` utility.
    
    re #222
    TheEdoRan authored Aug 13, 2024

    Verified

    This commit was signed with the committer’s verified signature.
    mpalmi Mike Palmiotto
    Copy the full SHA
    7f36bb5 View commit details
2 changes: 1 addition & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
@@ -29,6 +29,6 @@
"eslint-config-next": "15.0.0-canary.75",
"postcss": "8.4.38",
"tailwindcss": "3.4.3",
"typescript": "^5.5.3"
"typescript": "^5.5.4"
}
}
2 changes: 1 addition & 1 deletion apps/playground/src/lib/safe-action.ts
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ export const action = createSafeActionClient({
const start = Date.now();

// Here we await the next middleware.
const result = await next({ ctx });
const result = await next();

const end = Date.now();

7 changes: 4 additions & 3 deletions packages/next-safe-action/package.json
Original file line number Diff line number Diff line change
@@ -71,6 +71,7 @@
"@types/node": "^20.14.11",
"@types/react": "^18.3.1",
"@types/react-dom": "18.3.0",
"deepmerge-ts": "^7.1.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-define-config": "^2.1.0",
@@ -82,20 +83,20 @@
"semantic-release": "^23.0.8",
"tsup": "^8.0.2",
"tsx": "^4.11.2",
"typescript": "^5.5.3",
"typescript": "^5.5.4",
"typescript-eslint": "^7.8.0",
"valibot": "^0.36.0",
"yup": "^1.4.0",
"zod": "^3.23.6"
},
"peerDependencies": {
"@sinclair/typebox": ">= 0.33.3",
"next": ">= 14.0.0",
"react": ">= 18.2.0",
"react-dom": ">= 18.2.0",
"valibot": ">= 0.36.0",
"yup": ">= 1.0.0",
"zod": ">= 3.0.0",
"@sinclair/typebox": ">= 0.33.3"
"zod": ">= 3.0.0"
},
"peerDependenciesMeta": {
"zod": {
60 changes: 41 additions & 19 deletions packages/next-safe-action/src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import { test } from "node:test";
import { z } from "zod";
import {
createSafeActionClient,
experimental_createMiddleware,
formatBindArgsValidationErrors,
formatValidationErrors,
returnValidationErrors,
@@ -42,8 +43,8 @@ test("instance context value is accessible in server code function", async () =>

test("instance context value is extended in action middleware and both values are accessible in server code function", async () => {
const action = ac
.use(async ({ next, ctx }) => {
return next({ ctx: { ...ctx, bar: "baz" } });
.use(async ({ next }) => {
return next({ ctx: { bar: "baz" } });
})
.action(async ({ ctx }) => {
return {
@@ -70,7 +71,7 @@ test("instance context value is correctly overridden in subsequent middleware",
if (ctx.foo !== "baz") {
throw new Error("Expected ctx.foo to be 'baz'");
}
return next({ ctx });
return next();
})
.action(async ({ ctx }) => {
return {
@@ -96,8 +97,8 @@ test("action client inputs are passed to middleware", async () => {
})
)
.bindArgsSchemas([z.object({ age: z.number().positive() })])
.use(async ({ clientInput, bindArgsClientInputs, next, ctx }) => {
return next({ ctx: { ...ctx, clientInput, bindArgsClientInputs } });
.use(async ({ clientInput, bindArgsClientInputs, next }) => {
return next({ ctx: { clientInput, bindArgsClientInputs } });
})
.action(async ({ ctx }) => {
return {
@@ -130,9 +131,9 @@ test("happy path execution result from middleware is correct", async () => {
})
)
.bindArgsSchemas([z.object({ age: z.number().positive() })])
.use(async ({ next, ctx }) => {
.use(async ({ next }) => {
// Await action execution.
const res = await next({ ctx });
const res = await next();
middlewareResult = res;
return res;
})
@@ -176,9 +177,9 @@ test("server error execution result from middleware is correct", async () => {
})
)
.bindArgsSchemas([z.object({ age: z.number().positive() })])
.use(async ({ next, ctx }) => {
.use(async ({ next }) => {
// Await action execution.
const res = await next({ ctx });
const res = await next();
middlewareResult = res;
return res;
})
@@ -212,9 +213,9 @@ test("validation errors in execution result from middleware are correct", async
})
)
.bindArgsSchemas([z.object({ age: z.number().positive() })])
.use(async ({ next, ctx }) => {
.use(async ({ next }) => {
// Await action execution.
const res = await next({ ctx });
const res = await next();
middlewareResult = res;
return res;
})
@@ -259,9 +260,9 @@ test("server validation errors in execution result from middleware are correct",
const action = ac
.schema(schema)
.bindArgsSchemas([z.object({ age: z.number().positive() })])
.use(async ({ next, ctx }) => {
.use(async ({ next }) => {
// Await action execution.
const res = await next({ ctx });
const res = await next();
middlewareResult = res;
return res;
})
@@ -309,9 +310,9 @@ test("flattened validation errors in execution result from middleware are correc
})
)
.bindArgsSchemas([z.object({ age: z.number().positive() })])
.use(async ({ next, ctx }) => {
.use(async ({ next }) => {
// Await action execution.
const res = await next({ ctx });
const res = await next();
middlewareResult = res;
return res;
})
@@ -326,7 +327,7 @@ test("flattened validation errors in execution result from middleware are correc

const expectedResult = {
success: false,
ctx: undefined,
ctx: {},
validationErrors: {
formErrors: [],
fieldErrors: {
@@ -360,9 +361,9 @@ test("overridden formatted validation errors in execution result from middleware
.bindArgsSchemas([z.object({ age: z.number().positive() })], {
handleBindArgsValidationErrorsShape: formatBindArgsValidationErrors,
})
.use(async ({ next, ctx }) => {
.use(async ({ next }) => {
// Await action execution.
const res = await next({ ctx });
const res = await next();
middlewareResult = res;
return res;
})
@@ -377,7 +378,7 @@ test("overridden formatted validation errors in execution result from middleware

const expectedResult = {
success: false,
ctx: undefined,
ctx: {},
validationErrors: {
username: {
_errors: ["String must contain at most 3 character(s)"],
@@ -394,3 +395,24 @@ test("overridden formatted validation errors in execution result from middleware

assert.deepStrictEqual(middlewareResult, expectedResult);
});

test("standalone middleware extends context", async () => {
const myMiddleware = experimental_createMiddleware<{ ctx: { foo: string } }>().define(async ({ next }) => {
return next({ ctx: { baz: "qux" } });
});

const action = ac.use(myMiddleware).action(async ({ ctx }) => {
return {
ctx,
};
});

const actualResult = await action();
const expectedResult = {
data: {
ctx: { foo: "bar", baz: "qux" },
},
};

assert.deepStrictEqual(actualResult, expectedResult);
});
8 changes: 4 additions & 4 deletions packages/next-safe-action/src/__tests__/server-error.test.ts
Original file line number Diff line number Diff line change
@@ -39,7 +39,7 @@ test("unknown error occurred in server code function is masked by default", asyn

test("unknown error occurred in middleware function is masked by default", async () => {
const action = ac1
.use(async ({ next, ctx }) => next({ ctx }))
.use(async ({ next }) => next())
.use(async () => {
throw new Error("Something bad happened");
})
@@ -74,7 +74,7 @@ test("known error occurred in server code function is unmasked", async () => {

test("known error occurred in middleware function is unmasked", async () => {
const action = ac1
.use(async ({ next, ctx }) => next({ ctx }))
.use(async ({ next }) => next())
.use(async () => {
throw new ActionError("Something bad happened");
})
@@ -131,7 +131,7 @@ test("error occurred in server code function has the correct shape defined by `h

test("error occurred in middleware function has the correct shape defined by `handleReturnedServerError`", async () => {
const action = ac2
.use(async ({ next, ctx }) => next({ ctx }))
.use(async ({ next }) => next())
.use(async () => {
throw new Error("Something bad happened");
})
@@ -169,7 +169,7 @@ test("action throws if an error occurred in server code function and `handleRetu

test("action throws if an error occurred in middleware function and `handleReturnedServerError` rethrows it", async () => {
const action = ac3
.use(async ({ next, ctx }) => next({ ctx }))
.use(async ({ next }) => next())
.use(async () => {
throw new Error("Something bad happened");
})
32 changes: 17 additions & 15 deletions packages/next-safe-action/src/action-builder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deepmerge } from "deepmerge-ts";
import { isNotFoundError } from "next/dist/client/components/not-found.js";
import { isRedirectError } from "next/dist/client/components/redirect.js";
import type {} from "zod";
@@ -26,7 +27,7 @@ export function actionBuilder<
ServerError,
MetadataSchema extends Schema | undefined = undefined,
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
Ctx = undefined,
Ctx extends object = {},
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
const BAS extends readonly Schema[] = [],
@@ -71,8 +72,8 @@ export function actionBuilder<
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
) => {
return async (...clientInputs: unknown[]) => {
let prevCtx: unknown = undefined;
const middlewareResult: MiddlewareResult<ServerError, unknown> = { success: false };
let currentCtx: object = {};
const middlewareResult: MiddlewareResult<ServerError, object> = { success: false };
type PrevResult = SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data> | undefined;
let prevResult: PrevResult | undefined = undefined;
const parsedInputDatas: any[] = [];
@@ -99,7 +100,7 @@ export function actionBuilder<
}

const middlewareFn = args.middlewareFns[idx];
middlewareResult.ctx = prevCtx;
middlewareResult.ctx = currentCtx;

try {
if (idx === 0) {
@@ -118,10 +119,11 @@ export function actionBuilder<
await middlewareFn({
clientInput: clientInputs.at(-1), // pass raw client input
bindArgsClientInputs: bindArgsSchemas.length ? clientInputs.slice(0, -1) : [],
ctx: prevCtx,
ctx: currentCtx,
metadata: args.metadata,
next: async ({ ctx }) => {
prevCtx = ctx;
next: async (nextOpts) => {
currentCtx = deepmerge(currentCtx, nextOpts?.ctx ?? {});
// currentCtx = { ...cloneDeep(currentCtx), ...(nextOpts?.ctx ?? {}) };
await executeMiddlewareStack(idx + 1);
return middlewareResult;
},
@@ -196,7 +198,7 @@ export function actionBuilder<
scfArgs[0] = {
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
ctx: prevCtx as Ctx,
ctx: currentCtx as Ctx,
metadata: args.metadata,
};

@@ -234,7 +236,7 @@ export function actionBuilder<
args.handleReturnedServerError(error, {
clientInput: clientInputs.at(-1), // pass raw client input
bindArgsClientInputs: bindArgsSchemas.length ? clientInputs.slice(0, -1) : [],
ctx: prevCtx,
ctx: currentCtx,
metadata: args.metadata as MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
})
);
@@ -246,7 +248,7 @@ export function actionBuilder<
returnedError,
clientInput: clientInputs.at(-1), // pass raw client input
bindArgsClientInputs: bindArgsSchemas.length ? clientInputs.slice(0, -1) : [],
ctx: prevCtx,
ctx: currentCtx,
metadata: args.metadata as MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
})
);
@@ -263,7 +265,7 @@ export function actionBuilder<
utils?.onSuccess?.({
data: undefined,
metadata: args.metadata,
ctx: prevCtx as Ctx,
ctx: currentCtx as Ctx,
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
@@ -276,7 +278,7 @@ export function actionBuilder<
await Promise.resolve(
utils?.onSettled?.({
metadata: args.metadata,
ctx: prevCtx as Ctx,
ctx: currentCtx as Ctx,
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
result: {},
@@ -324,7 +326,7 @@ export function actionBuilder<
await Promise.resolve(
utils?.onSuccess?.({
metadata: args.metadata,
ctx: prevCtx as Ctx,
ctx: currentCtx as Ctx,
data: actionResult.data as Data,
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
@@ -338,7 +340,7 @@ export function actionBuilder<
await Promise.resolve(
utils?.onError?.({
metadata: args.metadata,
ctx: prevCtx as Ctx,
ctx: currentCtx as Ctx,
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
error: actionResult,
@@ -350,7 +352,7 @@ export function actionBuilder<
await Promise.resolve(
utils?.onSettled?.({
metadata: args.metadata,
ctx: prevCtx as Ctx,
ctx: currentCtx as Ctx,
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
result: actionResult,
5 changes: 3 additions & 2 deletions packages/next-safe-action/src/index.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ import {
formatValidationErrors,
} from "./validation-errors";

export { createMiddleware as experimental_createMiddleware } from "./middleware";
export { ActionMetadataError, DEFAULT_SERVER_ERROR_MESSAGE } from "./utils";
export {
ActionValidationError,
@@ -55,13 +56,13 @@ export const createSafeActionClient = <
>);

return new SafeActionClient({
middlewareFns: [async ({ next }) => next({ ctx: undefined })],
middlewareFns: [async ({ next }) => next({ ctx: {} })],
handleServerErrorLog,
handleReturnedServerError,
schemaFn: undefined,
bindArgsSchemas: [],
validationAdapter: createOpts?.validationAdapter ?? zodAdapter(), // use zod adapter by default
ctxType: undefined,
ctxType: {},
metadataSchema: (createOpts?.defineMetadataSchema?.() ?? undefined) as MetadataSchema,
metadata: undefined as MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
defaultValidationErrorsShape: (createOpts?.defaultValidationErrorsShape ?? "formatted") as ODVES,
Loading