any> = T extends (
21 | param: infer P,
22 | ) => infer R
23 | ? Equals extends true
24 | ? () => R
25 | : Equals
extends true
26 | ? (param?: P) => R
27 | : (param: P) => R
28 | : never;
29 |
30 | type InferParserType = T extends StandardSchemaV1
31 | ? TType extends "in"
32 | ? StandardSchemaV1.InferInput
33 | : StandardSchemaV1.InferOutput
34 | : never;
35 |
36 | type InferInputType = T extends UnsetMarker
37 | ? undefined
38 | : InferParserType;
39 |
40 | type InferContextType = RemoveUnsetMarker;
41 |
42 | interface ActionParams {
43 | _input: TInput;
44 | _context: TContext;
45 | }
46 |
47 | interface ActionBuilder {
48 | /**
49 | * Middleware allows you to run code before the action, its return value will pass as context to the action.
50 | */
51 | middleware: (
52 | middleware: () => Promise | TContext,
53 | ) => Omit<
54 | ActionBuilder<{ _input: TParams["_input"]; _context: TContext }>,
55 | "middleware"
56 | >;
57 | /**
58 | * Input validation for the action.
59 | */
60 | input: (
61 | input:
62 | | ((params: {
63 | ctx: InferContextType;
64 | }) => Promise | TParser)
65 | | TParser,
66 | ) => Omit<
67 | ActionBuilder<{ _input: TParser; _context: TParams["_context"] }>,
68 | "input"
69 | >;
70 | /**
71 | * Create an action.
72 | */
73 | action: (
74 | action: (params: {
75 | ctx: InferContextType;
76 | input: InferInputType;
77 | }) => Promise,
78 | ) => SanitizeFunctionParam<
79 | (input: InferInputType) => Promise
80 | >;
81 | /**
82 | * Create an action for React `useActionState`
83 | */
84 | stateAction: (
85 | action: (
86 | params: Prettify<
87 | {
88 | ctx: InferContextType;
89 | prevState: RemoveUnsetMarker;
90 | rawInput: InferInputType;
91 | } & (
92 | | {
93 | input: InferInputType;
94 | inputErrors?: undefined;
95 | }
96 | | {
97 | input?: undefined;
98 | inputErrors: ReturnType;
99 | }
100 | )
101 | >,
102 | ) => Promise,
103 | ) => (
104 | prevState: TState | RemoveUnsetMarker,
105 | input: InferInputType,
106 | ) => Promise>;
107 | /**
108 | * Create an action for React `useActionState`
109 | *
110 | * @deprecated Use `stateAction` instead.
111 | */
112 | formAction: (
113 | action: (
114 | params: Prettify<
115 | {
116 | ctx: InferContextType;
117 | prevState: RemoveUnsetMarker;
118 | formData: FormData;
119 | } & (
120 | | {
121 | input: InferInputType;
122 | formErrors?: undefined;
123 | }
124 | | {
125 | input?: undefined;
126 | formErrors: ReturnType;
127 | }
128 | )
129 | >,
130 | ) => Promise,
131 | ) => (
132 | prevState: TState | RemoveUnsetMarker,
133 | formData: InferInputType,
134 | ) => Promise>;
135 | }
136 | // biome-ignore lint/suspicious/noExplicitAny: Intended
137 | type AnyActionBuilder = ActionBuilder;
138 |
139 | // biome-ignore lint/suspicious/noExplicitAny: Intended
140 | interface ActionBuilderDef> {
141 | input:
142 | | ((params: {
143 | ctx: TParams["_context"];
144 | }) => Promise | TParams["_input"])
145 | | TParams["_input"]
146 | | undefined;
147 | middleware:
148 | | (() => Promise | TParams["_context"])
149 | | undefined;
150 | }
151 | // biome-ignore lint/suspicious/noExplicitAny: Intended
152 | type AnyActionBuilderDef = ActionBuilderDef;
153 |
154 | function createNewServerActionBuilder(def: Partial) {
155 | return createServerActionBuilder(def);
156 | }
157 |
158 | function createServerActionBuilder(
159 | initDef: Partial = {},
160 | ): ActionBuilder<{
161 | _input: UnsetMarker;
162 | _context: UnsetMarker;
163 | }> {
164 | const _def: ActionBuilderDef<{
165 | _input: StandardSchemaV1;
166 | _context: undefined;
167 | }> = {
168 | input: undefined,
169 | middleware: undefined,
170 | ...initDef,
171 | };
172 | return {
173 | middleware: (middleware) =>
174 | createNewServerActionBuilder({ ..._def, middleware }) as AnyActionBuilder,
175 | input: (input) =>
176 | createNewServerActionBuilder({ ..._def, input }) as AnyActionBuilder,
177 | action: (action) => {
178 | // biome-ignore lint/suspicious/noExplicitAny: Intended
179 | return async (input?: any) => {
180 | const ctx = await _def.middleware?.();
181 | if (_def.input) {
182 | const inputSchema =
183 | typeof _def.input === "function"
184 | ? await _def.input({ ctx })
185 | : _def.input;
186 | const result = await standardValidate(inputSchema, input);
187 | if (result.issues) {
188 | throw new SchemaError(result.issues);
189 | }
190 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
191 | return await action({ ctx, input: result.value as any });
192 | }
193 | return await action({ ctx, input: undefined });
194 | };
195 | },
196 | stateAction: (action) => {
197 | // biome-ignore lint/suspicious/noExplicitAny: Intended
198 | return async (prevState, rawInput?: any) => {
199 | const ctx = await _def.middleware?.();
200 | if (_def.input) {
201 | const inputSchema =
202 | typeof _def.input === "function"
203 | ? await _def.input({ ctx })
204 | : _def.input;
205 | const result = await standardValidate(inputSchema, rawInput);
206 | if (result.issues) {
207 | return await action({
208 | ctx,
209 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
210 | prevState: prevState as any,
211 | rawInput,
212 | inputErrors: getInputErrors(result.issues),
213 | });
214 | }
215 | return await action({
216 | ctx,
217 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
218 | prevState: prevState as any,
219 | rawInput,
220 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
221 | input: result.value as any,
222 | });
223 | }
224 | return await action({
225 | ctx,
226 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
227 | prevState: prevState as any,
228 | rawInput,
229 | input: undefined,
230 | });
231 | };
232 | },
233 | formAction: (action) => {
234 | // biome-ignore lint/suspicious/noExplicitAny: Intended
235 | return async (prevState, formData?: any) => {
236 | const ctx = await _def.middleware?.();
237 | if (_def.input) {
238 | const inputSchema =
239 | typeof _def.input === "function"
240 | ? await _def.input({ ctx })
241 | : _def.input;
242 | const result = await standardValidate(inputSchema, formData);
243 | if (result.issues) {
244 | return await action({
245 | ctx,
246 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
247 | prevState: prevState as any,
248 | formData,
249 | formErrors: getInputErrors(result.issues),
250 | });
251 | }
252 | return await action({
253 | ctx,
254 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
255 | prevState: prevState as any,
256 | formData,
257 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
258 | input: result.value as any,
259 | });
260 | }
261 | return await action({
262 | ctx,
263 | // biome-ignore lint/suspicious/noExplicitAny: It's fine
264 | prevState: prevState as any,
265 | formData,
266 | input: undefined,
267 | });
268 | };
269 | },
270 | };
271 | }
272 |
273 | /**
274 | * Server action builder
275 | */
276 | export const serverAct = createServerActionBuilder();
277 |
--------------------------------------------------------------------------------
/packages/server-act/tests/valibot.test.ts:
--------------------------------------------------------------------------------
1 | import * as v from "valibot";
2 | import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest";
3 | import { serverAct } from "../src";
4 | import { formDataToObject } from "../src/utils";
5 |
6 | describe("action", () => {
7 | test("should able to create action with input", async () => {
8 | const action = serverAct
9 | .input(v.string())
10 | .action(async () => Promise.resolve("bar"));
11 |
12 | expectTypeOf(action).toEqualTypeOf<(input: string) => Promise>();
13 |
14 | expect(action.constructor.name).toBe("AsyncFunction");
15 | await expect(action("foo")).resolves.toBe("bar");
16 | });
17 |
18 | test("should able to create action with input and check validation action", async () => {
19 | const action = serverAct
20 | .input(
21 | v.pipe(
22 | v.string(),
23 | v.check((s) => s.startsWith("f")),
24 | ),
25 | )
26 | .action(async () => Promise.resolve("bar"));
27 |
28 | expectTypeOf(action).toEqualTypeOf<(input: string) => Promise>();
29 |
30 | expect(action.constructor.name).toBe("AsyncFunction");
31 | await expect(action("foo")).resolves.toBe("bar");
32 | });
33 |
34 | test("should able to create action with optional input", async () => {
35 | const action = serverAct
36 | .input(v.optional(v.string()))
37 | .action(async ({ input }) => Promise.resolve(input ?? "bar"));
38 |
39 | expectTypeOf(action).toEqualTypeOf<(input?: string) => Promise>();
40 |
41 | expect(action.constructor.name).toBe("AsyncFunction");
42 | await expect(action("foo")).resolves.toBe("foo");
43 | await expect(action()).resolves.toBe("bar");
44 | });
45 |
46 | test("should throw error if the input is invalid", async () => {
47 | const action = serverAct
48 | .input(v.string())
49 | .action(async () => Promise.resolve("bar"));
50 |
51 | expectTypeOf(action).toEqualTypeOf<(input: string) => Promise>();
52 |
53 | expect(action.constructor.name).toBe("AsyncFunction");
54 | // @ts-expect-error
55 | await expect(action(1)).rejects.toThrowError();
56 | });
57 |
58 | describe("middleware should be called once", () => {
59 | const middlewareSpy = vi.fn(() => {
60 | return { prefix: "best" };
61 | });
62 |
63 | beforeEach(() => {
64 | vi.restoreAllMocks();
65 | });
66 |
67 | test("without input", async () => {
68 | const action = serverAct
69 | .middleware(middlewareSpy)
70 | .action(async ({ ctx }) => Promise.resolve(`${ctx.prefix}-bar`));
71 |
72 | expectTypeOf(action).toEqualTypeOf<() => Promise>();
73 |
74 | expect(action.constructor.name).toBe("AsyncFunction");
75 | await expect(action()).resolves.toBe("best-bar");
76 | expect(middlewareSpy).toBeCalledTimes(1);
77 | });
78 |
79 | test("with input", async () => {
80 | const action = serverAct
81 | .middleware(middlewareSpy)
82 | .input(v.string())
83 | .action(async ({ ctx, input }) =>
84 | Promise.resolve(`${ctx.prefix}-${input}-bar`),
85 | );
86 |
87 | expectTypeOf(action).toEqualTypeOf<(param: string) => Promise>();
88 |
89 | expect(action.constructor.name).toBe("AsyncFunction");
90 | await expect(action("foo")).resolves.toBe("best-foo-bar");
91 | expect(middlewareSpy).toBeCalledTimes(1);
92 | });
93 | });
94 |
95 | test("should able to access middleware context in input", async () => {
96 | const action = serverAct
97 | .middleware(() => ({ prefix: "best" }))
98 | .input(({ ctx }) =>
99 | v.pipe(
100 | v.string(),
101 | v.transform((v) => `${ctx.prefix}-${v}`),
102 | ),
103 | )
104 | .action(async ({ ctx, input }) => {
105 | return Promise.resolve(`${input}-${ctx.prefix}-bar`);
106 | });
107 |
108 | expectTypeOf(action).toEqualTypeOf<(param: string) => Promise>();
109 |
110 | expect(action.constructor.name).toBe("AsyncFunction");
111 |
112 | await expect(action("foo")).resolves.toBe("best-foo-best-bar");
113 | });
114 | });
115 |
116 | describe("stateAction", () => {
117 | test("should able to create action with input", async () => {
118 | const action = serverAct
119 | .input(v.object({ foo: v.string() }))
120 | .stateAction(async () => Promise.resolve("bar"));
121 |
122 | expectTypeOf(action).toEqualTypeOf<
123 | (
124 | prevState: string | undefined,
125 | input: { foo: string },
126 | ) => Promise
127 | >();
128 |
129 | expect(action.constructor.name).toBe("AsyncFunction");
130 | await expect(action("foo", { foo: "bar" })).resolves.toMatchObject("bar");
131 | });
132 |
133 | test("should able to work with `formDataToObject`", async () => {
134 | const action = serverAct
135 | .input(
136 | v.pipe(
137 | v.custom((value) => value instanceof FormData),
138 | v.transform(formDataToObject),
139 | v.object({ foo: v.string() }),
140 | ),
141 | )
142 | .stateAction(async ({ input, inputErrors }) => {
143 | if (inputErrors) {
144 | return inputErrors;
145 | }
146 | return Promise.resolve(input.foo);
147 | });
148 |
149 | type State =
150 | | string
151 | | { messages: string[]; fieldErrors: Record };
152 | expectTypeOf(action).toEqualTypeOf<
153 | (
154 | prevState: State | undefined,
155 | input: FormData,
156 | ) => Promise
157 | >();
158 |
159 | expect(action.constructor.name).toBe("AsyncFunction");
160 |
161 | const formData = new FormData();
162 | formData.append("foo", "bar");
163 | await expect(action("foo", formData)).resolves.toMatchObject("bar");
164 | });
165 |
166 | test("should return input errors if the input is invalid", async () => {
167 | const action = serverAct
168 | .input(v.object({ foo: v.string() }))
169 | .stateAction(async ({ inputErrors }) => {
170 | if (inputErrors) {
171 | return inputErrors;
172 | }
173 | return Promise.resolve("bar");
174 | });
175 |
176 | type State =
177 | | string
178 | | { messages: string[]; fieldErrors: Record };
179 | expectTypeOf(action).toEqualTypeOf<
180 | (
181 | prevState: State | undefined,
182 | input: { foo: string },
183 | ) => Promise
184 | >();
185 |
186 | expect(action.constructor.name).toBe("AsyncFunction");
187 |
188 | // @ts-expect-error
189 | const result = await action("foo", { bar: "foo" });
190 | expect(result).toHaveProperty("fieldErrors.foo");
191 | });
192 |
193 | test("should able to access middleware context", async () => {
194 | const action = serverAct
195 | .middleware(() => ({ prefix: "best" }))
196 | .input(({ ctx }) =>
197 | v.object({
198 | foo: v.pipe(
199 | v.string(),
200 | v.transform((v) => `${ctx.prefix}-${v}`),
201 | ),
202 | }),
203 | )
204 | .stateAction(async ({ ctx, inputErrors, input }) => {
205 | if (inputErrors) {
206 | return inputErrors;
207 | }
208 | return Promise.resolve(`${input.foo}-${ctx.prefix}-bar`);
209 | });
210 |
211 | type State =
212 | | string
213 | | { messages: string[]; fieldErrors: Record };
214 | expectTypeOf(action).toEqualTypeOf<
215 | (
216 | prevState: State | undefined,
217 | input: { foo: string },
218 | ) => Promise
219 | >();
220 |
221 | expect(action.constructor.name).toBe("AsyncFunction");
222 | await expect(action("foo", { foo: "bar" })).resolves.toMatchObject(
223 | "best-bar-best-bar",
224 | );
225 | });
226 | });
227 |
228 | describe("formAction", () => {
229 | test("should able to create form action with input", async () => {
230 | const action = serverAct
231 | .input(v.object({ foo: v.string() }))
232 | .formAction(async () => Promise.resolve("bar"));
233 |
234 | expectTypeOf(action).toEqualTypeOf<
235 | (
236 | prevState: string | undefined,
237 | formData: { foo: string },
238 | ) => Promise
239 | >();
240 |
241 | expect(action.constructor.name).toBe("AsyncFunction");
242 | await expect(action("foo", { foo: "bar" })).resolves.toMatchObject("bar");
243 | });
244 |
245 | test("should return form errors if the input is invalid", async () => {
246 | const action = serverAct
247 | .input(v.object({ foo: v.string() }))
248 | .formAction(async ({ formErrors }) => {
249 | if (formErrors) {
250 | return formErrors;
251 | }
252 | return Promise.resolve("bar");
253 | });
254 |
255 | type State =
256 | | string
257 | | { messages: string[]; fieldErrors: Record };
258 | expectTypeOf(action).toEqualTypeOf<
259 | (
260 | prevState: State | undefined,
261 | formData: { foo: string },
262 | ) => Promise
263 | >();
264 |
265 | expect(action.constructor.name).toBe("AsyncFunction");
266 |
267 | // @ts-expect-error
268 | const result = await action("foo", { bar: "foo" });
269 | expect(result).toHaveProperty("fieldErrors.foo");
270 | });
271 |
272 | test("should able to access middleware context", async () => {
273 | const action = serverAct
274 | .middleware(() => ({ prefix: "best" }))
275 | .input(({ ctx }) =>
276 | v.object({
277 | foo: v.pipe(
278 | v.string(),
279 | v.transform((v) => `${ctx.prefix}-${v}`),
280 | ),
281 | }),
282 | )
283 | .formAction(async ({ ctx, formErrors, input }) => {
284 | if (formErrors) {
285 | return formErrors;
286 | }
287 | return Promise.resolve(`${input.foo}-${ctx.prefix}-bar`);
288 | });
289 |
290 | type State =
291 | | string
292 | | { messages: string[]; fieldErrors: Record };
293 | expectTypeOf(action).toEqualTypeOf<
294 | (
295 | prevState: State | undefined,
296 | formData: { foo: string },
297 | ) => Promise
298 | >();
299 |
300 | expect(action.constructor.name).toBe("AsyncFunction");
301 | await expect(action("foo", { foo: "bar" })).resolves.toMatchObject(
302 | "best-bar-best-bar",
303 | );
304 | });
305 | });
306 |
--------------------------------------------------------------------------------
/packages/server-act/tests/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { formDataToObject } from "../src/utils";
3 |
4 | describe("formDataToObject", () => {
5 | test("should handle simple key-value pairs", () => {
6 | const formData = new FormData();
7 | formData.append("name", "John");
8 | formData.append("email", "john@example.com");
9 | formData.append("age", "30");
10 |
11 | const result = formDataToObject(formData);
12 |
13 | expect(result).toEqual({
14 | name: "John",
15 | email: "john@example.com",
16 | age: "30",
17 | });
18 | });
19 |
20 | test("should handle nested objects with dot notation", () => {
21 | const formData = new FormData();
22 | formData.append("user.name", "Alice");
23 | formData.append("user.email", "alice@example.com");
24 | formData.append("user.profile.bio", "Software Engineer");
25 |
26 | const result = formDataToObject(formData);
27 |
28 | expect(result).toEqual({
29 | user: {
30 | name: "Alice",
31 | email: "alice@example.com",
32 | profile: {
33 | bio: "Software Engineer",
34 | },
35 | },
36 | });
37 | });
38 |
39 | test("should handle array notation with bracket indices", () => {
40 | const formData = new FormData();
41 | formData.append("items[0]", "apple");
42 | formData.append("items[1]", "banana");
43 | formData.append("items[2]", "cherry");
44 |
45 | const result = formDataToObject(formData);
46 |
47 | expect(result).toEqual({
48 | items: ["apple", "banana", "cherry"],
49 | });
50 | });
51 |
52 | test("should handle mixed nested objects and arrays", () => {
53 | const formData = new FormData();
54 | formData.append("users[0].name", "John");
55 | formData.append("users[0].age", "25");
56 | formData.append("users[1].name", "Jane");
57 | formData.append("users[1].age", "30");
58 | formData.append("users[0].hobbies[0]", "reading");
59 | formData.append("users[0].hobbies[1]", "gaming");
60 |
61 | const result = formDataToObject(formData);
62 |
63 | expect(result).toEqual({
64 | users: [
65 | {
66 | name: "John",
67 | age: "25",
68 | hobbies: ["reading", "gaming"],
69 | },
70 | {
71 | name: "Jane",
72 | age: "30",
73 | },
74 | ],
75 | });
76 | });
77 |
78 | test("should handle empty FormData", () => {
79 | const formData = new FormData();
80 | const result = formDataToObject(formData);
81 |
82 | expect(result).toEqual({});
83 | });
84 |
85 | test("should handle File objects", () => {
86 | const formData = new FormData();
87 | const file = new File(["content"], "test.txt", { type: "text/plain" });
88 | formData.append("document", file);
89 | formData.append("user.avatar", file);
90 |
91 | const result = formDataToObject(formData);
92 |
93 | expect(result.document).toBe(file);
94 | expect((result.user as Record).avatar).toBe(file);
95 | });
96 |
97 | test("should handle multiple values with same key (creates array)", () => {
98 | const formData = new FormData();
99 | formData.append("name", "first");
100 | formData.append("name", "second");
101 | formData.append("name", "third");
102 |
103 | const result = formDataToObject(formData);
104 |
105 | expect(result).toEqual({
106 | name: ["first", "second", "third"],
107 | });
108 | });
109 |
110 | test("should handle complex nested structures", () => {
111 | const formData = new FormData();
112 | formData.append("form.sections[0].title", "Personal Info");
113 | formData.append("form.sections[0].fields[0].name", "firstName");
114 | formData.append("form.sections[0].fields[0].value", "John");
115 | formData.append("form.sections[0].fields[1].name", "lastName");
116 | formData.append("form.sections[0].fields[1].value", "Doe");
117 | formData.append("form.sections[1].title", "Contact");
118 | formData.append("form.sections[1].fields[0].name", "email");
119 | formData.append("form.sections[1].fields[0].value", "john.doe@example.com");
120 |
121 | const result = formDataToObject(formData);
122 |
123 | expect(result).toEqual({
124 | form: {
125 | sections: [
126 | {
127 | title: "Personal Info",
128 | fields: [
129 | { name: "firstName", value: "John" },
130 | { name: "lastName", value: "Doe" },
131 | ],
132 | },
133 | {
134 | title: "Contact",
135 | fields: [{ name: "email", value: "john.doe@example.com" }],
136 | },
137 | ],
138 | },
139 | });
140 | });
141 |
142 | test("should handle mixed bracket and dot notation", () => {
143 | const formData = new FormData();
144 | formData.append("data[key].nested", "value1");
145 | formData.append("data.key[0]", "value2");
146 | formData.append("mixed[0].prop.sub[1]", "value3");
147 |
148 | const result = formDataToObject(formData);
149 |
150 | expect(result).toEqual({
151 | data: {
152 | key: {
153 | nested: "value1",
154 | "0": "value2",
155 | },
156 | },
157 | mixed: [
158 | {
159 | prop: {
160 | sub: [undefined, "value3"],
161 | },
162 | },
163 | ],
164 | });
165 | });
166 |
167 | test("should handle array-to-object conversion when non-numeric key follows", () => {
168 | const formData = new FormData();
169 | formData.append("items[0]", "first");
170 | formData.append("items[1]", "second");
171 | formData.append("items.length", "2");
172 |
173 | const result = formDataToObject(formData);
174 |
175 | expect(result).toEqual({
176 | items: {
177 | "0": "first",
178 | "1": "second",
179 | length: "2",
180 | },
181 | });
182 | });
183 |
184 | test("should handle keys with special characters in brackets", () => {
185 | const formData = new FormData();
186 | formData.append("data[key-with-dash]", "value1");
187 | formData.append("data[key_with_underscore]", "value2");
188 | formData.append("data[key with spaces]", "value3");
189 |
190 | const result = formDataToObject(formData);
191 |
192 | expect(result).toEqual({
193 | data: {
194 | "key-with-dash": "value1",
195 | key_with_underscore: "value2",
196 | "key with spaces": "value3",
197 | },
198 | });
199 | });
200 |
201 | test("should handle deeply nested array structures", () => {
202 | const formData = new FormData();
203 | formData.append("matrix[0][0]", "a");
204 | formData.append("matrix[0][1]", "b");
205 | formData.append("matrix[1][0]", "c");
206 | formData.append("matrix[1][1]", "d");
207 |
208 | const result = formDataToObject(formData);
209 |
210 | expect(result).toEqual({
211 | matrix: [
212 | ["a", "b"],
213 | ["c", "d"],
214 | ],
215 | });
216 | });
217 |
218 | test("should handle multiple values with nested paths", () => {
219 | const formData = new FormData();
220 | formData.append("users[0].tags", "frontend");
221 | formData.append("users[0].tags", "react");
222 | formData.append("users[0].tags", "typescript");
223 |
224 | const result = formDataToObject(formData);
225 |
226 | expect(result).toEqual({
227 | users: [
228 | {
229 | tags: ["frontend", "react", "typescript"],
230 | },
231 | ],
232 | });
233 | });
234 |
235 | test("should handle empty bracket notation", () => {
236 | const formData = new FormData();
237 | formData.append("items[]", "first");
238 | formData.append("items[]", "second");
239 | formData.append("items[]", "third");
240 |
241 | const result = formDataToObject(formData);
242 |
243 | expect(result).toEqual({
244 | items: ["first", "second", "third"],
245 | });
246 | });
247 |
248 | test("should handle mixed data types with File objects in arrays", () => {
249 | const formData = new FormData();
250 | const file1 = new File(["content1"], "file1.txt", { type: "text/plain" });
251 | const file2 = new File(["content2"], "file2.txt", { type: "text/plain" });
252 |
253 | formData.append("uploads[0].file", file1);
254 | formData.append("uploads[0].name", "First File");
255 | formData.append("uploads[1].file", file2);
256 | formData.append("uploads[1].name", "Second File");
257 |
258 | const result = formDataToObject(formData);
259 |
260 | expect(result).toEqual({
261 | uploads: [
262 | {
263 | file: file1,
264 | name: "First File",
265 | },
266 | {
267 | file: file2,
268 | name: "Second File",
269 | },
270 | ],
271 | });
272 | });
273 |
274 | test("should handle string values that look like array indices", () => {
275 | const formData = new FormData();
276 | formData.append("data.0", "value0");
277 | formData.append("data.1", "value1");
278 | formData.append("data.10", "value10");
279 |
280 | const result = formDataToObject(formData);
281 |
282 | expect(result).toEqual({
283 | data: [
284 | "value0",
285 | "value1",
286 | undefined,
287 | undefined,
288 | undefined,
289 | undefined,
290 | undefined,
291 | undefined,
292 | undefined,
293 | undefined,
294 | "value10",
295 | ],
296 | });
297 | });
298 |
299 | test("should handle empty string values", () => {
300 | const formData = new FormData();
301 | formData.append("empty", "");
302 | formData.append("nested.empty", "");
303 | formData.append("array[0]", "");
304 |
305 | const result = formDataToObject(formData);
306 |
307 | expect(result).toEqual({
308 | empty: "",
309 | nested: {
310 | empty: "",
311 | },
312 | array: [""],
313 | });
314 | });
315 |
316 | test("should handle complex key patterns", () => {
317 | const formData = new FormData();
318 | formData.append("a.b.c.d.e", "deep");
319 | formData.append("x[0][1][2]", "nested-array");
320 | formData.append("mixed[0].deep.array[1]", "complex");
321 |
322 | const result = formDataToObject(formData);
323 |
324 | expect(result).toEqual({
325 | a: {
326 | b: {
327 | c: {
328 | d: {
329 | e: "deep",
330 | },
331 | },
332 | },
333 | },
334 | x: [[undefined, [undefined, undefined, "nested-array"]]],
335 | mixed: [
336 | {
337 | deep: {
338 | array: [undefined, "complex"],
339 | },
340 | },
341 | ],
342 | });
343 | });
344 |
345 | test("should handle whitespace in values", () => {
346 | const formData = new FormData();
347 | formData.append("text", " spaced ");
348 | formData.append("multiline", "line1\nline2\nline3");
349 | formData.append("tabs", "value\twith\ttabs");
350 |
351 | const result = formDataToObject(formData);
352 |
353 | expect(result).toEqual({
354 | text: " spaced ",
355 | multiline: "line1\nline2\nline3",
356 | tabs: "value\twith\ttabs",
357 | });
358 | });
359 |
360 | test("should handle single character keys", () => {
361 | const formData = new FormData();
362 | formData.append("a", "value-a");
363 | formData.append("b[0]", "value-b0");
364 | formData.append("c.d", "value-cd");
365 |
366 | const result = formDataToObject(formData);
367 |
368 | expect(result).toEqual({
369 | a: "value-a",
370 | b: ["value-b0"],
371 | c: {
372 | d: "value-cd",
373 | },
374 | });
375 | });
376 | });
377 |
--------------------------------------------------------------------------------
/packages/server-act/tests/zod.test.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach, describe, expect, expectTypeOf, test, vi } from "vitest";
2 | import { z } from "zod";
3 | import { serverAct } from "../src";
4 | import { formDataToObject } from "../src/utils";
5 |
6 | function zodFormData(
7 | schema: T,
8 | ): z.ZodPipe, FormData>, T> {
9 | return z.preprocess, T, FormData>(
10 | (v) => formDataToObject(v),
11 | schema,
12 | );
13 | }
14 |
15 | describe("action", () => {
16 | test("should able to create action without input", async () => {
17 | const action = serverAct.action(async () => Promise.resolve("bar"));
18 |
19 | expectTypeOf(action).toEqualTypeOf<() => Promise>();
20 |
21 | expect(action.constructor.name).toBe("AsyncFunction");
22 | await expect(action()).resolves.toBe("bar");
23 | });
24 |
25 | test("should able to create action with input", async () => {
26 | const action = serverAct
27 | .input(z.string())
28 | .action(async () => Promise.resolve("bar"));
29 |
30 | expectTypeOf(action).toEqualTypeOf<(input: string) => Promise>();
31 |
32 | expect(action.constructor.name).toBe("AsyncFunction");
33 | await expect(action("foo")).resolves.toBe("bar");
34 | });
35 |
36 | test("should able to create action with input and zod refinement", async () => {
37 | const action = serverAct
38 | .input(z.string().refine((s) => s.startsWith("f")))
39 | .action(async () => Promise.resolve("bar"));
40 |
41 | expectTypeOf(action).toEqualTypeOf<(input: string) => Promise>();
42 |
43 | expect(action.constructor.name).toBe("AsyncFunction");
44 | await expect(action("foo")).resolves.toBe("bar");
45 | });
46 |
47 | test("should able to create action with optional input", async () => {
48 | const action = serverAct
49 | .input(z.string().optional())
50 | .action(async ({ input }) => Promise.resolve(input ?? "bar"));
51 |
52 | expectTypeOf(action).toEqualTypeOf<(input?: string) => Promise>();
53 |
54 | expect(action.constructor.name).toBe("AsyncFunction");
55 | await expect(action("foo")).resolves.toBe("foo");
56 | await expect(action()).resolves.toBe("bar");
57 | });
58 |
59 | test("should throw error if the input is invalid", async () => {
60 | const action = serverAct
61 | .input(z.string())
62 | .action(async () => Promise.resolve("bar"));
63 |
64 | expectTypeOf(action).toEqualTypeOf<(input: string) => Promise>();
65 |
66 | expect(action.constructor.name).toBe("AsyncFunction");
67 | // @ts-expect-error
68 | await expect(action(1)).rejects.toThrowError();
69 | });
70 |
71 | describe("middleware should be called once", () => {
72 | const middlewareSpy = vi.fn(() => {
73 | return { prefix: "best" };
74 | });
75 |
76 | beforeEach(() => {
77 | vi.restoreAllMocks();
78 | });
79 |
80 | test("without input", async () => {
81 | const action = serverAct
82 | .middleware(middlewareSpy)
83 | .action(async ({ ctx }) => Promise.resolve(`${ctx.prefix}-bar`));
84 |
85 | expectTypeOf(action).toEqualTypeOf<() => Promise>();
86 |
87 | expect(action.constructor.name).toBe("AsyncFunction");
88 | await expect(action()).resolves.toBe("best-bar");
89 | expect(middlewareSpy).toBeCalledTimes(1);
90 | });
91 |
92 | test("with input", async () => {
93 | const action = serverAct
94 | .middleware(middlewareSpy)
95 | .input(z.string())
96 | .action(async ({ ctx, input }) =>
97 | Promise.resolve(`${ctx.prefix}-${input}-bar`),
98 | );
99 |
100 | expectTypeOf(action).toEqualTypeOf<(param: string) => Promise>();
101 |
102 | expect(action.constructor.name).toBe("AsyncFunction");
103 | await expect(action("foo")).resolves.toBe("best-foo-bar");
104 | expect(middlewareSpy).toBeCalledTimes(1);
105 | });
106 | });
107 |
108 | test("should able to access middleware context in input", async () => {
109 | const action = serverAct
110 | .middleware(() => ({ prefix: "best" }))
111 | .input(({ ctx }) => z.string().transform((v) => `${ctx.prefix}-${v}`))
112 | .action(async ({ ctx, input }) => {
113 | return Promise.resolve(`${input}-${ctx.prefix}-bar`);
114 | });
115 |
116 | expectTypeOf(action).toEqualTypeOf<(param: string) => Promise>();
117 |
118 | expect(action.constructor.name).toBe("AsyncFunction");
119 |
120 | await expect(action("foo")).resolves.toBe("best-foo-best-bar");
121 | });
122 | });
123 |
124 | describe("stateAction", () => {
125 | test("should able to create action without input", async () => {
126 | const action = serverAct.stateAction(async () => Promise.resolve("bar"));
127 |
128 | expectTypeOf(action).toEqualTypeOf<
129 | (
130 | prevState: string | undefined,
131 | input: undefined,
132 | ) => Promise
133 | >();
134 |
135 | expect(action.constructor.name).toBe("AsyncFunction");
136 |
137 | await expect(action("foo", undefined)).resolves.toMatchObject("bar");
138 | });
139 |
140 | test("should able to create action with input", async () => {
141 | const action = serverAct
142 | .input(z.object({ foo: z.string() }))
143 | .stateAction(async () => Promise.resolve("bar"));
144 |
145 | expectTypeOf(action).toEqualTypeOf<
146 | (
147 | prevState: string | undefined,
148 | input: { foo: string },
149 | ) => Promise
150 | >();
151 |
152 | expect(action.constructor.name).toBe("AsyncFunction");
153 | await expect(action("foo", { foo: "bar" })).resolves.toMatchObject("bar");
154 | });
155 |
156 | test("should return input errors if the input is invalid", async () => {
157 | const action = serverAct
158 | .input(z.object({ foo: z.string({ error: "Required" }) }))
159 | .stateAction(async ({ inputErrors }) => {
160 | if (inputErrors) {
161 | return inputErrors;
162 | }
163 | return Promise.resolve("bar");
164 | });
165 |
166 | type State =
167 | | string
168 | | { messages: string[]; fieldErrors: Record };
169 | expectTypeOf(action).toEqualTypeOf<
170 | (
171 | prevState: State | undefined,
172 | input: { foo: string },
173 | ) => Promise
174 | >();
175 |
176 | expect(action.constructor.name).toBe("AsyncFunction");
177 |
178 | // @ts-expect-error
179 | const result = await action("foo", { bar: "foo" });
180 | expect(result).toHaveProperty("fieldErrors.foo", ["Required"]);
181 | });
182 |
183 | test("should able to work with `formDataToObject`", async () => {
184 | const action = serverAct
185 | .input(
186 | zodFormData(
187 | z.object({
188 | list: z.array(z.object({ foo: z.string() })),
189 | }),
190 | ),
191 | )
192 | .stateAction(async ({ inputErrors, input }) => {
193 | if (inputErrors) {
194 | return inputErrors;
195 | }
196 | return Promise.resolve(
197 | `${input.list.map((item) => item.foo).join(",")}`,
198 | );
199 | });
200 |
201 | type State =
202 | | string
203 | | { messages: string[]; fieldErrors: Record };
204 | expectTypeOf(action).toEqualTypeOf<
205 | (
206 | prevState: State | undefined,
207 | input: FormData,
208 | ) => Promise
209 | >();
210 |
211 | expect(action.constructor.name).toBe("AsyncFunction");
212 |
213 | const formData = new FormData();
214 | formData.append("list.0.foo", "1");
215 | formData.append("list.1.foo", "2");
216 |
217 | const result = await action(undefined, formData);
218 | expect(result).toBe("1,2");
219 | });
220 |
221 | test("should return a correct input errors with `formDataToObject`", async () => {
222 | const action = serverAct
223 | .input(
224 | zodFormData(
225 | z.object({
226 | list: z.array(
227 | z.object({ foo: z.string().min(1, { error: "Required" }) }),
228 | ),
229 | }),
230 | ),
231 | )
232 | .stateAction(async ({ inputErrors }) => {
233 | if (inputErrors) {
234 | return inputErrors;
235 | }
236 | return Promise.resolve("bar");
237 | });
238 |
239 | type State =
240 | | string
241 | | { messages: string[]; fieldErrors: Record };
242 | expectTypeOf(action).toEqualTypeOf<
243 | (
244 | prevState: State | undefined,
245 | input: FormData,
246 | ) => Promise
247 | >();
248 |
249 | expect(action.constructor.name).toBe("AsyncFunction");
250 |
251 | const formData = new FormData();
252 | formData.append("list.0.foo", "");
253 |
254 | const result = await action(undefined, formData);
255 | expect(result).toHaveProperty("fieldErrors", {
256 | "list.0.foo": ["Required"],
257 | });
258 | });
259 |
260 | test("should able to access middleware context", async () => {
261 | const action = serverAct
262 | .middleware(() => ({ prefix: "best" }))
263 | .input(({ ctx }) =>
264 | z.object({
265 | foo: z.string().transform((v) => `${ctx.prefix}-${v}`),
266 | }),
267 | )
268 | .stateAction(async ({ ctx, inputErrors, input }) => {
269 | if (inputErrors) {
270 | return inputErrors;
271 | }
272 | return Promise.resolve(`${input.foo}-${ctx.prefix}-bar`);
273 | });
274 |
275 | type State =
276 | | string
277 | | { messages: string[]; fieldErrors: Record };
278 | expectTypeOf(action).toEqualTypeOf<
279 | (
280 | prevState: State | undefined,
281 | input: { foo: string },
282 | ) => Promise
283 | >();
284 |
285 | expect(action.constructor.name).toBe("AsyncFunction");
286 | await expect(action("foo", { foo: "bar" })).resolves.toMatchObject(
287 | "best-bar-best-bar",
288 | );
289 | });
290 |
291 | test("should able to infer the state correctly if `prevState` is being accessed", async () => {
292 | const action = serverAct.stateAction(async ({ prevState }) => {
293 | if (prevState == null) {
294 | return Promise.resolve("foo");
295 | }
296 | return Promise.resolve("bar");
297 | });
298 |
299 | expectTypeOf(action).toEqualTypeOf<
300 | (
301 | prevState: string | undefined,
302 | input: undefined,
303 | ) => Promise
304 | >();
305 |
306 | expect(action.constructor.name).toBe("AsyncFunction");
307 |
308 | await expect(action(undefined, undefined)).resolves.toMatchObject("foo");
309 | });
310 |
311 | test("should able to infer the state correctly if `prevState` is being typed", async () => {
312 | const action = serverAct.stateAction(
313 | async ({ prevState }) => {
314 | if (typeof prevState === "number") {
315 | return Promise.resolve("foo");
316 | }
317 | return Promise.resolve("bar");
318 | },
319 | );
320 |
321 | expectTypeOf(action).toEqualTypeOf<
322 | (
323 | prevState: string | number,
324 | formData: undefined,
325 | ) => Promise
326 | >();
327 |
328 | expect(action.constructor.name).toBe("AsyncFunction");
329 |
330 | await expect(action(123, undefined)).resolves.toMatchObject("foo");
331 | });
332 | });
333 |
334 | describe("formAction", () => {
335 | test("should able to create form action without input", async () => {
336 | const action = serverAct.formAction(async () => Promise.resolve("bar"));
337 |
338 | expectTypeOf(action).toEqualTypeOf<
339 | (
340 | prevState: string | undefined,
341 | formData: undefined,
342 | ) => Promise
343 | >();
344 |
345 | expect(action.constructor.name).toBe("AsyncFunction");
346 |
347 | await expect(action("foo", undefined)).resolves.toMatchObject("bar");
348 | });
349 |
350 | test("should able to create form action with input", async () => {
351 | const action = serverAct
352 | .input(zodFormData(z.object({ foo: z.string() })))
353 | .formAction(async () => Promise.resolve("bar"));
354 |
355 | expectTypeOf(action).toEqualTypeOf<
356 | (
357 | prevState: string | undefined,
358 | formData: FormData,
359 | ) => Promise
360 | >();
361 |
362 | expect(action.constructor.name).toBe("AsyncFunction");
363 |
364 | const formData = new FormData();
365 | formData.append("foo", "bar");
366 | await expect(action("foo", formData)).resolves.toMatchObject("bar");
367 | });
368 |
369 | test("should return form errors if the input is invalid", async () => {
370 | const action = serverAct
371 | .input(zodFormData(z.object({ foo: z.string({ error: "Required" }) })))
372 | .formAction(async ({ formErrors }) => {
373 | if (formErrors) {
374 | return formErrors;
375 | }
376 | return Promise.resolve("bar");
377 | });
378 |
379 | type State =
380 | | string
381 | | { messages: string[]; fieldErrors: Record };
382 | expectTypeOf(action).toEqualTypeOf<
383 | (
384 | prevState: State | undefined,
385 | formData: FormData,
386 | ) => Promise
387 | >();
388 |
389 | expect(action.constructor.name).toBe("AsyncFunction");
390 |
391 | const formData = new FormData();
392 | formData.append("bar", "foo");
393 |
394 | const result = await action("foo", formData);
395 | expect(result).toHaveProperty("fieldErrors.foo", ["Required"]);
396 | });
397 |
398 | test("should return a correct form errors with dotpath", async () => {
399 | const action = serverAct
400 | .input(
401 | zodFormData(
402 | z.object({
403 | list: z.array(
404 | z.object({ foo: z.string().min(1, { message: "Required" }) }),
405 | ),
406 | }),
407 | ),
408 | )
409 | .formAction(async ({ formErrors }) => {
410 | if (formErrors) {
411 | return formErrors;
412 | }
413 | return Promise.resolve("bar");
414 | });
415 |
416 | type State =
417 | | string
418 | | { messages: string[]; fieldErrors: Record };
419 | expectTypeOf(action).toEqualTypeOf<
420 | (
421 | prevState: State | undefined,
422 | input: FormData,
423 | ) => Promise
424 | >();
425 |
426 | expect(action.constructor.name).toBe("AsyncFunction");
427 |
428 | const formData = new FormData();
429 | formData.append("list.0.foo", "");
430 |
431 | const result = await action(undefined, formData);
432 | expect(result).toHaveProperty("fieldErrors", {
433 | "list.0.foo": ["Required"],
434 | });
435 | });
436 |
437 | test("should able to access middleware context", async () => {
438 | const action = serverAct
439 | .middleware(() => ({ prefix: "best" }))
440 | .input(({ ctx }) =>
441 | zodFormData(
442 | z.object({
443 | foo: z.string().transform((v) => `${ctx.prefix}-${v}`),
444 | }),
445 | ),
446 | )
447 | .formAction(async ({ ctx, formErrors, input }) => {
448 | if (formErrors) {
449 | return formErrors;
450 | }
451 | return Promise.resolve(`${input.foo}-${ctx.prefix}-bar`);
452 | });
453 |
454 | type State =
455 | | string
456 | | { messages: string[]; fieldErrors: Record };
457 | expectTypeOf(action).toEqualTypeOf<
458 | (
459 | prevState: State | undefined,
460 | formData: FormData,
461 | ) => Promise
462 | >();
463 |
464 | expect(action.constructor.name).toBe("AsyncFunction");
465 |
466 | const formData = new FormData();
467 | formData.append("foo", "bar");
468 | await expect(action("foo", formData)).resolves.toMatchObject(
469 | "best-bar-best-bar",
470 | );
471 | });
472 |
473 | test("should able to infer the state correctly if `prevState` is being accessed", async () => {
474 | const action = serverAct.formAction(async ({ prevState }) => {
475 | if (prevState == null) {
476 | return Promise.resolve("foo");
477 | }
478 | return Promise.resolve("bar");
479 | });
480 |
481 | expectTypeOf(action).toEqualTypeOf<
482 | (
483 | prevState: string | undefined,
484 | formData: undefined,
485 | ) => Promise
486 | >();
487 |
488 | expect(action.constructor.name).toBe("AsyncFunction");
489 |
490 | await expect(action(undefined, undefined)).resolves.toMatchObject("foo");
491 | });
492 |
493 | test("should able to infer the state correctly if `prevState` is being typed", async () => {
494 | const action = serverAct.formAction(
495 | async ({ prevState }) => {
496 | if (typeof prevState === "number") {
497 | return Promise.resolve("foo");
498 | }
499 | return Promise.resolve("bar");
500 | },
501 | );
502 |
503 | expectTypeOf(action).toEqualTypeOf<
504 | (
505 | prevState: string | number,
506 | formData: undefined,
507 | ) => Promise
508 | >();
509 |
510 | expect(action.constructor.name).toBe("AsyncFunction");
511 |
512 | await expect(action(123, undefined)).resolves.toMatchObject("foo");
513 | });
514 | });
515 |
--------------------------------------------------------------------------------