diff --git a/.changepacks/changepack_log_4duOtJPsRLtmWBlDS9xSR.json b/.changepacks/changepack_log_4duOtJPsRLtmWBlDS9xSR.json
new file mode 100644
index 0000000..0469615
--- /dev/null
+++ b/.changepacks/changepack_log_4duOtJPsRLtmWBlDS9xSR.json
@@ -0,0 +1 @@
+{"changes":{"packages/webpack-plugin/package.json":"Patch","packages/fetch/package.json":"Patch","packages/next-plugin/package.json":"Patch","packages/vite-plugin/package.json":"Patch","packages/rsbuild-plugin/package.json":"Patch","packages/core/package.json":"Patch","packages/utils/package.json":"Patch","packages/generator/package.json":"Patch"},"note":"Gen server code","date":"2026-04-27T20:38:10.725587800Z"}
\ No newline at end of file
diff --git a/README.md b/README.md
index 16c3534..b468b64 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Just write API calls — the types are already there.
- [Packages](#-packages)
- [API Usage](#-api-usage)
- [Multiple API Servers](#-multiple-api-servers)
+- [Next.js Server Actions](#-nextjs-server-actions)
- [React Query Integration](#-react-query-integration)
- [Advanced Usage](#-advanced-usage)
- [Configuration Options](#-configuration-options)
@@ -64,6 +65,11 @@ devup-api feels like using `fetch`, but with superpowers:
- Automatic type generation during build time
- Zero runtime overhead
+### **⚡ Generated Next.js Server Actions**
+- Generate top-level Server Action functions from OpenAPI operationIds
+- Import actions from `@devup-api/fetch/server` instead of reaching into `df`
+- Cold typing works before generated files exist, then becomes fully typed after the plugin runs
+
---
## 🚀 Quick Start
@@ -714,6 +720,57 @@ type Product = DevupObject<'response', 'openapi2.json'>['Product'] // From open
---
+## ⚡ Next.js Server Actions
+
+devup-api generates named Server Action wrappers for operationId-based API calls by default. This is useful in Next.js App Router projects when you want to call server-side API functions from Client Components without manually writing one action per endpoint.
+
+Set `serverActions.baseUrl` when generated actions should call a specific API origin:
+
+```ts
+// next.config.ts
+import devupApi from '@devup-api/next-plugin'
+
+export default devupApi({
+ reactStrictMode: true,
+ serverActions: {
+ baseUrl: 'https://api.example.com',
+ },
+})
+```
+
+Then import generated actions from the virtual server module:
+
+```tsx
+'use client'
+
+import { getUser } from '@devup-api/fetch/server'
+
+export function UserButton() {
+ return (
+
+ )
+}
+```
+
+The generated `df/server.ts` file contains `'use server'` and exports one named async function for every operationId in your OpenAPI schemas. You should import from `@devup-api/fetch/server`, not from `df/server.ts` directly; the build plugin aliases that module to the generated file.
+
+Generated actions return `DevupApiResponse`. This keeps the same `data` / `error` / `isOk` / `isError` shape as normal `api.get()` calls, while replacing the native `Response` instance with a plain serializable response object that can cross the Server Action boundary.
+
+During cold typing, `@devup-api/fetch/server` is still importable before `df` exists. The fallback keeps initial setup from failing, and the generated module replaces it with strict operation-specific types after `dev` or `build` runs.
+
+Server Actions are enabled by default. Disable generation explicitly with `serverActions: false` or `serverActions: { enabled: false }`.
+
+---
+
## 🔄 React Query Integration
devup-api provides first-class support for TanStack React Query through the `@devup-api/react-query` package. All hooks are fully typed based on your OpenAPI schema.
@@ -1531,6 +1588,16 @@ interface DevupApiOptions {
* @default true
*/
responseDefaultNonNullable?: boolean
+
+ /**
+ * Generate operationId-based Server Action wrappers and expose them via
+ * @devup-api/fetch/server.
+ * @default true
+ */
+ serverActions?: boolean | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
```
@@ -1542,7 +1609,8 @@ interface DevupApiOptions {
2. Extracts paths, methods, schemas, parameters, and request bodies
3. Generates TypeScript interface definitions automatically
4. Creates a URL map for operationId-based API calls
-5. Builds a typed wrapper around `fetch()` with full type safety
+5. Generates named Server Actions in `df/server.ts` by default
+6. Builds a typed wrapper around `fetch()` with full type safety
---
diff --git a/SKILL.md b/SKILL.md
index 10632db..af89d7e 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -150,6 +150,43 @@ api.use({
---
+## @devup-api/fetch/server — Next.js Server Actions
+
+Server Actions are generated by default. Set `serverActions.baseUrl` when generated actions should call a specific API origin.
+
+```ts
+// next.config.ts
+import devupApi from '@devup-api/next-plugin'
+
+export default devupApi({
+ reactStrictMode: true,
+ serverActions: {
+ baseUrl: 'https://api.example.com',
+ },
+})
+```
+
+Use generated actions through the virtual module, never by importing `df/server.ts` directly:
+
+```tsx
+'use client'
+
+import { getUser } from '@devup-api/fetch/server'
+
+const result = await getUser({ params: { id: '123' } })
+```
+
+Notes:
+
+- Generated `df/server.ts` contains `'use server'` and top-level named async exports.
+- Generated actions return `DevupApiResponse`.
+- `@devup-api/fetch/server` has a cold typing fallback before `df` exists.
+- The plugin aliases `@devup-api/fetch/server` to generated `df/server.ts` during dev/build.
+- When enabled, every operationId is generated as a named Server Action export.
+- Disable generation explicitly with `serverActions: false` or `serverActions: { enabled: false }`.
+
+---
+
## @devup-api/react-query — React Query Hooks
```ts
@@ -495,6 +532,11 @@ interface DevupApiOptions {
convertCase?: 'snake' | 'camel' | 'pascal' | 'maintain' // default: 'camel'
requestDefaultNonNullable?: boolean // default: false
responseDefaultNonNullable?: boolean // default: true
+ // default: true; use false or { enabled: false } to disable
+ serverActions?: boolean | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
```
@@ -531,6 +573,7 @@ const api = createApi(import.meta.env.VITE_API_URL || 'http://localhost:3000')
| Issue | Solution |
|-------|----------|
| Types not appearing | Run `npm run dev`, check tsconfig includes `df/**/*.d.ts` |
+| Server Action import fails at runtime | Configure the build plugin so `@devup-api/fetch/server` aliases to generated `df/server.ts` |
| operationId not found | Use path `/users/{id}` or verify openapi.json operationId |
| Zod schemas empty | Ensure bundler plugin is configured, run dev server |
| CRUD config missing | Add `devup:{name}:one` and `devup:{name}:create` tags to OpenAPI |
diff --git a/bun.lock b/bun.lock
index b7ad6b7..8142c31 100644
--- a/bun.lock
+++ b/bun.lock
@@ -96,7 +96,7 @@
},
"packages/core": {
"name": "@devup-api/core",
- "version": "0.1.15",
+ "version": "0.1.17",
"devDependencies": {
"@types/node": "^25.5",
"typescript": "^6.0",
@@ -104,7 +104,7 @@
},
"packages/fetch": {
"name": "@devup-api/fetch",
- "version": "0.1.19",
+ "version": "0.1.20",
"dependencies": {
"@devup-api/core": "workspace:^",
},
@@ -115,7 +115,7 @@
},
"packages/generator": {
"name": "@devup-api/generator",
- "version": "0.1.21",
+ "version": "0.1.23",
"dependencies": {
"@devup-api/core": "workspace:^",
"@devup-api/utils": "workspace:^",
@@ -128,7 +128,7 @@
},
"packages/hookform": {
"name": "@devup-api/hookform",
- "version": "0.1.2",
+ "version": "0.1.3",
"dependencies": {
"@devup-api/fetch": "workspace:^",
"@devup-api/zod": "workspace:^",
@@ -159,7 +159,7 @@
},
"packages/next-plugin": {
"name": "@devup-api/next-plugin",
- "version": "0.1.11",
+ "version": "0.1.12",
"dependencies": {
"@devup-api/core": "workspace:^",
"@devup-api/generator": "workspace:^",
@@ -178,7 +178,7 @@
},
"packages/react-query": {
"name": "@devup-api/react-query",
- "version": "0.1.12",
+ "version": "0.1.13",
"dependencies": {
"@devup-api/fetch": "workspace:^",
"@tanstack/react-query": ">=5.96",
@@ -197,7 +197,7 @@
},
"packages/rsbuild-plugin": {
"name": "@devup-api/rsbuild-plugin",
- "version": "0.1.11",
+ "version": "0.1.12",
"dependencies": {
"@devup-api/core": "workspace:^",
"@devup-api/generator": "workspace:^",
@@ -214,7 +214,7 @@
},
"packages/ui": {
"name": "@devup-api/ui",
- "version": "0.1.1",
+ "version": "0.1.2",
"dependencies": {
"@devup-api/hookform": "workspace:^",
},
@@ -233,7 +233,7 @@
},
"packages/utils": {
"name": "@devup-api/utils",
- "version": "0.1.8",
+ "version": "0.1.9",
"devDependencies": {
"@types/node": "^25.5",
"openapi-types": "^12.1",
@@ -242,7 +242,7 @@
},
"packages/vite-plugin": {
"name": "@devup-api/vite-plugin",
- "version": "0.1.11",
+ "version": "0.1.12",
"dependencies": {
"@devup-api/core": "workspace:^",
"@devup-api/generator": "workspace:^",
@@ -260,7 +260,7 @@
},
"packages/webpack-plugin": {
"name": "@devup-api/webpack-plugin",
- "version": "0.1.11",
+ "version": "0.1.12",
"dependencies": {
"@devup-api/core": "workspace:^",
"@devup-api/generator": "workspace:^",
@@ -277,7 +277,7 @@
},
"packages/zod": {
"name": "@devup-api/zod",
- "version": "0.1.2",
+ "version": "0.1.3",
"dependencies": {
"@devup-api/fetch": "workspace:^",
"zod": ">=4",
diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx
index 90665b4..c3bd902 100644
--- a/examples/next/app/page.tsx
+++ b/examples/next/app/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { createApi, type DevupObject } from '@devup-api/fetch'
+import { getUserById } from '@devup-api/fetch/server'
import { createQueryClient } from '@devup-api/react-query'
import { ApiCrud } from '@devup-api/ui'
import { schemas } from '@devup-api/zod'
@@ -113,6 +114,18 @@ export default function Home() {
+ {
+ getUserById({
+ params: { id: 1 },
+ query: { name: 'John Doe' },
+ }).then((res) => {
+ console.log(res)
+ })
+ }}
+ >
+ hello
+
{(() => {
diff --git a/examples/next/next.config.ts b/examples/next/next.config.ts
index 9e694da..346e5bc 100644
--- a/examples/next/next.config.ts
+++ b/examples/next/next.config.ts
@@ -7,6 +7,9 @@ const config = devupApi(
},
{
openapiFiles: ['./openapi.json', './openapi2.json', './openapi3.json'],
+ serverActions: {
+ baseUrl: 'https://api.example.com',
+ },
},
)
diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts
index fc59687..15f6cc2 100644
--- a/packages/core/src/options.ts
+++ b/packages/core/src/options.ts
@@ -28,4 +28,16 @@ export interface DevupApiOptions extends DevupApiTypeGeneratorOptions {
* @default {'openapi.json'}
*/
openapiFiles?: string[] | string
+
+ /**
+ * Generate Server Action wrappers for operationId-based API calls.
+ *
+ * @default {true}
+ */
+ serverActions?:
+ | boolean
+ | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
diff --git a/packages/fetch/package.json b/packages/fetch/package.json
index b165883..261be0a 100644
--- a/packages/fetch/package.json
+++ b/packages/fetch/package.json
@@ -8,13 +8,18 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
+ },
+ "./server": {
+ "types": "./dist/server.d.ts",
+ "import": "./dist/server.js",
+ "require": "./dist/server.cjs"
}
},
"files": [
"dist"
],
"scripts": {
- "build": "tsc && bun build --target node --outfile=dist/index.js src/index.ts --production --packages=external && bun build --target node --outfile=dist/index.cjs --format=cjs src/index.ts --production --packages=external"
+ "build": "tsc && bun -e \"await Bun.write('dist/server.d.ts', await Bun.file('src/server.d.ts').text())\" && bun build --target node --outfile=dist/index.js src/index.ts --production --packages=external && bun build --target node --outfile=dist/index.cjs --format=cjs src/index.ts --production --packages=external && bun build --target node --outfile=dist/server.js src/server.ts --production --packages=external && bun build --target node --outfile=dist/server.cjs --format=cjs src/server.ts --production --packages=external"
},
"publishConfig": {
"access": "public"
diff --git a/packages/fetch/src/__tests__/index.test.ts b/packages/fetch/src/__tests__/index.test.ts
index 3113f99..71c26e1 100644
--- a/packages/fetch/src/__tests__/index.test.ts
+++ b/packages/fetch/src/__tests__/index.test.ts
@@ -7,5 +7,6 @@ test('index.ts exports', () => {
expect({ ...indexModule }).toEqual({
DevupApi: expect.any(Function),
createApi: expect.any(Function),
+ serializeApiResponse: expect.any(Function),
})
})
diff --git a/packages/fetch/src/__tests__/server.test.ts b/packages/fetch/src/__tests__/server.test.ts
new file mode 100644
index 0000000..5f5ca04
--- /dev/null
+++ b/packages/fetch/src/__tests__/server.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from 'bun:test'
+import type { DevupApiResponse } from '../api'
+import { serializeApiResponse } from '../server-utils'
+
+describe('serializeApiResponse', () => {
+ test('converts successful responses to Server Action-safe plain objects', () => {
+ const response = new Response(JSON.stringify({ id: 1 }), {
+ status: 201,
+ statusText: 'Created',
+ headers: { 'X-Test': 'yes' },
+ })
+ const result: DevupApiResponse<{ id: number }, { message: string }> = {
+ data: { id: 1 },
+ isOk: true,
+ isError: false,
+ response,
+ }
+
+ expect(serializeApiResponse(result)).toEqual({
+ data: { id: 1 },
+ isOk: true,
+ isError: false,
+ response: {
+ headers: {
+ 'content-type': 'text/plain;charset=UTF-8',
+ 'x-test': 'yes',
+ },
+ redirected: false,
+ status: 201,
+ statusText: 'Created',
+ type: response.type,
+ url: '',
+ },
+ })
+ })
+
+ test('converts error responses to Server Action-safe plain objects', () => {
+ const response = new Response('missing', { status: 404 })
+ const result: DevupApiResponse<{ id: number }, { message: string }> = {
+ error: { message: 'Not found' },
+ isOk: false,
+ isError: true,
+ response,
+ }
+
+ expect(serializeApiResponse(result)).toEqual({
+ error: { message: 'Not found' },
+ isOk: false,
+ isError: true,
+ response: {
+ headers: { 'content-type': 'text/plain;charset=UTF-8' },
+ redirected: false,
+ status: 404,
+ statusText: '',
+ type: response.type,
+ url: '',
+ },
+ })
+ })
+})
diff --git a/packages/fetch/src/__tests__/types.test.ts b/packages/fetch/src/__tests__/types.test.ts
index c9d7403..bb98501 100644
--- a/packages/fetch/src/__tests__/types.test.ts
+++ b/packages/fetch/src/__tests__/types.test.ts
@@ -129,6 +129,26 @@ describe('DevupApiResponse', () => {
}>()
expectTypeOf().toEqualTypeOf<{ message: string; code: number }>()
})
+
+ test('response metadata type can be customized', () => {
+ type SerializableResponse = {
+ status: number
+ headers: Record
+ }
+ type Response = DevupApiResponse<
+ { id: number },
+ { message: string },
+ SerializableResponse
+ >
+
+ type SuccessCase = Extract
+ type ErrorCase = Extract
+
+ expectTypeOf<
+ SuccessCase['response']
+ >().toEqualTypeOf()
+ expectTypeOf().toEqualTypeOf()
+ })
})
// =============================================================================
diff --git a/packages/fetch/src/api.ts b/packages/fetch/src/api.ts
index d0c3df8..6533e89 100644
--- a/packages/fetch/src/api.ts
+++ b/packages/fetch/src/api.ts
@@ -29,20 +29,20 @@ import {
} from './utils'
// biome-ignore lint/suspicious/noExplicitAny: any is used to allow for flexibility in the type
-export type DevupApiResponse =
+export type DevupApiResponse =
| {
data: T
error?: undefined
isOk: true
isError: false
- response: Response
+ response: TResponse
}
| {
data?: undefined
error: E
isOk: false
isError: true
- response: Response
+ response: TResponse
}
export class DevupApi> {
diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts
index 1802b29..b0aef10 100644
--- a/packages/fetch/src/index.ts
+++ b/packages/fetch/src/index.ts
@@ -1,3 +1,4 @@
export * from '@devup-api/core'
export * from './api'
export { createApi } from './create-api'
+export * from './server-utils'
diff --git a/packages/fetch/src/server-utils.ts b/packages/fetch/src/server-utils.ts
new file mode 100644
index 0000000..1f380be
--- /dev/null
+++ b/packages/fetch/src/server-utils.ts
@@ -0,0 +1,52 @@
+import type { DevupApiResponse } from './api'
+
+export type SerializedResponse = {
+ headers: Record
+ redirected: boolean
+ status: number
+ statusText: string
+ type: string
+ url: string
+}
+
+export type SerializedDevupApiResponse = DevupApiResponse<
+ T,
+ E,
+ SerializedResponse
+>
+
+function serializeResponse(response: Response): SerializedResponse {
+ return {
+ headers: Object.fromEntries(
+ [...response.headers.entries()].map(([key, value]) => [
+ key.toLowerCase(),
+ value,
+ ]),
+ ),
+ redirected: response.redirected,
+ status: response.status,
+ statusText: response.statusText,
+ type: response.type,
+ url: response.url,
+ }
+}
+
+export function serializeApiResponse(
+ result: DevupApiResponse,
+): DevupApiResponse {
+ if (result.isOk) {
+ return {
+ data: result.data,
+ isOk: true,
+ isError: false,
+ response: serializeResponse(result.response),
+ }
+ }
+
+ return {
+ error: result.error,
+ isOk: false,
+ isError: true,
+ response: serializeResponse(result.response),
+ }
+}
diff --git a/packages/fetch/src/server.d.ts b/packages/fetch/src/server.d.ts
new file mode 100644
index 0000000..6276642
--- /dev/null
+++ b/packages/fetch/src/server.d.ts
@@ -0,0 +1,7 @@
+export type ColdServerAction = (...options: unknown[]) => Promise
+
+export declare const actions: Record
+
+declare module '@devup-api/fetch/server-generated' {}
+
+export * from '@devup-api/fetch/server-generated'
diff --git a/packages/fetch/src/server.ts b/packages/fetch/src/server.ts
new file mode 100644
index 0000000..fa47391
--- /dev/null
+++ b/packages/fetch/src/server.ts
@@ -0,0 +1,16 @@
+export type ColdServerAction = (...options: unknown[]) => Promise
+
+function createColdServerAction(name: string): ColdServerAction {
+ return async () => {
+ throw new Error(
+ `@devup-api/fetch/server.${name} is a cold-typing placeholder. Configure a devup-api bundler plugin so this import is replaced by generated Server Actions.`,
+ )
+ }
+}
+
+export const actions: Record = new Proxy(
+ {},
+ {
+ get: (_target, property) => createColdServerAction(String(property)),
+ },
+)
diff --git a/packages/generator/src/__tests__/generate-server-actions.test.ts b/packages/generator/src/__tests__/generate-server-actions.test.ts
new file mode 100644
index 0000000..2d8b6e1
--- /dev/null
+++ b/packages/generator/src/__tests__/generate-server-actions.test.ts
@@ -0,0 +1,236 @@
+import { describe, expect, test } from 'bun:test'
+import type { OpenAPIV3_1 } from 'openapi-types'
+import {
+ generateServerActionCode,
+ generateServerActionTypes,
+} from '../generate-server-actions'
+
+const createDocument = (
+ doc: Partial = {},
+): OpenAPIV3_1.Document =>
+ ({
+ openapi: '3.1.0',
+ info: { title: 'Test API', version: '1.0.0' },
+ paths: {},
+ ...doc,
+ }) as OpenAPIV3_1.Document
+
+describe('generateServerActionCode', () => {
+ test('generates top-level server action functions for operationIds by default', () => {
+ const result = generateServerActionCode(
+ {
+ 'openapi.json': createDocument({
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'getUsers',
+ responses: { '200': { description: 'Success' } },
+ },
+ post: {
+ operationId: 'createUser',
+ responses: { '201': { description: 'Created' } },
+ },
+ },
+ },
+ }),
+ },
+ { serverActions: { baseUrl: 'https://api.example.com' } },
+ )
+
+ expect(result).toContain("'use server'")
+ expect(result).toContain("import { createApi } from '@devup-api/fetch'")
+ expect(result).toContain(
+ "import { serializeApiResponse } from '@devup-api/fetch'",
+ )
+ expect(result).toContain("baseUrl: 'https://api.example.com'")
+ expect(result).toContain('export async function getUsers(')
+ expect(result).toContain(
+ "return serializeApiResponse(await api.get('getUsers'",
+ )
+ expect(result).toContain('export async function createUser(')
+ expect(result).toContain(
+ "return serializeApiResponse(await api.post('createUser'",
+ )
+ })
+
+ test('generates actions when serverActions is omitted', () => {
+ const result = generateServerActionCode({
+ 'openapi.json': createDocument({
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'getUsers',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ })
+
+ expect(result).toContain("'use server'")
+ expect(result).toContain('export async function getUsers')
+ })
+
+ test('does not generate actions when serverActions is explicitly disabled', () => {
+ const result = generateServerActionCode(
+ {
+ 'openapi.json': createDocument({
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'getUsers',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ },
+ { serverActions: false },
+ )
+
+ expect(result).toContain("'use server'")
+ expect(result).not.toContain('export async function getUsers')
+ })
+
+ test('does not generate actions when serverActions object is disabled', () => {
+ const result = generateServerActionCode(
+ {
+ 'openapi.json': createDocument({
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'getUsers',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ },
+ { serverActions: { enabled: false } },
+ )
+
+ expect(result).toContain("'use server'")
+ expect(result).not.toContain('export async function getUsers')
+ })
+
+ test('generates all operationIds with empty serverActions config', () => {
+ const result = generateServerActionCode(
+ {
+ 'openapi.json': createDocument({
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'getUsers',
+ responses: { '200': { description: 'Success' } },
+ },
+ post: {
+ operationId: 'createUser',
+ responses: { '201': { description: 'Created' } },
+ },
+ },
+ '/users/{id}': {
+ delete: {
+ operationId: 'deleteUser',
+ responses: { '204': { description: 'Deleted' } },
+ },
+ },
+ },
+ }),
+ },
+ {
+ serverActions: {},
+ },
+ )
+
+ expect(result).toContain('export async function getUsers')
+ expect(result).toContain('export async function createUser')
+ expect(result).toContain('export async function deleteUser')
+ })
+
+ test('uses serverName when generating actions for non-default schemas', () => {
+ const result = generateServerActionCode(
+ {
+ 'admin.json': createDocument({
+ paths: {
+ '/admins': {
+ get: {
+ operationId: 'getAdmins',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ },
+ undefined,
+ )
+
+ expect(result).toContain("serverName: 'admin.json'")
+ expect(result).toContain(
+ "return serializeApiResponse(await api.get('getAdmins'",
+ )
+ })
+
+ test('creates safe action names for invalid and duplicate operationIds', () => {
+ const result = generateServerActionCode({
+ 'openapi.json': createDocument({
+ paths: {
+ '/numeric': {
+ get: {
+ operationId: '123NumericUser',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ '/shared': {
+ get: {
+ operationId: 'getSharedUser',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ 'admin-api.json': createDocument({
+ paths: {
+ '/shared': {
+ get: {
+ operationId: 'getSharedUser',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ })
+
+ expect(result).toContain('export async function _123numericUser(')
+ expect(result).toContain('export async function getSharedUser(')
+ expect(result).toContain(
+ 'export async function adminApiJson_getSharedUser(',
+ )
+ })
+})
+
+describe('generateServerActionTypes', () => {
+ test('declares the generated server action module', () => {
+ const result = generateServerActionTypes(
+ {
+ 'openapi.json': createDocument({
+ paths: {
+ '/users': {
+ get: {
+ operationId: 'getUsers',
+ responses: { '200': { description: 'Success' } },
+ },
+ },
+ },
+ }),
+ },
+ undefined,
+ )
+
+ expect(result).toContain(
+ "declare module '@devup-api/fetch/server-generated'",
+ )
+ expect(result).toContain('export function getUsers(')
+ expect(result).toContain('Promise {
extractPathParams: expect.any(Function),
generateCrudConfigCode: expect.any(Function),
generateCrudConfigTypes: expect.any(Function),
+ // Server Action generation
+ generateServerActionCode: expect.any(Function),
+ generateServerActionTypes: expect.any(Function),
parseCrudConfigs: expect.any(Function),
parseCrudConfigsFromMultiple: expect.any(Function),
parseDevupOperations: expect.any(Function),
diff --git a/packages/generator/src/generate-server-actions.ts b/packages/generator/src/generate-server-actions.ts
new file mode 100644
index 0000000..7b57ccd
--- /dev/null
+++ b/packages/generator/src/generate-server-actions.ts
@@ -0,0 +1,194 @@
+import type { DevupApiOptions } from '@devup-api/core'
+import type { OpenAPIV3_1 } from 'openapi-types'
+import { convertCase } from './convert-case'
+
+type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch'
+
+type ServerActionOperation = {
+ actionName: string
+ method: HttpMethod
+ operationId: string
+ serverName: string
+ apiVariableName: string
+}
+
+function isServerActionsEnabled(options?: DevupApiOptions): boolean {
+ const serverActions = options?.serverActions
+ if (typeof serverActions === 'boolean') return serverActions
+ if (!serverActions) return true
+ return serverActions.enabled !== false
+}
+
+function getServerActionsBaseUrl(options?: DevupApiOptions): string {
+ const serverActions = options?.serverActions
+ if (typeof serverActions === 'object') return serverActions.baseUrl ?? ''
+ return ''
+}
+
+function quote(value: string): string {
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
+}
+
+function toIdentifier(value: string): string {
+ const identifier = value.replace(/[^a-zA-Z0-9_$]/g, '_')
+ return /^[a-zA-Z_$]/.test(identifier) ? identifier : `_${identifier}`
+}
+
+function toApiVariableName(serverName: string, index: number): string {
+ if (index === 0) return 'api'
+ return toIdentifier(`${convertCase(serverName, 'camel')}Api`)
+}
+
+function getMethodScopeType(method: HttpMethod): string {
+ const pascal = `${method[0]?.toUpperCase()}${method.slice(1)}`
+ return `Devup${pascal}ApiStructScope`
+}
+
+function collectOperations(
+ schemas: Record,
+ options?: DevupApiOptions,
+): ServerActionOperation[] {
+ const convertCaseType = options?.convertCase ?? 'camel'
+ const operations: ServerActionOperation[] = []
+ const usedActionNames = new Set()
+ let serverIndex = 0
+
+ for (const [serverName, schema] of Object.entries(schemas)) {
+ const apiVariableName = toApiVariableName(serverName, serverIndex)
+ serverIndex += 1
+
+ for (const pathItem of Object.values(schema.paths ?? {})) {
+ if (!pathItem) continue
+ for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) {
+ const operation = pathItem[method]
+ if (!operation?.operationId) continue
+
+ const rawActionName = toIdentifier(
+ convertCase(operation.operationId, convertCaseType),
+ )
+ const actionName = usedActionNames.has(rawActionName)
+ ? toIdentifier(`${convertCase(serverName, 'camel')}_${rawActionName}`)
+ : rawActionName
+ usedActionNames.add(actionName)
+
+ operations.push({
+ actionName,
+ method,
+ operationId: rawActionName,
+ serverName,
+ apiVariableName,
+ })
+ }
+ }
+ }
+
+ return operations
+}
+
+function generateApiDeclarations(
+ operations: ServerActionOperation[],
+ options?: DevupApiOptions,
+): string {
+ const baseUrl = getServerActionsBaseUrl(options)
+ const serverNames = [
+ ...new Set(operations.map((operation) => operation.serverName)),
+ ]
+
+ return serverNames
+ .map((serverName, index) => {
+ const apiVariableName = toApiVariableName(serverName, index)
+ const serverNameProperty =
+ serverName === 'openapi.json'
+ ? ''
+ : `, serverName: ${quote(serverName)}`
+ return `const ${apiVariableName} = createApi({ baseUrl: ${quote(baseUrl)}${serverNameProperty} })`
+ })
+ .join('\n')
+}
+
+function generateAction(operation: ServerActionOperation): string {
+ const scopeType = `${operation.actionName}Scope`
+ const methodScopeType = getMethodScopeType(operation.method)
+ return `type ${scopeType} = Additional<${quote(operation.operationId)}, ${methodScopeType}<${quote(operation.serverName)}>>
+
+export async function ${operation.actionName}(
+ ...options: ApiOption<${scopeType}>
+): Promise, ExtractValue<${scopeType}, 'error'>, SerializedResponse>> {
+ return serializeApiResponse(await ${operation.apiVariableName}.${operation.method}(${quote(operation.operationId)}, ...options))
+}`
+}
+
+function generateActionDeclaration(operation: ServerActionOperation): string {
+ const scopeType = `${operation.actionName}Scope`
+ const methodScopeType = getMethodScopeType(operation.method)
+ return `type ${scopeType} = Additional<${quote(operation.operationId)}, ${methodScopeType}<${quote(operation.serverName)}>>
+ export function ${operation.actionName}(
+ ...options: ApiOption<${scopeType}>
+ ): Promise, ExtractValue<${scopeType}, 'error'>, SerializedResponse>>`
+}
+
+export function generateServerActionCode(
+ schemas: Record,
+ options?: DevupApiOptions,
+): string {
+ const operations = isServerActionsEnabled(options)
+ ? collectOperations(schemas, options)
+ : []
+ const apiDeclarations = generateApiDeclarations(operations, options)
+ const actions = operations.map(generateAction).join('\n\n')
+
+ return `// Auto-generated Server Actions from OpenAPI specs
+// Do not edit this file directly
+'use server'
+
+import type {
+ Additional,
+ ApiOption,
+ ExtractValue,
+ DevupDeleteApiStructScope,
+ DevupGetApiStructScope,
+ DevupPatchApiStructScope,
+ DevupPostApiStructScope,
+ DevupPutApiStructScope,
+ DevupApiResponse,
+ SerializedResponse,
+} from '@devup-api/fetch'
+import { createApi } from '@devup-api/fetch'
+import { serializeApiResponse } from '@devup-api/fetch'
+
+${apiDeclarations}
+
+${actions}
+`
+}
+
+export function generateServerActionTypes(
+ schemas: Record,
+ options?: DevupApiOptions,
+): string {
+ const operations = isServerActionsEnabled(options)
+ ? collectOperations(schemas, options)
+ : []
+ const declarations = operations.map(generateActionDeclaration).join('\n\n')
+
+ return `// Auto-generated Server Action types from OpenAPI specs
+// Do not edit this file directly
+
+declare module '@devup-api/fetch/server-generated' {
+ import type {
+ Additional,
+ ApiOption,
+ ExtractValue,
+ DevupDeleteApiStructScope,
+ DevupGetApiStructScope,
+ DevupPatchApiStructScope,
+ DevupPostApiStructScope,
+ DevupPutApiStructScope,
+ DevupApiResponse,
+ SerializedResponse,
+ } from '@devup-api/fetch'
+
+ ${declarations}
+}
+`
+}
diff --git a/packages/generator/src/index.ts b/packages/generator/src/index.ts
index ef9a697..f02852b 100644
--- a/packages/generator/src/index.ts
+++ b/packages/generator/src/index.ts
@@ -2,6 +2,7 @@ export * from './create-url-map'
export * from './crud-types'
export * from './generate-crud-config'
export * from './generate-interface'
+export * from './generate-server-actions'
export * from './generate-zod'
export * from './openapi-utils'
export * from './parse-crud-tags'
diff --git a/packages/next-plugin/README.md b/packages/next-plugin/README.md
index 687b939..78898aa 100644
--- a/packages/next-plugin/README.md
+++ b/packages/next-plugin/README.md
@@ -72,6 +72,16 @@ interface DevupApiOptions {
* @default true
*/
responseDefaultNonNullable?: boolean
+
+ /**
+ * Generate operationId-based Server Action wrappers.
+ * Disable with false or { enabled: false }.
+ * @default true
+ */
+ serverActions?: boolean | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
```
@@ -83,14 +93,16 @@ When using Turbopack (Next.js 13+), the plugin:
1. Reads your `openapi.json` file
2. Generates TypeScript interface definitions
3. Creates a URL map and sets it as `process.env.DEVUP_API_URL_MAP`
-4. Makes types available for use with `@devup-api/fetch`
+4. Generates Server Actions in `df/server.ts` by default
+5. Makes types available for use with `@devup-api/fetch`
### Webpack Mode
When using Webpack, the plugin uses `@devup-api/webpack-plugin` internally to:
1. Generate types during build time
2. Inject URL map via webpack DefinePlugin
-3. Make types available for use with `@devup-api/fetch`
+3. Generate Server Actions in `df/server.ts` by default
+4. Make types available for use with `@devup-api/fetch`
## TypeScript Configuration
diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts
index 6fd132d..a62c23e 100644
--- a/packages/next-plugin/src/plugin.ts
+++ b/packages/next-plugin/src/plugin.ts
@@ -5,6 +5,8 @@ import {
generateCrudConfigCode,
generateCrudConfigTypes,
generateInterface,
+ generateServerActionCode,
+ generateServerActionTypes,
generateZodSchemas,
generateZodTypeDeclarations,
} from '@devup-api/generator'
@@ -47,6 +49,8 @@ export function devupApi(
generateZodTypeDeclarations,
generateCrudConfigCode,
generateCrudConfigTypes,
+ generateServerActionCode,
+ generateServerActionTypes,
createUrlMap,
}
@@ -69,6 +73,7 @@ export function devupApi(
'@devup-api/ui/crud': toRelativePath(
resolve(tempDir, 'crud-configs.jsx'),
),
+ '@devup-api/fetch/server': toRelativePath(resolve(tempDir, 'server.ts')),
})
return config
diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md
index 7c184e3..5c4be53 100644
--- a/packages/rsbuild-plugin/README.md
+++ b/packages/rsbuild-plugin/README.md
@@ -73,6 +73,16 @@ interface DevupApiOptions {
* @default true
*/
responseDefaultNonNullable?: boolean
+
+ /**
+ * Generate operationId-based Server Action wrappers.
+ * Disable with false or { enabled: false }.
+ * @default true
+ */
+ serverActions?: boolean | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
```
@@ -81,7 +91,8 @@ interface DevupApiOptions {
1. Reads your `openapi.json` file during build
2. Generates TypeScript interface definitions (`api.d.ts`)
3. Creates a URL map and injects it as `process.env.DEVUP_API_URL_MAP` via Rsbuild's define feature
-4. Makes types available for use with `@devup-api/fetch`
+4. Generates Server Actions in `df/server.ts` by default
+5. Makes types available for use with `@devup-api/fetch`
## TypeScript Configuration
diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts
index cca7f2e..28266d2 100644
--- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts
+++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts
@@ -130,8 +130,8 @@ test.each([
mockSchema,
options,
)
- // 5 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts
- expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(5)
+ // 7 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts, server.ts, server-module.d.ts
+ expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(7)
expect(mockWriteInterfaceAsync).toHaveBeenCalledWith(
join('df', 'api.d.ts'),
mockInterfaceContent,
@@ -165,6 +165,7 @@ test('devupApiRsbuildPlugin setup hook modifies config with urlMap and alias', a
alias: {
'@devup-api/zod': resolve('df', 'zod-schemas.js'),
'@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'),
+ '@devup-api/fetch/server': resolve('df', 'server.ts'),
},
},
source: {
@@ -192,6 +193,7 @@ test('devupApiRsbuildPlugin setup hook handles config without source', async ()
alias: {
'@devup-api/zod': resolve('df', 'zod-schemas.js'),
'@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'),
+ '@devup-api/fetch/server': resolve('df', 'server.ts'),
},
},
source: {
@@ -221,6 +223,7 @@ test('devupApiRsbuildPlugin setup hook handles config without define', async ()
alias: {
'@devup-api/zod': resolve('df', 'zod-schemas.js'),
'@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'),
+ '@devup-api/fetch/server': resolve('df', 'server.ts'),
},
},
source: {
@@ -251,6 +254,7 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is null',
alias: {
'@devup-api/zod': resolve('df', 'zod-schemas.js'),
'@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'),
+ '@devup-api/fetch/server': resolve('df', 'server.ts'),
},
},
source: {
@@ -277,6 +281,7 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is undefi
alias: {
'@devup-api/zod': resolve('df', 'zod-schemas.js'),
'@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'),
+ '@devup-api/fetch/server': resolve('df', 'server.ts'),
},
},
source: {
@@ -303,6 +308,7 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is empty
alias: {
'@devup-api/zod': resolve('df', 'zod-schemas.js'),
'@devup-api/ui/crud': resolve('df', 'crud-configs.jsx'),
+ '@devup-api/fetch/server': resolve('df', 'server.ts'),
},
},
source: {
diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts
index 19ceaad..a216928 100644
--- a/packages/rsbuild-plugin/src/plugin.ts
+++ b/packages/rsbuild-plugin/src/plugin.ts
@@ -5,6 +5,8 @@ import {
generateCrudConfigCode,
generateCrudConfigTypes,
generateInterface,
+ generateServerActionCode,
+ generateServerActionTypes,
generateZodSchemas,
generateZodTypeDeclarations,
} from '@devup-api/generator'
@@ -38,6 +40,8 @@ export function devupApiRsbuildPlugin(
generateZodTypeDeclarations,
generateCrudConfigCode,
generateCrudConfigTypes,
+ generateServerActionCode,
+ generateServerActionTypes,
createUrlMap,
}
@@ -50,6 +54,7 @@ export function devupApiRsbuildPlugin(
// Get absolute paths for virtual modules
const zodSchemasPath = resolve(tempDir, 'zod-schemas.js')
const crudConfigsPath = resolve(tempDir, 'crud-configs.jsx')
+ const serverPath = resolve(tempDir, 'server.ts')
build.modifyRsbuildConfig((config) => {
config.source ??= {}
@@ -70,6 +75,9 @@ export function devupApiRsbuildPlugin(
;(config.resolve.alias as Record)[
'@devup-api/ui/crud'
] = crudConfigsPath
+ ;(config.resolve.alias as Record)[
+ '@devup-api/fetch/server'
+ ] = serverPath
return config
})
diff --git a/packages/utils/src/__tests__/generate-devup.test.ts b/packages/utils/src/__tests__/generate-devup.test.ts
index b3dbe4d..e3223d6 100644
--- a/packages/utils/src/__tests__/generate-devup.test.ts
+++ b/packages/utils/src/__tests__/generate-devup.test.ts
@@ -47,6 +47,8 @@ function createMockGenerators(): DevupGenerators<{
generateZodTypeDeclarations: mock(() => 'zod-types-content'),
generateCrudConfigCode: mock(() => 'crud-config-content'),
generateCrudConfigTypes: mock(() => 'crud-types-content'),
+ generateServerActionCode: mock(() => 'server-actions-content'),
+ generateServerActionTypes: mock(() => 'server-actions-types-content'),
createUrlMap: mock(() => mockUrlMap),
}
}
@@ -86,6 +88,8 @@ test('generateDevupArtifactsAsync returns correct artifacts', async () => {
expect(result.files.zodTypes).toBe('zod-types-content')
expect(result.files.crudConfig).toBe('crud-config-content')
expect(result.files.crudTypes).toBe('crud-types-content')
+ expect(result.files.serverActions).toBe('server-actions-content')
+ expect(result.files.serverActionTypes).toBe('server-actions-types-content')
expect(result.urlMap).toBe(mockUrlMap)
})
@@ -122,16 +126,24 @@ test('generateDevupArtifactsAsync calls all generators with schemas and options'
)
expect(generators.generateCrudConfigCode).toHaveBeenCalledWith(mockSchemas)
expect(generators.generateCrudConfigTypes).toHaveBeenCalledWith(mockSchemas)
+ expect(generators.generateServerActionCode).toHaveBeenCalledWith(
+ mockSchemas,
+ options,
+ )
+ expect(generators.generateServerActionTypes).toHaveBeenCalledWith(
+ mockSchemas,
+ options,
+ )
expect(generators.createUrlMap).toHaveBeenCalledWith(mockSchemas, options)
})
-test('generateDevupArtifactsAsync writes all 5 files to tempDir', async () => {
+test('generateDevupArtifactsAsync writes all 7 files to tempDir', async () => {
const io = createMockIOAsync()
const generators = createMockGenerators()
await generateDevupArtifactsAsync(io, generators)
- expect(io.writeInterfaceAsync).toHaveBeenCalledTimes(5)
+ expect(io.writeInterfaceAsync).toHaveBeenCalledTimes(7)
expect(io.writeInterfaceAsync).toHaveBeenCalledWith(
join('df', 'api.d.ts'),
'interface-content',
@@ -152,6 +164,14 @@ test('generateDevupArtifactsAsync writes all 5 files to tempDir', async () => {
join('df', 'ui.d.ts'),
'crud-types-content',
)
+ expect(io.writeInterfaceAsync).toHaveBeenCalledWith(
+ join('df', 'server.ts'),
+ 'server-actions-content',
+ )
+ expect(io.writeInterfaceAsync).toHaveBeenCalledWith(
+ join('df', 'server-module.d.ts'),
+ 'server-actions-types-content',
+ )
})
test('generateDevupArtifactsAsync passes undefined options when none provided', async () => {
@@ -186,6 +206,8 @@ test('generateDevupArtifacts returns correct artifacts', () => {
expect(result.files.zodTypes).toBe('zod-types-content')
expect(result.files.crudConfig).toBe('crud-config-content')
expect(result.files.crudTypes).toBe('crud-types-content')
+ expect(result.files.serverActions).toBe('server-actions-content')
+ expect(result.files.serverActionTypes).toBe('server-actions-types-content')
expect(result.urlMap).toBe(mockUrlMap)
})
@@ -222,16 +244,24 @@ test('generateDevupArtifacts calls all generators with schemas and options', ()
)
expect(generators.generateCrudConfigCode).toHaveBeenCalledWith(mockSchemas)
expect(generators.generateCrudConfigTypes).toHaveBeenCalledWith(mockSchemas)
+ expect(generators.generateServerActionCode).toHaveBeenCalledWith(
+ mockSchemas,
+ options,
+ )
+ expect(generators.generateServerActionTypes).toHaveBeenCalledWith(
+ mockSchemas,
+ options,
+ )
expect(generators.createUrlMap).toHaveBeenCalledWith(mockSchemas, options)
})
-test('generateDevupArtifacts writes all 5 files to tempDir', () => {
+test('generateDevupArtifacts writes all 7 files to tempDir', () => {
const io = createMockIOSync()
const generators = createMockGenerators()
generateDevupArtifacts(io, generators)
- expect(io.writeInterface).toHaveBeenCalledTimes(5)
+ expect(io.writeInterface).toHaveBeenCalledTimes(7)
expect(io.writeInterface).toHaveBeenCalledWith(
join('df', 'api.d.ts'),
'interface-content',
@@ -252,6 +282,14 @@ test('generateDevupArtifacts writes all 5 files to tempDir', () => {
join('df', 'ui.d.ts'),
'crud-types-content',
)
+ expect(io.writeInterface).toHaveBeenCalledWith(
+ join('df', 'server.ts'),
+ 'server-actions-content',
+ )
+ expect(io.writeInterface).toHaveBeenCalledWith(
+ join('df', 'server-module.d.ts'),
+ 'server-actions-types-content',
+ )
})
test('generateDevupArtifacts passes undefined options when none provided', () => {
diff --git a/packages/utils/src/generate-devup.ts b/packages/utils/src/generate-devup.ts
index bb14a97..267a65c 100644
--- a/packages/utils/src/generate-devup.ts
+++ b/packages/utils/src/generate-devup.ts
@@ -33,6 +33,14 @@ export interface DevupGenerators {
generateCrudConfigTypes: (
schemas: Record,
) => string
+ generateServerActionCode: (
+ schemas: Record,
+ options?: TOptions,
+ ) => string
+ generateServerActionTypes: (
+ schemas: Record,
+ options?: TOptions,
+ ) => string
createUrlMap: (
schemas: Record,
options?: TOptions,
@@ -70,6 +78,8 @@ export interface DevupGeneratedFiles {
zodTypes: string
crudConfig: string
crudTypes: string
+ serverActions: string
+ serverActionTypes: string
}
/**
@@ -103,6 +113,8 @@ export async function generateDevupArtifactsAsync<
zodTypes: generators.generateZodTypeDeclarations(schemas, options),
crudConfig: generators.generateCrudConfigCode(schemas),
crudTypes: generators.generateCrudConfigTypes(schemas),
+ serverActions: generators.generateServerActionCode(schemas, options),
+ serverActionTypes: generators.generateServerActionTypes(schemas, options),
}
await Promise.all([
@@ -111,6 +123,11 @@ export async function generateDevupArtifactsAsync<
io.writeInterfaceAsync(join(tempDir, 'zod.d.ts'), files.zodTypes),
io.writeInterfaceAsync(join(tempDir, 'crud-configs.jsx'), files.crudConfig),
io.writeInterfaceAsync(join(tempDir, 'ui.d.ts'), files.crudTypes),
+ io.writeInterfaceAsync(join(tempDir, 'server.ts'), files.serverActions),
+ io.writeInterfaceAsync(
+ join(tempDir, 'server-module.d.ts'),
+ files.serverActionTypes,
+ ),
])
const urlMap = generators.createUrlMap(schemas, options)
@@ -136,6 +153,8 @@ export function generateDevupArtifacts(
zodTypes: generators.generateZodTypeDeclarations(schemas, options),
crudConfig: generators.generateCrudConfigCode(schemas),
crudTypes: generators.generateCrudConfigTypes(schemas),
+ serverActions: generators.generateServerActionCode(schemas, options),
+ serverActionTypes: generators.generateServerActionTypes(schemas, options),
}
io.writeInterface(join(tempDir, 'api.d.ts'), files.interface)
@@ -143,6 +162,11 @@ export function generateDevupArtifacts(
io.writeInterface(join(tempDir, 'zod.d.ts'), files.zodTypes)
io.writeInterface(join(tempDir, 'crud-configs.jsx'), files.crudConfig)
io.writeInterface(join(tempDir, 'ui.d.ts'), files.crudTypes)
+ io.writeInterface(join(tempDir, 'server.ts'), files.serverActions)
+ io.writeInterface(
+ join(tempDir, 'server-module.d.ts'),
+ files.serverActionTypes,
+ )
const urlMap = generators.createUrlMap(schemas, options)
return { tempDir, schemas, files, urlMap }
diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md
index 7a75877..0447720 100644
--- a/packages/vite-plugin/README.md
+++ b/packages/vite-plugin/README.md
@@ -73,6 +73,16 @@ interface DevupApiOptions {
* @default true
*/
responseDefaultNonNullable?: boolean
+
+ /**
+ * Generate operationId-based Server Action wrappers.
+ * Disable with false or { enabled: false }.
+ * @default true
+ */
+ serverActions?: boolean | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
```
@@ -81,7 +91,8 @@ interface DevupApiOptions {
1. Reads your `openapi.json` file during build
2. Generates TypeScript interface definitions (`api.d.ts`)
3. Creates a URL map and injects it as `process.env.DEVUP_API_URL_MAP`
-4. Makes types available for use with `@devup-api/fetch`
+4. Generates Server Actions in `df/server.ts` by default
+5. Makes types available for use with `@devup-api/fetch`
## TypeScript Configuration
diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts
index 97bbdab..0d4cbbd 100644
--- a/packages/vite-plugin/src/__tests__/plugin.test.ts
+++ b/packages/vite-plugin/src/__tests__/plugin.test.ts
@@ -14,6 +14,7 @@ let mockGenerateZodSchemas: ReturnType
let mockGenerateZodTypeDeclarations: ReturnType
let mockGenerateCrudConfigCode: ReturnType
let mockGenerateCrudConfigTypes: ReturnType
+let mockGenerateServerActionCode: ReturnType
const mockSchema = {
openapi: '3.1.0',
@@ -53,6 +54,7 @@ const mockZodSchemasContent = 'export const schemas = {}'
const mockZodTypeDeclarationsContent = 'declare module "@devup-api/zod" {}'
const mockCrudConfigCodeContent = 'export function UserCrud() {}'
const mockCrudConfigTypesContent = 'declare module "@devup-api/ui/crud" {}'
+const mockServerActionsContent = "'use server'"
beforeEach(() => {
mockCreateTmpDirAsync = spyOn(utils, 'createTmpDirAsync').mockResolvedValue(
@@ -87,6 +89,10 @@ beforeEach(() => {
generator,
'generateCrudConfigTypes',
).mockReturnValue(mockCrudConfigTypesContent)
+ mockGenerateServerActionCode = spyOn(
+ generator,
+ 'generateServerActionCode',
+ ).mockReturnValue(mockServerActionsContent)
})
test('devupApi returns plugin with correct name', () => {
@@ -294,6 +300,34 @@ test('devupApi resolveId returns null for partial ui module path', () => {
expect(resolveId('@devup-api/ui/other')).toBeNull()
})
+// =============================================================================
+// Virtual Server Module Tests
+// =============================================================================
+
+test('devupApi resolveId returns resolved virtual module for @devup-api/fetch/server', () => {
+ const plugin = devupApi()
+ const resolveId = plugin.resolveId as (id: string) => string | null
+
+ const result = resolveId('@devup-api/fetch/server')
+ expect(result).toBe('\0@devup-api/fetch/server')
+})
+
+test('devupApi load returns server action code for virtual server module', async () => {
+ const plugin = devupApi()
+ const load = plugin.load as (id: string) => Promise
+
+ const result = await load('\0@devup-api/fetch/server')
+ expect(result).toBe(mockServerActionsContent)
+ expect(mockGenerateServerActionCode).toHaveBeenCalled()
+})
+
+test('devupApi load returns null for non-resolved server module', async () => {
+ const plugin = devupApi()
+ const load = plugin.load as (id: string) => Promise
+
+ expect(await load('@devup-api/fetch/server')).toBeNull()
+})
+
test('devupApi load returns crud config code for virtual ui module', async () => {
const plugin = devupApi()
const load = plugin.load as (id: string) => Promise
diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts
index dd48000..ff98154 100644
--- a/packages/vite-plugin/src/plugin.ts
+++ b/packages/vite-plugin/src/plugin.ts
@@ -4,6 +4,8 @@ import {
generateCrudConfigCode,
generateCrudConfigTypes,
generateInterface,
+ generateServerActionCode,
+ generateServerActionTypes,
generateZodSchemas,
generateZodTypeDeclarations,
} from '@devup-api/generator'
@@ -25,6 +27,9 @@ const RESOLVED_VIRTUAL_ZOD_MODULE = `\0${VIRTUAL_ZOD_MODULE}`
const VIRTUAL_UI_MODULE = '@devup-api/ui/crud'
const RESOLVED_VIRTUAL_UI_MODULE = `\0${VIRTUAL_UI_MODULE}`
+const VIRTUAL_SERVER_MODULE = '@devup-api/fetch/server'
+const RESOLVED_VIRTUAL_SERVER_MODULE = `\0${VIRTUAL_SERVER_MODULE}`
+
export function devupApi(options?: DevupApiOptions): Plugin {
let artifacts: DevupArtifacts | null = null
@@ -41,6 +46,8 @@ export function devupApi(options?: DevupApiOptions): Plugin {
generateZodTypeDeclarations,
generateCrudConfigCode,
generateCrudConfigTypes,
+ generateServerActionCode,
+ generateServerActionTypes,
createUrlMap,
}
@@ -62,6 +69,9 @@ export function devupApi(options?: DevupApiOptions): Plugin {
if (id === VIRTUAL_UI_MODULE) {
return RESOLVED_VIRTUAL_UI_MODULE
}
+ if (id === VIRTUAL_SERVER_MODULE) {
+ return RESOLVED_VIRTUAL_SERVER_MODULE
+ }
return null
},
@@ -75,6 +85,10 @@ export function devupApi(options?: DevupApiOptions): Plugin {
const { files } = await getArtifacts()
return files.crudConfig
}
+ if (id === RESOLVED_VIRTUAL_SERVER_MODULE) {
+ const { files } = await getArtifacts()
+ return files.serverActions
+ }
return null
},
diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md
index e9fa691..01ee273 100644
--- a/packages/webpack-plugin/README.md
+++ b/packages/webpack-plugin/README.md
@@ -86,6 +86,16 @@ interface DevupApiOptions {
* @default true
*/
responseDefaultNonNullable?: boolean
+
+ /**
+ * Generate operationId-based Server Action wrappers.
+ * Disable with false or { enabled: false }.
+ * @default true
+ */
+ serverActions?: boolean | {
+ enabled?: boolean
+ baseUrl?: string
+ }
}
```
@@ -94,7 +104,8 @@ interface DevupApiOptions {
1. Reads your `openapi.json` file during build (before compilation)
2. Generates TypeScript interface definitions (`api.d.ts`)
3. Creates a URL map and injects it as `process.env.DEVUP_API_URL_MAP` via webpack DefinePlugin
-4. Makes types available for use with `@devup-api/fetch`
+4. Generates Server Actions in `df/server.ts` by default
+5. Makes types available for use with `@devup-api/fetch`
## TypeScript Configuration
diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts
index c9e6fba..f460bec 100644
--- a/packages/webpack-plugin/src/__tests__/plugin.test.ts
+++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts
@@ -231,8 +231,8 @@ test.each([
join('df', 'zod.d.ts'),
mockZodTypeDeclarationsContent,
)
- // 5 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts
- expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(5)
+ // 7 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts, server.ts, server-module.d.ts
+ expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(7)
expect(mockCreateUrlMap).toHaveBeenCalledWith(mockSchema, options || {})
expect(definePluginApplySpy).toHaveBeenCalled()
expect(normalModuleReplacementPluginApplySpy).toHaveBeenCalled()
@@ -322,8 +322,8 @@ test('devupApiWebpackPlugin beforeCompile hook only runs once when called multip
expect(mockGenerateInterface).toHaveBeenCalledTimes(1)
expect(mockGenerateZodSchemas).toHaveBeenCalledTimes(1)
expect(mockGenerateZodTypeDeclarations).toHaveBeenCalledTimes(1)
- // 5 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts
- expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(5)
+ // 7 files written: api.d.ts, zod-schemas.js, zod.d.ts, crud-config.js, ui.d.ts, server.ts, server-module.d.ts
+ expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(7)
expect(mockCreateUrlMap).toHaveBeenCalledTimes(1)
expect(mockCallback1).toHaveBeenCalled()
expect(mockCallback2).toHaveBeenCalled()
diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts
index e812cea..bc68c87 100644
--- a/packages/webpack-plugin/src/plugin.ts
+++ b/packages/webpack-plugin/src/plugin.ts
@@ -5,6 +5,8 @@ import {
generateCrudConfigCode,
generateCrudConfigTypes,
generateInterface,
+ generateServerActionCode,
+ generateServerActionTypes,
generateZodSchemas,
generateZodTypeDeclarations,
} from '@devup-api/generator'
@@ -54,6 +56,8 @@ export class devupApiWebpackPlugin {
generateZodTypeDeclarations,
generateCrudConfigCode,
generateCrudConfigTypes,
+ generateServerActionCode,
+ generateServerActionTypes,
createUrlMap,
}
@@ -90,6 +94,12 @@ export class devupApiWebpackPlugin {
crudConfigPath,
).apply(compiler)
+ const serverPath = resolve(tempDir, 'server.ts')
+ new compiler.webpack.NormalModuleReplacementPlugin(
+ /^@devup-api\/fetch\/server$/,
+ serverPath,
+ ).apply(compiler)
+
callback()
} catch (error) {
this.initialized = false