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.

663 lines (647 loc) 18.5 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"), 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, serverOutDir = "dist", serverMinify = false, serverBuild = (config) => config } = 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 }, USE: { method: "use", priority: 20 }, GET: { method: "get", priority: 30 }, POST: { method: "post", priority: 40 }, PATCH: { method: "patch", priority: 50 }, PUT: { method: "put", priority: 60 }, DELETE: { method: "delete", priority: 70 }, // 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 routersFile = path.join(cacheDir, "routers.js"); 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, routersFile, 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, serverOutDir, serverMinify, serverBuild }; }; // 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, routersFile, cacheDir } = apiConfig; const binFiles = [configureFile, handlerFile, routersFile]; 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(""); viteConfig.logger.info("\x1B[1m\x1B[31mSERVER BUILD\x1B[0m"); await doBuildServer(apiConfig, viteConfig); viteConfig.logger.info(""); viteConfig.logger.info("\x1B[1m\x1B[31mCLIENT BUILD\x1B[0m"); await doBuildClient(apiConfig, viteConfig); viteConfig.logger.info(""); } }; }; // src/plugin-route/index.ts import path5 from "slash-path"; // src/plugin-route/routersFile.ts import fs from "fs"; import path4 from "slash-path"; // src/plugin-route/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.root, 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 + ".default" }; }).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-route/routersFile.ts var writeRoutersFile = (apiConfig, vite) => { const { moduleId, cacheDir, routersFile } = apiConfig; if (routersFile.startsWith(cacheDir)) { const fileRouters = getAllFileRouters(apiConfig); const methodRouters = parseMethodRouters(fileRouters, apiConfig); const max = methodRouters.map((it) => { it.url = path4.join("/", vite.base, apiConfig.routeBase, it.route); return it; }).reduce( (max2, it) => { const cb = it.cb.length; const url = it.url.length; const route = it.route.length; const method = it.method.length; const source = it.source.length; max2.cb = cb > max2.cb ? cb : max2.cb; max2.url = url > max2.url ? url : max2.url; max2.route = route > max2.route ? route : max2.route; max2.method = method > max2.method ? method : max2.method; max2.source = source > max2.source ? source : max2.source; return max2; }, { cb: 0, method: 0, route: 0, url: 0, source: 0 } ); const debug = methodRouters.map( (it) => "// " + it.method.toUpperCase().padEnd(max.method + 1, " ") + it.url.padEnd(max.url + 4, " ") + it.source ).join("\n").trim(); const writeRouter = (c) => { return [ " ", `${c.cb}`.padEnd(max.cb + 1, " "), ` && { cb: `, `${c.cb}`.padEnd(max.cb + 1, " "), ", method: ", `"${c.method}"`.padEnd(max.method + 3, " "), `, route: `, `"${c.route}"`.padEnd(max.route + 3, " "), ", url: ", `"${c.url}"`.padEnd(max.url + 3, " "), ", source: ", `"${c.source}"`.padEnd(max.source + 3, " "), "}" ].join(""); }; const importFiles = fileRouters.map( (it) => `import * as ${it.varName} from "${moduleId}/root/${it.file}";` ).join("\n"); const internalRouter = methodRouters.map((c) => writeRouter(c)).join(",\n"); const code = ` // Files Imports import * as configure from "${moduleId}/configure"; ${importFiles} // Public RESTful API Methods and Paths // This section describes the available HTTP methods and their corresponding endpoints (paths). ${debug} const internal = [ ${internalRouter} ].filter(it => it); export const routers = internal.map((it) => { const { method, route, url, source } = it; return { method, url, route, source }; }); export const endpoints = internal.map( (it) => it.method?.toUpperCase() + "\\t" + it.url ); export const applyRouters = (applyRouter) => { internal.forEach((it) => { it.cb = configure.callbackBefore?.(it.cb, it) || it.cb; applyRouter(it); }); }; `; fs.writeFileSync(routersFile, code); } }; // src/plugin-route/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:route", enforce: "pre", config: () => { return { resolve: { alias: { [`${apiConfig.moduleId}/root`]: apiConfig.root, [`${apiConfig.moduleId}/server`]: apiConfig.serverFile, [`${apiConfig.moduleId}/handler`]: apiConfig.handlerFile, [`${apiConfig.moduleId}/routers`]: apiConfig.routersFile, [`${apiConfig.moduleId}/configure`]: apiConfig.configureFile } } }; }, configResolved: (viteConfig) => { writeRoutersFile(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)) { writeRoutersFile(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 fs2 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 (fs2.existsSync(dirPath)) { return dirPath; } relative += "../"; } throw Error(`Not found: ${dirPath}`); }; var cleanDirectory = (target) => { if (fs2.existsSync(target)) { fs2.rmSync(target, { recursive: true, force: true }); } fs2.mkdirSync(target, { recursive: true }); }; var copyFilesDirectory = (origin, target, { files = [], oldId = "", newId = "" }) => { files.forEach((file) => { const sourceFilePath = path7.join(origin, file); const targetFilePath = path7.join(target, file); if (oldId !== newId) { let fileContent = fs2.readFileSync(sourceFilePath, "utf-8"); fileContent = fileContent.replace(new RegExp(oldId, "g"), newId); fs2.writeFileSync(targetFilePath, fileContent, "utf-8"); } else { fs2.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"], oldId: "vite-plugin-api-routes", newId: apiConfig.moduleId }); copyFilesDirectory(apiDir, apiConfig.cacheDir, { files: ["env.d.ts"], 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 };