UNPKG

vite-plugin-api-routes

Version:

A Vite.js plugin that creates API routes by mapping the directory structure, similar to Next.js API Routes. This plugin enhances the functionality for backend development using Vite.

677 lines (665 loc) 18.7 kB
// src/model.ts import path from "slash-path"; var assertConfig = (opts) => { let { moduleId = "@api", mode = "legacy", root = process.cwd(), cacheDir = ".api", server = path.join(cacheDir, "server.js"), handler = path.join(cacheDir, "handler.js"), configure = path.join(cacheDir, "configure.js"), openapi = path.join(cacheDir, "openapi.yml"), routeBase = "api", dirs = [{ dir: "src/api", route: "", exclude: [], skip: false }], include = ["**/*.ts", "**/*.js"], exclude = [], mapper = {}, filePriority = 100, paramPriority = 110, forceRestart = false, disableBuild = false, clientOutDir = "dist/client", clientMinify = false, clientBuild = (config) => config, clientSkip = false, serverOutDir = "dist", serverMinify = false, serverBuild = (config) => config, serverSkip = false } = opts; if (moduleId !== "@api") { console.warn("The moduleId will be remove in the next release"); } if (cacheDir !== ".api") { console.warn("The cacheDir will be remove in the next release"); } cacheDir = path.join(root, cacheDir); dirs = dirs.map((it) => { it.dir = path.join(root, it.dir); return it; }); mapper = { default: { method: "use", priority: 10 }, AUTH: { method: "use", priority: 11 }, CRUD: { method: "use", priority: 12 }, USE: { method: "use", priority: 20 }, PING: { method: "get", priority: 21 }, GET: { method: "get", priority: 30 }, POST: { method: "post", priority: 40 }, ACTION: { method: "post", priority: 41 }, PATCH: { method: "patch", priority: 50 }, PUT: { method: "put", priority: 60 }, DELETE: { method: "delete", priority: 70 }, ERROR: { method: "use", priority: 120 }, // Overwrite ...mapper }; if (mode === "isolated") { delete mapper.default; } routeBase = path.join("/", routeBase); clientOutDir = path.join(root, clientOutDir); serverOutDir = path.join(root, serverOutDir); const serverFile = path.join(root, server); const handlerFile = path.join(root, handler); const openapiFile = path.join(root, openapi); const configureFile = path.join(root, configure); const mapperList = Object.entries(mapper).map(([name, value]) => { if (value === false) { return { name, method: "", priority: "000" }; } if (typeof value === "string") { return { name, method: value, priority: "010" }; } return { name, method: value.method, priority: value.priority.toString().padStart(3, "0") }; }).filter((it) => it.method !== ""); if (mode === "isolated") { include = mapperList.map((it) => `**/${it.name}.{js,ts}`); } const watcherList = dirs.map((it) => it.dir); watcherList.push(configureFile); watcherList.push(handlerFile); return { mode, moduleId, server, handler, configure, root, serverFile, handlerFile, openapiFile, filePriority: filePriority.toString().padStart(3, "0"), paramPriority: paramPriority.toString().padStart(3, "0"), configureFile, routeBase, dirs, include, exclude, mapperList, watcherList, cacheDir, forceRestart, disableBuild, clientOutDir, clientMinify, clientBuild, clientSkip, serverOutDir, serverMinify, serverBuild, serverSkip }; }; // src/plugin-build/index.ts import path2 from "slash-path"; import { build } from "vite"; var ENTRY_NONE = "____.html"; var simplePath = (filePath) => filePath.replace(/(\.[^\.]+)$/, "").replace(/^[\/\\]/gi, "").replace(/[\/\\]$/, ""); var doBuildServer = async (apiConfig, viteConfig) => { const { root, serverOutDir, clientOutDir, serverMinify, serverBuild, routeBase, serverFile, configureFile, handlerFile, routerFile, cacheDir } = apiConfig; const binFiles = [configureFile, handlerFile, routerFile]; const clientDir = path2.relative(serverOutDir, clientOutDir); const viteServer = await serverBuild({ appType: "custom", root, publicDir: "private", define: { "API_ROUTES.BASE": JSON.stringify(viteConfig.base), "API_ROUTES.BASE_API": JSON.stringify( path2.join(viteConfig.base, routeBase) ), "API_ROUTES.PUBLIC_DIR": JSON.stringify(clientDir) }, build: { outDir: serverOutDir, ssr: serverFile, write: true, minify: serverMinify, target: "esnext", emptyOutDir: true, rollupOptions: { external: viteConfig.build?.rollupOptions?.external, output: { format: "es", entryFileNames: "app.js", chunkFileNames: "bin/[name]-[hash].js", assetFileNames: "assets/[name].[ext]", manualChunks: (id) => { const isApi = apiConfig.dirs.find((it) => id.includes(it.dir)); if (isApi) { const file = path2.join( apiConfig.routeBase || "", isApi.route || "", path2.relative(isApi.dir, id) ); return simplePath(file); } const isBin = binFiles.find((bin) => id.includes(bin)); if (isBin) { return "index"; } if (id.includes("node_modules")) { const pkg = id.split("node_modules/")[1]; const lib = path2.join("libs", pkg); return simplePath(lib); } } }, onwarn: (warning, handler) => { if (warning.code === "MISSING_EXPORT" && warning.id.startsWith(cacheDir)) { return; } handler(warning); } } } }); await build(viteServer); }; var doBuildClient = async (apiConfig, viteConfig) => { const { root, clientOutDir, clientMinify, clientBuild } = apiConfig; const preloadFiles = [ "modulepreload-polyfill", "commonjsHelpers", "vite/", "installHook" ]; const viteClient = await clientBuild({ root, build: { outDir: clientOutDir, write: true, minify: clientMinify, emptyOutDir: true, rollupOptions: { external: viteConfig.build?.rollupOptions?.external, output: { manualChunks: (id) => { const isInternal = preloadFiles.find((it) => id.includes(it)); if (isInternal) { return "preload"; } if (id.includes("node_modules")) { return "vendor"; } } } } } }); await build(viteClient); }; var apiRoutesBuild = (apiConfig) => { if (apiConfig.disableBuild) { return null; } let viteConfig = {}; return { name: "vite-plugin-api-routes:build", enforce: "pre", apply: "build", config: () => { if (process.env.IS_API_ROUTES_BUILD) return {}; return { build: { emptyOutDir: true, copyPublicDir: false, write: false, rollupOptions: { input: { main: ENTRY_NONE } } } }; }, resolveId: (id) => { if (id === ENTRY_NONE) { return id; } return null; }, load: (id) => { if (id === ENTRY_NONE) { return ""; } return null; }, configResolved: (config) => { viteConfig = config; }, buildStart: async () => { if (process.env.IS_API_ROUTES_BUILD) return; process.env.IS_API_ROUTES_BUILD = "true"; viteConfig.logger.info(""); if (apiConfig.serverSkip) { viteConfig.logger.info("\x1B[1m\x1B[90mSERVER BUILD SKIPPED\x1B[0m"); } else { viteConfig.logger.info("\x1B[1m\x1B[31mSERVER BUILD\x1B[0m"); await doBuildServer(apiConfig, viteConfig); } viteConfig.logger.info(""); if (apiConfig.clientSkip) { viteConfig.logger.info("\x1B[1m\x1B[90mCLIENT BUILD SKIPPED\x1B[0m"); } else { viteConfig.logger.info("\x1B[1m\x1B[31mCLIENT BUILD\x1B[0m"); await doBuildClient(apiConfig, viteConfig); } viteConfig.logger.info(""); process.exit(0); } }; }; // src/plugin-router/index.ts import path5 from "slash-path"; // src/plugin-router/writeHandlerFile.ts import fs from "fs"; // src/plugin-router/common.ts import fg from "fast-glob"; import path3 from "slash-path"; var byKey = (a, b) => a.key.localeCompare(b.key); var isParam = (name) => /:|\[|\$/.test(name); var createKeyRoute = (route, apiConfig) => { return route.split("/").filter((it) => it).map((n) => { const s = "_" + n; const p = apiConfig.mapperList.find((r) => r.name === n); if (p) { return p.priority + s; } else if (isParam(n)) { return apiConfig.paramPriority + s; } return apiConfig.filePriority + s; }).join("_"); }; var parseFileToRoute = (names) => { const route = names.split("/").map((name) => { name = name.replaceAll("$", ":").replaceAll("[", ":").replaceAll("]", ""); return name; }).join("/"); return route.replace(/\.[^.]+$/, "").replaceAll(/index$/gi, "").replaceAll(/_index$/gi, ""); }; var getAllFileRouters = (apiConfig) => { let { dirs, include, exclude } = apiConfig; const currentMode = process.env.NODE_ENV; return dirs.filter((dir) => { if (dir.skip === true || dir.skip === currentMode) { return false; } return true; }).flatMap((it) => { it.exclude = it.exclude || []; const ignore = [...exclude, ...it.exclude]; const files = fg.sync(include, { ignore, onlyDirectories: false, dot: true, unique: true, cwd: it.dir }); return files.map((file) => { const routeFile = path3.join("/", it.route, file); const route = parseFileToRoute(routeFile); const key = createKeyRoute(route, apiConfig); const relativeFile = path3.relative( apiConfig.cacheDir, path3.join(it.dir, file) ); return { name: path3.basename(routeFile), file: relativeFile, route, key }; }); }).map((it) => ({ varName: "API_", name: it.name, file: it.file, route: it.route, key: it.key })).sort(byKey).map((it, ix) => { it.varName = "API_" + ix.toString().padStart(3, "0"); return it; }); }; var parseMethodRouters = (fileRoutes, apiConfig) => { if (apiConfig.mode === "isolated") { return fileRoutes.map((r) => { const m = apiConfig.mapperList.find( (m2) => r.route.endsWith(`/${m2.name}`) ); if (m == null) { return null; } const re = new RegExp(`${m.name}$`); const route = r.route.replace(re, ""); return { key: r.key, source: r.file, method: m.method, route, url: path3.join("/", apiConfig.routeBase, route), cb: r.varName }; }).filter((r) => !!r); } return fileRoutes.flatMap((it) => { return apiConfig.mapperList.map((m) => { const route = path3.join(it.route, m.name); const key = createKeyRoute(route, apiConfig); return { ...it, key, name: m.name, method: m.method }; }); }).sort(byKey).map((it) => { let cb = it.varName + "." + it.name; const source = it.file + "?fn=" + it.name; const route = it.route; return { key: it.key, source, method: it.method, route, url: path3.join("/", apiConfig.routeBase, route), cb }; }); }; // src/plugin-router/writeHandlerFile.ts var printList = (list, fn, end = "\n") => { return list.map((it, ix) => fn(it, ix)).join(end); }; var writeHandlerFile = (apiConfig, vite) => { const { moduleId, handlerFile } = apiConfig; const fileRouters = getAllFileRouters(apiConfig); const methodRouters = parseMethodRouters(fileRouters, apiConfig); const code = ` // Files Imports import Express from "express"; ${printList(fileRouters, (it) => `import ${it.varName} from "./${it.file}";`)} import * as configure from "${moduleId}/configure"; export const handler = Express(); configure.handlerBefore?.(handler); ${printList(methodRouters, (it) => `handler.${it.method}("${it.route}", ${it.cb});`)} configure.handlerAfter?.(handler); `; fs.writeFileSync(handlerFile, code); }; // src/plugin-router/writeOpenapiFile.ts import fs2 from "fs"; import path4 from "path"; import YAML from "yaml"; var VALID_METHODS = /* @__PURE__ */ new Set([ "get", "post", "put", "patch", "delete", "options" ]); var writeOpenapiFile = (apiConfig, vite) => { var _a, _b; const { openapiFile, mode, cacheDir } = apiConfig; if (mode !== "isolated") return; const baseDoc = fs2.existsSync(openapiFile) ? fs2.readFileSync(openapiFile, "utf8") : `swagger: "2.0" info: title: Simple Open API version: 1.0.0 `; const doc = YAML.parse(baseDoc); doc.paths = {}; const fileRouters = getAllFileRouters(apiConfig); const methodRouters = parseMethodRouters(fileRouters, apiConfig); for (const route of methodRouters) { const method = route.method?.toLowerCase(); if (!VALID_METHODS.has(method)) continue; const ymlPath = path4.join(cacheDir, route.source.replace(path4.extname(route.source), ".yml")); try { const file = fs2.readFileSync(ymlPath, "utf8"); const data = YAML.parse(file); (_a = doc.paths)[_b = route.route] ?? (_a[_b] = {}); doc.paths[route.route][method] = data; } catch { continue; } } fs2.writeFileSync(openapiFile, YAML.stringify(doc)); }; // src/plugin-router/index.ts var apiRoutesRoute = (apiConfig) => { const isReload = (file) => { file = path5.slash(file); return apiConfig.watcherList.find((it) => file.startsWith(it)); }; return { name: "vite-plugin-api-routes:router", enforce: "pre", config: () => { return { build: { watch: { exclude: [ apiConfig.cacheDir ] } }, resolve: { alias: { [`${apiConfig.moduleId}/server`]: apiConfig.serverFile, [`${apiConfig.moduleId}/handler`]: apiConfig.handlerFile, [`${apiConfig.moduleId}/configure`]: apiConfig.configureFile } } }; }, configResolved: (viteConfig) => { writeHandlerFile(apiConfig, viteConfig); writeOpenapiFile(apiConfig, viteConfig); }, handleHotUpdate: async (data) => { if (isReload(data.file)) { return []; } }, configureServer: async (devServer) => { const { // watcher, restart, config: viteConfig } = devServer; const onReload = (file) => { if (isReload(file)) { writeHandlerFile(apiConfig, viteConfig); writeOpenapiFile(apiConfig, viteConfig); watcher.off("add", onReload); watcher.off("change", onReload); if (apiConfig.forceRestart) { restart(true); } } }; watcher.on("add", onReload); watcher.on("change", onReload); } }; }; // src/plugin-serve/index.ts import express from "express"; import path6 from "slash-path"; var apiRoutesServe = (config) => { return { name: "vite-plugin-api-routes:serve", enforce: "pre", apply: "serve", configureServer: async (devServer) => { const { // config: vite, middlewares, ssrLoadModule, ssrFixStacktrace } = devServer; const baseApi = path6.join(vite.base, config.routeBase); const { viteServerBefore, viteServerAfter } = await ssrLoadModule( config.configure, { fixStacktrace: true } ); var appServer = express(); viteServerBefore?.(appServer, devServer, vite); appServer.use("/", async (req, res, next) => { try { const { handler } = await ssrLoadModule(config.handler, { fixStacktrace: true }); handler(req, res, next); } catch (error) { ssrFixStacktrace(error); process.exitCode = 1; next(error); } }); viteServerAfter?.(appServer, devServer, vite); middlewares.use(baseApi, appServer); } }; }; // src/utils.ts import fs3 from "fs-extra"; import path7 from "path"; import { fileURLToPath } from "url"; var getPluginDirectory = () => { if (typeof __dirname === "undefined") { const filename = fileURLToPath(import.meta.url); return path7.dirname(filename); } else { return __dirname; } }; var findDirPlugin = (dirname, max = 5) => { const basedir = getPluginDirectory(); let relative = "/"; let dirPath = ""; for (var i = 0; i < max; i++) { dirPath = path7.join(basedir, relative, dirname); if (fs3.existsSync(dirPath)) { return dirPath; } relative += "../"; } throw Error(`Not found: ${dirPath}`); }; var cleanDirectory = (target) => { if (fs3.existsSync(target)) { fs3.rmSync(target, { recursive: true, force: true }); } fs3.mkdirSync(target, { recursive: true }); }; var copyFilesDirectory = (origin, target, { files = [], alias = [] }) => { files.forEach((file) => { const sourceFilePath = path7.join(origin, file); const targetFilePath = path7.join(target, file); alias.forEach(({ oldId, newId }) => { if (oldId !== newId) { let fileContent = fs3.readFileSync(sourceFilePath, "utf-8"); fileContent = fileContent.replace(new RegExp(oldId, "g"), newId); fs3.writeFileSync(targetFilePath, fileContent, "utf-8"); } else { fs3.copySync(sourceFilePath, targetFilePath, { overwrite: true }); } }); }); }; // src/index.ts var pluginAPIRoutes = (opts = {}) => { const apiConfig = assertConfig(opts); const apiDir = findDirPlugin(".api"); cleanDirectory(apiConfig.cacheDir); copyFilesDirectory(apiDir, apiConfig.cacheDir, { files: ["configure.js", "handler.js", "server.js"], alias: [ { oldId: "vite-plugin-api-routes", newId: apiConfig.moduleId } ] }); copyFilesDirectory(apiDir, apiConfig.cacheDir, { files: ["env.d.ts"], alias: [ { oldId: "@api", newId: apiConfig.moduleId } ] }); return [ // apiRoutesRoute(apiConfig), apiRoutesServe(apiConfig), apiRoutesBuild(apiConfig) ]; }; var pluginAPI = pluginAPIRoutes; var createAPI = pluginAPIRoutes; var index_default = pluginAPIRoutes; export { createAPI, index_default as default, pluginAPI, pluginAPIRoutes };