Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 18 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ed3bd85
refactor: require context to be an object
TheEdoRan Aug 11, 2024
53489fe
feat(middleware): extend context by default when defining middleware
TheEdoRan Aug 11, 2024
eb537a1
chore(website): add context extension recipe
TheEdoRan Aug 11, 2024
97587ae
chore(playground): update middleware code
TheEdoRan Aug 11, 2024
e70a74b
test(middleware): remove args from `next` function
TheEdoRan Aug 11, 2024
158f395
chore(website): fix context extension code snippet
TheEdoRan Aug 12, 2024
cfc1b8c
chore(website): add wip `createMiddleware` section
TheEdoRan Aug 12, 2024
87868a2
feat(middleware): support standalone middleware definition via export…
TheEdoRan Aug 12, 2024
37af4fa
refactor(middleware): move `createMiddleware` function to separate file
TheEdoRan Aug 12, 2024
8813200
refactor(middleware): export `createMiddleware` function as `experime…
TheEdoRan Aug 12, 2024
1764d8d
test(middleware): don't spread `ctx` when extending it
TheEdoRan Aug 12, 2024
70612d5
test(middleware): add standalone middleware test
TheEdoRan Aug 12, 2024
f810202
chore(website): add standalone middleware documentation
TheEdoRan Aug 12, 2024
33a4e9d
chore(website): update types
TheEdoRan Aug 12, 2024
07966b7
chore(website): update dependencies
TheEdoRan Aug 12, 2024
0a13fa9
chore: update TypeScript dependency
TheEdoRan Aug 12, 2024
b18bf3c
refactor(middleware): use deepmerge-ts to merge context object
TheEdoRan Aug 13, 2024
d818405
chore(website): update middleware context extension section
TheEdoRan Aug 13, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
6 changes: 3 additions & 3 deletions packages/next-safe-action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,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
33 changes: 17 additions & 16 deletions packages/next-safe-action/src/action-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
ServerCodeFn,
StateServerCodeFn,
} from "./index.types";
import { ActionMetadataError, DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils";
import { ActionMetadataError, deepMerge, DEFAULT_SERVER_ERROR_MESSAGE, isError } from "./utils";
import { ActionServerValidationError, ActionValidationError, buildValidationErrors } from "./validation-errors";
import type {
BindArgsValidationErrors,
Expand All @@ -26,7 +26,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 +71,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 +99,7 @@ export function actionBuilder<
}

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

try {
if (idx === 0) {
Expand All @@ -118,10 +118,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 +197,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 +235,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 +247,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 +264,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 +277,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 +325,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 +339,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 +351,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