├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── .nuxtrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── playground ├── api │ ├── test.ts │ └── test2.ts ├── app.vue ├── nuxt.config.ts ├── package.json ├── server │ ├── api │ │ └── test.ts │ └── tsconfig.json └── tsconfig.json ├── pnpm-lock.yaml ├── src ├── module.ts ├── plugins │ ├── clientApi.ts │ └── serverApi.ts ├── resolver.ts ├── runtime │ ├── clientHandler.ts │ ├── defineApi.ts │ ├── defineFormDataApi.ts │ ├── defineServerApi.ts │ └── zod.ts └── utils │ ├── __test__ │ └── api.test.ts │ └── api.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@nuxt/eslint-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | imports.autoImport=true 2 | typescript.includeWorkspace=true 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## v0.0.9 5 | 6 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.8...v0.0.9) 7 | 8 | ### 🩹 Fixes 9 | 10 | - Ensure `handler` return type in `defineApi` is always a Promise ([a5b9aa2](https://github.com/hminghe/nuxt-unapi/commit/a5b9aa2)) 11 | 12 | ### 📖 Documentation 13 | 14 | - Update README.md ([618baf6](https://github.com/hminghe/nuxt-unapi/commit/618baf6)) 15 | 16 | ### ❤️ Contributors 17 | 18 | - Hminghe ([@hminghe](http://github.com/hminghe)) 19 | 20 | ## v0.0.8 21 | 22 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.7...v0.0.8) 23 | 24 | ### 🚀 Enhancements 25 | 26 | - Add `TransformClientType` to distinguish server and client types ([dbffba2](https://github.com/hminghe/nuxt-unapi/commit/dbffba2)) 27 | 28 | ### 💅 Refactors 29 | 30 | - Rename `zFile` to `zodFile` and move to `zod.ts` ([908c8b0](https://github.com/hminghe/nuxt-unapi/commit/908c8b0)) 31 | - Organize automatic import methods ([9a2372e](https://github.com/hminghe/nuxt-unapi/commit/9a2372e)) 32 | 33 | ### 🏡 Chore 34 | 35 | - Remove console.log ([00105b5](https://github.com/hminghe/nuxt-unapi/commit/00105b5)) 36 | 37 | ### ❤️ Contributors 38 | 39 | - Hminghe ([@hminghe](http://github.com/hminghe)) 40 | 41 | ## v0.0.7 42 | 43 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.6...v0.0.7) 44 | 45 | ### 🏡 Chore 46 | 47 | - Auto-import defineFormDataApi ([901a7d1](https://github.com/hminghe/nuxt-unapi/commit/901a7d1)) 48 | 49 | ### ❤️ Contributors 50 | 51 | - Hminghe ([@hminghe](http://github.com/hminghe)) 52 | 53 | ## v0.0.6 54 | 55 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.5...v0.0.6) 56 | 57 | ### 🩹 Fixes 58 | 59 | - Replace 'handle' with 'handler' ([add9240](https://github.com/hminghe/nuxt-unapi/commit/add9240)) 60 | 61 | ### ❤️ Contributors 62 | 63 | - Hminghe ([@hminghe](http://github.com/hminghe)) 64 | 65 | ## v0.0.5 66 | 67 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.4...v0.0.5) 68 | 69 | ### 🚀 Enhancements 70 | 71 | - Enhance file upload compatibility ([94677ef](https://github.com/hminghe/nuxt-unapi/commit/94677ef)) 72 | - Add defineFormDataApi ([ea9922d](https://github.com/hminghe/nuxt-unapi/commit/ea9922d)) 73 | 74 | ### 💅 Refactors 75 | 76 | - Rename parameter in defineApi ([0028cef](https://github.com/hminghe/nuxt-unapi/commit/0028cef)) 77 | 78 | ### ❤️ Contributors 79 | 80 | - Hminghe ([@hminghe](http://github.com/hminghe)) 81 | 82 | ## v0.0.4 83 | 84 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.1...v0.0.4) 85 | 86 | ### 🚀 Enhancements 87 | 88 | - Add middleware ([8741c7b](https://github.com/hminghe/nuxt-unapi/commit/8741c7b)) 89 | 90 | ### 🩹 Fixes 91 | 92 | - Resolve runtime path issue after build ([0d700e6](https://github.com/hminghe/nuxt-unapi/commit/0d700e6)) 93 | - Resolve import error for generator ([88492a3](https://github.com/hminghe/nuxt-unapi/commit/88492a3)) 94 | 95 | ### 💅 Refactors 96 | 97 | - Gen server api ([7d4ce0c](https://github.com/hminghe/nuxt-unapi/commit/7d4ce0c)) 98 | 99 | ### 🏡 Chore 100 | 101 | - **release:** V0.0.2 ([62dc793](https://github.com/hminghe/nuxt-unapi/commit/62dc793)) 102 | - **code:** Code cleanup ([d837dc2](https://github.com/hminghe/nuxt-unapi/commit/d837dc2)) 103 | - **release:** V0.0.3 ([670ba99](https://github.com/hminghe/nuxt-unapi/commit/670ba99)) 104 | 105 | ### ❤️ Contributors 106 | 107 | - Hminghe ([@hminghe](http://github.com/hminghe)) 108 | - Minghe ([@hminghe](http://github.com/hminghe)) 109 | 110 | ## v0.0.3 111 | 112 | [compare changes](https://github.com/hminghe/nuxt-unapi/compare/v0.0.1...v0.0.3) 113 | 114 | ### 💅 Refactors 115 | 116 | - Gen server api ([7d4ce0c](https://github.com/hminghe/nuxt-unapi/commit/7d4ce0c)) 117 | 118 | ### 🏡 Chore 119 | 120 | - **release:** V0.0.2 ([62dc793](https://github.com/hminghe/nuxt-unapi/commit/62dc793)) 121 | - **code:** Code cleanup ([d837dc2](https://github.com/hminghe/nuxt-unapi/commit/d837dc2)) 122 | 123 | ### ❤️ Contributors 124 | 125 | - Hminghe ([@hminghe](http://github.com/hminghe)) 126 | - Minghe ([@hminghe](http://github.com/hminghe)) 127 | 128 | ## v0.0.2 129 | 130 | [compare changes](https://undefined/undefined/compare/v0.0.1...v0.0.2) 131 | 132 | ### 💅 Refactors 133 | 134 | - Gen server api (7d4ce0c) 135 | 136 | ### ❤️ Contributors 137 | 138 | - Minghe ([@hminghe](http://github.com/hminghe)) 139 | 140 | ## v0.0.1 141 | 142 | [compare changes](https://undefined/undefined/compare/v0.0.2...v0.0.1) 143 | 144 | ## v0.0.2 145 | 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 hminghe 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # nuxt-unapi 11 | 12 | [![npm version][npm-version-src]][npm-version-href] 13 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 14 | [![License][license-src]][license-href] 15 | [![Nuxt][nuxt-src]][nuxt-href] 16 | 17 | Full-stack API for Nuxt 3. 18 | 19 | - [✨  Release Notes](/CHANGELOG.md) 20 | - [🏀 Online playground](https://stackblitz.com/github/hminghe/nuxt-unapi?file=playground%2Fapp.vue) 21 | 22 | 23 | ## Install 24 | 25 | 1. Add `nuxt-unapi` dependency to your project 26 | 27 | ```bash 28 | # Using pnpm 29 | pnpm add -D nuxt-unapi 30 | 31 | # Using yarn 32 | yarn add --dev nuxt-unapi 33 | 34 | # Using npm 35 | npm install --save-dev nuxt-unapi 36 | ``` 37 | 38 | 2. Add `nuxt-unapi` to the `modules` section of `nuxt.config.ts` 39 | 40 | ```js 41 | export default defineNuxtConfig({ 42 | modules: [ 43 | 'nuxt-unapi' 44 | ], 45 | 46 | // Required enable asyncContext 47 | experimental: { 48 | asyncContext: true, 49 | }, 50 | }) 51 | ``` 52 | 53 | ✨✨✨ 54 | 55 | ## Usage 56 | 57 | Expose server functions under `api/**.ts` 58 | ```typescript 59 | // api/user.ts 60 | import { z } from 'zod' 61 | 62 | export const getUser = defineApi({ 63 | props: z.number(), 64 | // id type is number 65 | async handler (id) { 66 | return db.user.get(id) 67 | }, 68 | }) 69 | 70 | export const updateUser = defineApi({ 71 | props: z.object({ 72 | id: z.number(), 73 | name: z.string(), 74 | age: z.number().optional() 75 | }), 76 | 77 | // props are secure data validated by Zod. 78 | async handler (props) { 79 | return db.user.update(props) 80 | } 81 | }) 82 | ``` 83 | 84 | On the client side: 85 | ```typescript 86 | import { getUser, updateUser } from '~/api/user.ts' 87 | 88 | const user = await getUser(1) 89 | 90 | user.name = 'nuxt-unapi' 91 | 92 | const result = await updateUser(user) 93 | ``` 94 | 95 | Client validate. [Zod Docs](https://zod.dev/) 96 | ```typescript 97 | updateUser.props.parse(user) 98 | ``` 99 | 100 | 101 | [npm-version-src]: https://img.shields.io/npm/v/nuxt-unapi/latest.svg?style=flat&colorA=18181B&colorB=28CF8D 102 | [npm-version-href]: https://npmjs.com/package/nuxt-unapi 103 | 104 | [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-unapi.svg?style=flat&colorA=18181B&colorB=28CF8D 105 | [npm-downloads-href]: https://npmjs.com/package/nuxt-unapi 106 | 107 | [license-src]: https://img.shields.io/npm/l/nuxt-unapi.svg?style=flat&colorA=18181B&colorB=28CF8D 108 | [license-href]: https://npmjs.com/package/nuxt-unapi 109 | 110 | [nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt.js 111 | [nuxt-href]: https://nuxt.com 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-unapi", 3 | "version": "0.0.9", 4 | "description": "My new Nuxt module", 5 | "repository": "hminghe/nuxt-unapi", 6 | "license": "MIT", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types.d.ts", 11 | "import": "./dist/module.mjs", 12 | "require": "./dist/module.cjs" 13 | } 14 | }, 15 | "main": "./dist/module.cjs", 16 | "types": "./dist/types.d.ts", 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "prepack": "nuxt-module-build build", 22 | "dev": "nuxi dev playground", 23 | "dev:build": "nuxi build playground", 24 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 25 | "release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags", 26 | "lint": "eslint .", 27 | "test": "vitest run", 28 | "test:watch": "vitest watch" 29 | }, 30 | "dependencies": { 31 | "@babel/generator": "^7.23.0", 32 | "@babel/parser": "^7.23.0", 33 | "@babel/traverse": "^7.23.2", 34 | "@babel/types": "^7.23.0", 35 | "@nuxt/kit": "^3.8.0", 36 | "chokidar": "^3.5.3", 37 | "globby": "^13.2.2", 38 | "pathe": "^1.1.1", 39 | "perfect-debounce": "^1.0.0", 40 | "strip-literal": "^1.3.0", 41 | "ufo": "^1.3.1", 42 | "unplugin": "^1.5.0", 43 | "zod": "^3.22.4" 44 | }, 45 | "devDependencies": { 46 | "@nuxt/devtools": "latest", 47 | "@nuxt/eslint-config": "^0.2.0", 48 | "@nuxt/module-builder": "^0.5.2", 49 | "@nuxt/schema": "^3.8.0", 50 | "@nuxt/test-utils": "^3.8.0", 51 | "@types/babel__generator": "^7.6.6", 52 | "@types/babel__traverse": "^7.20.3", 53 | "@types/node": "^20.8.9", 54 | "changelogen": "^0.5.5", 55 | "eslint": "^8.52.0", 56 | "nitropack": "^2.7.2", 57 | "nuxt": "^3.8.0", 58 | "vitest": "^0.33.0" 59 | } 60 | } -------------------------------------------------------------------------------- /playground/api/test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | import { defineFormDataApi } from "../../src/runtime/defineFormDataApi" 3 | 4 | export const test = defineApi({ 5 | async handler () { 6 | const ctx = useEvent().context 7 | return 'unapi test ' + ctx.test1 + ' ' + ctx.test2 8 | }, 9 | middlewares: [ 10 | defineEventHandler((event) => { 11 | event.context.test1 = 'test1' 12 | console.log('middlewares test1') 13 | }), 14 | () => { 15 | useEvent().context.test2 = 'test2' 16 | console.log('middlewares test2') 17 | }, 18 | ] 19 | }) 20 | 21 | export const testParamsObject = defineApi({ 22 | props: z.object({ 23 | number: z.number(), 24 | string: z.string(), 25 | optional: z.string().optional(), 26 | }), 27 | async handler (data) { 28 | return data 29 | }, 30 | middlewares: [ 31 | defineEventHandler(() => { 32 | console.log('middlewares test/testParamsObject') 33 | }), 34 | // 中断 35 | () => { 36 | if (Math.random() < 0.5) { 37 | // return 'error' 38 | return createError({ 39 | statusCode: 401, 40 | message: 'Unauthorized' 41 | }) 42 | } 43 | } 44 | ] 45 | }) 46 | 47 | export const testParamsString = defineApi({ 48 | props: z.string(), 49 | 50 | async handler (data) { 51 | return data + ' test' 52 | } 53 | }) 54 | 55 | 56 | export const uploadFileTest = defineFormDataApi({ 57 | props: z.object({ 58 | test: z.coerce.number(), 59 | test1: z.string(), 60 | file: zodFile(), 61 | }), 62 | 63 | async handler (props) { 64 | console.log('props', props) 65 | 66 | return `File size: ${props.file.data?.length}` 67 | } 68 | }) 69 | 70 | export const uploadMultipleFileTest = defineFormDataApi({ 71 | props: z.object({ 72 | test: z.coerce.number(), 73 | test1: z.string(), 74 | files: zodFile().array(), 75 | }), 76 | 77 | async handler (props) { 78 | console.log('props', props) 79 | 80 | return `File: ${props.files.map(v => v.filename + ': ' + v.data?.length).join('----')}` 81 | } 82 | }) 83 | -------------------------------------------------------------------------------- /playground/api/test2.ts: -------------------------------------------------------------------------------- 1 | export const test = defineApi({ 2 | async handler () { 3 | return 'unapi test2' 4 | } 5 | }) 6 | 7 | 8 | export const test2 = defineApi({ 9 | async handler () { 10 | return 'unapi test2' 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 102 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['../src/module'], 3 | unapi: { 4 | 5 | }, 6 | experimental: { 7 | asyncContext: true 8 | }, 9 | devtools: { enabled: true } 10 | }) 11 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-module-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "devDependencies": { 11 | "nuxt": "latest" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /playground/server/api/test.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(function () { 2 | return '12345253' 3 | }) -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule } from '@nuxt/kit' 2 | 3 | import { clientApi } from './plugins/clientApi' 4 | import { serverApi } from './plugins/serverApi' 5 | 6 | 7 | // Module options TypeScript interface definition 8 | export interface ModuleOptions { 9 | apiDir?: string 10 | routePrefix?: string 11 | clientHandler?: { 12 | name: string 13 | from?: string 14 | } 15 | serverHandler?: { 16 | name: string 17 | from?: string 18 | } 19 | } 20 | 21 | export default defineNuxtModule({ 22 | meta: { 23 | name: 'unapi', 24 | configKey: 'unapi' 25 | }, 26 | 27 | // Default configuration options of the Nuxt module 28 | defaults: { 29 | apiDir: 'api', 30 | routePrefix: '/api', 31 | }, 32 | 33 | setup (options, nuxt) { 34 | clientApi(options, nuxt) 35 | serverApi(options, nuxt) 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /src/plugins/clientApi.ts: -------------------------------------------------------------------------------- 1 | import { createUnplugin } from 'unplugin' 2 | import type { Nuxt } from '@nuxt/schema' 3 | import { addBuildPlugin, addImports } from '@nuxt/kit' 4 | 5 | import type { ModuleOptions } from '../module' 6 | import { getApiDirs, transformClientApi } from '../utils/api' 7 | import { resolver } from '../resolver' 8 | 9 | export function ClientApiPlugin(options: ModuleOptions, nuxt: Nuxt) { 10 | const apiDirs = getApiDirs(options, nuxt) 11 | return createUnplugin(() => { 12 | return { 13 | name: 'unplugin-unapi', 14 | enforce: 'pre', 15 | transformInclude(id) { 16 | return id.endsWith('.ts') && apiDirs.some(dir => { 17 | return id.startsWith(dir) 18 | }) 19 | }, 20 | transform(code, id) { 21 | return transformClientApi(code, id, apiDirs, options) 22 | }, 23 | } 24 | }) 25 | } 26 | 27 | export function clientApi(options: ModuleOptions, nuxt: Nuxt) { 28 | 29 | const imports = ['defineApi', 'defineFormDataApi', ['zodFile', 'zod']] 30 | 31 | imports.forEach((value) => { 32 | if (typeof value === 'string') { 33 | value = [value, value] 34 | } 35 | 36 | addImports([ 37 | { 38 | name: value[0], 39 | from: resolver.resolve(`./runtime/${value[1]}`), 40 | }, 41 | ]) 42 | }) 43 | 44 | if (!options.clientHandler) { 45 | options.clientHandler = { 46 | name: 'clientHandler', 47 | from: resolver.resolve('./runtime/clientHandler'), 48 | } 49 | } 50 | 51 | addBuildPlugin(ClientApiPlugin(options, nuxt)) 52 | } 53 | -------------------------------------------------------------------------------- /src/plugins/serverApi.ts: -------------------------------------------------------------------------------- 1 | import { addServerHandler, addServerImports, addTemplate, updateTemplates } from '@nuxt/kit' 2 | import type { Nuxt } from '@nuxt/schema' 3 | import type { ModuleOptions } from '../module' 4 | import { generateServerApi, getLayerDirs } from '../utils/api' 5 | import { join } from 'pathe'; 6 | import { resolver } from '../resolver'; 7 | 8 | function addServerImportsUtilsDirs(nuxt: Nuxt) { 9 | // 把 utils 目录加到 server 的自动导入去 10 | nuxt.hook('nitro:config', async (nitroConfig) => { 11 | const importsDirs = getLayerDirs(nuxt, 'utils') 12 | nitroConfig.imports = nitroConfig.imports || {} 13 | if (nitroConfig.imports.dirs) { 14 | nitroConfig.imports.dirs.push(...importsDirs) 15 | } else { 16 | nitroConfig.imports.dirs = importsDirs 17 | } 18 | }) 19 | } 20 | 21 | 22 | export async function serverApi(options: ModuleOptions, nuxt: Nuxt) { 23 | 24 | addServerImportsUtilsDirs(nuxt) 25 | 26 | const imports = ['defineApi', 'defineFormDataApi', ['zodFile', 'zod']] 27 | 28 | imports.forEach((value) => { 29 | if (typeof value === 'string') { 30 | value = [value, value] 31 | } 32 | 33 | addServerImports([ 34 | { 35 | name: value[0], 36 | from: resolver.resolve(`./runtime/${value[1]}`), 37 | }, 38 | ]) 39 | }) 40 | 41 | const genServerFileName = 'server-unapi.ts' 42 | 43 | addTemplate({ 44 | filename: genServerFileName, 45 | write: true, 46 | async getContents() { 47 | return generateServerApi(options, nuxt) 48 | } 49 | }) 50 | 51 | addServerHandler({ 52 | handler: join(nuxt.options.buildDir, genServerFileName), 53 | lazy: true, 54 | route: '/api/**' 55 | }) 56 | 57 | nuxt.hook('builder:watch', async (event, path) => { 58 | if (path.startsWith(options.apiDir!)) { 59 | await updateTemplates({ 60 | filter: (t) => t.filename === genServerFileName 61 | }) 62 | } 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /src/resolver.ts: -------------------------------------------------------------------------------- 1 | import { createResolver } from '@nuxt/kit' 2 | 3 | 4 | export const resolver = createResolver(import.meta.url) -------------------------------------------------------------------------------- /src/runtime/clientHandler.ts: -------------------------------------------------------------------------------- 1 | import { useRequestHeaders } from '#imports' 2 | 3 | export function clientHandler(api: string, body: any) { 4 | if (typeof body === 'string') { 5 | body = JSON.stringify(body) 6 | } 7 | 8 | return $fetch(api, { 9 | method: 'POST', 10 | body: body, 11 | headers: import.meta.server ? useRequestHeaders(['cookie']) : {} 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/runtime/defineApi.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType, z } from 'zod' 2 | import { EventHandler } from 'h3' 3 | 4 | 5 | export interface DefineApiOptions, HandlerReturn> { 6 | props: Props 7 | middlewares?: EventHandler[], 8 | handler: (data: z.infer) => Promise 9 | } 10 | 11 | export interface DefineApiOptions2 { 12 | middlewares?: EventHandler[], 13 | handler: (data?: any) => Promise 14 | } 15 | 16 | export function defineApi, HandlerReturn>(options: DefineApiOptions): DefineApiOptions['handler'] & Omit, 'handler'> 17 | export function defineApi(options: DefineApiOptions2): DefineApiOptions2['handler'] & Omit, 'handler'> 18 | export function defineApi(handler: DefineApiOptions2['handler']): DefineApiOptions2['handler'] 19 | 20 | export function defineApi, HandlerReturn>( 21 | handler: DefineApiOptions['handler'], 22 | options: Omit, 'handler'> 23 | ): DefineApiOptions['handler'] & Omit, 'handler'> 24 | 25 | export function defineApi( 26 | handler: DefineApiOptions2['handler'], 27 | options: Omit, 'handler'> 28 | ): DefineApiOptions2['handler'] & Omit, 'handler'> 29 | 30 | export function defineApi(optionsOrHandle: T | T['handler'], options?: T) { 31 | const o: any = { 32 | ...options, 33 | ...(typeof optionsOrHandle === 'function' ? { handler: optionsOrHandle } : optionsOrHandle), 34 | } 35 | 36 | Object.keys(o).forEach((key) => { 37 | if (key !== 'handler') { 38 | o.handler[key] = o[key] 39 | } 40 | }) 41 | 42 | return o.handler 43 | } 44 | -------------------------------------------------------------------------------- /src/runtime/defineFormDataApi.ts: -------------------------------------------------------------------------------- 1 | import { ZodType, z } from 'zod' 2 | import { DefineApiOptions } from './defineApi' 3 | import { MultiPartData } from 'h3' 4 | 5 | type UnArray = T extends (infer U)[] ? U : T; 6 | 7 | // @ts-ignore 8 | export interface SafeFormData extends FormData { 9 | // @ts-ignore 10 | append(name: K, value: UnArray, fileName?: string): void 11 | // @ts-ignore 12 | get(name: K): void 13 | } 14 | 15 | export type TransformClientType = { 16 | [P in keyof T]?: T[P] extends MultiPartData ? File : T[P] 17 | }; 18 | 19 | 20 | export function defineFormDataApi< 21 | Props extends ZodType, 22 | HandlerReturn 23 | >(options: DefineApiOptions) { 24 | type Options = typeof options 25 | 26 | 27 | const safeFormData = () => new FormData() as SafeFormData>> 28 | type SafeFormDataType = typeof safeFormData 29 | 30 | 31 | const o: any = options 32 | o.sfd = o.safeFormData = safeFormData 33 | Object.keys(options).forEach((key) => { 34 | o.handler[key] = o[key] 35 | }) 36 | 37 | type Handler = (props: ReturnType) => Promise 38 | 39 | return o.handler as Handler & Options & { 40 | sfd: SafeFormDataType 41 | safeFormData: SafeFormDataType 42 | handler: Handler 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /src/runtime/defineServerApi.ts: -------------------------------------------------------------------------------- 1 | import { defineEventHandler, readBody, readMultipartFormData, getHeaders } from 'h3' 2 | import type { H3Event, EventHandlerRequest } from 'h3' 3 | import { createError } from '#imports' 4 | 5 | import type { ZodType } from 'zod' 6 | import type { DefineApiOptions } from './defineApi' 7 | import { isPromise } from 'node:util/types' 8 | 9 | async function getBody (event: H3Event) { 10 | const contentType = getHeaders(event)['content-type']?.split(';', 1)[0] 11 | 12 | if (contentType === 'multipart/form-data') { 13 | const body: Record = {} 14 | 15 | const formData = await readMultipartFormData(event) 16 | if (formData) { 17 | formData.forEach(row => { 18 | if (row.name) { 19 | const value = row.filename ? row : row.data.toString() 20 | 21 | if (body[row.name]) { 22 | if (!Array.isArray(body[row.name])) { 23 | body[row.name] = [ 24 | body[row.name], 25 | value, 26 | ] 27 | } else { 28 | body[row.name].push(value) 29 | } 30 | } else { 31 | body[row.name] = value 32 | } 33 | } 34 | }) 35 | } 36 | 37 | return body 38 | } else { 39 | return readBody(event) 40 | } 41 | } 42 | 43 | 44 | export function defineServerApi< 45 | HandlerReturn, Props extends ZodType, T extends DefineApiOptions['handler'] & Omit, 'handler'>, 46 | >(handler: T, eventHandler = defineEventHandler) { 47 | 48 | 49 | return eventHandler(async (event) => { 50 | if (handler.middlewares && handler.middlewares.length > 0) { 51 | for (const middleware of handler.middlewares) { 52 | let middlewareResult = middleware(event) 53 | if (isPromise(middlewareResult)) { 54 | middlewareResult = await middlewareResult 55 | } 56 | if (middlewareResult !== undefined) { 57 | return middlewareResult 58 | } 59 | } 60 | } 61 | 62 | const props = handler.props 63 | if (props) { 64 | const body = await getBody(event) 65 | 66 | const res = props.safeParse(body) 67 | 68 | if (!res.success) { 69 | throw createError({ 70 | statusCode: 400, 71 | statusMessage: 'Parameter Error', 72 | // @ts-ignore 73 | data: res.error.errors 74 | }) 75 | } 76 | 77 | return handler(res.data) 78 | } 79 | 80 | // @ts-ignore 81 | return handler() 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /src/runtime/zod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * custom zod type 3 | */ 4 | import { MultiPartData } from 'h3' 5 | import { z } from 'zod' 6 | 7 | export type ZodFile = MultiPartData 8 | 9 | export const zodFile = () => z.custom((val) => { 10 | // @ts-ignore 11 | return val && val.name && val.filename && val.data && val.type 12 | }) 13 | -------------------------------------------------------------------------------- /src/utils/__test__/api.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { parse } from '@babel/parser' 3 | import type { Nuxt } from 'nuxt/schema' 4 | import { getApiRoute, getLayerDirs, scanExportApis, transformClientApi } from '../api' 5 | 6 | // test('scanExportApis', () => { 7 | // const ast = parse(` 8 | // export const aSchema = z.object({ 9 | // test: z.string(), 10 | // name: z.string(), 11 | // }).partial({ 12 | // test: true, 13 | // }) 14 | 15 | // export function a(data) { 16 | // return data 17 | // } 18 | 19 | // export const a1 = () => 1; 20 | 21 | // function a2 () { 22 | 23 | // } 24 | 25 | // export const b = () => 123, c = '123', d = function (test) { console.log(test) }, e = (test4) => { console.log(test4) }; 26 | // `, { 27 | // sourceType: 'module', 28 | // plugins: ['typescript'], 29 | // }) 30 | 31 | // const apis = scanExportApis(ast) 32 | 33 | // expect(apis.map(v => v.name)).toEqual(['a', 'a1', 'b', 'd', 'e']) 34 | 35 | // expect(apis.map(v => !!v.schemaName)).toEqual([true, false, false, false, false]) 36 | // }) 37 | 38 | test('scanExportApis-defineApi', () => { 39 | const ast = parse(` 40 | export const test = 123; 41 | export const a = defineApi({ 42 | handler() { 43 | return 123 44 | }, 45 | }) 46 | 47 | export const b = () => { 48 | const c = defineApi({ 49 | handler() { 50 | return 123 51 | }, 52 | }) 53 | } 54 | `, { 55 | sourceType: 'module', 56 | plugins: ['typescript'], 57 | }) 58 | 59 | const apis = scanExportApis(ast) 60 | 61 | expect(apis.map(v => v.name)).toEqual(['a']) 62 | }) 63 | 64 | test('getApiRoute', () => { 65 | expect(getApiRoute([ 66 | '/a', 67 | '/a/b', 68 | '/a/c', 69 | '/a/b/c', 70 | ], '/a/b/index.ts', 'create')).toEqual('/create') 71 | }) 72 | 73 | test('transformClientApi', () => { 74 | const code = ` 75 | import path from 'node:path' 76 | 77 | export const a = defineApi({ 78 | handler: function(data) { 79 | return path.join('/test', 'test') 80 | }, 81 | props: z.number(), 82 | test: 123, 83 | }) 84 | 85 | export const b = defineApi(function(data) { 86 | return data 87 | }, { props: z.number(), test: 123 }) 88 | ` 89 | 90 | const transform = transformClientApi(code, '/a/user.ts', ['/a'], { clientHandler: { name: 'apiPost' } }) 91 | expect(transform.code).toEqual(`export const a = defineApi({ 92 | handler: data => apiPost("/user/a", data), 93 | props: z.number() 94 | }); 95 | export const b = defineApi(data => apiPost("/user/b", data), { 96 | props: z.number() 97 | });`) 98 | }) 99 | 100 | test('getLayerDirs', () => { 101 | const nuxt = { 102 | options: { 103 | _layers: [ 104 | { 105 | config: { 106 | rootDir: '/test/', 107 | }, 108 | }, 109 | { 110 | config: { 111 | rootDir: 'C:\\test\\', 112 | }, 113 | }, 114 | ], 115 | }, 116 | } as Nuxt 117 | 118 | expect(getLayerDirs(nuxt, 'api')).toEqual([ 119 | '/test/api', 120 | 'C:/test/api', 121 | ]) 122 | }) 123 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { join, relative } from 'pathe' 2 | import { readFileSync } from 'node:fs' 3 | import { parse } from '@babel/parser' 4 | import type { ParseResult, ParserOptions } from '@babel/parser' 5 | import _traverse from '@babel/traverse' 6 | import _generator from '@babel/generator' 7 | import type { CallExpression, ExportNamedDeclaration, File, Function } from '@babel/types' 8 | import type { NodePath } from '@babel/traverse' 9 | import type { Nuxt } from '@nuxt/schema' 10 | import { globby } from 'globby' 11 | import * as t from '@babel/types' 12 | import { joinURL, withLeadingSlash, withoutTrailingSlash } from 'ufo' 13 | import type { ModuleOptions } from '../module' 14 | import { resolver } from '../resolver' 15 | 16 | // @ts-ignore 17 | const traverse: typeof _traverse = _traverse.default ? _traverse.default : _traverse 18 | // @ts-ignore 19 | const generator: typeof _generator = _generator.default ? _generator.default : _generator 20 | 21 | interface ExportApi { 22 | name: string 23 | exportPath: NodePath 24 | defineApiPath: NodePath 25 | // schemaName?: string 26 | } 27 | 28 | export function scanExportApis(ast: ParseResult) { 29 | const exportApis: ExportApi[] = [] 30 | // const exportVariables: string[] = [] 31 | 32 | traverse(ast, { 33 | CallExpression(path) { 34 | const callee = path.get('callee') 35 | if (callee.isIdentifier({ name: 'defineApi' }) || callee.isIdentifier({ name: 'defineFormDataApi' })) { 36 | const parentPath = path.parentPath 37 | if (parentPath.node.type === 'VariableDeclarator' && parentPath.node.id.type === 'Identifier') { 38 | const name = parentPath.node.id.name 39 | 40 | const exportPath = parentPath?.parentPath?.parentPath 41 | if (exportPath && exportPath.isExportNamedDeclaration()) { 42 | exportApis.push({ 43 | name, 44 | exportPath, 45 | defineApiPath: path, 46 | }) 47 | } 48 | } 49 | } 50 | }, 51 | 52 | }) 53 | 54 | return exportApis 55 | } 56 | 57 | export function transformClientApi(code: string, file: string, apiDirs: string[], options: ModuleOptions) { 58 | const ast = parseCode(code, { 59 | sourceType: 'module', 60 | plugins: ['typescript'], 61 | }) 62 | 63 | if (!ast) { 64 | return 65 | } 66 | 67 | // 遍历AST以查找import语句 68 | const exportApis = scanExportApis(ast) 69 | 70 | if (exportApis.length) { 71 | if (options.clientHandler?.from) { 72 | const importClientHandlerStatement = t.importDeclaration( 73 | [t.importSpecifier(t.identifier(options.clientHandler.name), t.identifier(options.clientHandler.name))], 74 | t.stringLiteral(options.clientHandler.from) 75 | ); 76 | ast.program.body.unshift(importClientHandlerStatement); 77 | } 78 | 79 | exportApis.forEach(({ name, defineApiPath }) => { 80 | const apiRoute = getApiRoute(apiDirs, file, name, options.routePrefix)! 81 | 82 | // 创建 handler 函数:(data) => clientHandler("${url}", data) 83 | const clientApiArrowFunction = t.arrowFunctionExpression( 84 | [t.identifier('data')], 85 | t.callExpression(t.identifier(options.clientHandler!.name), [t.stringLiteral(apiRoute), t.identifier('data')]), 86 | ) 87 | 88 | defineApiPath.node.arguments = defineApiPath.node.arguments.map((v) => { 89 | if (v.type === 'ObjectExpression') { 90 | // @ts-ignore 91 | v.properties = v.properties 92 | .map((p) => { 93 | if ( 94 | p.type === 'SpreadElement' 95 | || p.key.type !== 'Identifier' 96 | || !['handler', 'props'].includes(p.key.name) 97 | ) { 98 | return null 99 | } 100 | 101 | if (p.key.name === 'handler') { 102 | return t.objectProperty(t.identifier('handler'), clientApiArrowFunction) 103 | } 104 | 105 | return p 106 | }) 107 | .filter(p => p !== null) 108 | } else if (v.type === 'FunctionExpression' || v.type === 'ArrowFunctionExpression') { 109 | return clientApiArrowFunction 110 | } 111 | 112 | return v 113 | }) 114 | }) 115 | 116 | removeNotUsedImport(ast) 117 | 118 | const g = generator(ast) 119 | 120 | return { 121 | code: g.code, 122 | map: g.map, 123 | } 124 | } 125 | } 126 | 127 | // 删除未使用的 import 128 | export function removeNotUsedImport(ast: ParseResult) { 129 | const importSpecifiers = new Map() 130 | traverse(ast, { 131 | ImportDeclaration(path) { 132 | const { node } = path 133 | node.specifiers.forEach((specifier) => { 134 | if (t.isImportSpecifier(specifier) || t.isImportDefaultSpecifier(specifier)) { 135 | importSpecifiers.set(specifier.local.name, { 136 | count: 0, 137 | }) 138 | } 139 | }) 140 | }, 141 | }) 142 | 143 | traverse(ast, { 144 | Identifier(path) { 145 | if (path.isReferencedIdentifier() && importSpecifiers.has(path.node.name)) { 146 | const s = importSpecifiers.get(path.node.name) 147 | s.count++ 148 | } 149 | }, 150 | }) 151 | 152 | traverse(ast, { 153 | ImportDeclaration(path) { 154 | path.node.specifiers = path.node.specifiers.filter((specifier) => { 155 | return importSpecifiers.get(specifier.local.name)?.count > 0 156 | }) 157 | 158 | if (path.node.specifiers.length === 0) { 159 | path.remove() 160 | } 161 | }, 162 | }) 163 | } 164 | 165 | export function getApiRoute(apiDirs: string[], file: string, name: string = '', prefix = '/') { 166 | let dir: string = '' 167 | let dirLength = 0 168 | apiDirs 169 | .map(v => relative(v, file)) 170 | .filter(v => !v.startsWith('..')) 171 | .forEach((v) => { 172 | if (!dir || dirLength > v.length) { 173 | dir = v 174 | dirLength = dir.length 175 | } 176 | }) 177 | 178 | if (dir) { 179 | dir = dir.replace(/\.ts$/, '').replace(/index$/, '').replace(/\\/g, '/') 180 | 181 | return withLeadingSlash(withoutTrailingSlash(joinURL(prefix, dir, name === 'index' ? '' : name))) 182 | } 183 | } 184 | 185 | export function getApiDirs(options: ModuleOptions, nuxt: Nuxt) { 186 | return getLayerDirs(nuxt, options.apiDir!) 187 | } 188 | 189 | export function getLayerDirs(nuxt: Nuxt, dir: string) { 190 | return nuxt.options._layers.map(c => join(c.config.rootDir, dir)) 191 | } 192 | 193 | export async function scanFiles(dirs: string[]) { 194 | const files = await Promise.all( 195 | dirs.map(dir => scanDir(dir)), 196 | ).then(r => r.flat()) 197 | 198 | return files 199 | } 200 | 201 | export async function scanDir(dir: string) { 202 | const fileNames = await globby('**/*.ts', { 203 | cwd: dir, 204 | dot: true, 205 | absolute: true, 206 | }) 207 | return fileNames 208 | .map((fullPath) => { 209 | return { 210 | fullPath, 211 | path: relative(dir, fullPath), 212 | } 213 | }) 214 | .sort((a, b) => a.path.localeCompare(b.path)) 215 | } 216 | 217 | export async function generateServerApi(options: ModuleOptions, nuxt: Nuxt) { 218 | 219 | let genAliasXID = 0; 220 | function genAlias() { 221 | return `$as_${++genAliasXID}` 222 | } 223 | 224 | const apiDirs = getApiDirs(options, nuxt) 225 | 226 | const files = await scanFiles(apiDirs) 227 | 228 | const routes: string[] = [] 229 | const imports = [ 230 | `import { createRouter, defineEventHandler, useBase } from 'h3'`, 231 | `import { defineServerApi } from '${resolver.resolve('./runtime/defineServerApi')}';` 232 | ] 233 | 234 | const serverHandlerAlias = genAlias() 235 | if (options.serverHandler?.from) { 236 | imports.push(`import { ${options.serverHandler.name} as ${serverHandlerAlias} } from '${options.serverHandler.from}'`) 237 | } 238 | 239 | await Promise.all(files.map(async (file) => { 240 | const fileRoute = getApiRoute(apiDirs, file.fullPath) 241 | if (!fileRoute || !/^[a-z0-9\-_/]+$/i.test(fileRoute)) { 242 | return 243 | } 244 | 245 | 246 | const code = readFileSync(file.fullPath, 'utf-8') 247 | 248 | const ast = parseCode(code) 249 | 250 | if (!ast) { 251 | return false 252 | } 253 | 254 | const importApiStrs: string[] = [] 255 | 256 | scanExportApis(ast).forEach(({ name }) => { 257 | const alias = genAlias() 258 | importApiStrs.push(`${name} as ${alias}`); 259 | 260 | const url = withLeadingSlash(withoutTrailingSlash(joinURL(options.routePrefix || '', fileRoute, name === 'index' ? '' : name))) 261 | const defineServerApiParams = [alias] 262 | if (options.serverHandler) { 263 | defineServerApiParams.push(serverHandlerAlias) 264 | } 265 | 266 | routes.push(`router.post('${url}', defineServerApi(${defineServerApiParams.join(',')}))`) 267 | }) 268 | 269 | imports.push(`import { ${importApiStrs.join(',')} } from '${file.fullPath.replace(/.ts$/, '')}';`) 270 | })) 271 | 272 | return `${imports.join('\n')} 273 | const router = createRouter() 274 | ${routes.join('\n')} 275 | 276 | export default useBase('/', router.handler) 277 | ` 278 | } 279 | 280 | export function parseCode(code: string, options: ParserOptions = { 281 | sourceType: 'module', 282 | plugins: ['typescript'], 283 | }) { 284 | try { 285 | return parse(code, options) 286 | } catch (error) { 287 | return null 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "esnext", 5 | // "lib": ["esnext"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "skipDefaultLibCheck": true, 13 | "types": [] 14 | }, 15 | "include": [ 16 | "./src/**/*.*", 17 | "./test/**/*.*" 18 | ] 19 | } 20 | --------------------------------------------------------------------------------