Skip to content

Commit 7f36bb5

Browse files
authoredAug 13, 2024··
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
1 parent 3c37269 commit 7f36bb5

24 files changed

+845
-625
lines changed
 

‎apps/playground/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@
2929
"eslint-config-next": "15.0.0-canary.75",
3030
"postcss": "8.4.38",
3131
"tailwindcss": "3.4.3",
32-
"typescript": "^5.5.3"
32+
"typescript": "^5.5.4"
3333
}
3434
}

‎apps/playground/src/lib/safe-action.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const action = createSafeActionClient({
3838
const start = Date.now();
3939

4040
// Here we await the next middleware.
41-
const result = await next({ ctx });
41+
const result = await next();
4242

4343
const end = Date.now();
4444

‎packages/next-safe-action/package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"@types/node": "^20.14.11",
7272
"@types/react": "^18.3.1",
7373
"@types/react-dom": "18.3.0",
74+
"deepmerge-ts": "^7.1.0",
7475
"eslint": "^8.57.0",
7576
"eslint-config-prettier": "^9.1.0",
7677
"eslint-define-config": "^2.1.0",
@@ -82,20 +83,20 @@
8283
"semantic-release": "^23.0.8",
8384
"tsup": "^8.0.2",
8485
"tsx": "^4.11.2",
85-
"typescript": "^5.5.3",
86+
"typescript": "^5.5.4",
8687
"typescript-eslint": "^7.8.0",
8788
"valibot": "^0.36.0",
8889
"yup": "^1.4.0",
8990
"zod": "^3.23.6"
9091
},
9192
"peerDependencies": {
93+
"@sinclair/typebox": ">= 0.33.3",
9294
"next": ">= 14.0.0",
9395
"react": ">= 18.2.0",
9496
"react-dom": ">= 18.2.0",
9597
"valibot": ">= 0.36.0",
9698
"yup": ">= 1.0.0",
97-
"zod": ">= 3.0.0",
98-
"@sinclair/typebox": ">= 0.33.3"
99+
"zod": ">= 3.0.0"
99100
},
100101
"peerDependenciesMeta": {
101102
"zod": {

‎packages/next-safe-action/src/__tests__/middleware.test.ts

+41-19
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { test } from "node:test";
55
import { z } from "zod";
66
import {
77
createSafeActionClient,
8+
experimental_createMiddleware,
89
formatBindArgsValidationErrors,
910
formatValidationErrors,
1011
returnValidationErrors,
@@ -42,8 +43,8 @@ test("instance context value is accessible in server code function", async () =>
4243

4344
test("instance context value is extended in action middleware and both values are accessible in server code function", async () => {
4445
const action = ac
45-
.use(async ({ next, ctx }) => {
46-
return next({ ctx: { ...ctx, bar: "baz" } });
46+
.use(async ({ next }) => {
47+
return next({ ctx: { bar: "baz" } });
4748
})
4849
.action(async ({ ctx }) => {
4950
return {
@@ -70,7 +71,7 @@ test("instance context value is correctly overridden in subsequent middleware",
7071
if (ctx.foo !== "baz") {
7172
throw new Error("Expected ctx.foo to be 'baz'");
7273
}
73-
return next({ ctx });
74+
return next();
7475
})
7576
.action(async ({ ctx }) => {
7677
return {
@@ -96,8 +97,8 @@ test("action client inputs are passed to middleware", async () => {
9697
})
9798
)
9899
.bindArgsSchemas([z.object({ age: z.number().positive() })])
99-
.use(async ({ clientInput, bindArgsClientInputs, next, ctx }) => {
100-
return next({ ctx: { ...ctx, clientInput, bindArgsClientInputs } });
100+
.use(async ({ clientInput, bindArgsClientInputs, next }) => {
101+
return next({ ctx: { clientInput, bindArgsClientInputs } });
101102
})
102103
.action(async ({ ctx }) => {
103104
return {
@@ -130,9 +131,9 @@ test("happy path execution result from middleware is correct", async () => {
130131
})
131132
)
132133
.bindArgsSchemas([z.object({ age: z.number().positive() })])
133-
.use(async ({ next, ctx }) => {
134+
.use(async ({ next }) => {
134135
// Await action execution.
135-
const res = await next({ ctx });
136+
const res = await next();
136137
middlewareResult = res;
137138
return res;
138139
})
@@ -176,9 +177,9 @@ test("server error execution result from middleware is correct", async () => {
176177
})
177178
)
178179
.bindArgsSchemas([z.object({ age: z.number().positive() })])
179-
.use(async ({ next, ctx }) => {
180+
.use(async ({ next }) => {
180181
// Await action execution.
181-
const res = await next({ ctx });
182+
const res = await next();
182183
middlewareResult = res;
183184
return res;
184185
})
@@ -212,9 +213,9 @@ test("validation errors in execution result from middleware are correct", async
212213
})
213214
)
214215
.bindArgsSchemas([z.object({ age: z.number().positive() })])
215-
.use(async ({ next, ctx }) => {
216+
.use(async ({ next }) => {
216217
// Await action execution.
217-
const res = await next({ ctx });
218+
const res = await next();
218219
middlewareResult = res;
219220
return res;
220221
})
@@ -259,9 +260,9 @@ test("server validation errors in execution result from middleware are correct",
259260
const action = ac
260261
.schema(schema)
261262
.bindArgsSchemas([z.object({ age: z.number().positive() })])
262-
.use(async ({ next, ctx }) => {
263+
.use(async ({ next }) => {
263264
// Await action execution.
264-
const res = await next({ ctx });
265+
const res = await next();
265266
middlewareResult = res;
266267
return res;
267268
})
@@ -309,9 +310,9 @@ test("flattened validation errors in execution result from middleware are correc
309310
})
310311
)
311312
.bindArgsSchemas([z.object({ age: z.number().positive() })])
312-
.use(async ({ next, ctx }) => {
313+
.use(async ({ next }) => {
313314
// Await action execution.
314-
const res = await next({ ctx });
315+
const res = await next();
315316
middlewareResult = res;
316317
return res;
317318
})
@@ -326,7 +327,7 @@ test("flattened validation errors in execution result from middleware are correc
326327

327328
const expectedResult = {
328329
success: false,
329-
ctx: undefined,
330+
ctx: {},
330331
validationErrors: {
331332
formErrors: [],
332333
fieldErrors: {
@@ -360,9 +361,9 @@ test("overridden formatted validation errors in execution result from middleware
360361
.bindArgsSchemas([z.object({ age: z.number().positive() })], {
361362
handleBindArgsValidationErrorsShape: formatBindArgsValidationErrors,
362363
})
363-
.use(async ({ next, ctx }) => {
364+
.use(async ({ next }) => {
364365
// Await action execution.
365-
const res = await next({ ctx });
366+
const res = await next();
366367
middlewareResult = res;
367368
return res;
368369
})
@@ -377,7 +378,7 @@ test("overridden formatted validation errors in execution result from middleware
377378

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

395396
assert.deepStrictEqual(middlewareResult, expectedResult);
396397
});
398+
399+
test("standalone middleware extends context", async () => {
400+
const myMiddleware = experimental_createMiddleware<{ ctx: { foo: string } }>().define(async ({ next }) => {
401+
return next({ ctx: { baz: "qux" } });
402+
});
403+
404+
const action = ac.use(myMiddleware).action(async ({ ctx }) => {
405+
return {
406+
ctx,
407+
};
408+
});
409+
410+
const actualResult = await action();
411+
const expectedResult = {
412+
data: {
413+
ctx: { foo: "bar", baz: "qux" },
414+
},
415+
};
416+
417+
assert.deepStrictEqual(actualResult, expectedResult);
418+
});

‎packages/next-safe-action/src/__tests__/server-error.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ test("unknown error occurred in server code function is masked by default", asyn
3939

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

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

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

170170
test("action throws if an error occurred in middleware function and `handleReturnedServerError` rethrows it", async () => {
171171
const action = ac3
172-
.use(async ({ next, ctx }) => next({ ctx }))
172+
.use(async ({ next }) => next())
173173
.use(async () => {
174174
throw new Error("Something bad happened");
175175
})

‎packages/next-safe-action/src/action-builder.ts

+17-15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { deepmerge } from "deepmerge-ts";
12
import { isNotFoundError } from "next/dist/client/components/not-found.js";
23
import { isRedirectError } from "next/dist/client/components/redirect.js";
34
import type {} from "zod";
@@ -26,7 +27,7 @@ export function actionBuilder<
2627
ServerError,
2728
MetadataSchema extends Schema | undefined = undefined,
2829
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
29-
Ctx = undefined,
30+
Ctx extends object = {},
3031
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
3132
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
3233
const BAS extends readonly Schema[] = [],
@@ -71,8 +72,8 @@ export function actionBuilder<
7172
utils?: SafeActionUtils<ServerError, MD, Ctx, S, BAS, CVE, CBAVE, Data>
7273
) => {
7374
return async (...clientInputs: unknown[]) => {
74-
let prevCtx: unknown = undefined;
75-
const middlewareResult: MiddlewareResult<ServerError, unknown> = { success: false };
75+
let currentCtx: object = {};
76+
const middlewareResult: MiddlewareResult<ServerError, object> = { success: false };
7677
type PrevResult = SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data> | undefined;
7778
let prevResult: PrevResult | undefined = undefined;
7879
const parsedInputDatas: any[] = [];
@@ -99,7 +100,7 @@ export function actionBuilder<
99100
}
100101

101102
const middlewareFn = args.middlewareFns[idx];
102-
middlewareResult.ctx = prevCtx;
103+
middlewareResult.ctx = currentCtx;
103104

104105
try {
105106
if (idx === 0) {
@@ -118,10 +119,11 @@ export function actionBuilder<
118119
await middlewareFn({
119120
clientInput: clientInputs.at(-1), // pass raw client input
120121
bindArgsClientInputs: bindArgsSchemas.length ? clientInputs.slice(0, -1) : [],
121-
ctx: prevCtx,
122+
ctx: currentCtx,
122123
metadata: args.metadata,
123-
next: async ({ ctx }) => {
124-
prevCtx = ctx;
124+
next: async (nextOpts) => {
125+
currentCtx = deepmerge(currentCtx, nextOpts?.ctx ?? {});
126+
// currentCtx = { ...cloneDeep(currentCtx), ...(nextOpts?.ctx ?? {}) };
125127
await executeMiddlewareStack(idx + 1);
126128
return middlewareResult;
127129
},
@@ -196,7 +198,7 @@ export function actionBuilder<
196198
scfArgs[0] = {
197199
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
198200
bindArgsParsedInputs: parsedInputDatas.slice(0, -1) as InferArray<BAS>,
199-
ctx: prevCtx as Ctx,
201+
ctx: currentCtx as Ctx,
200202
metadata: args.metadata,
201203
};
202204

@@ -234,7 +236,7 @@ export function actionBuilder<
234236
args.handleReturnedServerError(error, {
235237
clientInput: clientInputs.at(-1), // pass raw client input
236238
bindArgsClientInputs: bindArgsSchemas.length ? clientInputs.slice(0, -1) : [],
237-
ctx: prevCtx,
239+
ctx: currentCtx,
238240
metadata: args.metadata as MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
239241
})
240242
);
@@ -246,7 +248,7 @@ export function actionBuilder<
246248
returnedError,
247249
clientInput: clientInputs.at(-1), // pass raw client input
248250
bindArgsClientInputs: bindArgsSchemas.length ? clientInputs.slice(0, -1) : [],
249-
ctx: prevCtx,
251+
ctx: currentCtx,
250252
metadata: args.metadata as MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
251253
})
252254
);
@@ -263,7 +265,7 @@ export function actionBuilder<
263265
utils?.onSuccess?.({
264266
data: undefined,
265267
metadata: args.metadata,
266-
ctx: prevCtx as Ctx,
268+
ctx: currentCtx as Ctx,
267269
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
268270
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
269271
parsedInput: parsedInputDatas.at(-1) as S extends Schema ? Infer<S> : undefined,
@@ -276,7 +278,7 @@ export function actionBuilder<
276278
await Promise.resolve(
277279
utils?.onSettled?.({
278280
metadata: args.metadata,
279-
ctx: prevCtx as Ctx,
281+
ctx: currentCtx as Ctx,
280282
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
281283
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
282284
result: {},
@@ -324,7 +326,7 @@ export function actionBuilder<
324326
await Promise.resolve(
325327
utils?.onSuccess?.({
326328
metadata: args.metadata,
327-
ctx: prevCtx as Ctx,
329+
ctx: currentCtx as Ctx,
328330
data: actionResult.data as Data,
329331
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
330332
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
@@ -338,7 +340,7 @@ export function actionBuilder<
338340
await Promise.resolve(
339341
utils?.onError?.({
340342
metadata: args.metadata,
341-
ctx: prevCtx as Ctx,
343+
ctx: currentCtx as Ctx,
342344
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
343345
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
344346
error: actionResult,
@@ -350,7 +352,7 @@ export function actionBuilder<
350352
await Promise.resolve(
351353
utils?.onSettled?.({
352354
metadata: args.metadata,
353-
ctx: prevCtx as Ctx,
355+
ctx: currentCtx as Ctx,
354356
clientInput: clientInputs.at(-1) as S extends Schema ? InferIn<S> : undefined,
355357
bindArgsClientInputs: (bindArgsSchemas.length ? clientInputs.slice(0, -1) : []) as InferInArray<BAS>,
356358
result: actionResult,

‎packages/next-safe-action/src/index.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
formatValidationErrors,
1111
} from "./validation-errors";
1212

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

5758
return new SafeActionClient({
58-
middlewareFns: [async ({ next }) => next({ ctx: undefined })],
59+
middlewareFns: [async ({ next }) => next({ ctx: {} })],
5960
handleServerErrorLog,
6061
handleReturnedServerError,
6162
schemaFn: undefined,
6263
bindArgsSchemas: [],
6364
validationAdapter: createOpts?.validationAdapter ?? zodAdapter(), // use zod adapter by default
64-
ctxType: undefined,
65+
ctxType: {},
6566
metadataSchema: (createOpts?.defineMetadataSchema?.() ?? undefined) as MetadataSchema,
6667
metadata: undefined as MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined,
6768
defaultValidationErrorsShape: (createOpts?.defaultValidationErrorsShape ?? "formatted") as ODVES,

‎packages/next-safe-action/src/index.types.ts

+29-15
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type DVES = "formatted" | "flattened";
1414
export type ServerErrorFunctionUtils<MetadataSchema extends Schema | undefined> = {
1515
clientInput: unknown;
1616
bindArgsClientInputs: unknown[];
17-
ctx: unknown;
17+
ctx: object;
1818
metadata: MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined;
1919
};
2020

@@ -53,7 +53,7 @@ export type SafeActionResult<
5353
CBAVE = BindArgsValidationErrors<BAS>,
5454
Data = unknown,
5555
// eslint-disable-next-line
56-
NextCtx = unknown,
56+
NextCtx = object,
5757
> = {
5858
data?: Data;
5959
serverError?: ServerError;
@@ -90,35 +90,49 @@ export type SafeStateActionFn<
9090
* Type of the result of a middleware function. It extends the result of a safe action with
9191
* information about the action execution.
9292
*/
93-
export type MiddlewareResult<ServerError, NextCtx> = SafeActionResult<ServerError, any, any, any, any, any, NextCtx> & {
93+
export type MiddlewareResult<ServerError, NextCtx extends object> = SafeActionResult<
94+
ServerError,
95+
any,
96+
any,
97+
any,
98+
any,
99+
any,
100+
NextCtx
101+
> & {
94102
parsedInput?: unknown;
95103
bindArgsParsedInputs?: unknown[];
96-
ctx?: unknown;
104+
ctx?: object;
97105
success: boolean;
98106
};
99107

100108
/**
101109
* Type of the middleware function passed to a safe action client.
102110
*/
103-
export type MiddlewareFn<ServerError, MD, Ctx, NextCtx> = {
111+
export type MiddlewareFn<ServerError, MD, Ctx extends object, NextCtx extends object> = {
104112
(opts: {
105113
clientInput: unknown;
106114
bindArgsClientInputs: unknown[];
107-
ctx: Ctx;
115+
ctx: Prettify<Ctx>;
108116
metadata: MD;
109117
next: {
110-
<NC>(opts: { ctx: NC }): Promise<MiddlewareResult<ServerError, NC>>;
118+
<NC extends object = {}>(opts?: { ctx?: NC }): Promise<MiddlewareResult<ServerError, NC>>;
111119
};
112120
}): Promise<MiddlewareResult<ServerError, NextCtx>>;
113121
};
114122

115123
/**
116124
* Type of the function that executes server code when defining a new safe action.
117125
*/
118-
export type ServerCodeFn<MD, Ctx, S extends Schema | undefined, BAS extends readonly Schema[], Data> = (args: {
126+
export type ServerCodeFn<
127+
MD,
128+
Ctx extends object,
129+
S extends Schema | undefined,
130+
BAS extends readonly Schema[],
131+
Data,
132+
> = (args: {
119133
parsedInput: S extends Schema ? Infer<S> : undefined;
120134
bindArgsParsedInputs: InferArray<BAS>;
121-
ctx: Ctx;
135+
ctx: Prettify<Ctx>;
122136
metadata: MD;
123137
}) => Promise<Data>;
124138

@@ -128,7 +142,7 @@ export type ServerCodeFn<MD, Ctx, S extends Schema | undefined, BAS extends read
128142
export type StateServerCodeFn<
129143
ServerError,
130144
MD,
131-
Ctx,
145+
Ctx extends object,
132146
S extends Schema | undefined,
133147
BAS extends readonly Schema[],
134148
CVE,
@@ -138,7 +152,7 @@ export type StateServerCodeFn<
138152
args: {
139153
parsedInput: S extends Schema ? Infer<S> : undefined;
140154
bindArgsParsedInputs: InferArray<BAS>;
141-
ctx: Ctx;
155+
ctx: Prettify<Ctx>;
142156
metadata: MD;
143157
},
144158
utils: { prevResult: Prettify<SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>> }
@@ -150,7 +164,7 @@ export type StateServerCodeFn<
150164
export type SafeActionUtils<
151165
ServerError,
152166
MD,
153-
Ctx,
167+
Ctx extends object,
154168
S extends Schema | undefined,
155169
BAS extends readonly Schema[],
156170
CVE,
@@ -162,7 +176,7 @@ export type SafeActionUtils<
162176
onSuccess?: (args: {
163177
data?: Data;
164178
metadata: MD;
165-
ctx?: Ctx;
179+
ctx?: Prettify<Ctx>;
166180
clientInput: S extends Schema ? InferIn<S> : undefined;
167181
bindArgsClientInputs: InferInArray<BAS>;
168182
parsedInput: S extends Schema ? Infer<S> : undefined;
@@ -173,14 +187,14 @@ export type SafeActionUtils<
173187
onError?: (args: {
174188
error: Prettify<Omit<SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>, "data">>;
175189
metadata: MD;
176-
ctx?: Ctx;
190+
ctx?: Prettify<Ctx>;
177191
clientInput: S extends Schema ? InferIn<S> : undefined;
178192
bindArgsClientInputs: InferInArray<BAS>;
179193
}) => MaybePromise<void>;
180194
onSettled?: (args: {
181195
result: Prettify<SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>>;
182196
metadata: MD;
183-
ctx?: Ctx;
197+
ctx?: Prettify<Ctx>;
184198
clientInput: S extends Schema ? InferIn<S> : undefined;
185199
bindArgsClientInputs: InferInArray<BAS>;
186200
hasRedirected: boolean;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { MiddlewareFn } from "./index.types";
2+
3+
/**
4+
* Creates a standalone middleware function. It accepts a generic object with optional `serverError`, `ctx` and `metadata`
5+
* properties, if you need one or all of them to be typed. The type for each property that is passed as generic is the
6+
* **minimum** shape required to define the middleware function, but it can also be larger than that.
7+
*
8+
* {@link https://next-safe-action.dev/docs/safe-action-client/middleware#create-standalone-middleware-with-createmiddleware See docs for more information}
9+
*/
10+
export const createMiddleware = <BaseData extends { serverError?: any; ctx?: object; metadata?: any }>() => {
11+
return {
12+
define: <NextCtx extends object>(
13+
middlewareFn: MiddlewareFn<
14+
BaseData extends { serverError: infer SE } ? SE : any,
15+
BaseData extends { metadata: infer MD } ? MD : any,
16+
BaseData extends { ctx: infer Ctx extends object } ? Ctx : object,
17+
NextCtx
18+
>
19+
) => middlewareFn,
20+
};
21+
};

‎packages/next-safe-action/src/safe-action-client.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class SafeActionClient<
2323
ODVES extends DVES | undefined, // override default validation errors shape
2424
MetadataSchema extends Schema | undefined = undefined,
2525
MD = MetadataSchema extends Schema ? Infer<Schema> : undefined,
26-
Ctx = undefined,
26+
Ctx extends object = {},
2727
SF extends (() => Promise<Schema>) | undefined = undefined, // schema function
2828
S extends Schema | undefined = SF extends Function ? Awaited<ReturnType<SF>> : undefined,
2929
const BAS extends readonly Schema[] = [],
@@ -37,10 +37,10 @@ export class SafeActionClient<
3737
SafeActionClientOpts<ServerError, MetadataSchema, ODVES>["handleReturnedServerError"]
3838
>;
3939
readonly #middlewareFns: MiddlewareFn<ServerError, any, any, any>[];
40-
readonly #ctxType = undefined as Ctx;
4140
readonly #metadataSchema: MetadataSchema;
4241
readonly #metadata: MD;
4342
readonly #schemaFn: SF;
43+
readonly #ctxType: Ctx;
4444
readonly #bindArgsSchemas: BAS;
4545
readonly #validationAdapter: ValidationAdapter;
4646
readonly #handleValidationErrorsShape: HandleValidationErrorsShapeFn<S, CVE>;
@@ -74,6 +74,7 @@ export class SafeActionClient<
7474
this.#schemaFn = (opts.schemaFn ?? undefined) as SF;
7575
this.#bindArgsSchemas = opts.bindArgsSchemas ?? [];
7676
this.#validationAdapter = opts.validationAdapter;
77+
this.#ctxType = opts.ctxType as unknown as Ctx;
7778
this.#handleValidationErrorsShape = opts.handleValidationErrorsShape;
7879
this.#handleBindArgsValidationErrorsShape = opts.handleBindArgsValidationErrorsShape;
7980
this.#defaultValidationErrorsShape = opts.defaultValidationErrorsShape;
@@ -86,7 +87,7 @@ export class SafeActionClient<
8687
*
8788
* {@link https://next-safe-action.dev/docs/safe-action-client/instance-methods#use See docs for more information}
8889
*/
89-
use<NextCtx>(middlewareFn: MiddlewareFn<ServerError, MD, Ctx, NextCtx>) {
90+
use<NextCtx extends object>(middlewareFn: MiddlewareFn<ServerError, MD, Ctx, Ctx & NextCtx>) {
9091
return new SafeActionClient({
9192
middlewareFns: [...this.#middlewareFns, middlewareFn],
9293
handleReturnedServerError: this.#handleReturnedServerError,
@@ -98,7 +99,7 @@ export class SafeActionClient<
9899
validationAdapter: this.#validationAdapter,
99100
handleValidationErrorsShape: this.#handleValidationErrorsShape,
100101
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
101-
ctxType: undefined as NextCtx,
102+
ctxType: {} as Ctx & NextCtx,
102103
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
103104
throwValidationErrors: this.#throwValidationErrors,
104105
});
@@ -122,7 +123,7 @@ export class SafeActionClient<
122123
validationAdapter: this.#validationAdapter,
123124
handleValidationErrorsShape: this.#handleValidationErrorsShape,
124125
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
125-
ctxType: undefined as Ctx,
126+
ctxType: {} as Ctx,
126127
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
127128
throwValidationErrors: this.#throwValidationErrors,
128129
});
@@ -164,7 +165,7 @@ export class SafeActionClient<
164165
handleValidationErrorsShape: (utils?.handleValidationErrorsShape ??
165166
this.#handleValidationErrorsShape) as HandleValidationErrorsShapeFn<AS, OCVE>,
166167
handleBindArgsValidationErrorsShape: this.#handleBindArgsValidationErrorsShape,
167-
ctxType: undefined as Ctx,
168+
ctxType: {} as Ctx,
168169
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
169170
throwValidationErrors: this.#throwValidationErrors,
170171
});
@@ -198,7 +199,7 @@ export class SafeActionClient<
198199
handleValidationErrorsShape: this.#handleValidationErrorsShape,
199200
handleBindArgsValidationErrorsShape: (utils?.handleBindArgsValidationErrorsShape ??
200201
this.#handleBindArgsValidationErrorsShape) as HandleBindArgsValidationErrorsShapeFn<OBAS, OCBAVE>,
201-
ctxType: undefined as Ctx,
202+
ctxType: {} as Ctx,
202203
defaultValidationErrorsShape: this.#defaultValidationErrorsShape,
203204
throwValidationErrors: this.#throwValidationErrors,
204205
});

‎packages/next-safe-action/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"compilerOptions": {
33
"target": "ESNext",
44
"module": "CommonJS",
5-
"lib": ["ES2021.String"],
5+
"lib": ["ES2022"],
66
"skipLibCheck": true,
77
"sourceMap": true,
88
"outDir": "./dist",

‎pnpm-lock.yaml

+95-86
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎website/docs/recipes/additional-validation-errors.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 5
2+
sidebar_position: 6
33
description: Set additional custom validation errors in schema or in action's server code function.
44
---
55

‎website/docs/recipes/customize-validation-errors-format.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 6
2+
sidebar_position: 7
33
description: Learn how to customize validation errors format returned to the client.
44
---
55

@@ -49,7 +49,7 @@ export const loginUser = actionClient
4949
```
5050

5151
:::note
52-
If you chain multiple `schema` methods, as explained in the [Extend previous schema](/docs/recipes/extend-previous-schema) page, and want to override the default validation errors shape, you **must** use `handleValidationErrorsShape` inside the last `schema` method, otherwise there would be a type mismatch in the returned action result.
52+
If you chain multiple `schema` methods, as explained in the [Extend previous schema](/docs/safe-action-client/extend-previous-schema) page, and want to override the default validation errors shape, you **must** use `handleValidationErrorsShape` inside the last `schema` method, otherwise there would be a type mismatch in the returned action result.
5353
:::
5454

5555
### `flattenValidationErrors` and `flattenBindArgsValidationErrors` utility functions

‎website/docs/recipes/form-actions.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 7
2+
sidebar_position: 8
33
description: Learn how to use next-safe-action with Form Actions.
44
---
55

‎website/docs/recipes/i18n.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 9
2+
sidebar_position: 10
33
description: Learn how to use next-safe-action with a i18n solution.
44
---
55

‎website/docs/recipes/upload-files.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 8
2+
sidebar_position: 9
33
description: Learn how to upload a file using next-safe-action.
44
---
55

‎website/docs/recipes/extend-base-client.md ‎website/docs/safe-action-client/extend-a-client.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
---
2-
sidebar_position: 3
2+
sidebar_position: 5
33
description: Learn how to use both a basic and an authorization client at the same time in your project.
44
---
55

6-
# Extend base client
6+
# Extend a client
77

88
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.
99

‎website/docs/recipes/extend-previous-schema.md ‎website/docs/safe-action-client/extend-previous-schema.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 4
2+
sidebar_position: 6
33
description: Learn how to use next-safe-action with a i18n solution.
44
---
55

‎website/docs/safe-action-client/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ description: Safe action client is the instance that you can use to create types
55

66
# Safe action client
77

8-
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/recipes/extend-base-client).
8+
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 page](/docs/safe-action-client/extend-a-client).
99

1010
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 next sections.

‎website/docs/safe-action-client/middleware.md

+87-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Learn how to use middleware functions in your actions.
55

66
# Middleware
77

8-
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.
8+
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).
99

1010
Middleware functions are defined using [`use`](/docs/safe-action-client/instance-methods#use) method in your action clients, via the `middlewareFn` argument.
1111

@@ -144,7 +144,7 @@ const deleteUser = authActionClient
144144

145145
// Here we pass the same untouched context (`userId`) to the next function, since we don't need
146146
// to add data to the context here.
147-
return next({ ctx });
147+
return next();
148148
})
149149
.metadata({ actionName: "deleteUser" })
150150
.action(async ({ ctx: { userId } }) => {
@@ -200,3 +200,88 @@ Note that the second line comes from the default `handleServerErrorLog` function
200200
## `middlewareFn` return value
201201

202202
`middlewareFn` returns a Promise of a [`MiddlewareResult`](/docs/types#middlewareresult) object. It extends the result of a safe action with `success` property, and `parsedInput`, `bindArgsParsedInputs` 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.
203+
204+
## Extend context
205+
206+
Context is a special object that holds information about the current execution state. This object is passed to middleware functions and server code functions when defining actions.
207+
208+
Starting from version 7.6.0, context is extended by default when defining middleware functions. For instance, if you want both the `sessionId` and `userId` in the context, by using two different middleware functions (trivial example), you can do it like this:
209+
210+
```typescript title="src/lib/safe-action.ts"
211+
import { createSafeActionClient } from "next-safe-action";
212+
213+
export const actionClient = createSafeActionClient()
214+
.use(async ({ next }) => {
215+
const sessionId = await getSessionId();
216+
return next({ ctx: { sessionId } })
217+
})
218+
.use(async ({ next, ctx }) => {
219+
const { sessionId } = ctx; // Context contains `sessionId`
220+
const userId = await getUserIdBySessionId(sessionId);
221+
return next({ ctx: { userId } })
222+
})
223+
.use(async ({ next }) => {
224+
// You can also define a middleware function that doesn't extend or modify the context.
225+
return next();
226+
})
227+
```
228+
229+
```typescript title="src/app/test-action.ts"
230+
"use server";
231+
232+
import { actionClient } from "@/lib/safe-action";
233+
234+
export const testAction = actionClient
235+
.action(async ({ ctx }) => {
236+
// Context contains `sessionId` and `userId` thanks to the middleware.
237+
const { sessionId, userId } = ctx;
238+
});
239+
```
240+
241+
## Create standalone middleware
242+
243+
:::info
244+
Experimental feature
245+
:::
246+
247+
Starting from version 7.6.0, you can create standalone middleware functions using the built-in `experimental_createMiddleware()` function. It's labelled as experimental because the API could change in the future, but it's perfectly fine to use it, as it's a pretty simple function that just wraps the creation of middleware.
248+
249+
Thanks to this feature, and the previously mentioned [context extension](#extend-context), you can now define standalone middleware functions and even publish them as packages, if you want to.
250+
251+
Here's how to use `experimental_createMiddleware()`:
252+
253+
```typescript title="src/lib/safe-action.ts"
254+
import { createSafeActionClient, experimental_createMiddleware } from "next-safe-action";
255+
import { z } from "zod";
256+
257+
export const actionClient = createSafeActionClient({
258+
defineMetadataSchema: () => z.object({
259+
actionName: z.string()
260+
}),
261+
}).use(async ({ next }) => {
262+
return next({ ctx: { foo: "bar" } });
263+
});
264+
265+
// This middleware works with any client.
266+
const myMiddleware1 = experimental_createMiddleware().define(async ({ next }) => {
267+
// Do something useful here...
268+
return next({ ctx: { baz: "qux" } });
269+
});
270+
271+
// This middleware works with clients that at minimum have `ctx.foo` and `metadata.actionName` properties.
272+
// More information below. *
273+
const myMiddleware2 = experimental_createMiddleware<{
274+
ctx: { foo: string }; // [1]
275+
metadata: { actionName: string }; // [2]
276+
}>().define(async ({ next }) => {
277+
// Do something useful here...
278+
return next({ ctx: { john: "doe" } });
279+
});
280+
281+
// You can use it like a regular middleware function.
282+
export const actionClientWithMyMiddleware = actionClient.use(myMiddleware1).use(myMiddleware2);
283+
```
284+
285+
An action defined using the `actionClientWithMyMiddleware` will contain `foo`, `baz` and `john` in its context.
286+
287+
\* Note that you can pass, **but not required to**, an object with two generic properties to the `experimental_createMiddleware()` function: `ctx` \[1\], and `metadata` \[2\]. Those keys are optional, and you should only provide them if you want your middleware to require **at minimum** the shape you passed in as generic. By doing that, following the above example, you can then access `ctx.foo` and `metadata.actionName` in the middleware you're defining. If you pass a middleware that requires those properties to a client that doesn't have them, you'll get an error in `use()` method.

‎website/docs/types.md

+29-11
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Type of the util properties passed to server error handler functions.
2323
export type ServerErrorFunctionUtils<MetadataSchema extends Schema | undefined> = {
2424
clientInput: unknown;
2525
bindArgsClientInputs: unknown[];
26-
ctx: unknown;
26+
ctx: object;
2727
metadata: MetadataSchema extends Schema ? Infer<MetadataSchema> : undefined;
2828
};
2929
```
@@ -67,7 +67,7 @@ export type SafeActionResult<
6767
CBAVE = BindArgsValidationErrors<BAS>,
6868
Data = unknown,
6969
// eslint-disable-next-line
70-
NextCtx = unknown,
70+
NextCtx = object,
7171
> = {
7272
data?: Data;
7373
serverError?: ServerError;
@@ -119,7 +119,7 @@ export type SafeStateActionFn<
119119
Type of the result of a middleware function. It extends the result of a safe action with information about the action execution.
120120

121121
```typescript
122-
export type MiddlewareResult<ServerError, NextCtx> = SafeActionResult<
122+
export type MiddlewareResult<ServerError, NextCtx extends object> = SafeActionResult<
123123
ServerError,
124124
any,
125125
any,
@@ -130,7 +130,7 @@ export type MiddlewareResult<ServerError, NextCtx> = SafeActionResult<
130130
> & {
131131
parsedInput?: unknown;
132132
bindArgsParsedInputs?: unknown[];
133-
ctx?: unknown;
133+
ctx?: object;
134134
success: boolean;
135135
};
136136
```
@@ -140,14 +140,14 @@ export type MiddlewareResult<ServerError, NextCtx> = SafeActionResult<
140140
Type of the middleware function passed to a safe action client.
141141

142142
```typescript
143-
export type MiddlewareFn<ServerError, MD, Ctx, NextCtx> = {
143+
export type MiddlewareFn<ServerError, MD, Ctx, NextCtx extends object> = {
144144
(opts: {
145145
clientInput: unknown;
146146
bindArgsClientInputs: unknown[];
147147
ctx: Ctx;
148148
metadata: MD;
149149
next: {
150-
<NC>(opts: { ctx: NC }): Promise<MiddlewareResult<ServerError, NC>>;
150+
<NC extends object = {}>(opts: { ctx: NC }): Promise<MiddlewareResult<ServerError, NC>>;
151151
};
152152
}): Promise<MiddlewareResult<ServerError, NextCtx>>;
153153
};
@@ -158,10 +158,16 @@ export type MiddlewareFn<ServerError, MD, Ctx, NextCtx> = {
158158
Type of the function that executes server code when defining a new safe action.
159159

160160
```typescript
161-
export type ServerCodeFn<MD, Ctx, S extends Schema | undefined, BAS extends readonly Schema[], Data> = (args: {
161+
export type ServerCodeFn<
162+
MD,
163+
Ctx extends object,
164+
S extends Schema | undefined,
165+
BAS extends readonly Schema[],
166+
Data,
167+
> = (args: {
162168
parsedInput: S extends Schema ? Infer<S> : undefined;
163169
bindArgsParsedInputs: InferArray<BAS>;
164-
ctx: Ctx;
170+
ctx: Prettify<Ctx>;
165171
metadata: MD;
166172
}) => Promise<Data>;
167173
```
@@ -174,7 +180,7 @@ Type of the function that executes server code when defining a new stateful safe
174180
export type StateServerCodeFn<
175181
ServerError,
176182
MD,
177-
Ctx
183+
Ctx extends object,
178184
S extends Schema | undefined,
179185
BAS extends readonly Schema[],
180186
CVE,
@@ -198,6 +204,8 @@ Type of action execution utils. It includes action callbacks and other utils.
198204
```typescript
199205
export type SafeActionUtils<
200206
ServerError,
207+
MD,
208+
Ctx extends object,
201209
S extends Schema | undefined,
202210
BAS extends readonly Schema[],
203211
CVE,
@@ -208,20 +216,30 @@ export type SafeActionUtils<
208216
throwValidationErrors?: boolean;
209217
onSuccess?: (args: {
210218
data?: Data;
219+
metadata: MD;
220+
ctx?: Prettify<Ctx>;
211221
clientInput: S extends Schema ? InferIn<S> : undefined;
212222
bindArgsClientInputs: InferInArray<BAS>;
213223
parsedInput: S extends Schema ? Infer<S> : undefined;
214224
bindArgsParsedInputs: InferArray<BAS>;
225+
hasRedirected: boolean;
226+
hasNotFound: boolean;
215227
}) => MaybePromise<void>;
216228
onError?: (args: {
217-
error: Omit<SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>, "data">;
229+
error: Prettify<Omit<SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>, "data">>;
230+
metadata: MD;
231+
ctx?: Prettify<Ctx>;
218232
clientInput: S extends Schema ? InferIn<S> : undefined;
219233
bindArgsClientInputs: InferInArray<BAS>;
220234
}) => MaybePromise<void>;
221235
onSettled?: (args: {
222-
result: SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>;
236+
result: Prettify<SafeActionResult<ServerError, S, BAS, CVE, CBAVE, Data>>;
237+
metadata: MD;
238+
ctx?: Prettify<Ctx>;
223239
clientInput: S extends Schema ? InferIn<S> : undefined;
224240
bindArgsClientInputs: InferInArray<BAS>;
241+
hasRedirected: boolean;
242+
hasNotFound: boolean;
225243
}) => MaybePromise<void>;
226244
};
227245
```

‎website/package.json

+12-12
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,26 @@
1515
"typecheck": "tsc"
1616
},
1717
"dependencies": {
18-
"@docusaurus/core": "3.4.0",
19-
"@docusaurus/preset-classic": "3.4.0",
20-
"@docusaurus/remark-plugin-npm2yarn": "^3.4.0",
18+
"@docusaurus/core": "3.5.1",
19+
"@docusaurus/preset-classic": "3.5.1",
20+
"@docusaurus/remark-plugin-npm2yarn": "^3.5.1",
2121
"@mdx-js/react": "^3.0.1",
22-
"acorn": "8.11.3",
22+
"acorn": "8.12.1",
2323
"clsx": "^2.1.1",
24-
"lucide-react": "^0.383.0",
24+
"lucide-react": "^0.427.0",
2525
"prism-react-renderer": "^2.3.1",
2626
"react": "^18.3.1",
2727
"react-dom": "^18.3.1"
2828
},
2929
"devDependencies": {
30-
"@docusaurus/module-type-aliases": "3.4.0",
31-
"@docusaurus/tsconfig": "^3.4.0",
32-
"autoprefixer": "^10.4.19",
33-
"postcss": "^8.4.38",
34-
"postcss-nested": "^6.0.1",
35-
"tailwindcss": "^3.4.3",
30+
"@docusaurus/module-type-aliases": "3.5.1",
31+
"@docusaurus/tsconfig": "^3.5.1",
32+
"autoprefixer": "^10.4.20",
33+
"postcss": "^8.4.41",
34+
"postcss-nested": "^6.2.0",
35+
"tailwindcss": "^3.4.9",
3636
"tailwindcss-bg-patterns": "^0.3.0",
37-
"typescript": "^5.5.3"
37+
"typescript": "^5.5.4"
3838
},
3939
"browserslist": {
4040
"production": [

‎website/pnpm-lock.yaml

+482-436
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.