nuxt-apex
Version:
Auto-generates fully typed useFetch composables for Nuxt 3/4 server endpoints with Zod validation and zero boilerplate
389 lines (385 loc) • 17.8 kB
JavaScript
import { defineNuxtModule, createResolver, addImportsDir, addServerImportsDir } from '@nuxt/kit';
import { relative, resolve, dirname, join } from 'node:path';
import { rename, readFile, unlink, rm, access, mkdir, writeFile } from 'node:fs/promises';
import { Project, SyntaxKind, Node } from 'ts-morph';
import { glob } from 'tinyglobby';
import pLimit from 'p-limit';
import xxhash from 'xxhash-wasm';
import storage from 'node-persist';
import { existsSync } from 'node:fs';
import { defu } from 'defu';
import chalk from 'chalk';
const levelConfig = {
info: { icon: "\u2139\uFE0F", style: chalk.blueBright },
warn: { icon: "\u26A0\uFE0F", style: chalk.yellowBright },
error: { icon: "\u274C", style: chalk.redBright },
success: { icon: "\u2705", style: chalk.greenBright }
};
function log(level, msg, ...args) {
const { icon, style } = levelConfig[level];
console.log(style(`${icon} ${msg}`), ...args);
}
const info = (msg, ...args) => log("info", msg, ...args);
const warn = (msg, ...args) => log("warn", msg, ...args);
const error = (msg, ...args) => log("error", msg, ...args);
const success = (msg, ...args) => log("success", msg, ...args);
const DEFAULTS = {
sourcePath: "api",
outputPath: ".nuxt/nuxt-apex/files",
cacheFolder: ".nuxt/nuxt-apex/cache",
composablePrefix: "useTFetch",
namingFucntion: void 0,
listenFileDependenciesChanges: true,
serverEventHandlerName: "defineApexHandler",
tsConfigFilePath: "simple",
ignore: [],
concurrency: 50,
tsMorphOptions: {
addFilesFromTsConfig: false,
skipFileDependencyResolution: true,
compilerOptions: {
skipLibCheck: true,
allowJs: false,
declaration: false,
noEmit: true,
preserveConstEnums: false
}
}
};
const EXT_RX = /\.ts$/;
const SLUG_RX = /^\[([^\]]+)\]$/;
const METHOD_MAP = {
get: "get",
post: "create",
put: "update",
delete: "remove"
};
const _fileGenIds = /* @__PURE__ */ new Map();
const { h64Raw } = await xxhash();
const module$1 = defineNuxtModule({
meta: {
name: "nuxt-apex",
configKey: "apex"
},
defaults: DEFAULTS,
async setup(options, nuxt) {
if (nuxt.options._prepare) return;
const { resolve: resolve2 } = createResolver(process.cwd());
const { resolve: resolveInner } = createResolver(import.meta.url);
const addAutoImport = (o) => {
nuxt.options.imports = defu({ presets: Array.isArray(o) ? o : [o] }, nuxt.options.imports);
};
const simpleTsFileConfig = resolve2(nuxt.options.serverDir, "tsconfig.nuxt-apex.json");
if (options.tsConfigFilePath === "simple" && !existsSync(simpleTsFileConfig)) {
await rename(resolveInner("./runtime/templates/tsconfig.txt"), simpleTsFileConfig);
}
const tsConfigFilePath = (options.tsConfigFilePath === "simple" && simpleTsFileConfig || options.tsConfigFilePath && resolve2(nuxt.options.serverDir, "tsconfig.json") || "").replace(/\\/g, "/");
if (!existsSync(tsConfigFilePath)) {
warn("No tsconfig.json found, skipping nuxt-apex module setup. Check your apex.tsConfigFilePath option.");
return;
}
const tsProject = new Project({ tsConfigFilePath, ...options.tsMorphOptions });
const composableTemplate = await readFile(resolveInner("./runtime/templates/fetch.txt"), "utf8");
const outputFolder = resolve2(nuxt.options.rootDir, options.outputPath).replace(/\\/g, "/");
const sourcePath = resolve2(nuxt.options.serverDir, options.sourcePath).replace(/\\/g, "/");
if (!await isFolderExists(sourcePath)) {
error(`Source path "${sourcePath}" doesn't exist`);
return;
}
await storage.init({ dir: resolve2(nuxt.options.rootDir, options.cacheFolder).replace(/\\/g, "/"), encoding: "utf-8" });
const limit = pLimit(options.concurrency || 50);
const executor = async (e, isUpdate, silent = true) => {
try {
const id = (_fileGenIds.get(e) || 0) + 1;
_fileGenIds.set(e, id);
const et = await extractTypesFromEndpoint(e, tsProject, options.serverEventHandlerName, isUpdate);
const es = getEndpointStructure(e, sourcePath, options.sourcePath);
const code = constructComposableCode(composableTemplate, et, es, options.composablePrefix);
const fileName = options.composablePrefix + es.name;
const path = resolve2(outputFolder, `${fileName}.ts`).replace(/\\/g, "/");
if (_fileGenIds.get(e) !== id) return;
const fnForImport = { from: path, imports: [fileName, fileName + "Async", ...et.alias ? [et.alias, et.alias + "Async"] : []] };
await createFile(path, code);
await storage.setItem(absToRel(e), { c: absToRel(path), hash: await hashFile(e), et: {
inputType: et.inputType,
inputFilePath: absToRel(et.inputFilePath),
responseType: et.responseType,
responseFilePath: absToRel(et.responseFilePath),
fnForImport
} });
addAutoImport(fnForImport);
if (!silent) success(`Successfully ${isUpdate ? "updated" : "generated"} ${fileName} fetcher`);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`${message} for file ${e}`);
}
};
const executeMany = async (endpoints2, isUpdate = false, isSilent = true) => {
const result = await Promise.allSettled(endpoints2.map((e) => limit(() => executor(e, isUpdate, isSilent))));
const fulfilled = result.filter((r) => r.status === "fulfilled").map((r) => r.value);
if (fulfilled.length && !isSilent && !isUpdate) success(`Generated ${fulfilled.length} endpoints`);
const errors = result.filter((r) => r.status === "rejected").map((r) => r.reason);
if (errors.length) error(`Errors during generation ${errors.length}:`);
for (const err of errors) error(err);
};
const endpoints = await findEndpoints(sourcePath, options.ignore?.map((x) => "!" + resolve2(nuxt.options.serverDir, x).replace(/\\/g, "/")));
if (endpoints.length > 0) {
await executeMany(endpoints);
}
if (!nuxt.options._prepare) {
info("The endpoint change watcher has started successfully");
nuxt.hook("builder:watch", async (event, path) => {
const isProcessFile = path.includes(sourcePath.split("/").slice(-1 * (options.sourcePath.split("/").length + 1)).join("/")) && /\.(get|post|put|delete)\.ts$/s.test(path);
const endpoint = resolve2(nuxt.options.rootDir, path).replace(/\\/g, "/");
if (options.listenFileDependenciesChanges) {
const reversableRelated = (await storage.data()).filter((x) => {
const u = x.value.et;
return u.inputFilePath === absToRel(endpoint) || u.responseFilePath === absToRel(endpoint);
}).map((x) => relToAbs(x.key));
if (event === "change" && reversableRelated.length) {
await executeMany(reversableRelated, true, false);
return;
}
}
if ((event === "change" || event === "add") && isProcessFile) {
try {
await executor(endpoint, event === "change", false);
} catch (err) {
error(`Error during generation: ${err.message}`);
}
} else if (event === "unlink" && isProcessFile) {
try {
const key = absToRel(endpoint);
const value = await storage.getItem(key);
if (value?.c) await unlink(relToAbs(value.c));
await storage.removeItem(key);
} catch (err) {
error(`Error during deletion: ${err.message}`);
}
}
});
}
addAutoImport((await storage.data()).map((x) => x.value?.et?.fnForImport).flat());
addImportsDir([resolveInner("runtime/utils"), resolveInner("runtime/composables")], { prepend: true });
addServerImportsDir([resolveInner("runtime/server/utils")], { prepend: true });
}
});
async function findEndpoints(apiDir, ignoreEndpoints = []) {
const endpoints = await glob(["**/*.(get|post|put|delete).ts", ...ignoreEndpoints], { cwd: apiDir, absolute: true });
return await compareWithStore(endpoints);
}
async function extractTypesFromEndpoint(endpoint, tsProject, handlerName, isUpdate) {
const result = {
inputType: "unknown",
inputFilePath: "unknown",
responseType: "unknown",
responseFilePath: "unknown"
};
const sf = tsProject.getSourceFile(endpoint) || tsProject.addSourceFileAtPath(endpoint);
if (isUpdate) {
for (const r of (await getRelatedFiles(endpoint)).filter((r2) => r2 !== endpoint)) {
const rsf = tsProject.getSourceFile(r);
if (rsf) tsProject.removeSourceFile(rsf) && tsProject.addSourceFileAtPath(r);
}
await sf.refreshFromFileSystem();
tsProject.resolveSourceFileDependencies();
}
const handlerCall = sf.getExportAssignment((exp) => {
const expression = exp.getExpressionIfKind(SyntaxKind.CallExpression);
if (!expression) return false;
const id = expression.getExpressionIfKind(SyntaxKind.Identifier);
return !!id && new RegExp(handlerName, "s").test(id?.getText() || "");
})?.getExpressionIfKind(SyntaxKind.CallExpression);
if (!handlerCall) {
throw new Error(`No ${handlerName} found in ${endpoint}`);
}
function extractAliasFromComments(sf2) {
const ALIAS_RX = /^\s*(?:(?:\/\/)|(?:\/\*+)|\*)\s*(?:as|@alias)\s*:?[\s]*([A-Za-z_$][\w$]*)/im;
const fullText = sf2.getFullText();
const aliasMatch = ALIAS_RX.exec(fullText);
return aliasMatch ? aliasMatch[1] : void 0;
}
function getAliasText(sym) {
if (!sym) return void 0;
sym = sym.getAliasedSymbol?.() ?? sym;
const decl = sym?.getDeclarations().find((d) => Node.isTypeAliasDeclaration(d) || Node.isInterfaceDeclaration(d));
if (!decl) return void 0;
const typeNode = Node.isTypeAliasDeclaration(decl) ? decl.getTypeNode() : decl;
const text = typeNode ? typeNode?.getText({ trimLeadingIndentation: true }).replace(/\s+/gm, "") : sym.getName();
return text.includes("exportinterface") ? "{" + text.replace(/^exportinterface\w+{/s, "") : text;
}
function getAliasFile(sym) {
sym = sym.getAliasedSymbol?.() ?? sym;
const decl = sym.getDeclarations().find((d) => Node.isTypeAliasDeclaration(d) || Node.isInterfaceDeclaration(d));
return decl ? decl.getSourceFile().getFilePath() : "unknown";
}
function getCallDeclFile(callExpr) {
const id = callExpr.getExpressionIfKindOrThrow(SyntaxKind.Identifier);
const [decl] = id.getDefinitions().map((d) => d.getDeclarationNode()).filter(Boolean);
if (!decl) return "unknown";
let filePath = decl.getSourceFile().getFilePath().toString();
if (filePath.endsWith(".d.ts")) {
const importType = decl.getFirstDescendantByKind(SyntaxKind.ImportType);
if (importType) {
const moduleSpecifier = importType.getArgument().getText().replace(/['"]+/g, "");
const baseDir = dirname(decl.getSourceFile().getFilePath());
const candidate = join(baseDir, moduleSpecifier);
if (existsSync(candidate + ".ts")) {
filePath = candidate + ".ts";
} else if (existsSync(join(candidate, "index.ts"))) {
filePath = join(candidate, "index.ts");
} else {
filePath = candidate;
}
}
}
filePath = filePath.replace(/\\/g, "/");
let fnNode;
if (Node.isFunctionDeclaration(decl) || Node.isFunctionExpression(decl) || Node.isArrowFunction(decl)) {
fnNode = decl;
} else if (Node.isVariableDeclaration(decl)) {
const init = decl.getInitializer();
if (Node.isFunctionExpression(init) || Node.isArrowFunction(init)) {
fnNode = init;
}
}
if (!fnNode) return filePath;
const returns = fnNode.getDescendantsOfKind(SyntaxKind.ReturnStatement);
for (const ret of returns) {
const expr = ret.getExpression();
if (expr && Node.isCallExpression(expr)) {
return getCallDeclFile(expr);
}
}
return filePath;
}
result.alias = extractAliasFromComments(sf);
const [payloadArg] = handlerCall.getTypeArguments();
const payloadAlias = payloadArg?.getType().getAliasSymbol() ?? payloadArg?.getType().getSymbol();
result.inputType = getAliasText(payloadAlias) || payloadArg?.getText().replace(/\s+/gm, "") || "unknown";
result.inputFilePath = payloadAlias ? getAliasFile(payloadAlias) : sf.getFilePath();
const arrow = handlerCall?.getArguments()[0]?.asKindOrThrow(SyntaxKind.ArrowFunction);
let responseType = arrow?.getSignature().getReturnType();
while (responseType?.getSymbol()?.getName() === "Promise") {
const args = responseType.getTypeArguments();
if (args.length === 0) break;
responseType = args[0];
}
result.responseType = responseType?.getText().replace(/\s+/gm, "") || "unknown";
let firstCall;
if (Node.isCallExpression(arrow?.getBody())) {
firstCall = arrow.getBody();
} else {
const ret = arrow?.getBody().getDescendantsOfKind(SyntaxKind.ReturnStatement)[0];
const expr = ret?.getExpression();
if (expr && Node.isCallExpression(expr)) {
firstCall = expr;
}
}
for (const [k, v] of Object.entries(result)) {
if (k.endsWith("Type") && v === "unknown") {
result[k] = "Record<string, any>";
warn(`Unable to determine ${k.replace("Type", "")} type for endpoint ${endpoint}, using 'Record<string, any>' as fallback`);
}
}
result.responseFilePath = firstCall ? getCallDeclFile(firstCall) : sf.getFilePath();
return result;
}
function getEndpointStructure(endpoint, sourcePath, baseUrl, namingFucntion) {
const relPath = relative(sourcePath.replace(/\\/g, "/"), endpoint.replace(/\\/g, "/")).replace(EXT_RX, "").replace(/\\/g, "/");
const segments = relPath.split("/");
const last = segments.pop();
const [rawName, methodKey] = last.split(".");
const action = METHOD_MAP[methodKey];
const folderPart = segments.map((p) => pascalCase(p)).join("");
const name = namingFucntion?.(relPath) ?? baseNamingFunction(action, rawName, folderPart);
if (typeof name !== "string" || name.length === 0) throw new Error("namingFucntion must return a valid stiring");
const slugs = [];
const url = ["", baseUrl, ...segments, rawName].map((segment) => {
const m = SLUG_RX.exec(segment);
if (m && m[1]) {
slugs.push(m[1]);
return `\${encodeURIComponent(data.${m[1]})}`;
}
return segment;
}).join("/").replace(/\/{2,}/g, "/");
return { url, slugs, method: methodKey, name };
}
function constructComposableCode(template, et, es, composablePrefix) {
const propForData = ["get", "delete"].includes(es.method) ? "query" : "body";
const dataText = es.slugs.length ? `:omit(data, ${es.slugs.map((x) => `'${x}'`).join(",")})` : ":data";
const inputKind = `with the given data as \`${propForData}\``;
const alias = et.alias ? `* @alias \`${et.alias}\`.` : "";
const aliaseFunctions = et.alias ? ["", "Async"].map((x) => `export const ${et.alias}${x} = ${composablePrefix}${es.name}${x}`).join("\n") : "";
return template.replace(/:inputType/g, et.inputType).replace(/:responseType/g, et.responseType).replaceAll(/:fallback/g, et.inputType === "Record<string, any>" ? " = {} as T" : "").replaceAll(/:url/g, `\`${es.url}\``).replace(/:method/g, `\`${es.method}\``).replace(/:apiNamePrefix/g, composablePrefix).replace(/:apiFnName/g, es.name).replace(/:inputData/g, propForData + dataText).replace(/:inputKind/g, inputKind).replace(/:propForData/g, propForData).replace(/:alias/g, alias).replace(/:aFunctions/g, aliaseFunctions);
}
function baseNamingFunction(action, rawName, folderPart) {
let baseName;
if (SLUG_RX.test(rawName)) {
const slug = rawName.slice(1, -1);
baseName = action + "By" + pascalCase(slug);
} else if (rawName === "index") {
baseName = action;
} else {
baseName = pascalCase(rawName) + pascalCase(action);
}
return folderPart ? folderPart + pascalCase(baseName) : pascalCase(baseName);
}
async function createFile(path, content) {
const dir = dirname(path);
await mkdir(dir, { recursive: true });
await writeFile(`${path}.tmp`, content);
await rename(`${path}.tmp`, path);
}
async function hashFile(filePath) {
const data = await readFile(filePath);
return h64Raw(new Uint8Array(data), 0n).toString(16).padStart(16, "0");
}
async function compareWithStore(endpoints) {
const changed = [];
const lookup = /* @__PURE__ */ Object.create(null);
for (let i = 0, len = endpoints.length; i < len; i++) {
if (!endpoints[i]) continue;
const k = absToRel(endpoints[i]);
if (k) lookup[k] = true;
}
for (const key of await storage.keys()) {
if (!lookup[key]) {
await rm(relToAbs((await storage.getItem(key)).c));
await storage.removeItem(key);
}
}
for (let i = 0, len = endpoints.length; i < len; i++) {
const k = endpoints[i];
if (k) {
if ((await storage.getItem(absToRel(k)))?.hash === await hashFile(k)) continue;
changed.push(k);
}
}
return changed;
}
async function getRelatedFiles(endpoint) {
return await storage.getItem(absToRel(endpoint)).then(
({ et }) => et ? [et.inputFilePath, et.responseFilePath].filter((p) => p && p !== "unknown").map((p) => relToAbs(p)) : []
);
}
async function isFolderExists(folder) {
try {
await access(folder);
return true;
} catch {
return false;
}
}
function pascalCase(input) {
return input.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((s) => s[0]?.toUpperCase() + s.slice(1)).join("");
}
function absToRel(absPath, root = process.cwd()) {
return relative(root, absPath).replace(/\\/g, "/");
}
function relToAbs(relPath, root = process.cwd()) {
const trimmed = relPath.replace(/^[/\\]+/, "");
return resolve(root, trimmed).replace(/\\/g, "/");
}
export { DEFAULTS, constructComposableCode, module$1 as default, extractTypesFromEndpoint, getEndpointStructure };