├── .node-version ├── README.md ├── pnpm-workspace.yaml ├── .github ├── FUNDING.yml ├── scripts │ └── cleanup-package-json.mjs └── workflows │ ├── ci.yml │ └── release.yml ├── examples └── nextjs │ ├── postcss.config.js │ ├── next.config.ts │ ├── src │ └── app │ │ ├── page.tsx │ │ ├── layout.tsx │ │ ├── globals.css │ │ ├── action │ │ ├── _actions.ts │ │ └── page.tsx │ │ └── form-action │ │ ├── _actions.ts │ │ └── page.tsx │ ├── .gitignore │ ├── package.json │ ├── tsconfig.json │ └── README.md ├── packages └── server-act │ ├── src │ ├── internal │ │ ├── assert.ts │ │ └── schema.ts │ ├── utils.ts │ └── index.ts │ ├── tsdown.config.ts │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ ├── CHANGELOG.md │ └── tests │ ├── valibot.test.ts │ ├── utils.test.ts │ └── zod.test.ts ├── .vscode └── settings.json ├── .zed ├── tasks.json └── settings.json ├── .changeset ├── config.json └── README.md ├── turbo.json ├── .gitignore ├── biome.json ├── package.json └── LICENSE /.node-version: -------------------------------------------------------------------------------- 1 | v24.12.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/server-act/README.md -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "examples/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [chungweileong94] 4 | -------------------------------------------------------------------------------- /examples/nextjs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = {}; 4 | 5 | export default nextConfig; 6 | -------------------------------------------------------------------------------- /packages/server-act/src/internal/assert.ts: -------------------------------------------------------------------------------- 1 | export function assert( 2 | condition: boolean, 3 | message?: string, 4 | ): asserts condition { 5 | if (!condition) { 6 | throw new Error(message || "Assertion failed"); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "biome.enabled": true, 4 | "editor.defaultFormatter": "biomejs.biome", 5 | "[json]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.zed/tasks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "vitest run $ZED_SYMBOL", 4 | "command": "npx vitest run -t", 5 | "args": ["\"$ZED_SYMBOL\" $ZED_FILE"], 6 | "tags": ["js-test", "ts-test"], 7 | "use_new_terminal": false 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /packages/server-act/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsdown"; 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: "./src/index.ts", 6 | utils: "./src/utils.ts", 7 | }, 8 | dts: true, 9 | format: ["esm", "cjs"], 10 | }); 11 | -------------------------------------------------------------------------------- /.github/scripts/cleanup-package-json.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs"; 2 | 3 | const packageJson = JSON.parse(readFileSync("package.json", "utf8")); 4 | 5 | packageJson.scripts = undefined; 6 | packageJson.devDependencies = undefined; 7 | 8 | writeFileSync("package.json", JSON.stringify(packageJson, null, 2)); 9 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | 👉 Action Example 7 | 👉 Form Action Example 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "chungweileong94/server-act" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": ["@examples/nextjs"] 16 | } 17 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "topo": { 5 | "dependsOn": ["^topo"] 6 | }, 7 | "build": { 8 | "dependsOn": ["^build"], 9 | "outputs": [".next/**", "!.next/cache/**", "dist/**"] 10 | }, 11 | "test": {}, 12 | "typecheck": { 13 | "dependsOn": ["^topo", "build"] 14 | }, 15 | "dev": { 16 | "cache": false, 17 | "persistent": true 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import "./globals.css"; 3 | 4 | const inter = Inter({ subsets: ["latin"] }); 5 | 6 | export const metadata = { 7 | title: "Server-Act Example", 8 | }; 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | # vercel 36 | .vercel 37 | 38 | dist/ -------------------------------------------------------------------------------- /packages/server-act/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "verbatimModuleSyntax": true, 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true, 13 | "noImplicitOverride": true, 14 | "module": "preserve", 15 | "noEmit": true, 16 | "lib": ["es2022", "dom", "dom.iterable"] 17 | }, 18 | "include": ["src"], 19 | "exclude": ["dist"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | /* 4 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 5 | so we've added these compatibility styles to make sure everything still 6 | looks the same as it did with Tailwind CSS v3. 7 | 8 | If we ever want to remove these styles, we need to add an explicit border 9 | color utility to any element that depends on these defaults. 10 | */ 11 | @layer base { 12 | *, 13 | ::after, 14 | ::before, 15 | ::backdrop, 16 | ::file-selector-button { 17 | border-color: var(--color-gray-200, currentcolor); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "typecheck": "tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "next": "16.1.1", 13 | "react": "19.2.3", 14 | "react-dom": "19.2.3", 15 | "server-act": "workspace:*", 16 | "zod": "^4.0.10" 17 | }, 18 | "devDependencies": { 19 | "@tailwindcss/postcss": "^4.1.18", 20 | "@types/node": "^20", 21 | "@types/react": "19.2.7", 22 | "@types/react-dom": "19.2.3", 23 | "postcss": "8.4.30", 24 | "tailwindcss": "4.1.18", 25 | "typescript": "^5.9.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/action/_actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { serverAct } from "server-act"; 4 | import { z } from "zod"; 5 | 6 | const requestTimeMiddleware = () => { 7 | return { 8 | requestTime: new Date(), 9 | }; 10 | }; 11 | 12 | export const sayHelloAction = serverAct 13 | .middleware(requestTimeMiddleware) 14 | .input( 15 | z.object({ 16 | name: z.string().optional(), 17 | }), 18 | ) 19 | .action(async ({ input, ctx }) => { 20 | console.log( 21 | `Someone say hi from the client at ${ctx.requestTime.toTimeString()}!`, 22 | ); 23 | await new Promise((resolve) => setTimeout(resolve, 1000)); 24 | return input.name 25 | ? `Hello, ${input.name}!` 26 | : "You need to tell me your name!"; 27 | }); 28 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 9 | "css": { 10 | "parser": { 11 | "tailwindDirectives": true 12 | } 13 | }, 14 | "linter": { 15 | "enabled": true, 16 | "rules": { 17 | "recommended": true, 18 | "a11y": { 19 | "useButtonType": "off" 20 | } 21 | } 22 | }, 23 | "overrides": [ 24 | { 25 | "includes": ["**/packages/**/*.ts"], 26 | "linter": { 27 | "rules": { 28 | "suspicious": { 29 | "noConsole": { "level": "error", "options": { "allow": ["log"] } } 30 | } 31 | } 32 | } 33 | } 34 | ], 35 | "formatter": { 36 | "indentStyle": "space", 37 | "indentWidth": 2, 38 | "lineEnding": "lf", 39 | "lineWidth": 80 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-act-root", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo run build", 6 | "dev": "turbo run dev", 7 | "test": "turbo run test", 8 | "lint": "biome check .", 9 | "typecheck": "turbo run typecheck", 10 | "sherif": "sherif", 11 | "changeset:add": "changeset add", 12 | "changeset:publish": "changeset publish && biome format --write .", 13 | "changeset:version": "changeset version && biome format --write ." 14 | }, 15 | "devDependencies": { 16 | "@biomejs/biome": "2.3.10", 17 | "@changesets/changelog-github": "^0.4.8", 18 | "@changesets/cli": "^2.26.2", 19 | "sherif": "^1.9.0", 20 | "turbo": "^2.7.1", 21 | "typescript": "^5.9.3", 22 | "vitest": "^1.6.0" 23 | }, 24 | "engines": { 25 | "node": "24.x", 26 | "pnpm": "10.26.1" 27 | }, 28 | "packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa" 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Chung Wei Leong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/server-act/src/internal/schema.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 | import { getDotPath } from "@standard-schema/utils"; 3 | 4 | export async function standardValidate( 5 | schema: T, 6 | input: StandardSchemaV1.InferInput, 7 | ): Promise< 8 | StandardSchemaV1.Result< 9 | StandardSchemaV1.InferOutput | StandardSchemaV1.FailureResult 10 | > 11 | > { 12 | let result = schema["~standard"].validate(input); 13 | if (result instanceof Promise) result = await result; 14 | return result; 15 | } 16 | 17 | export function getInputErrors(issues: ReadonlyArray) { 18 | const messages: string[] = []; 19 | const fieldErrors: Record = {}; 20 | for (const issue of issues) { 21 | const dotPath = getDotPath(issue); 22 | if (dotPath) { 23 | if (fieldErrors[dotPath]) { 24 | fieldErrors[dotPath].push(issue.message); 25 | } else { 26 | fieldErrors[dotPath] = [issue.message]; 27 | } 28 | } else { 29 | messages.push(issue.message); 30 | } 31 | } 32 | return { messages, fieldErrors }; 33 | } 34 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/form-action/_actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { serverAct } from "server-act"; 4 | import { formDataToObject } from "server-act/utils"; 5 | import { z } from "zod"; 6 | 7 | function zodFormData(schema: T) { 8 | return z.preprocess, T, FormData>( 9 | (v) => formDataToObject(v), 10 | schema, 11 | ); 12 | } 13 | 14 | const requestTimeMiddleware = () => { 15 | return { 16 | requestTime: new Date(), 17 | }; 18 | }; 19 | 20 | export const sayHelloAction = serverAct 21 | .middleware(requestTimeMiddleware) 22 | .input( 23 | zodFormData( 24 | z.object({ 25 | name: z 26 | .string() 27 | .min(1, { error: `You haven't told me your name` }) 28 | .max(20, { error: "Any shorter name? You name is too long 😬" }), 29 | }), 30 | ), 31 | ) 32 | .stateAction(async ({ rawInput, input, inputErrors, ctx }) => { 33 | if (inputErrors) { 34 | return { formData: rawInput, inputErrors: inputErrors.fieldErrors }; 35 | } 36 | 37 | console.log( 38 | `Someone say hi from the client at ${ctx.requestTime.toTimeString()}!`, 39 | ); 40 | await new Promise((resolve) => setTimeout(resolve, 1000)); 41 | 42 | return { message: `Hello, ${input.name}!` }; 43 | }); 44 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "languages": { 3 | "JavaScript": { 4 | "language_servers": [ 5 | "vtsls", 6 | "!typescript-language-server", 7 | "biome", 8 | "!eslint", 9 | "..." 10 | ], 11 | "formatter": { 12 | "language_server": { "name": "biome" } 13 | }, 14 | "code_actions_on_format": { 15 | "source.organizeImports.biome": true 16 | } 17 | }, 18 | "TypeScript": { 19 | "language_servers": [ 20 | "vtsls", 21 | "!typescript-language-server", 22 | "biome", 23 | "!eslint", 24 | "..." 25 | ], 26 | "formatter": { 27 | "language_server": { "name": "biome" } 28 | }, 29 | "code_actions_on_format": { 30 | "source.organizeImports.biome": true 31 | } 32 | }, 33 | "TSX": { 34 | "language_servers": [ 35 | "vtsls", 36 | "!typescript-language-server", 37 | "biome", 38 | "!eslint", 39 | "..." 40 | ], 41 | "formatter": { 42 | "language_server": { "name": "biome" } 43 | }, 44 | "code_actions_on_format": { 45 | "source.organizeImports.biome": true 46 | } 47 | }, 48 | "JSON": { 49 | "formatter": { 50 | "language_server": { "name": "biome" } 51 | } 52 | }, 53 | "JSONC": { 54 | "formatter": { 55 | "language_server": { "name": "biome" } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: ["*"] 5 | push: 6 | branches: ["main"] 7 | merge_group: 8 | 9 | env: 10 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 11 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 12 | 13 | jobs: 14 | ci: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Use PNPM 23 | uses: pnpm/action-setup@v4.0.0 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: ".node-version" 29 | 30 | - name: Get pnpm store directory 31 | id: pnpm-cache 32 | run: | 33 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 34 | 35 | - name: Setup pnpm cache 36 | uses: actions/cache@v3 37 | with: 38 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 39 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 40 | restore-keys: | 41 | ${{ runner.os }}-pnpm-store- 42 | 43 | - name: Install deps 44 | run: pnpm install 45 | 46 | - name: Build 47 | run: pnpm build 48 | 49 | - name: Lint 50 | run: pnpm lint 51 | 52 | - name: Test 53 | run: pnpm test 54 | 55 | - name: Typecheck 56 | run: pnpm typecheck 57 | 58 | - name: Sherif 59 | run: pnpm sherif 60 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/action/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useTransition } from "react"; 4 | import { sayHelloAction } from "./_actions"; 5 | 6 | export default function Action() { 7 | const [pending, startTransition] = useTransition(); 8 | const [message, setMessage] = useState(); 9 | 10 | const onSubmit = (e: React.FormEvent) => { 11 | e.preventDefault(); 12 | const formData = new FormData(e.currentTarget); 13 | startTransition(async () => { 14 | const msg = await sayHelloAction({ 15 | name: formData.get("name")?.toString(), 16 | }); 17 | setMessage(msg); 18 | }); 19 | }; 20 | 21 | return ( 22 |
23 |
27 | 28 | 33 | 40 | {!!message &&

{message}

} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /examples/nextjs/src/app/form-action/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useActionState } from "react"; 4 | import { useFormStatus } from "react-dom"; 5 | import { sayHelloAction } from "./_actions"; 6 | 7 | function SubmitButton() { 8 | const status = useFormStatus(); 9 | return ( 10 | 17 | ); 18 | } 19 | 20 | export default function FormAction() { 21 | const [state, dispatch] = useActionState(sayHelloAction, undefined); 22 | 23 | return ( 24 |
25 |
29 | 30 | 36 | 37 | {state?.message &&

{state.message}

} 38 | {state?.inputErrors?.name?.map((error) => ( 39 |

40 | {error} 41 |

42 | ))} 43 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | env: 8 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 9 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Use PNPM 22 | uses: pnpm/action-setup@v4.0.0 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version-file: ".node-version" 28 | 29 | - name: Get pnpm store directory 30 | id: pnpm-cache 31 | run: | 32 | echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT 33 | 34 | - name: Setup pnpm cache 35 | uses: actions/cache@v4 36 | with: 37 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 38 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 39 | restore-keys: | 40 | ${{ runner.os }}-pnpm-store- 41 | 42 | - name: Install deps 43 | run: pnpm install 44 | 45 | - name: Build 46 | run: pnpm turbo --filter "./packages/*" build 47 | 48 | - name: Create Release 49 | id: changeset 50 | uses: changesets/action@v1.4.9 51 | with: 52 | commit: "chore(release): 📦 version packages" 53 | title: "chore(release): 📦 version packages" 54 | publish: pnpm changeset:publish 55 | version: pnpm changeset:version 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /packages/server-act/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-act", 3 | "version": "1.6.1", 4 | "homepage": "https://github.com/chungweileong94/server-act#readme", 5 | "author": "chungweileong94", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/chungweileong94/server-act.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/chungweileong94/server-act/issues" 13 | }, 14 | "exports": { 15 | ".": { 16 | "types": "./dist/index.d.ts", 17 | "import": "./dist/index.mjs", 18 | "require": "./dist/index.js" 19 | }, 20 | "./utils": { 21 | "types": "./dist/utils.d.ts", 22 | "import": "./dist/utils.mjs", 23 | "require": "./dist/utils.js" 24 | }, 25 | "./package.json": "./package.json" 26 | }, 27 | "files": [ 28 | "dist", 29 | "package.json", 30 | "LICENSE", 31 | "README.md" 32 | ], 33 | "scripts": { 34 | "build": "tsdown", 35 | "dev": "tsdown --watch", 36 | "typecheck": "tsc --noEmit", 37 | "test": "vitest run", 38 | "prepack": "node ../../.github/scripts/cleanup-package-json.mjs" 39 | }, 40 | "keywords": [ 41 | "next", 42 | "nextjs", 43 | "react", 44 | "react server component", 45 | "react server action", 46 | "rsc", 47 | "server component", 48 | "server action", 49 | "action" 50 | ], 51 | "dependencies": { 52 | "@standard-schema/spec": "^1.0.0", 53 | "@standard-schema/utils": "^0.3.0" 54 | }, 55 | "peerDependencies": { 56 | "typescript": ">=5.0.0", 57 | "valibot": "^1.0.0", 58 | "zod": "^3.24.0 || ^4.0.0-beta.0" 59 | }, 60 | "peerDependenciesMeta": { 61 | "typescript": { 62 | "optional": true 63 | }, 64 | "zod": { 65 | "optional": true 66 | }, 67 | "valibot": { 68 | "optional": true 69 | } 70 | }, 71 | "devDependencies": { 72 | "tsdown": "^0.13.0", 73 | "typescript": "^5.9.3", 74 | "valibot": "1.0.0-rc.0", 75 | "zod": "^4.0.10" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/server-act/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "./internal/assert"; 2 | 3 | function isNumberString(str: string) { 4 | return /^\d+$/.test(str); 5 | } 6 | 7 | function set( 8 | // biome-ignore lint/suspicious/noExplicitAny: No worries 9 | obj: Record, 10 | path: readonly string[], 11 | value: unknown, 12 | ): void { 13 | if (path.length > 1) { 14 | const newPath = [...path]; 15 | const key = newPath.shift(); 16 | assert(key != null); 17 | const nextKey = newPath[0]; 18 | assert(nextKey != null); 19 | 20 | if (!obj[key]) { 21 | obj[key] = isNumberString(nextKey) ? [] : {}; 22 | } else if (Array.isArray(obj[key]) && !isNumberString(nextKey)) { 23 | obj[key] = Object.fromEntries(Object.entries(obj[key])); 24 | } 25 | 26 | set(obj[key], newPath, value); 27 | 28 | return; 29 | } 30 | const p = path[0]; 31 | assert(p != null); 32 | if (obj[p] === undefined) { 33 | obj[p] = value; 34 | } else if (Array.isArray(obj[p])) { 35 | obj[p].push(value); 36 | } else { 37 | obj[p] = [obj[p], value]; 38 | } 39 | } 40 | 41 | /** 42 | * Converts FormData to a structured JavaScript object 43 | * 44 | * This function parses FormData entries and converts them into a nested object structure. 45 | * It supports dot notation, array notation, and mixed nested structures. 46 | * 47 | * @param formData - The FormData object to convert 48 | * @returns A structured object representing the form data 49 | * 50 | * @example 51 | * Basic usage: 52 | * ```ts 53 | * const formData = new FormData(); 54 | * formData.append('name', 'John'); 55 | * formData.append('email', 'john@example.com'); 56 | * 57 | * const result = formDataToObject(formData); 58 | * // Result: { name: 'John', email: 'john@example.com' } 59 | * ``` 60 | * 61 | * @example 62 | * Nested objects with dot notation: 63 | * ```ts 64 | * const formData = new FormData(); 65 | * formData.append('user.name', 'John'); 66 | * formData.append('user.profile.age', '30'); 67 | * 68 | * const result = formDataToObject(formData); 69 | * // Result: { user: { name: 'John', profile: { age: '30' } } } 70 | * ``` 71 | * 72 | * @example 73 | * Arrays with bracket notation: 74 | * ```ts 75 | * const formData = new FormData(); 76 | * formData.append('items[0]', 'apple'); 77 | * formData.append('items[1]', 'banana'); 78 | * 79 | * const result = formDataToObject(formData); 80 | * // Result: { items: ['apple', 'banana'] } 81 | * ``` 82 | * 83 | * @example 84 | * Multiple values for the same key: 85 | * ```ts 86 | * const formData = new FormData(); 87 | * formData.append('tags', 'javascript'); 88 | * formData.append('tags', 'typescript'); 89 | * 90 | * const result = formDataToObject(formData); 91 | * // Result: { tags: ['javascript', 'typescript'] } 92 | * ``` 93 | * 94 | * @example 95 | * Mixed nested structures: 96 | * ```ts 97 | * const formData = new FormData(); 98 | * formData.append('users[0].name', 'John'); 99 | * formData.append('users[0].emails[0]', 'john@work.com'); 100 | * formData.append('users[0].emails[1]', 'john@personal.com'); 101 | * 102 | * const result = formDataToObject(formData); 103 | * // Result: { 104 | * // users: [{ 105 | * // name: 'John', 106 | * // emails: ['john@work.com', 'john@personal.com'] 107 | * // }] 108 | * // } 109 | * ``` 110 | */ 111 | export function formDataToObject(formData: FormData) { 112 | const obj: Record = {}; 113 | 114 | for (const [key, value] of formData.entries()) { 115 | const parts = key.split(/[.[\]]/).filter(Boolean); 116 | set(obj, parts, value); 117 | } 118 | 119 | return obj; 120 | } 121 | -------------------------------------------------------------------------------- /packages/server-act/README.md: -------------------------------------------------------------------------------- 1 | # Server-Act 2 | 3 | [![npm version](https://badge.fury.io/js/server-act.svg)](https://badge.fury.io/js/server-act) 4 | 5 | A simple React server action builder that provides input validation. 6 | 7 | You can use any validation library that supports [Standard Schema](https://standardschema.dev/). 8 | 9 | ## Installation 10 | 11 | ```bash 12 | # npm 13 | npm install server-act zod 14 | 15 | # yarn 16 | yarn add server-act zod 17 | 18 | # pnpm 19 | pnpm add server-act zod 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```ts 25 | // action.ts 26 | "use server"; 27 | 28 | import { serverAct } from "server-act"; 29 | import { z } from "zod"; 30 | 31 | export const sayHelloAction = serverAct 32 | .input( 33 | z.object({ 34 | name: z.string(), 35 | }), 36 | ) 37 | .action(async ({ input }) => { 38 | return `Hello, ${input.name}`; 39 | }); 40 | ``` 41 | 42 | ```tsx 43 | // client-component.tsx 44 | "use client"; 45 | 46 | import { sayHelloAction } from "./action"; 47 | 48 | export const ClientComponent = () => { 49 | const onClick = () => { 50 | const message = await sayHelloAction({ name: "John" }); 51 | console.log(message); // Hello, John 52 | }; 53 | 54 | return ( 55 |
56 | 57 |
58 | ); 59 | }; 60 | ``` 61 | 62 | ### With Middleware 63 | 64 | ```ts 65 | // action.ts 66 | "use server"; 67 | 68 | import { serverAct } from "server-act"; 69 | import { z } from "zod"; 70 | 71 | export const sayHelloAction = serverAct 72 | .middleware(() => { 73 | const t = i18n(); 74 | const userId = "..." 75 | return { t, userId }; 76 | }) 77 | .input((ctx) => { 78 | return z.object({ 79 | name: z.string().min(1, { message: ctx.t("form.name.required") }), 80 | }); 81 | }) 82 | .action(async ({ ctx, input }) => { 83 | console.log("User ID", ctx.userId); 84 | return `Hello, ${input.name}`; 85 | }); 86 | ``` 87 | 88 | ### `useActionState` Support 89 | 90 | > `useActionState` Documentation: 91 | > 92 | > - https://react.dev/reference/react/useActionState 93 | 94 | ```ts 95 | // action.ts; 96 | "use server"; 97 | 98 | import { serverAct } from "server-act"; 99 | import { formDataToObject } from "server-act/utils"; 100 | import { z } from "zod"; 101 | 102 | function zodFormData(schema: T) { 103 | return z.preprocess, T, FormData>( 104 | (v) => formDataToObject(v), 105 | schema, 106 | ); 107 | } 108 | 109 | export const sayHelloAction = serverAct 110 | .input( 111 | zodFormData( 112 | z.object({ 113 | name: z 114 | .string() 115 | .min(1, { error: `You haven't told me your name` }) 116 | .max(20, { error: "Any shorter name? You name is too long 😬" }), 117 | }), 118 | ), 119 | ) 120 | .stateAction(async ({ rawInput, input, inputErrors, ctx }) => { 121 | if (inputErrors) { 122 | return { formData: rawInput, inputErrors: inputErrors.fieldErrors }; 123 | } 124 | return { message: `Hello, ${input.name}!` }; 125 | }); 126 | ``` 127 | 128 | ## Utilities 129 | 130 | ### `formDataToObject` 131 | 132 | The `formDataToObject` utility converts FormData to a structured JavaScript object, supporting nested objects, arrays, and complex form structures. 133 | 134 | ```ts 135 | import { formDataToObject } from "server-act/utils"; 136 | ``` 137 | 138 | #### Basic Usage 139 | 140 | ```ts 141 | const formData = new FormData(); 142 | formData.append('name', 'John'); 143 | 144 | const result = formDataToObject(formData); 145 | // Result: { name: 'John' } 146 | ``` 147 | 148 | #### Nested Objects and Arrays 149 | 150 | ```ts 151 | const formData = new FormData(); 152 | formData.append('user.name', 'John'); 153 | 154 | const result = formDataToObject(formData); 155 | // Result: { user: { name: 'John' } } 156 | ``` 157 | 158 | #### With Zod 159 | 160 | ```ts 161 | "use server"; 162 | 163 | import { serverAct } from "server-act"; 164 | import { formDataToObject } from "server-act/utils"; 165 | import { z } from "zod"; 166 | 167 | function zodFormData(schema: T) { 168 | return z.preprocess, T, FormData>( 169 | (v) => formDataToObject(v), 170 | schema, 171 | ); 172 | } 173 | 174 | export const createUserAction = serverAct 175 | .input( 176 | zodFormData( 177 | z.object({ 178 | name: z.string().min(1, "Name is required"), 179 | }), 180 | ), 181 | ) 182 | .stateAction(async ({ rawInput, input, inputErrors }) => { 183 | if (inputErrors) { 184 | return { formData: rawInput, errors: inputErrors.fieldErrors }; 185 | } 186 | 187 | // Process the validated input 188 | console.log("User:", input.name); 189 | 190 | return { success: true, userId: "123" }; 191 | }); 192 | ``` 193 | 194 | #### With Valibot 195 | 196 | ```ts 197 | "use server"; 198 | 199 | import { serverAct } from "server-act"; 200 | import { formDataToObject } from "server-act/utils"; 201 | import * as v from "valibot"; 202 | 203 | export const createPostAction = serverAct 204 | .input( 205 | v.pipe( 206 | v.custom((value) => value instanceof FormData), 207 | v.transform(formDataToObject), 208 | v.object({ 209 | title: v.pipe(v.string(), v.minLength(1, "Title is required")), 210 | }), 211 | ), 212 | ) 213 | .stateAction(async ({ rawInput, input, inputErrors }) => { 214 | if (inputErrors) { 215 | return { formData: rawInput, errors: inputErrors.fieldErrors }; 216 | } 217 | 218 | // Process the validated input 219 | console.log("Post:", input.title); 220 | 221 | return { success: true, postId: "456" }; 222 | }); 223 | ``` 224 | -------------------------------------------------------------------------------- /packages/server-act/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # server-act 2 | 3 | ## 1.6.1 4 | 5 | ### Patch Changes 6 | 7 | - [#47](https://github.com/chungweileong94/server-act/pull/47) [`3beb28f`](https://github.com/chungweileong94/server-act/commit/3beb28f63f6afbaabe6ea9094b592d4f1d50363c) Thanks [@chungweileong94](https://github.com/chungweileong94)! - ### ✨ Refactored `stateAction` and reintroduced `formAction` (deprecated) 8 | 9 | This update improves the developer experience when working with `stateAction`, aligning it more closely with modern React conventions and removing legacy form-related naming. Please refer to this [PR](https://github.com/chungweileong94/server-act/pull/47) for more details. 10 | 11 | #### Breaking Changes 12 | 13 | - `formAction` has been officially deprecated in favor of `stateAction`. 14 | - Inside `stateAction`: 15 | - `formData` → `rawInput` 16 | - `formErrors` → `inputErrors` 17 | - The input type now infers directly from the schema instead of defaulting to `FormData`. 18 | 19 | #### New Utility 20 | 21 | - Introduced `formDataToObject`, a new utility inspired by [tRPC](https://trpc.io), to help transition from `zod-form-data`: 22 | ```ts 23 | function zodFormData( 24 | schema: T 25 | ): z.ZodPipe, FormData>, T> { 26 | return z.preprocess((v) => formDataToObject(v), schema); 27 | } 28 | ``` 29 | 30 | ## 1.6.0 31 | 32 | ### Minor Changes 33 | 34 | - [`0e4d9d6`](https://github.com/chungweileong94/server-act/commit/0e4d9d6fa6043758e22317614f7721eb394c886d) Thanks [@chungweileong94](https://github.com/chungweileong94)! - `.formAction` has been renamed to `.stateAction`. 35 | 36 | If you are using `.formAction`, you must update all references to `.stateAction`: 37 | 38 | ```diff 39 | - const action = serverAct.formAction(...) 40 | + const action = serverAct.stateAction(...) 41 | ``` 42 | 43 | ## 1.5.3 44 | 45 | ### Patch Changes 46 | 47 | - [`a8fecb2`](https://github.com/chungweileong94/server-act/commit/a8fecb234c42efada3f5692fc7084c3a5e15dad5) Thanks [@chungweileong94](https://github.com/chungweileong94)! - There are no code changes in this release, but it is now bundled using tsdown. 48 | 49 | ## 1.5.2 50 | 51 | ### Patch Changes 52 | 53 | - [`7f3e2d7`](https://github.com/chungweileong94/server-act/commit/7f3e2d7863cef4c25ad9088ef54a09d459d241f7) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Update peer dependencies to support Zod v4 54 | 55 | ## 1.5.1 56 | 57 | ### Patch Changes 58 | 59 | - [#39](https://github.com/chungweileong94/server-act/pull/39) [`764a047`](https://github.com/chungweileong94/server-act/commit/764a047670daa43d978bcb5d77f0b285c38da25f) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Support `prevState` typing 60 | 61 | ## 1.5.0 62 | 63 | ### Minor Changes 64 | 65 | - [#37](https://github.com/chungweileong94/server-act/pull/37) [`b4e65ee`](https://github.com/chungweileong94/server-act/commit/b4e65eea8812d6c57a142fd67bbc5d1ec011e892) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Support [Standard Schema](https://standardschema.dev/)! 66 | 67 | You can now use any validation library that supports Standard Schema. 68 | 69 | Breaking changes: 70 | 71 | - Minimum required version of Zod is now `^3.24.0`. 72 | - `formErrors` in `formAction` will now return `{ messages: string[]; fieldErrors: Record }` instead of `ZodError`. 73 | - You can no longer use an object as input if you are using `zfd.formData` from `zod-form-data`. 74 | 75 | ## 1.4.0 76 | 77 | ### Minor Changes 78 | 79 | - [#35](https://github.com/chungweileong94/server-act/pull/35) [`113557d`](https://github.com/chungweileong94/server-act/commit/113557dd85e9a92a4d175cd74d87906a17296120) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Improved input type infer 80 | 81 | ## 1.3.1 82 | 83 | ### Patch Changes 84 | 85 | - [`2854c03`](https://github.com/chungweileong94/server-act/commit/2854c0332a752aeea8a958d0fdde0283a22e0c78) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Fixed async context access from `.input()` 86 | 87 | ## 1.3.0 88 | 89 | ### Minor Changes 90 | 91 | - [#30](https://github.com/chungweileong94/server-act/pull/30) [`7288143`](https://github.com/chungweileong94/server-act/commit/7288143f7f9a4dff39613569af2fb6c439eea097) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Support middleware ctx access in `.input()` 92 | 93 | ## 1.2.2 94 | 95 | ### Patch Changes 96 | 97 | - [#27](https://github.com/chungweileong94/server-act/pull/27) [`2ab472c`](https://github.com/chungweileong94/server-act/commit/2ab472cda8d404406a7ddeec4645e012c81abcd9) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Prevent duplicate chaining methods 98 | 99 | ## 1.2.1 100 | 101 | ### Patch Changes 102 | 103 | - [#24](https://github.com/chungweileong94/server-act/pull/24) [`c9decae`](https://github.com/chungweileong94/server-act/commit/c9decaec540e3824a10738282bb71775d3cfce04) Thanks [@rvndev](https://github.com/rvndev)! - Enables zod refinments in input validation 104 | 105 | ## 1.2.0 106 | 107 | ### Minor Changes 108 | 109 | - [#22](https://github.com/chungweileong94/server-act/pull/22) [`a71e8ba`](https://github.com/chungweileong94/server-act/commit/a71e8ba1131b226ad3acc58b8b8f3dc91f759d77) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Better React 19 support 110 | 111 | - Updated `useFormState` example to `useActionState`. 112 | - `prevState` from form action is now `undefined` type by default. 113 | - You can now access `formData` in form action. 114 | 115 | ## 1.1.7 116 | 117 | ### Patch Changes 118 | 119 | - [#20](https://github.com/chungweileong94/server-act/pull/20) [`7f92a29`](https://github.com/chungweileong94/server-act/commit/7f92a29a19f308f174b405365fd2633c06c9b686) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Support async validation 120 | 121 | ## 1.1.6 122 | 123 | ### Patch Changes 124 | 125 | - [#18](https://github.com/chungweileong94/server-act/pull/18) [`b1cbc4a`](https://github.com/chungweileong94/server-act/commit/b1cbc4a3ba62d3613d8ada41794c900676e9636b) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Improve action optional param type 126 | 127 | ## 1.1.5 128 | 129 | ### Patch Changes 130 | 131 | - [`0782a06`](https://github.com/chungweileong94/server-act/commit/0782a0626045823344048fbc00652144f6f14eca) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Fixed README 132 | 133 | ## 1.1.4 134 | 135 | ### Patch Changes 136 | 137 | - [`4201412`](https://github.com/chungweileong94/server-act/commit/4201412c2d22afb69f9c640d23bad76102ae8285) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Refactor types 138 | 139 | ## 1.1.3 140 | 141 | ### Patch Changes 142 | 143 | - [#14](https://github.com/chungweileong94/server-act/pull/14) [`8bb348e`](https://github.com/chungweileong94/server-act/commit/8bb348ee0ed7a60a2498a37cab86c7271c205752) Thanks [@chungweileong94](https://github.com/chungweileong94)! - Remove `zod-validation-error` dependency 144 | 145 | ## 1.1.2 146 | 147 | ### Patch Changes 148 | 149 | - a832b11: Improve form errors type infer 150 | 151 | ## 1.1.1 152 | 153 | ### Patch Changes 154 | 155 | - 8d5b6e5: Fixed zod error type in `formAction` when using FormData 156 | 157 | ## 1.1.0 158 | 159 | ### Minor Changes 160 | 161 | - 48b9164: Remove formData parsing for `formAction` 162 | 163 | ## 1.0.0 164 | 165 | ### Major Changes 166 | 167 | - b4318a8: Get `formAction` out of experimental! 168 | 169 | ## 0.0.10 170 | 171 | ### Patch Changes 172 | 173 | - a2ab457: Fixed form action doc 174 | 175 | ## 0.0.9 176 | 177 | ### Patch Changes 178 | 179 | - d8682f2: Documentation for experimental form action 180 | 181 | ## 0.0.8 182 | 183 | ### Patch Changes 184 | 185 | - 566261e: Change form action error to ZodError 186 | - ead7149: Prettify form action params type 187 | 188 | ## 0.0.7 189 | 190 | ### Patch Changes 191 | 192 | - 50e2853: New experimental form action 193 | 194 | ## 0.0.6 195 | 196 | ### Patch Changes 197 | 198 | - fedd1d4: Update README 199 | 200 | ## 0.0.5 201 | 202 | ### Patch Changes 203 | 204 | - 7e5d9c9: Support middleware 205 | 206 | ## 0.0.1 207 | 208 | ### Patch Changes 209 | 210 | - 01d52a7: First Release! 211 | 212 | ## 0.0.1 213 | 214 | ### Patch Changes 215 | 216 | - First Release! 217 | -------------------------------------------------------------------------------- /packages/server-act/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { StandardSchemaV1 } from "@standard-schema/spec"; 2 | import { SchemaError } from "@standard-schema/utils"; 3 | import { getInputErrors, standardValidate } from "./internal/schema"; 4 | 5 | const unsetMarker = Symbol("unsetMarker"); 6 | type UnsetMarker = typeof unsetMarker; 7 | 8 | type RemoveUnsetMarker = T extends UnsetMarker ? undefined : T; 9 | 10 | type Equals = 11 | (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 12 | ? true 13 | : false; 14 | 15 | type Prettify = { 16 | [P in keyof T]: T[P]; 17 | } & {}; 18 | 19 | // biome-ignore lint/suspicious/noExplicitAny: Intended 20 | type SanitizeFunctionParam 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 | --------------------------------------------------------------------------------