UNPKG

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