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
JavaScript
// 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
};