UNPKG

quickwire

Version:

Automatic API generator for Next.js applications that creates API routes and TypeScript client functions from backend functions

1,170 lines (1,157 loc) β€’ 63.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.fileOperationQueue = exports.changedFiles = exports.deletedFiles = exports.backendToGenerated = exports.docCache = exports.generatedFiles = void 0; exports.markFileChanged = markFileChanged; exports.markFileDeleted = markFileDeleted; exports.generateQuickwireFile = generateQuickwireFile; exports.generateApiRoutesForFile = generateApiRoutesForFile; exports.processBackendFile = processBackendFile; exports.scanAllBackendFunctions = scanAllBackendFunctions; exports.getGenerationStats = getGenerationStats; exports.validateGeneratedFiles = validateGeneratedFiles; exports.recoverFromCorruption = recoverFromCorruption; exports.ensureDirectoryExists = ensureDirectoryExists; exports.safeWriteFile = safeWriteFile; exports.safeRemoveFile = safeRemoveFile; exports.calculateFileChecksum = calculateFileChecksum; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const config_1 = require("./config"); const utils_1 = require("./utils/utils"); const ast_1 = require("./ast"); const cache_1 = require("./cache"); exports.generatedFiles = new Map(); exports.docCache = new Map(); exports.backendToGenerated = new Map(); exports.deletedFiles = new Set(); exports.changedFiles = new Set(); exports.fileOperationQueue = new Set(); let lastFullScan = 0; let isProcessing = false; // Utility functions for better file management function calculateFileChecksum(filePath) { try { const content = fs_1.default.readFileSync(filePath, 'utf8'); // Simple checksum using content hash let hash = 0; for (let i = 0; i < content.length; i++) { const char = content.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(16); } catch (error) { console.warn(`⚠️ Failed to calculate checksum for ${filePath}:`, error); return Date.now().toString(); } } function ensureDirectoryExists(dirPath) { try { if (!fs_1.default.existsSync(dirPath)) { fs_1.default.mkdirSync(dirPath, { recursive: true }); console.log(`πŸ“ Created directory: ${path_1.default.relative(process.cwd(), dirPath)}`); } return true; } catch (error) { console.error(`❌ Failed to create directory ${dirPath}:`, error); return false; } } function safeWriteFile(filePath, content, sourceFile) { try { const dirPath = path_1.default.dirname(filePath); if (!ensureDirectoryExists(dirPath)) { return false; } // Create temporary file first const tempPath = `${filePath}.tmp`; fs_1.default.writeFileSync(tempPath, content, "utf8"); // Atomic move fs_1.default.renameSync(tempPath, filePath); // Track the generated file const metadata = { generatedAt: Date.now(), sourceFile, checksum: calculateFileChecksum(filePath) }; exports.generatedFiles.set(filePath, metadata); console.log(`βœ… Generated: ${path_1.default.relative(process.cwd(), filePath)}`); return true; } catch (error) { console.error(`❌ Failed to write file ${filePath}:`, error); // Clean up temp file if it exists try { const tempPath = `${filePath}.tmp`; if (fs_1.default.existsSync(tempPath)) { fs_1.default.unlinkSync(tempPath); } } catch (cleanupError) { console.warn(`⚠️ Failed to cleanup temp file:`, cleanupError); } return false; } } function safeRemoveFile(filePath) { try { if (fs_1.default.existsSync(filePath)) { // Check if it's actually a generated file const stat = fs_1.default.statSync(filePath); if (!stat.isFile()) { console.warn(`⚠️ Skipping removal of non-file: ${filePath}`); return false; } fs_1.default.unlinkSync(filePath); console.log(`πŸ—‘οΈ Removed: ${path_1.default.relative(process.cwd(), filePath)}`); // After file removal, check if should delete parent folder (applicable inside API folder) const parentDir = path_1.default.dirname(filePath); // Define your API folder root path - adjust as per your CONFIG or project structure const apiRoot = path_1.default.resolve(config_1.CONFIG.apiDir); if (parentDir.startsWith(apiRoot)) { // Check if folder is now empty const entries = fs_1.default.readdirSync(parentDir); if (entries.length === 0) { // Remove the empty folder try { fs_1.default.rmdirSync(parentDir); console.log(`πŸ—‘οΈ Removed empty directory: ${path_1.default.relative(process.cwd(), parentDir)}`); } catch (dirRemoveError) { console.warn(`⚠️ Failed to remove directory ${parentDir}:`, dirRemoveError); } } } } exports.generatedFiles.delete(filePath); return true; } catch (error) { console.error(`❌ Failed to remove file ${filePath}:`, error); return false; } } function safeRemoveDirectory(dirPath) { try { if (!fs_1.default.existsSync(dirPath)) return true; const entries = fs_1.default.readdirSync(dirPath); if (entries.length === 0) { fs_1.default.rmdirSync(dirPath); console.log(`πŸ—‘οΈ Removed empty directory: ${path_1.default.relative(process.cwd(), dirPath)}`); return true; } return false; } catch (error) { console.warn(`⚠️ Failed to remove directory ${dirPath}:`, error); return false; } } function markFileChanged(filePath) { if (isProcessing) { console.log(`⏸️ Queueing change for ${path_1.default.relative(process.cwd(), filePath)} (processing in progress)`); } exports.changedFiles.add(filePath); cache_1.fileCache.delete(filePath); // Invalidate cache // Remove from deleted files if it was marked as deleted exports.deletedFiles.delete(filePath); } function markFileDeleted(filePath) { exports.deletedFiles.add(filePath); exports.changedFiles.delete(filePath); cache_1.fileCache.delete(filePath); console.log(`πŸ—‘οΈ Marked for deletion: ${path_1.default.relative(process.cwd(), filePath)}`); } function shouldRegenerateEverything() { const now = Date.now(); const timeSinceLastScan = now - lastFullScan; const hasChanges = exports.changedFiles.size > 0 || exports.deletedFiles.size > 0; const cacheExpired = timeSinceLastScan > config_1.CONFIG.performance.cacheExpiryMs; const tooManyChanges = exports.changedFiles.size > config_1.CONFIG.performance.maxFilesToProcess * 0.5; // Always log details console.log(`πŸ” Regeneration check: Changes present: ${hasChanges} (${exports.changedFiles.size} files), ` + `Deleted files: ${exports.deletedFiles.size}, Cache expired: ${cacheExpired}, Too many changes: ${tooManyChanges}`); if (hasChanges && exports.changedFiles.size > 0) { const recentChanges = Array.from(exports.changedFiles) .slice(0, 5) .map(f => path_1.default.relative(process.cwd(), f)) .join(", "); console.log(` Recent changed files: ${recentChanges}${exports.changedFiles.size > 5 ? '...' : ''}`); } if (exports.deletedFiles.size > 0) { const deletedList = Array.from(exports.deletedFiles) .slice(0, 3) .map(f => path_1.default.relative(process.cwd(), f)) .join(", "); console.log(` Recently deleted files: ${deletedList}${exports.deletedFiles.size > 3 ? '...' : ''}`); } // Only trigger full regeneration on cache expiry or too many changes if (cacheExpired || tooManyChanges) { console.log("πŸ”„ Triggering FULL regeneration due to cache expiry or too many changes"); return true; // full regeneration } // Return false to avoid full regeneration for ordinary changes console.log("⚑ Skipping full regeneration; incremental regeneration can proceed"); return false; } function isObjectDestructured(param) { return param.type.startsWith("{") && param.type.endsWith("}"); } function generateFunctionSignature(func) { // Filter out context parameters from client signature const clientParams = func.parameters.filter(p => { const type = p.type.toLowerCase(); return !(type.includes('quickwirecontext') || type.includes('context') && (type.includes('request') || type.includes('req')) || p.name.toLowerCase().includes('context') && p.optional); }); if (clientParams.length === 0) { return { parameterType: "void", callSignature: "() =>", parameterUsage: "", }; } if (clientParams.length === 1) { const param = clientParams[0]; if (isObjectDestructured(param)) { return { parameterType: param.type, callSignature: `(${param.name}${param.optional ? "?" : ""}: ${param.type}) =>`, parameterUsage: param.name, }; } const originalParamType = param.type; const objectParamType = `{ ${param.name}${param.optional ? "?" : ""}: ${param.type} }`; return { parameterType: `${originalParamType} | ${objectParamType}`, callSignature: `(${param.name}${param.optional ? "?" : ""}: ${originalParamType} | ${objectParamType}) =>`, parameterUsage: `typeof ${param.name} === 'object' && '${param.name}' in ${param.name} ? ${param.name} : { ${param.name}: ${param.name} }`, }; } const paramStrings = clientParams.map((p) => `${p.name}${p.optional ? "?" : ""}: ${p.type}`); const objectType = `{ ${paramStrings.join("; ")} }`; return { parameterType: objectType, callSignature: `(params: ${objectType}) =>`, parameterUsage: "params", }; } function generateQuickwireFile(filePath, endpoints, moduleExports) { try { const relativePath = (0, utils_1.sanitizeFilePath)(path_1.default.relative(config_1.CONFIG.backendDir, filePath)); if (!moduleExports) { moduleExports = (0, ast_1.analyzeModuleExports)(filePath); } if (moduleExports.functions.length === 0) { console.log(`⏭️ Skipping ${relativePath} - no exported functions`); return false; } const quickwireFilePath = path_1.default.join(config_1.CONFIG.quickwireDir, relativePath); const lines = [ "// Auto-generated by Quickwire", "// Do not edit manually - changes will be overwritten", `// Generated from: ${relativePath}`, `// Generated at: ${new Date().toISOString()}`, "", "type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;", "", "// Define proper request data types", "type RequestData = FormData | Record<string, unknown> | string | number | boolean | null | undefined;", "", ]; lines.push(`import { makeQuickwireRequest, convertToFormData } from "@/lib/utils.quickwire";`); lines.push(`import type { AxiosRequestConfig } from "axios";`); lines.push(""); lines.push("// Type-safe request helpers"); lines.push("type SafeRequestData<T> = T extends FormData ? FormData : T extends Record<string, unknown> ? T : RequestData;"); lines.push(""); const backendImportPath = `@/backend/${relativePath.replace(/\.[tj]s$/, "")}`; const functionImports = moduleExports.functions .map((func) => `${func.name} as ${func.name}Internal`) .join(", "); // Extract type names for import const typeImports = moduleExports.types .map((type) => type.name) .join(", "); // Combine function and type imports const allImports = functionImports + (functionImports && typeImports ? ", " : "") + typeImports; if (allImports) { lines.push(`import { ${allImports} } from "${backendImportPath}";`); lines.push(""); } let functionsGenerated = 0; moduleExports.functions.forEach((func) => { const endpointInfo = endpoints[func.name]; if (!endpointInfo) { console.warn(`⚠️ No endpoint info for function ${func.name} in ${relativePath}`); return; } let route = endpointInfo.route; const method = endpointInfo.method; if (!route.startsWith("/api")) { route = `/api${route}`; } function typeStringHasFile(typeStr) { if (!typeStr) return false; const type = typeStr.toLowerCase(); return (type === "file" || type === "file[]" || type === "blob" || type === "blob[]" || type === "formdata" || type.includes("file") || type.includes("blob") || type.includes("formdata") || /\bfile\b/i.test(typeStr) || /\bblob\b/i.test(typeStr) || /\bformdata\b/i.test(typeStr)); } // Filter out context parameters for client-side functions const clientParams = func.parameters.filter(p => { const type = p.type.toLowerCase(); const name = p.name.toLowerCase(); return !(type.includes('quickwirecontext') || type.includes('requestcontext') || type.includes('context') || name.includes('context') || name === 'ctx' || name === 'req' || name === 'request'); }); // Generate signature based only on client parameters const clientSignature = clientParams.length > 0 ? generateFunctionSignature({ ...func, parameters: clientParams }) : { parameterType: 'void', parameterUsage: undefined }; const hasFileParam = clientParams.some((p) => typeStringHasFile(p.type)); // Unified parameter handling for ALL methods - use body for everything if (hasFileParam) { // Handle file uploads with FormData if (clientParams.length > 0) { lines.push(`export const ${func.name} = (params: ${clientSignature.parameterType}, axiosConfig?: AxiosRequestConfig) => {`); lines.push(` const formData: FormData = convertToFormData(params as Record<string, unknown>);`); } else { lines.push(`export const ${func.name} = (axiosConfig?: AxiosRequestConfig) => {`); lines.push(` const formData: FormData = new FormData();`); } lines.push(` return makeQuickwireRequest<UnwrapPromise<ReturnType<typeof ${func.name}Internal>>>( \`${route}\`, "${method}", formData as SafeRequestData<FormData>, axiosConfig );`); lines.push("};"); } else { // All other methods (including GET) - use JSON body const allOptional = clientParams.length > 0 && clientParams.every((p) => p.optional); const optionalModifier = allOptional && !clientSignature.parameterType.includes("|") ? "?" : ""; if (clientParams.length === 0) { // No parameters lines.push(`export const ${func.name} = (axiosConfig?: AxiosRequestConfig) => {`); lines.push(` return makeQuickwireRequest<UnwrapPromise<ReturnType<typeof ${func.name}Internal>>>( \`${route}\`, "${method}", undefined as SafeRequestData<void>, axiosConfig );`); } else if (clientParams.length === 1 && !isObjectDestructured(clientParams[0])) { // Single parameter - wrap in object for consistent body structure const param = clientParams[0]; lines.push(`export const ${func.name} = (${param.name}${optionalModifier}: ${param.type}, axiosConfig?: AxiosRequestConfig) => {`); lines.push(` return makeQuickwireRequest<UnwrapPromise<ReturnType<typeof ${func.name}Internal>>>( \`${route}\`, "${method}", ${param.name} as unknown as RequestData, axiosConfig );`); } else { // Multiple parameters or object destructured lines.push(`export const ${func.name} = (params${optionalModifier}: ${clientSignature.parameterType}, axiosConfig?: AxiosRequestConfig) => {`); lines.push(` return makeQuickwireRequest<UnwrapPromise<ReturnType<typeof ${func.name}Internal>>>( \`${route}\`, "${method}", params as SafeRequestData<${clientSignature.parameterType}>, axiosConfig );`); } lines.push("};"); } lines.push(""); functionsGenerated++; }); const success = safeWriteFile(quickwireFilePath, lines.join("\n"), filePath); if (success) { console.log(`βœ… Generated quickwire file with ${functionsGenerated} functions: ${path_1.default.relative(process.cwd(), quickwireFilePath)}`); } return success; } catch (error) { console.error(`❌ Failed to generate quickwire file for ${filePath}:`, error); return false; } } function generateApiRoutesForFile(filePath, moduleExports) { const relativePath = (0, utils_1.sanitizeFilePath)(path_1.default.relative(config_1.CONFIG.backendDir, filePath)); if (!moduleExports) { moduleExports = (0, ast_1.analyzeModuleExports)(filePath); } const endpoints = {}; if (moduleExports.functions.length === 0) { return endpoints; } moduleExports.functions.forEach((func) => { try { const route = `/${relativePath.replace(/\.[tj]s$/, "")}/${(0, utils_1.pascalToKebab)(func.name)}`; const method = func.httpMethod || "POST"; endpoints[func.name] = { route, method }; const apiFilePath = path_1.default.join(config_1.CONFIG.apiDir, route, config_1.CONFIG.apiRouteTemplate); // Filter out context parameters to get only client parameters const clientParams = func.parameters.filter(p => { const type = p.type.toLowerCase(); const name = p.name.toLowerCase(); return !(type.includes('quickwirecontext') || type.includes('requestcontext') || type.includes('context') || name.includes('context') || name === 'ctx' || name === 'req' || name === 'request'); }); // Check if function has context parameter and find its position const contextParamIndex = func.parameters.findIndex(p => { const type = p.type.toLowerCase(); const name = p.name.toLowerCase(); return (type.includes('quickwirecontext') || type.includes('requestcontext') || type.includes('context') || name.includes('context') || name === 'ctx'); }); const hasContextParam = contextParamIndex !== -1; let parameterHandling = ""; let functionCallParams = []; const fileParams = clientParams.filter((p) => { const type = p.type.toLowerCase(); return (type === "file" || type === "file[]" || type === "blob" || type === "blob[]" || type === "formdata" || type.includes("file") || type.includes("blob") || type.includes("formdata") || /\bfile\b/i.test(p.type) || /\bblob\b/i.test(p.type) || /\bformdata\b/i.test(p.type)); }); // Unified body parameter handling for ALL HTTP methods (including GET) if (fileParams.length > 0) { // File upload handling remains the same parameterHandling = ` type ParsedValue = string | number | boolean | object | File; function parseNestedValue(value: string): ParsedValue { // Try to parse as JSON for nested objects/arrays if (value.startsWith('{') || value.startsWith('[')) { try { return JSON.parse(value); } catch { return value; } } // Try to parse as boolean if (value === 'true') return true; if (value === 'false') return false; // Try to parse as number if (!isNaN(Number(value)) && value !== '') { return Number(value); } return value; } function assignDeep(obj: Record<string, ParsedValue | ParsedValue[]>, keyPath: string, value: ParsedValue): void { const keys = keyPath.replace(/\\]/g, '').split(/[\\[.]/); let current: Record<string, ParsedValue | ParsedValue[]> | ParsedValue[] = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!key) continue; // Skip empty keys from split if (!(current as Record<string, ParsedValue | ParsedValue[]>)[key]) { // Check if next key is a number (array index) const nextKey = keys[i + 1]; (current as Record<string, ParsedValue | ParsedValue[]>)[key] = !isNaN(Number(nextKey)) ? [] : {}; } current = (current as Record<string, ParsedValue | ParsedValue[]>)[key] as Record<string, ParsedValue | ParsedValue[]> | ParsedValue[]; } const lastKey = keys[keys.length - 1]; if (lastKey) { (current as Record<string, ParsedValue | ParsedValue[]>)[lastKey] = value instanceof File ? value : parseNestedValue(value.toString()); } } function formDataToObject(formData: FormData): Record<string, ParsedValue | ParsedValue[]> { const obj: Record<string, ParsedValue | ParsedValue[]> = {}; for (const [key, value] of formData.entries()) { assignDeep(obj, key, value as ParsedValue); } return obj; } let formData: FormData; try { const contentType = req.headers.get('content-type'); if (!contentType || !contentType.includes('multipart/form-data')) { throw new Error('Expected multipart/form-data'); } formData = await req.formData(); console.log('FormData entries:'); for (const [key, value] of formData.entries()) { console.log(\` \${key}: \${value instanceof File ? \`File(\${value.name})\` : value}\`); } } catch (error) { throw new Error(\`Invalid file upload: \${error instanceof Error ? error.message : 'Unknown error'}\`); } const params = formDataToObject(formData); console.log('Parsed params:', JSON.stringify(params, (key, value) => value instanceof File ? \`File(\${value.name})\` : value, 2)); `; // Build function call parameters with context in correct position functionCallParams = []; if (clientParams.length === 1 && isObjectDestructured(clientParams[0])) { // Single object parameter for (let i = 0; i < func.parameters.length; i++) { if (i === contextParamIndex) { functionCallParams.push("context"); } else { functionCallParams.push(`params as Parameters<typeof ${func.name}>[${i}]`); } } } else { // Multiple parameters let clientParamIndex = 0; for (let i = 0; i < func.parameters.length; i++) { if (i === contextParamIndex) { functionCallParams.push("context"); } else { functionCallParams.push(`params.${clientParams[clientParamIndex].name} as Parameters<typeof ${func.name}>[${i}]`); clientParamIndex++; } } } } else { // JSON body handling for ALL HTTP methods (including GET) // Generate specific interface for the request body based on client parameters only let bodyInterface = ""; if (clientParams.length === 0) { // No client parameters // bodyInterface = " interface RequestBody {\n [key: string]: never;\n }"; } else if (clientParams.length === 1 && isObjectDestructured(clientParams[0])) { // Single object parameter - use the exact type // const param = clientParams[0]; // bodyInterface = ` type RequestBody = ${param.type};`; } else { // Multiple parameters - create interface with exact parameter types // const paramDeclarations = clientParams.map(p => // ` ${p.name}${p.optional ? "?" : ""}: ${p.type};` // ).join("\n"); // bodyInterface = ` interface RequestBody {\n${paramDeclarations}\n }`; } parameterHandling = ` ${bodyInterface} let body; try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { body = await req.json(); } else { const textBody = await req.text(); body = textBody ? JSON.parse(textBody) : {}; } } catch (error) { body = {}; } `; // Build function call parameters with context in correct position functionCallParams = []; if (clientParams.length === 0) { // No client parameters, only context if it exists if (hasContextParam) { functionCallParams.push("context"); } } else if (clientParams.length === 1 && !isObjectDestructured(clientParams[0])) { // Single parameter - extract from body object let clientParamIndex = 0; for (let i = 0; i < func.parameters.length; i++) { if (i === contextParamIndex) { functionCallParams.push("context"); } else { functionCallParams.push(`body${clientParams.length === 1 ? "" : "." + clientParams[clientParamIndex].name}`); clientParamIndex++; } } } else if (clientParams.length === 1 && isObjectDestructured(clientParams[0])) { // Single object parameter for (let i = 0; i < func.parameters.length; i++) { if (i === contextParamIndex) { functionCallParams.push("context"); } else { functionCallParams.push("body"); } } } else { // Multiple parameters let clientParamIndex = 0; for (let i = 0; i < func.parameters.length; i++) { if (i === contextParamIndex) { functionCallParams.push("context"); } else { functionCallParams.push(`body.${clientParams.length === 1 ? "" : clientParams[clientParamIndex].name}`); clientParamIndex++; } } } } const functionCall = `${func.isAsync ? "await " : ""}${func.name}(${functionCallParams.map((param, index) => `${param} as unknown as Parameters<typeof ${func.name}>[${index}]`).join(", ")})`; // Only generate context setup if the function has a QuickwireContext parameter const contextSetup = hasContextParam ? ` // Setup request context for backend function const context = { req, getHeaders: () => Object.fromEntries(req.headers.entries()), getCookies: () => Object.fromEntries( req.headers.get('cookie') ?.split(';') ?.map(c => c.trim().split('=')) ?.filter(([k, v]) => k && v) || [] ), getIp: () => req.headers.get('x-forwarded-for')?.split(',')[0] || req.headers.get('x-real-ip') || 'unknown', getUserAgent: () => req.headers.get('user-agent') || 'unknown' }; ` : ''; // Extract types from module exports to import them const typeImports = moduleExports.types.map(type => type.name).join(', '); const importStatement = typeImports ? `import { ${func.name}, ${typeImports} } from "@/backend/${relativePath.replace(/\.[tj]s$/, "")}"; ` : `import { ${func.name} } from "@/backend/${relativePath.replace(/\.[tj]s$/, "")}"; `; const handlerCode = `${importStatement}import { NextResponse } from "next/server"; ${hasContextParam ? 'import type { QuickwireContext } from "quickwire/types";' : ''} export async function ${method}(req: Request) { try { ${parameterHandling}${contextSetup} const result = ${functionCall}; return NextResponse.json(result ?? null); } catch (error) { console.error("API Error:", error); const errorMessage = error instanceof Error ? error.message : "Internal server error"; return NextResponse.json( { error: errorMessage }, { status: 500 } ); } } `; const success = safeWriteFile(apiFilePath, handlerCode, filePath); if (!success) { console.error(`❌ Failed to generate API route for ${func.name}`); } } catch (error) { console.error(`❌ Error generating API route for ${func.name}:`, error); } }); return endpoints; } // ... [Rest of the API documentation functions remain the same] ... function cleanupOrphanedFiles() { console.log("🧹 Starting cleanup of orphaned files..."); const cleanupDirs = [config_1.CONFIG.quickwireDir, config_1.CONFIG.apiDir]; let removedCount = 0; for (const dir of cleanupDirs) { if (!fs_1.default.existsSync(dir)) continue; try { removedCount += walkAndCleanup(dir); } catch (error) { console.warn(`⚠️ Failed to cleanup directory ${dir}:`, error); } } console.log(`🧹 Cleanup completed: removed ${removedCount} orphaned files`); } function walkAndCleanup(dir) { let removedCount = 0; try { const entries = fs_1.default.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path_1.default.join(dir, entry.name); if (entry.isDirectory()) { removedCount += walkAndCleanup(fullPath); // Try to remove empty directory try { const isEmpty = fs_1.default.readdirSync(fullPath).length === 0; if (isEmpty && safeRemoveDirectory(fullPath)) { // Directory was removed, don't count in removedCount as it's not a file } } catch { // Directory not empty or other error, ignore } } else if (entry.isFile()) { // Only remove TypeScript/JavaScript files that we didn't generate if ((fullPath.endsWith(".ts") || fullPath.endsWith(".js")) && !exports.generatedFiles.has(fullPath)) { // Additional safety: check if file contains generated marker try { const content = fs_1.default.readFileSync(fullPath, 'utf8'); if (content.includes('Auto-generated by Quickwire')) { if (safeRemoveFile(fullPath)) { removedCount++; } } } catch (error) { console.warn(`⚠️ Could not read file for cleanup check ${fullPath}:`, error); } } } } } catch (error) { console.warn(`⚠️ Error during cleanup walk in ${dir}:`, error); } return removedCount; } function processBackendFile(filePath) { if (!(0, utils_1.shouldProcessFile)(filePath, config_1.CONFIG) || !fs_1.default.existsSync(filePath)) { return false; } try { // Remove all old generated files for this backend file before generating new ones const oldGeneratedFiles = exports.backendToGenerated.get(filePath); if (oldGeneratedFiles) { console.log(`🧹 Cleaning up ${oldGeneratedFiles.size} old generated files for ${path_1.default.relative(process.cwd(), filePath)}`); for (const oldFile of oldGeneratedFiles) { if (safeRemoveFile(oldFile)) { console.log(`πŸ—‘οΈ Removed orphaned generated file: ${path_1.default.relative(process.cwd(), oldFile)}`); } } exports.backendToGenerated.delete(filePath); } // Analyze current exports const moduleExports = (0, ast_1.analyzeModuleExports)(filePath); if (moduleExports.functions.length === 0) { console.log(`⏭️ No exported functions found in ${path_1.default.relative(process.cwd(), filePath)} - skipping generation`); return false; } // Generate API routes and Quickwire files for existing exports const endpoints = generateApiRoutesForFile(filePath, moduleExports); const quickwireSuccess = generateQuickwireFile(filePath, endpoints, moduleExports); if (!quickwireSuccess) { console.warn(`⚠️ Failed to generate quickwire client file for ${path_1.default.relative(process.cwd(), filePath)}`); return false; } // Track newly generated files for this backend file const newGeneratedFiles = new Set(); for (const [generatedFilePath, metadata] of exports.generatedFiles) { if (metadata.sourceFile === filePath) { newGeneratedFiles.add(generatedFilePath); } } if (newGeneratedFiles.size > 0) { exports.backendToGenerated.set(filePath, newGeneratedFiles); console.log(`πŸ“Š Generated ${newGeneratedFiles.size} files for ${path_1.default.relative(process.cwd(), filePath)}`); } else { exports.backendToGenerated.delete(filePath); console.log(`🧹 No files generated; cleared tracking for ${path_1.default.relative(process.cwd(), filePath)}`); } return true; } catch (error) { console.error(`❌ Error processing ${path_1.default.relative(process.cwd(), filePath)}:`, error); return false; } } async function scanAllBackendFunctions() { if (isProcessing) { console.log("⏸️ Scan already in progress, skipping..."); return; } isProcessing = true; try { const now = Date.now(); const isFullRegen = shouldRegenerateEverything(); const hasChanges = exports.changedFiles.size > 0 || exports.deletedFiles.size > 0; if (!hasChanges && !isFullRegen) { console.log("⚑ No changes detected, skipping regeneration"); return; } if (isFullRegen) { console.log("πŸ”„ Performing full regeneration..."); // Clear tracking maps but keep file metadata for cleanup const oldGenerated = new Map(exports.generatedFiles); exports.generatedFiles.clear(); exports.backendToGenerated.clear(); if (now - lastFullScan > config_1.CONFIG.performance.cacheExpiryMs) { cache_1.fileCache.clear(); console.log("πŸ—‘οΈ Cleared file cache due to expiry"); } } console.log("πŸ” Scanning backend functions..."); const startTime = Date.now(); // Process deleted files first for (const deletedPath of exports.deletedFiles) { const genSet = exports.backendToGenerated.get(deletedPath); if (genSet) { console.log(`πŸ—‘οΈ Cleaning up ${genSet.size} files for deleted source: ${path_1.default.relative(process.cwd(), deletedPath)}`); for (const f of genSet) { safeRemoveFile(f); } exports.backendToGenerated.delete(deletedPath); } cache_1.fileCache.delete(deletedPath); } exports.deletedFiles.clear(); let processedCount = 0; let skippedCount = 0; let errorCount = 0; async function walk(dir) { if (!fs_1.default.existsSync(dir)) return; let entries; try { entries = fs_1.default.readdirSync(dir, { withFileTypes: true }); } catch (error) { console.warn(`⚠️ Failed to read directory ${dir}:`, error); return; } for (const entry of entries) { const filePath = path_1.default.join(dir, entry.name); if (processedCount + skippedCount + errorCount > config_1.CONFIG.performance.maxFilesToProcess) { console.warn(`⚠️ Reached maximum file limit (${config_1.CONFIG.performance.maxFilesToProcess}), stopping scan`); return; } if (entry.isDirectory()) { if (!["node_modules", ".git", ".next", "dist", "build"].includes(entry.name)) { await walk(filePath); } } else if (entry.isFile()) { // Check if we need to process this file if (!isFullRegen && !exports.changedFiles.has(filePath)) { skippedCount++; continue; } try { console.log(`πŸ”§ Processing: ${path_1.default.relative(process.cwd(), filePath)}`); if (processBackendFile(filePath)) { processedCount++; } else { skippedCount++; } } catch (error) { console.error(`❌ Error processing ${filePath}:`, error); errorCount++; } } } } await walk(config_1.CONFIG.backendDir); // Generate documentation if enabled if (config_1.CONFIG.performance.enableDocGeneration && (isFullRegen || hasChanges)) { try { generateDocumentationFiles(); } catch (error) { console.error("❌ Failed to generate documentation:", error); } } // Cleanup orphaned files cleanupOrphanedFiles(); const endTime = Date.now(); const duration = endTime - startTime; console.log(`βœ… Scan completed: ${processedCount} processed, ${skippedCount} skipped, ${errorCount} errors (${duration}ms)`); if (isFullRegen) { lastFullScan = now; } exports.changedFiles.clear(); // Performance summary if (processedCount > 0) { console.log("πŸ“Š Performance Summary:"); console.log(` Generated files: ${exports.generatedFiles.size}`); console.log(` Cache entries: ${cache_1.fileCache.size}`); console.log(` Processing time: ${duration}ms`); console.log(` Avg per file: ${Math.round(duration / Math.max(processedCount, 1))}ms`); // Count HTTP methods const methodCounts = {}; for (const [filePath] of exports.generatedFiles) { if (filePath.includes("/api/") && !filePath.includes("quickwire-docs")) { try { const content = fs_1.default.readFileSync(filePath, "utf-8"); const methodMatch = content.match(/export async function (GET|POST|PUT|PATCH|DELETE)/); if (methodMatch) { const method = methodMatch[1]; methodCounts[method] = (methodCounts[method] || 0) + 1; } } catch { // Ignore read errors } } } if (Object.keys(methodCounts).length > 0) { console.log("πŸ“Š HTTP Method Distribution:"); Object.entries(methodCounts) .sort(([, a], [, b]) => b - a) .forEach(([method, count]) => { console.log(` ${method}: ${count} endpoints`); }); } } } catch (error) { console.error("❌ Fatal error during scan:", error); } finally { isProcessing = false; } } // Utility function to get generation statistics function getGenerationStats() { const bySourceFile = {}; let oldestFile = null; let newestFile = null; const now = Date.now(); for (const [filePath, metadata] of exports.generatedFiles) { const sourceRelative = path_1.default.relative(process.cwd(), metadata.sourceFile); bySourceFile[sourceRelative] = (bySourceFile[sourceRelative] || 0) + 1; const age = now - metadata.generatedAt; if (!oldestFile || age > oldestFile.age) { oldestFile = { path: path_1.default.relative(process.cwd(), filePath), age }; } if (!newestFile || age < newestFile.age) { newestFile = { path: path_1.default.relative(process.cwd(), filePath), age }; } } return { totalGenerated: exports.generatedFiles.size, bySourceFile, oldestFile, newestFile, }; } function generateParameterSchema(parameters) { if (parameters.length === 0) { return null; } if (parameters.length === 1) { const param = parameters[0]; if (isObjectDestructured(param)) { return parseTypeToSchema(param.type); } const directSchema = parseTypeToSchema(param.type); const wrappedSchema = { type: "object", properties: { [param.name]: directSchema, }, required: param.optional ? [] : [param.name], }; return { oneOf: [directSchema, wrappedSchema], }; } const properties = {}; const required = []; parameters.forEach((param) => { properties[param.name] = parseTypeToSchema(param.type); if (!param.optional) { required.push(param.name); } }); return { type: "object", properties, required: required.length > 0 ? required : undefined, }; } function parseTypeToSchema(typeStr) { const type = typeStr.trim(); // Basic types if (type === "string") return { type: "string" }; if (type === "number") return { type: "number" }; if (type === "boolean") return { type: "boolean" }; if (type === "any") return {}; if (type === "unknown") return {}; if (type === "void") return { type: "null" }; if (type === "File") return { type: "string", format: "binary" }; if (type === "Blob") return { type: "string", format: "binary" }; if (type === "FormData") return { type: "object" }; // Array types if (type.endsWith("[]")) { const itemType = type.slice(0, -2); return { type: "array", items: parseTypeToSchema(itemType), }; } const arrayMatch = type.match(/^Array<(.+)>$/); if (arrayMatch) { return { type: "array", items: parseTypeToSchema(arrayMatch[1]), }; } // Promise types const promiseMatch = type.match(/^Promise<(.+)>$/); if (promiseMatch) { return parseTypeToSchema(promiseMatch[1]); } // Union types if (type.includes("|")) { const unionTypes = type.split("|").map((t) => t.trim()); return { oneOf: unionTypes.map((t) => parseTypeToSchema(t)), }; } // Object types if (type.startsWith("{") && type.endsWith("}")) { const properties = {}; const required = []; const content = type.slice(1, -1).trim(); if (content) { const props = splitObjectProperties(content); props.forEach((prop) => { const colonIndex = prop.indexOf(":"); if (colonIndex > 0) { let key = prop.substring(0, colonIndex).trim(); const optional = key.endsWith("?"); if (optional) { key = key.slice(0, -1).trim(); } const valueType = prop.substring(colonIndex + 1).trim(); properties[key] = parseTypeToSchema(valueType); if (!optional) { required.push(key); } } }); } return { type: "object", properties, required: required.length > 0 ? required : undefined, }; } // Custom/unknown types return { type: "object", description: `Custom type: ${type}`, }; } function splitObjectProperties(content) { const props = []; let current = ""; let braceCount = 0; let inString = false; let stringChar = ""; for (let i = 0; i < content.length; i++) { const char = content[i]; if (!inString && (char === '"' || char === "'")) { inString = true; stringChar = char; } else if (inString && char === stringChar && content[i - 1] !== "\\") { inString = false; } else if (!inString) { if (char === "{") braceCount++; else if (char === "}") braceCount--; else if ((char === ";" || char === ",") && braceCount === 0) { if (current.trim()) props.push(current.trim()); current = ""; continue; } } current += char; } if (current.trim()) props.push(current.trim()); return props; } function generateFunctionTags(filePath, functionName) { const relativePath = (0, utils_1.sanitizeFilePath)(path_1.default.relative(config_1.CONFIG.backendDir, filePath)); const pathParts = relativePath.replace(/\.[tj]s$/, "").split("/"); return pathParts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)); } function generateFunctionSummary(functionName, httpMethod) { const methodActions = { GET: "Retrieve", POST: "Create or process", PUT: "Update or replace", PATCH: "Partially update", DELETE: "Delete or remove", }; const action = methodActions[httpMethod] || "Execute"; const readableName = functionName .replace(/([A-Z])/g, " $1") .toLowerCase() .trim(); return `${action} ${readableName}`; } function generateApiDocumentation() { const docs = []; function walkDir(dir) { if (!fs_1.default.existsSync(dir)) return; const entries = fs_1.default.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const filePath = path_1.default.join(dir, entry.name); if (entry.isDirectory()) { if (!["node_modules", ".git", ".next", "dist", "build"].includes(entry.name)) { walkDir(filePath); } } else if (entry.isFile() && (0, utils_1.shouldProcessFile)(filePath, config_1.CONFIG)) { try { const moduleExports = (0, ast_1.analyzeModuleExports)(filePath); const relat