Skip to content

Commit

Permalink
feat(middleware): support creation of standalone functions (#229)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
TheEdoRan authored Aug 13, 2024
1 parent 3c37269 commit 7f36bb5
Show file tree
Hide file tree
Showing 24 changed files with 845 additions and 625 deletions.
2 changes: 1 addition & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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();

Expand Down
7 changes: 4 additions & 3 deletions packages/next-safe-action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
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
Expand Up @@ -5,6 +5,7 @@ import { test } from "node:test";
import { z } from "zod";
import {
createSafeActionClient,
experimental_createMiddleware,
formatBindArgsValidationErrors,
formatValidationErrors,
returnValidationErrors,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
})
Expand Down Expand Up @@ -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;
})
Expand Down Expand Up @@ -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;
})
Expand Down Expand Up @@ -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;
})
Expand Down Expand Up @@ -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;
})
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
})
Expand All @@ -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)"],
Expand All @@ -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
Expand Up @@ -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");
})
Expand Down Expand Up @@ -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");
})
Expand Down Expand Up @@ -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");
})
Expand Down Expand Up @@ -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");
})
Expand Down
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";
Expand Down Expand Up @@ -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[] = [],
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -99,7 +100,7 @@ export function actionBuilder<
}

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

try {
if (idx === 0) {
Expand All @@ -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;
},
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
})
);
Expand All @@ -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,
})
);
Expand All @@ -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,
Expand All @@ -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: {},
Expand Down Expand Up @@ -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>,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/next-safe-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 7f36bb5

Please sign in to comment.