nuxt-safe-action 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philip Rutberg
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.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # nuxt-safe-action
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![npm downloads][npm-downloads-src]][npm-downloads-href]
5
+ [![License][license-src]][license-href]
6
+ [![Nuxt][nuxt-src]][nuxt-href]
7
+
8
+ Type-safe and validated server actions for Nuxt.
9
+
10
+ End-to-end type safety, input/output validation via Zod, composable middleware, and Vue composables with loading states, error handling, and optimistic updates.
11
+
12
+ ```ts
13
+ // server/actions/create-post.ts
14
+ import { z } from 'zod'
15
+ import { actionClient } from '../utils/action-client'
16
+
17
+ export default actionClient
18
+ .schema(z.object({
19
+ title: z.string().min(1).max(200),
20
+ body: z.string().min(1),
21
+ }))
22
+ .action(async ({ parsedInput }) => {
23
+ const post = await db.post.create({ data: parsedInput })
24
+ return { id: post.id, title: post.title }
25
+ })
26
+ ```
27
+
28
+ ```vue
29
+ <script setup lang="ts">
30
+ import { createPost } from '#safe-action/actions'
31
+
32
+ const { execute, data, status, validationErrors } = useAction(createPost)
33
+ </script>
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - **End-to-end type safety** — Input and output types flow from server to client automatically
39
+ - **Input validation** — Zod schemas validate input before your handler runs
40
+ - **Composable middleware** — Chain auth checks, rate limiting, logging, and more
41
+ - **Vue composables** — `useAction` with reactive `status`, `data`, `error`, and `validationErrors`
42
+ - **Auto route generation** — Define actions in `server/actions/`, routes are created for you
43
+ - **H3Event access** — Full request context in middleware (headers, cookies, IP, sessions)
44
+ - **Nuxt-native** — Auto-imports, works with Nuxt DevTools, familiar conventions
45
+
46
+ ## Quick Setup
47
+
48
+ Install the module:
49
+
50
+ ```bash
51
+ npx nuxi module add nuxt-safe-action
52
+ ```
53
+
54
+ Or manually:
55
+
56
+ ```bash
57
+ pnpm add nuxt-safe-action zod
58
+ ```
59
+
60
+ ```ts
61
+ // nuxt.config.ts
62
+ export default defineNuxtConfig({
63
+ modules: ['nuxt-safe-action'],
64
+ })
65
+ ```
66
+
67
+ ## Usage
68
+
69
+ ### 1. Create an action client
70
+
71
+ ```ts
72
+ // server/utils/action-client.ts
73
+ import { createSafeActionClient } from '#safe-action'
74
+
75
+ export const actionClient = createSafeActionClient({
76
+ handleServerError: (error) => {
77
+ console.error('Action error:', error.message)
78
+ return error.message
79
+ },
80
+ })
81
+
82
+ // Optional: create an authenticated client
83
+ export const authActionClient = actionClient
84
+ .use(async ({ next, event }) => {
85
+ const session = await getUserSession(event)
86
+ if (!session) throw new Error('Unauthorized')
87
+ return next({ ctx: { userId: session.user.id } })
88
+ })
89
+ ```
90
+
91
+ ### 2. Define actions
92
+
93
+ Create action files in `server/actions/`. Each file should export a default action:
94
+
95
+ ```ts
96
+ // server/actions/greet.ts
97
+ import { z } from 'zod'
98
+ import { actionClient } from '../utils/action-client'
99
+
100
+ export default actionClient
101
+ .schema(z.object({
102
+ name: z.string().min(1, 'Name is required'),
103
+ }))
104
+ .action(async ({ parsedInput }) => {
105
+ return { greeting: `Hello, ${parsedInput.name}!` }
106
+ })
107
+ ```
108
+
109
+ ### 3. Use in components
110
+
111
+ ```vue
112
+ <script setup lang="ts">
113
+ import { greet } from '#safe-action/actions'
114
+
115
+ const { execute, data, status, validationErrors, isExecuting, hasSucceeded } = useAction(greet, {
116
+ onSuccess({ data }) {
117
+ console.log(data.greeting) // fully typed!
118
+ },
119
+ onError({ error }) {
120
+ console.error(error)
121
+ },
122
+ })
123
+ </script>
124
+
125
+ <template>
126
+ <form @submit.prevent="execute({ name: 'World' })">
127
+ <button :disabled="isExecuting">
128
+ {{ isExecuting ? 'Loading...' : 'Greet' }}
129
+ </button>
130
+ <p v-if="hasSucceeded">{{ data?.greeting }}</p>
131
+ </form>
132
+ </template>
133
+ ```
134
+
135
+ ## API
136
+
137
+ ### `createSafeActionClient(opts?)`
138
+
139
+ Creates a new action client. Call this once and reuse it across actions.
140
+
141
+ | Option | Type | Description |
142
+ |--------|------|-------------|
143
+ | `handleServerError` | `(error: Error) => string` | Transform server errors before sending to client |
144
+
145
+ ### Builder chain
146
+
147
+ | Method | Description |
148
+ |--------|-------------|
149
+ | `.schema(zodSchema)` | Set input validation schema |
150
+ | `.outputSchema(zodSchema)` | Set output validation schema |
151
+ | `.use(middleware)` | Add middleware to the chain |
152
+ | `.metadata(meta)` | Attach metadata (accessible in middleware) |
153
+ | `.action(handler)` | Define the action handler (terminal) |
154
+
155
+ ### `useAction(action, callbacks?)`
156
+
157
+ Vue composable for executing actions.
158
+
159
+ **Returns:**
160
+
161
+ | Property | Type | Description |
162
+ |----------|------|-------------|
163
+ | `execute(input)` | `(input: TInput) => void` | Fire-and-forget execution |
164
+ | `executeAsync(input)` | `(input: TInput) => Promise<ActionResult>` | Awaitable execution |
165
+ | `data` | `Ref<TOutput \| undefined>` | Success data |
166
+ | `serverError` | `Ref<string \| undefined>` | Server error message |
167
+ | `validationErrors` | `Ref<Record<string, string[]> \| undefined>` | Per-field validation errors |
168
+ | `status` | `Ref<ActionStatus>` | `'idle' \| 'executing' \| 'hasSucceeded' \| 'hasErrored'` |
169
+ | `isIdle` | `ComputedRef<boolean>` | Status shortcut |
170
+ | `isExecuting` | `ComputedRef<boolean>` | Status shortcut |
171
+ | `hasSucceeded` | `ComputedRef<boolean>` | Status shortcut |
172
+ | `hasErrored` | `ComputedRef<boolean>` | Status shortcut |
173
+ | `reset()` | `() => void` | Reset all state to initial |
174
+
175
+ **Callbacks:**
176
+
177
+ | Callback | Description |
178
+ |----------|-------------|
179
+ | `onSuccess({ data, input })` | Called when the action succeeds |
180
+ | `onError({ error, input })` | Called on server or validation error |
181
+ | `onSettled({ result, input })` | Called after every execution |
182
+ | `onExecute({ input })` | Called when execution starts |
183
+
184
+ ### Middleware
185
+
186
+ ```ts
187
+ actionClient.use(async ({ ctx, next, event, metadata, clientInput }) => {
188
+ // ctx: context from previous middleware
189
+ // event: H3Event with full request access
190
+ // metadata: action metadata
191
+ // clientInput: raw input before validation
192
+ return next({ ctx: { ...ctx, myData: 'value' } })
193
+ })
194
+ ```
195
+
196
+ ### Error handling
197
+
198
+ ```ts
199
+ import { ActionError, returnValidationErrors } from '#safe-action'
200
+
201
+ // Throw a server error
202
+ throw new ActionError('Not enough credits')
203
+
204
+ // Return per-field validation errors
205
+ returnValidationErrors({
206
+ email: ['This email is already taken'],
207
+ })
208
+ ```
209
+
210
+ ## Configuration
211
+
212
+ ```ts
213
+ // nuxt.config.ts
214
+ export default defineNuxtConfig({
215
+ modules: ['nuxt-safe-action'],
216
+ safeAction: {
217
+ actionsDir: 'actions', // relative to server/ directory (default: 'actions')
218
+ },
219
+ })
220
+ ```
221
+
222
+ ## How it works
223
+
224
+ 1. You define actions in `server/actions/` using the builder chain
225
+ 2. The module scans this directory and auto-generates Nitro API routes at `/api/_actions/<name>`
226
+ 3. A typed virtual module `#safe-action/actions` provides client-side references with full type inference
227
+ 4. `useAction()` calls the generated route via `$fetch` and returns reactive state
228
+
229
+ ## Inspiration
230
+
231
+ Inspired by [next-safe-action](https://github.com/TheEdoRan/next-safe-action) — adapted for the Nuxt ecosystem with H3Event access, auto route generation, and Vue reactivity.
232
+
233
+ ## Contributing
234
+
235
+ <details>
236
+ <summary>Local development</summary>
237
+
238
+ ```bash
239
+ # Install dependencies
240
+ pnpm install
241
+
242
+ # Generate type stubs
243
+ pnpm run dev:prepare
244
+
245
+ # Develop with the playground
246
+ pnpm run dev
247
+
248
+ # Build the playground
249
+ pnpm run dev:build
250
+
251
+ # Run ESLint
252
+ pnpm run lint
253
+
254
+ # Run Vitest
255
+ pnpm run test
256
+ pnpm run test:watch
257
+ ```
258
+
259
+ </details>
260
+
261
+ ## License
262
+
263
+ [MIT](./LICENSE)
264
+
265
+ <!-- Badges -->
266
+ [npm-version-src]: https://img.shields.io/npm/v/nuxt-safe-action/latest.svg?style=flat&colorA=020420&colorB=00DC82
267
+ [npm-version-href]: https://npmjs.com/package/nuxt-safe-action
268
+
269
+ [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-safe-action.svg?style=flat&colorA=020420&colorB=00DC82
270
+ [npm-downloads-href]: https://npm.chart.dev/nuxt-safe-action
271
+
272
+ [license-src]: https://img.shields.io/npm/l/nuxt-safe-action.svg?style=flat&colorA=020420&colorB=00DC82
273
+ [license-href]: https://npmjs.com/package/nuxt-safe-action
274
+
275
+ [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt
276
+ [nuxt-href]: https://nuxt.com
@@ -0,0 +1,13 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ interface ModuleOptions {
4
+ /**
5
+ * Directory where action files are located, relative to the server directory.
6
+ * @default 'actions'
7
+ */
8
+ actionsDir?: string;
9
+ }
10
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
11
+
12
+ export { _default as default };
13
+ export type { ModuleOptions };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "nuxt-safe-action",
3
+ "configKey": "safeAction",
4
+ "compatibility": {
5
+ "nuxt": ">=4.0.0"
6
+ },
7
+ "version": "0.1.1",
8
+ "builder": {
9
+ "@nuxt/module-builder": "1.0.2",
10
+ "unbuild": "unknown"
11
+ }
12
+ }
@@ -0,0 +1,130 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join, posix, basename } from 'node:path';
3
+ import { useLogger, defineNuxtModule, createResolver, addImportsDir, addServerImports, addTemplate } from '@nuxt/kit';
4
+
5
+ const logger = useLogger("nuxt-safe-action");
6
+ const module$1 = defineNuxtModule({
7
+ meta: {
8
+ name: "nuxt-safe-action",
9
+ configKey: "safeAction",
10
+ compatibility: {
11
+ nuxt: ">=4.0.0"
12
+ }
13
+ },
14
+ defaults: {
15
+ actionsDir: "actions"
16
+ },
17
+ setup(options, nuxt) {
18
+ const { resolve } = createResolver(import.meta.url);
19
+ addImportsDir(resolve("./runtime/composables"));
20
+ addServerImports([
21
+ { name: "createSafeActionClient", from: resolve("./runtime/server/createSafeActionClient") },
22
+ { name: "ActionError", from: resolve("./runtime/server/errors") },
23
+ { name: "ActionValidationError", from: resolve("./runtime/server/errors") },
24
+ { name: "returnValidationErrors", from: resolve("./runtime/server/errors") }
25
+ ]);
26
+ nuxt.options.alias["#safe-action"] = resolve("./runtime/server/index");
27
+ const actionsDir = options.actionsDir || "actions";
28
+ nuxt.hook("nitro:config", (nitroConfig) => {
29
+ const serverDir2 = nuxt.options.serverDir;
30
+ const fullActionsDir2 = join(serverDir2, actionsDir);
31
+ if (!existsSync(fullActionsDir2)) {
32
+ logger.info(
33
+ `No actions directory found at ${fullActionsDir2} \u2014 skipping action route generation.`
34
+ );
35
+ return;
36
+ }
37
+ const actionFiles = scanActionFiles(fullActionsDir2);
38
+ if (actionFiles.length === 0) {
39
+ logger.info("No action files found.");
40
+ return;
41
+ }
42
+ logger.info(
43
+ `Found ${actionFiles.length} action file(s): ${actionFiles.map((a) => a.name).join(", ")}`
44
+ );
45
+ nitroConfig.virtual = nitroConfig.virtual || {};
46
+ nitroConfig.handlers = nitroConfig.handlers || [];
47
+ for (const action of actionFiles) {
48
+ const virtualKey = `#safe-action-handler/${action.name}`;
49
+ nitroConfig.virtual[virtualKey] = generateHandlerCode(action);
50
+ nitroConfig.handlers.push({
51
+ route: `/api/_actions/${action.name}`,
52
+ method: "post",
53
+ handler: virtualKey
54
+ });
55
+ }
56
+ });
57
+ const serverDir = nuxt.options.serverDir;
58
+ const fullActionsDir = join(serverDir, actionsDir);
59
+ addTemplate({
60
+ filename: "safe-action/actions.ts",
61
+ write: true,
62
+ getContents: () => {
63
+ if (!existsSync(fullActionsDir)) {
64
+ return "export {}\n";
65
+ }
66
+ const actionFiles = scanActionFiles(fullActionsDir);
67
+ if (actionFiles.length === 0) {
68
+ return "export {}\n";
69
+ }
70
+ const lines = [
71
+ `import type { SafeActionReference } from '#safe-action'`,
72
+ ""
73
+ ];
74
+ for (const action of actionFiles) {
75
+ const exportName = toCamelCase(action.name);
76
+ const relativePath = posix.join("../..", "server", actionsDir, action.name);
77
+ lines.push(`import type _action_${exportName} from '${relativePath}'`);
78
+ }
79
+ lines.push("");
80
+ for (const action of actionFiles) {
81
+ const exportName = toCamelCase(action.name);
82
+ lines.push(
83
+ `export const ${exportName}: SafeActionReference<(typeof _action_${exportName})['_types']['input'], (typeof _action_${exportName})['_types']['output'], (typeof _action_${exportName})['_types']['serverError']> = Object.freeze({ __safeActionPath: '${action.name}' }) as any`
84
+ );
85
+ }
86
+ return lines.join("\n") + "\n";
87
+ }
88
+ });
89
+ nuxt.hook("prepare:types", ({ tsConfig }) => {
90
+ tsConfig.compilerOptions = tsConfig.compilerOptions || {};
91
+ tsConfig.compilerOptions.paths = tsConfig.compilerOptions.paths || {};
92
+ tsConfig.compilerOptions.paths["#safe-action/actions"] = ["./safe-action/actions"];
93
+ tsConfig.compilerOptions.paths["#safe-action"] = [
94
+ resolve("./runtime/server/index").replace(/\.ts$/, "")
95
+ ];
96
+ });
97
+ nuxt.options.alias["#safe-action/actions"] = join(nuxt.options.buildDir, "safe-action/actions");
98
+ }
99
+ });
100
+ function scanActionFiles(dir, prefix = "") {
101
+ const results = [];
102
+ if (!existsSync(dir)) return results;
103
+ const entries = readdirSync(dir, { withFileTypes: true });
104
+ for (const entry of entries) {
105
+ const fullPath = join(dir, entry.name);
106
+ if (entry.isDirectory()) {
107
+ results.push(...scanActionFiles(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name));
108
+ } else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && entry.name !== "index.ts") {
109
+ const name = prefix ? `${prefix}/${basename(entry.name, ".ts")}` : basename(entry.name, ".ts");
110
+ results.push({ name, filePath: fullPath });
111
+ }
112
+ }
113
+ return results;
114
+ }
115
+ function generateHandlerCode(action) {
116
+ return `
117
+ import { defineEventHandler, readBody } from 'h3'
118
+ import action from '${action.filePath}'
119
+
120
+ export default defineEventHandler(async (event) => {
121
+ const body = await readBody(event).catch(() => undefined)
122
+ return action._execute(body, event)
123
+ })
124
+ `;
125
+ }
126
+ function toCamelCase(name) {
127
+ return name.replace(/[/-](\w)/g, (_, c) => c.toUpperCase()).replace(/^(\w)/, (_, c) => c.toLowerCase());
128
+ }
129
+
130
+ export { module$1 as default };
@@ -0,0 +1,15 @@
1
+ import type { SafeAction, SafeActionReference, UseActionReturn, UseActionCallbacks } from '../types.js';
2
+ /**
3
+ * Vue composable for executing a safe action with reactive status tracking.
4
+ *
5
+ * ```vue
6
+ * <script setup lang="ts">
7
+ * import { createPost } from '#safe-action/actions'
8
+ *
9
+ * const { execute, data, status, error } = useAction(createPost, {
10
+ * onSuccess({ data }) { console.log('Created:', data) },
11
+ * })
12
+ * </script>
13
+ * ```
14
+ */
15
+ export declare function useAction<TInput, TOutput, TServerError = string>(action: SafeAction<TInput, TOutput, TServerError> | SafeActionReference<TInput, TOutput, TServerError>, callbacks?: UseActionCallbacks<TInput, TOutput, TServerError>): UseActionReturn<TInput, TOutput, TServerError>;
@@ -0,0 +1,76 @@
1
+ import { ref, computed, readonly } from "vue";
2
+ export function useAction(action, callbacks) {
3
+ const actionPath = action.__safeActionPath;
4
+ const data = ref();
5
+ const serverError = ref();
6
+ const validationErrors = ref();
7
+ const status = ref("idle");
8
+ async function executeAsync(input) {
9
+ serverError.value = void 0;
10
+ validationErrors.value = void 0;
11
+ status.value = "executing";
12
+ callbacks?.onExecute?.({ input });
13
+ try {
14
+ const result = await $fetch(
15
+ `/api/_actions/${actionPath}`,
16
+ {
17
+ method: "POST",
18
+ body: input
19
+ }
20
+ );
21
+ if (result.data !== void 0) {
22
+ data.value = result.data;
23
+ status.value = "hasSucceeded";
24
+ callbacks?.onSuccess?.({ data: result.data, input });
25
+ } else if (result.serverError !== void 0) {
26
+ serverError.value = result.serverError;
27
+ status.value = "hasErrored";
28
+ callbacks?.onError?.({
29
+ error: { serverError: result.serverError },
30
+ input
31
+ });
32
+ } else if (result.validationErrors !== void 0) {
33
+ validationErrors.value = result.validationErrors;
34
+ status.value = "hasErrored";
35
+ callbacks?.onError?.({
36
+ error: { validationErrors: result.validationErrors },
37
+ input
38
+ });
39
+ }
40
+ callbacks?.onSettled?.({ result, input });
41
+ return result;
42
+ } catch (fetchError) {
43
+ const message = fetchError instanceof Error ? fetchError.message : "An unexpected error occurred";
44
+ serverError.value = message;
45
+ status.value = "hasErrored";
46
+ const result = {
47
+ serverError: message
48
+ };
49
+ callbacks?.onError?.({ error: { serverError: message }, input });
50
+ callbacks?.onSettled?.({ result, input });
51
+ return result;
52
+ }
53
+ }
54
+ function execute(input) {
55
+ executeAsync(input);
56
+ }
57
+ function reset() {
58
+ data.value = void 0;
59
+ serverError.value = void 0;
60
+ validationErrors.value = void 0;
61
+ status.value = "idle";
62
+ }
63
+ return {
64
+ execute,
65
+ executeAsync,
66
+ data: readonly(data),
67
+ serverError: readonly(serverError),
68
+ validationErrors: readonly(validationErrors),
69
+ status: readonly(status),
70
+ isIdle: computed(() => status.value === "idle"),
71
+ isExecuting: computed(() => status.value === "executing"),
72
+ hasSucceeded: computed(() => status.value === "hasSucceeded"),
73
+ hasErrored: computed(() => status.value === "hasErrored"),
74
+ reset
75
+ };
76
+ }
@@ -0,0 +1,52 @@
1
+ import type { ZodType } from 'zod';
2
+ import type { SafeAction, SafeActionClientOpts, ActionMetadata, MiddlewareFn, ActionHandler } from '../types.js';
3
+ declare class SafeActionBuilder<TCtx, TInput, TServerError> {
4
+ private _middlewares;
5
+ private _inputSchema?;
6
+ private _outputSchema?;
7
+ private _metadata;
8
+ private _handleServerError?;
9
+ constructor(opts: {
10
+ middlewares: MiddlewareFn<any, any>[];
11
+ inputSchema?: ZodType;
12
+ outputSchema?: ZodType;
13
+ metadata: ActionMetadata;
14
+ handleServerError?: (error: Error) => TServerError;
15
+ });
16
+ /**
17
+ * Add a Zod schema for input validation.
18
+ */
19
+ schema<TSchema extends ZodType>(schema: TSchema): SafeActionBuilder<TCtx, TSchema extends ZodType<infer T> ? T : never, TServerError>;
20
+ /**
21
+ * Add a Zod schema for output validation.
22
+ */
23
+ outputSchema<TSchema extends ZodType>(schema: TSchema): SafeActionBuilder<TCtx, TInput, TServerError>;
24
+ /**
25
+ * Add middleware to the action chain.
26
+ * Middleware runs in order, and each can extend the context.
27
+ */
28
+ use<TNewCtx>(middleware: MiddlewareFn<TCtx, TNewCtx>): SafeActionBuilder<TNewCtx, TInput, TServerError>;
29
+ /**
30
+ * Attach metadata to the action (e.g. action name, tags).
31
+ * Accessible in middleware via `args.metadata`.
32
+ */
33
+ metadata(meta: ActionMetadata): SafeActionBuilder<TCtx, TInput, TServerError>;
34
+ /**
35
+ * Define the action handler. This is the terminal method of the chain.
36
+ * Returns a `SafeAction` object that can be exported from `server/actions/`.
37
+ */
38
+ action<TOutput>(handler: ActionHandler<TCtx, TInput, TOutput>): SafeAction<TInput, TOutput, TServerError>;
39
+ }
40
+ /**
41
+ * Create a safe action client with optional global configuration.
42
+ *
43
+ * ```ts
44
+ * import { createSafeActionClient } from '#safe-action'
45
+ *
46
+ * export const actionClient = createSafeActionClient({
47
+ * handleServerError: (e) => e.message,
48
+ * })
49
+ * ```
50
+ */
51
+ export declare function createSafeActionClient<TServerError = string>(opts?: SafeActionClientOpts<TServerError>): SafeActionBuilder<Record<string, never>, undefined, TServerError>;
52
+ export {};
@@ -0,0 +1,169 @@
1
+ import { ActionError, ActionValidationError } from "./errors.js";
2
+ function formatZodErrors(zodError) {
3
+ const errors = {};
4
+ for (const issue of zodError.issues) {
5
+ const path = issue.path.join(".") || "_root";
6
+ if (!errors[path]) {
7
+ errors[path] = [];
8
+ }
9
+ errors[path].push(issue.message);
10
+ }
11
+ return errors;
12
+ }
13
+ async function executeMiddlewareChain(middlewares, initialCtx, clientInput, metadata, event, innerFn) {
14
+ let execute = innerFn;
15
+ for (let i = middlewares.length - 1; i >= 0; i--) {
16
+ const nextExecute = execute;
17
+ const middleware = middlewares[i];
18
+ execute = async (ctx) => {
19
+ let result;
20
+ await middleware({
21
+ ctx,
22
+ clientInput,
23
+ metadata,
24
+ event,
25
+ next: async ({ ctx: newCtx }) => {
26
+ result = await nextExecute(newCtx);
27
+ return { ctx: newCtx };
28
+ }
29
+ });
30
+ return result;
31
+ };
32
+ }
33
+ return execute(initialCtx);
34
+ }
35
+ class SafeActionBuilder {
36
+ _middlewares;
37
+ _inputSchema;
38
+ _outputSchema;
39
+ _metadata;
40
+ _handleServerError;
41
+ constructor(opts) {
42
+ this._middlewares = opts.middlewares;
43
+ this._inputSchema = opts.inputSchema;
44
+ this._outputSchema = opts.outputSchema;
45
+ this._metadata = opts.metadata;
46
+ this._handleServerError = opts.handleServerError;
47
+ }
48
+ /**
49
+ * Add a Zod schema for input validation.
50
+ */
51
+ schema(schema) {
52
+ return new SafeActionBuilder({
53
+ middlewares: this._middlewares,
54
+ inputSchema: schema,
55
+ outputSchema: this._outputSchema,
56
+ metadata: this._metadata,
57
+ handleServerError: this._handleServerError
58
+ });
59
+ }
60
+ /**
61
+ * Add a Zod schema for output validation.
62
+ */
63
+ outputSchema(schema) {
64
+ return new SafeActionBuilder({
65
+ middlewares: this._middlewares,
66
+ inputSchema: this._inputSchema,
67
+ outputSchema: schema,
68
+ metadata: this._metadata,
69
+ handleServerError: this._handleServerError
70
+ });
71
+ }
72
+ /**
73
+ * Add middleware to the action chain.
74
+ * Middleware runs in order, and each can extend the context.
75
+ */
76
+ use(middleware) {
77
+ return new SafeActionBuilder({
78
+ middlewares: [...this._middlewares, middleware],
79
+ inputSchema: this._inputSchema,
80
+ outputSchema: this._outputSchema,
81
+ metadata: this._metadata,
82
+ handleServerError: this._handleServerError
83
+ });
84
+ }
85
+ /**
86
+ * Attach metadata to the action (e.g. action name, tags).
87
+ * Accessible in middleware via `args.metadata`.
88
+ */
89
+ metadata(meta) {
90
+ return new SafeActionBuilder({
91
+ middlewares: this._middlewares,
92
+ inputSchema: this._inputSchema,
93
+ outputSchema: this._outputSchema,
94
+ metadata: { ...this._metadata, ...meta },
95
+ handleServerError: this._handleServerError
96
+ });
97
+ }
98
+ /**
99
+ * Define the action handler. This is the terminal method of the chain.
100
+ * Returns a `SafeAction` object that can be exported from `server/actions/`.
101
+ */
102
+ action(handler) {
103
+ const config = {
104
+ middlewares: this._middlewares,
105
+ inputSchema: this._inputSchema,
106
+ outputSchema: this._outputSchema,
107
+ metadata: this._metadata,
108
+ handler,
109
+ handleServerError: this._handleServerError
110
+ };
111
+ return {
112
+ _execute: (rawInput, event) => executeAction(rawInput, event, config)
113
+ };
114
+ }
115
+ }
116
+ async function executeAction(rawInput, event, config) {
117
+ try {
118
+ return await executeMiddlewareChain(
119
+ config.middlewares,
120
+ {},
121
+ rawInput,
122
+ config.metadata,
123
+ event,
124
+ async (ctx) => {
125
+ let parsedInput = rawInput;
126
+ if (config.inputSchema) {
127
+ const result = config.inputSchema.safeParse(rawInput);
128
+ if (!result.success) {
129
+ return { validationErrors: formatZodErrors(result.error) };
130
+ }
131
+ parsedInput = result.data;
132
+ }
133
+ const data = await config.handler({
134
+ parsedInput,
135
+ ctx,
136
+ event
137
+ });
138
+ if (config.outputSchema) {
139
+ const result = config.outputSchema.safeParse(data);
140
+ if (!result.success) {
141
+ throw new Error(
142
+ `Output validation failed: ${JSON.stringify(formatZodErrors(result.error))}`
143
+ );
144
+ }
145
+ return { data: result.data };
146
+ }
147
+ return { data };
148
+ }
149
+ );
150
+ } catch (error) {
151
+ if (error instanceof ActionValidationError) {
152
+ return { validationErrors: error.validationErrors };
153
+ }
154
+ if (error instanceof ActionError) {
155
+ return { serverError: error.message };
156
+ }
157
+ if (config.handleServerError) {
158
+ return { serverError: config.handleServerError(error) };
159
+ }
160
+ return { serverError: "An unexpected error occurred" };
161
+ }
162
+ }
163
+ export function createSafeActionClient(opts = {}) {
164
+ return new SafeActionBuilder({
165
+ middlewares: [],
166
+ metadata: {},
167
+ handleServerError: opts.handleServerError
168
+ });
169
+ }
@@ -0,0 +1,31 @@
1
+ import type { ValidationErrors } from '../types.js';
2
+ /**
3
+ * Throw this inside an action handler or middleware to return a typed
4
+ * server error to the client.
5
+ *
6
+ * ```ts
7
+ * throw new ActionError('Not enough credits')
8
+ * ```
9
+ */
10
+ export declare class ActionError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ /**
14
+ * Throw this inside an action handler to return per-field validation
15
+ * errors to the client (useful when Zod alone isn't enough).
16
+ *
17
+ * ```ts
18
+ * throw new ActionValidationError({
19
+ * email: ['This email is already taken'],
20
+ * })
21
+ * ```
22
+ */
23
+ export declare class ActionValidationError extends Error {
24
+ readonly validationErrors: ValidationErrors;
25
+ constructor(validationErrors: ValidationErrors);
26
+ }
27
+ /**
28
+ * Utility to throw validation errors from within an action handler.
29
+ * Shorthand for `throw new ActionValidationError(errors)`.
30
+ */
31
+ export declare function returnValidationErrors(errors: ValidationErrors): never;
@@ -0,0 +1,17 @@
1
+ export class ActionError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "ActionError";
5
+ }
6
+ }
7
+ export class ActionValidationError extends Error {
8
+ validationErrors;
9
+ constructor(validationErrors) {
10
+ super("Validation failed");
11
+ this.name = "ActionValidationError";
12
+ this.validationErrors = validationErrors;
13
+ }
14
+ }
15
+ export function returnValidationErrors(errors) {
16
+ throw new ActionValidationError(errors);
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { SafeAction } from '../types.js';
2
+ /**
3
+ * Creates a Nitro event handler for a given safe action.
4
+ * Used internally by the module to generate route handlers.
5
+ */
6
+ export declare function createActionHandler(action: SafeAction<any, any, any>): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../types.js").ActionResult<any, any>>>;
@@ -0,0 +1,7 @@
1
+ import { defineEventHandler, readBody } from "h3";
2
+ export function createActionHandler(action) {
3
+ return defineEventHandler(async (event) => {
4
+ const body = await readBody(event).catch(() => void 0);
5
+ return action._execute(body, event);
6
+ });
7
+ }
@@ -0,0 +1,3 @@
1
+ export { createSafeActionClient } from './createSafeActionClient.js';
2
+ export { ActionError, ActionValidationError, returnValidationErrors } from './errors.js';
3
+ export type { SafeAction, SafeActionReference, InferSafeActionInput, InferSafeActionOutput, InferSafeActionServerError, } from '../types.js';
@@ -0,0 +1,2 @@
1
+ export { createSafeActionClient } from "./createSafeActionClient.js";
2
+ export { ActionError, ActionValidationError, returnValidationErrors } from "./errors.js";
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "../../../.nuxt/tsconfig.server.json"
3
+ }
@@ -0,0 +1,103 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { ZodType } from 'zod';
3
+ export interface ActionResult<TOutput, TServerError = string> {
4
+ data?: TOutput;
5
+ serverError?: TServerError;
6
+ validationErrors?: ValidationErrors;
7
+ }
8
+ export type ValidationErrors = Record<string, string[]>;
9
+ /**
10
+ * A safe action carries phantom types for input/output inference plus
11
+ * an internal `_execute` method used by the generated Nitro handler.
12
+ *
13
+ * On the **client** side the runtime value is a lightweight reference
14
+ * `{ __safeActionPath: string }` — the types are applied via a generated
15
+ * declaration file so that `useAction` can infer `TInput` and `TOutput`.
16
+ */
17
+ export interface SafeAction<TInput = unknown, TOutput = unknown, TServerError = string> {
18
+ /** phantom field — carries generic types for inference, never set at runtime */
19
+ readonly _types: {
20
+ input: TInput;
21
+ output: TOutput;
22
+ serverError: TServerError;
23
+ };
24
+ /** used by the generated Nitro handler */
25
+ _execute: (rawInput: unknown, event: H3Event) => Promise<ActionResult<TOutput, TServerError>>;
26
+ }
27
+ export interface SafeActionReference<TInput = unknown, TOutput = unknown, TServerError = string> {
28
+ readonly __safeActionPath: string;
29
+ /** phantom field — carries generic types for inference, never set at runtime */
30
+ readonly _types: {
31
+ input: TInput;
32
+ output: TOutput;
33
+ serverError: TServerError;
34
+ };
35
+ }
36
+ export interface MiddlewareArgs<TCtx> {
37
+ ctx: TCtx;
38
+ clientInput: unknown;
39
+ metadata: ActionMetadata;
40
+ event: H3Event;
41
+ next: <TNewCtx>(opts: {
42
+ ctx: TNewCtx;
43
+ }) => Promise<MiddlewareResult<TNewCtx>>;
44
+ }
45
+ export interface MiddlewareResult<TCtx> {
46
+ ctx: TCtx;
47
+ }
48
+ export type MiddlewareFn<TCtxIn = Record<string, unknown>, TCtxOut = TCtxIn> = (args: MiddlewareArgs<TCtxIn>) => Promise<MiddlewareResult<TCtxOut>>;
49
+ export interface ActionHandlerArgs<TCtx, TInput> {
50
+ parsedInput: TInput;
51
+ ctx: TCtx;
52
+ event: H3Event;
53
+ }
54
+ export type ActionHandler<TCtx, TInput, TOutput> = (args: ActionHandlerArgs<TCtx, TInput>) => Promise<TOutput> | TOutput;
55
+ export type ActionMetadata = Record<string, unknown>;
56
+ export interface SafeActionClientOpts<TServerError = string> {
57
+ handleServerError?: (error: Error) => TServerError;
58
+ }
59
+ export interface ActionConfig<TServerError = string> {
60
+ middlewares: MiddlewareFn<any, any>[];
61
+ inputSchema?: ZodType;
62
+ outputSchema?: ZodType;
63
+ metadata: ActionMetadata;
64
+ handler: ActionHandler<any, any, any>;
65
+ handleServerError?: (error: Error) => TServerError;
66
+ }
67
+ export type ActionStatus = 'idle' | 'executing' | 'hasSucceeded' | 'hasErrored';
68
+ export type InferSafeActionInput<T> = T extends SafeAction<infer I, any, any> ? I : T extends SafeActionReference<infer I, any, any> ? I : never;
69
+ export type InferSafeActionOutput<T> = T extends SafeAction<any, infer O, any> ? O : T extends SafeActionReference<any, infer O, any> ? O : never;
70
+ export type InferSafeActionServerError<T> = T extends SafeAction<any, any, infer E> ? E : T extends SafeActionReference<any, any, infer E> ? E : string;
71
+ export interface UseActionReturn<TInput, TOutput, TServerError = string> {
72
+ execute: (input: TInput) => void;
73
+ executeAsync: (input: TInput) => Promise<ActionResult<TOutput, TServerError>>;
74
+ data: Readonly<import('vue').Ref<TOutput | undefined>>;
75
+ serverError: Readonly<import('vue').Ref<TServerError | undefined>>;
76
+ validationErrors: Readonly<import('vue').Ref<ValidationErrors | undefined>>;
77
+ status: Readonly<import('vue').Ref<ActionStatus>>;
78
+ isIdle: import('vue').ComputedRef<boolean>;
79
+ isExecuting: import('vue').ComputedRef<boolean>;
80
+ hasSucceeded: import('vue').ComputedRef<boolean>;
81
+ hasErrored: import('vue').ComputedRef<boolean>;
82
+ reset: () => void;
83
+ }
84
+ export interface UseActionCallbacks<TInput, TOutput, TServerError = string> {
85
+ onSuccess?: (args: {
86
+ data: TOutput;
87
+ input: TInput;
88
+ }) => void;
89
+ onError?: (args: {
90
+ error: {
91
+ serverError?: TServerError;
92
+ validationErrors?: ValidationErrors;
93
+ };
94
+ input: TInput;
95
+ }) => void;
96
+ onSettled?: (args: {
97
+ result: ActionResult<TOutput, TServerError>;
98
+ input: TInput;
99
+ }) => void;
100
+ onExecute?: (args: {
101
+ input: TInput;
102
+ }) => void;
103
+ }
File without changes
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "nuxt-safe-action",
3
+ "version": "0.1.1",
4
+ "description": "Type-safe and validated server actions for Nuxt",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/rutbergphilip/nuxt-safe-action.git"
8
+ },
9
+ "homepage": "https://github.com/rutbergphilip/nuxt-safe-action",
10
+ "bugs": {
11
+ "url": "https://github.com/rutbergphilip/nuxt-safe-action/issues"
12
+ },
13
+ "keywords": [
14
+ "nuxt",
15
+ "nuxt-module",
16
+ "server-actions",
17
+ "type-safe",
18
+ "validation",
19
+ "zod",
20
+ "middleware",
21
+ "vue"
22
+ ],
23
+ "author": "Philip Rutberg",
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/types.d.mts",
29
+ "import": "./dist/module.mjs"
30
+ }
31
+ },
32
+ "main": "./dist/module.mjs",
33
+ "typesVersions": {
34
+ "*": {
35
+ ".": [
36
+ "./dist/types.d.mts"
37
+ ]
38
+ }
39
+ },
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "scripts": {
44
+ "prepack": "nuxt-module-build build",
45
+ "dev": "npm run dev:prepare && nuxt dev playground",
46
+ "dev:build": "nuxt build playground",
47
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
48
+ "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
49
+ "lint": "eslint .",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest watch",
52
+ "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
53
+ },
54
+ "dependencies": {
55
+ "@nuxt/kit": "^4.3.1",
56
+ "defu": "^6.1.4"
57
+ },
58
+ "devDependencies": {
59
+ "@nuxt/devtools": "^3.1.1",
60
+ "@nuxt/eslint-config": "^1.14.0",
61
+ "@nuxt/module-builder": "^1.0.2",
62
+ "@nuxt/schema": "^4.3.1",
63
+ "@nuxt/test-utils": "^4.0.0",
64
+ "@types/node": "latest",
65
+ "changelogen": "^0.6.2",
66
+ "eslint": "^10.0.0",
67
+ "eslint-config-prettier": "^10.1.8",
68
+ "nuxt": "^4.3.1",
69
+ "typescript": "~5.9.3",
70
+ "vitest": "^4.0.18",
71
+ "vue-tsc": "^3.2.4",
72
+ "zod": "^3.24.0"
73
+ },
74
+ "peerDependencies": {
75
+ "zod": "^3.0.0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "zod": {
79
+ "optional": true
80
+ }
81
+ }
82
+ }