UNPKG

vite-plugin-server-actions

Version:

Server actions for Vite - call backend functions directly from your frontend with automatic API generation, TypeScript support, and zero configuration

1,014 lines (892 loc) 34.4 kB
import fs from "fs/promises"; import path from "path"; import express from "express"; import { rollup } from "rollup"; import { minimatch } from "minimatch"; import esbuild from "esbuild"; import { createRequire } from "module"; import { fileURLToPath } from "url"; import os from "os"; import { middleware } from "./middleware.js"; import { defaultSchemaDiscovery, createValidationMiddleware } from "./validation.js"; import { OpenAPIGenerator, setupOpenAPIEndpoints } from "./openapi.js"; import { generateValidationCode } from "./build-utils.js"; import { extractExportedFunctions, isValidFunctionName } from "./ast-parser.js"; import { generateTypeDefinitions, generateEnhancedClientProxy } from "./type-generator.js"; import { sanitizePath, isValidModuleName, createSecureModuleName, createErrorResponse } from "./security.js"; import { enhanceFunctionNotFoundError, enhanceParsingError, enhanceValidationError, enhanceModuleLoadError, createDevelopmentWarning, } from "./error-enhancer.js"; import { validateFunctionSignature, validateFileStructure, createDevelopmentFeedback, validateSchemaAttachment, } from "./dev-validator.js"; // Module cache moved to plugin instance to avoid cross-instance pollution /** * Import a module, handling TypeScript files in development * @param {string} id - Module path * @param {any} viteServer - Vite dev server instance * @param {Map} cache - Module cache for this plugin instance * @returns {Promise<any>} - Imported module */ async function importModule(id, viteServer = null, cache = new Map()) { // In production or for JS files, use regular import if (process.env.NODE_ENV === "production" || !id.endsWith(".ts")) { return import(id); } // Use Vite's SSR module loader if available (preferred method) if (viteServer && viteServer.ssrLoadModule) { try { // Clear from cache if it exists to ensure fresh load if (cache.has(id)) { cache.delete(id); } const module = await viteServer.ssrLoadModule(id); cache.set(id, module); return module; } catch (error) { console.error(`Failed to load module ${id} via Vite SSR:`, error); // Fall through to manual compilation } } // Check cache first if (cache.has(id)) { return cache.get(id); } // Fallback: Manual TypeScript compilation (when Vite server is not available) // Retry logic for TypeScript compilation failures let retryCount = 0; const maxRetries = 3; while (retryCount < maxRetries) { try { // Read and transform TypeScript file const tsCode = await fs.readFile(id, "utf-8"); // Transform imports to be relative to the original file location const result = await esbuild.transform(tsCode, { loader: "ts", target: "node16", format: "esm", sourcefile: id, sourcemap: "inline", }); // Create a temporary file in the same directory as the original // This ensures relative imports work correctly const dir = path.dirname(id); const basename = path.basename(id, ".ts"); const tmpFile = path.join(dir, `.${basename}.tmp.mjs`); // Write compiled JavaScript await fs.writeFile(tmpFile, result.code, "utf-8"); try { // Add a small delay to ensure file is written await new Promise((resolve) => setTimeout(resolve, 50)); // Import the compiled module with cache busting const module = await import(`${tmpFile}?t=${Date.now()}`); // Cache the module cache.set(id, module); // Clean up temp file immediately await fs.unlink(tmpFile).catch(() => {}); return module; } catch (importError) { // Clean up on error await fs.unlink(tmpFile).catch(() => {}); throw importError; } } catch (error) { retryCount++; if (retryCount >= maxRetries) { console.error(`Failed to import TypeScript module ${id} after ${maxRetries} attempts:`, error); throw error; } // Wait before retry await new Promise((resolve) => setTimeout(resolve, 100 * retryCount)); } } } // Utility functions for path transformation export const pathUtils = { /** * Default path normalizer - creates underscore-separated module names (preserves original behavior) * @param {string} filePath - Relative file path (e.g., "src/actions/todo.server.js") * @returns {string} - Normalized module name (e.g., "src_actions_todo") */ createModuleName: (filePath) => { return filePath .replace(/\//g, "_") // Replace slashes with underscores .replace(/\./g, "_") // Replace dots with underscores .replace(/_server_(js|ts)$/, ""); // Remove .server.js or .server.ts extension }, /** * Clean route transformer - creates hierarchical paths: /api/actions/todo/create * @param {string} filePath - Relative file path (e.g., "src/actions/todo.server.js") * @param {string} functionName - Function name (e.g., "create") * @returns {string} - Clean route (e.g., "actions/todo/create") */ createCleanRoute: (filePath, functionName) => { const cleanPath = filePath .replace(/^src\//, "") // Remove src/ prefix .replace(/\.server\.(js|ts)$/, ""); // Remove .server.js or .server.ts suffix return `${cleanPath}/${functionName}`; }, /** * Legacy route transformer - creates underscore-separated paths: /api/src_actions_todo/create * @param {string} filePath - Relative file path (e.g., "src/actions/todo.server.js") * @param {string} functionName - Function name (e.g., "create") * @returns {string} - Legacy route (e.g., "src_actions_todo/create") */ createLegacyRoute: (filePath, functionName) => { const legacyPath = filePath .replace(/\//g, "_") // Replace slashes with underscores .replace(/\.server\.(js|ts)$/, ""); // Remove .server.js or .server.ts extension return `${legacyPath}/${functionName}`; }, /** * Minimal route transformer - keeps original structure: /api/actions/todo.server/create * @param {string} filePath - Relative file path (e.g., "actions/todo.server.js") * @param {string} functionName - Function name (e.g., "create") * @returns {string} - Minimal route (e.g., "actions/todo.server/create") */ createMinimalRoute: (filePath, functionName) => { const minimalPath = filePath.replace(/\.(js|ts)$/, ""); // Just remove .js or .ts return `${minimalPath}/${functionName}`; }, }; const DEFAULT_OPTIONS = { apiPrefix: "/api", include: ["**/*.server.js", "**/*.server.ts"], exclude: [], middleware: [], moduleNameTransform: pathUtils.createModuleName, routeTransform: (filePath, functionName) => { // Default to clean hierarchical paths: /api/actions/todo/create const cleanPath = filePath .replace(/^src\//, "") // Remove src/ prefix .replace(/\.server\.(js|ts)$/, ""); // Remove .server.js or .server.ts suffix return `${cleanPath}/${functionName}`; }, validation: { enabled: false, adapter: "zod", }, }; function shouldProcessFile(filePath, options) { // Normalize the options to arrays const includePatterns = Array.isArray(options.include) ? options.include : [options.include]; const excludePatterns = Array.isArray(options.exclude) ? options.exclude : [options.exclude]; // Check if file matches any include pattern const isIncluded = includePatterns.some((pattern) => minimatch(filePath, pattern)); // Check if file matches any exclude pattern const isExcluded = excludePatterns.length > 0 && excludePatterns.some((pattern) => minimatch(filePath, pattern)); return isIncluded && !isExcluded; } export default function serverActions(userOptions = {}) { const options = { ...DEFAULT_OPTIONS, ...userOptions, validation: { ...DEFAULT_OPTIONS.validation, ...userOptions.validation }, openAPI: { enabled: false, info: { title: "Server Actions API", version: "1.0.0", description: "Auto-generated API documentation for Vite Server Actions", }, docsPath: "/api/docs", specPath: "/api/openapi.json", swaggerUI: true, ...userOptions.openAPI, }, }; const serverFunctions = new Map(); const schemaDiscovery = defaultSchemaDiscovery; const tsModuleCache = new Map(); // Per-instance cache for TypeScript modules let app; let openAPIGenerator; let validationMiddleware = null; let viteConfig = null; let viteDevServer = null; // Initialize OpenAPI generator if enabled if (options.openAPI.enabled) { openAPIGenerator = new OpenAPIGenerator({ info: options.openAPI.info, }); } // Initialize validation middleware if enabled if (options.validation.enabled) { validationMiddleware = createValidationMiddleware({ schemaDiscovery, }); } return { name: "vite-plugin-server-actions", configResolved(config) { // Store Vite config for later use viteConfig = config; }, configureServer(server) { viteDevServer = server; app = express(); app.use(express.json()); // Clean up on HMR if (server.watcher) { server.watcher.on("change", (file) => { // If a server file changed, remove it from the map if (shouldProcessFile(file, options)) { // Clear TypeScript cache for this file if (file.endsWith(".ts")) { tsModuleCache.delete(file); } for (const [moduleName, moduleInfo] of serverFunctions.entries()) { if (moduleInfo.id === file) { serverFunctions.delete(moduleName); schemaDiscovery.clear(); // Clear associated schemas console.log(`[HMR] Cleaned up server module: ${moduleName}`); } } } }); } // Setup dynamic OpenAPI endpoints in development if (process.env.NODE_ENV !== "production" && options.openAPI.enabled && openAPIGenerator) { // OpenAPI spec endpoint - generates spec dynamically from current serverFunctions app.get(options.openAPI.specPath, (req, res) => { // Get the actual port from the request const port = req.get("host")?.split(":")[1] || viteConfig.server?.port || 5173; const openAPISpec = openAPIGenerator.generateSpec(serverFunctions, schemaDiscovery, { apiPrefix: options.apiPrefix, routeTransform: options.routeTransform, port, }); // Add a note if no functions are found if (serverFunctions.size === 0) { openAPISpec.info.description = (openAPISpec.info.description || "") + "\n\nNote: No server functions found yet. Try refreshing after accessing your app to trigger module loading."; } res.json(openAPISpec); }); // Swagger UI setup if (options.openAPI.swaggerUI) { try { // Dynamic import swagger-ui-express import("swagger-ui-express") .then(({ default: swaggerUi }) => { const docsPath = options.openAPI.docsPath; app.use( docsPath, swaggerUi.serve, swaggerUi.setup(null, { swaggerOptions: { url: options.openAPI.specPath, }, }), ); // Wait for server to start and get the actual port, then log URLs server.httpServer?.on("listening", () => { const address = server.httpServer.address(); const port = address?.port || viteConfig.server?.port || 5173; // Always use localhost for consistent display const host = "localhost"; // Delay to appear after Vite's startup messages global.setTimeout(() => { if (viteConfig?.logger) { console.log(` \x1b[2;32m➜\x1b[0m API Docs: http://${host}:${port}${docsPath}`); console.log(` \x1b[2;32m➜\x1b[0m OpenAPI: http://${host}:${port}${options.openAPI.specPath}`); } else { console.log(`📖 API Documentation: http://${host}:${port}${docsPath}`); console.log(`📄 OpenAPI Spec: http://${host}:${port}${options.openAPI.specPath}`); } }, 50); // Small delay to appear after Vite's ready message }); }) .catch((error) => { console.warn("Swagger UI setup failed:", error.message); }); } catch (error) { console.warn("Swagger UI setup failed:", error.message); } } } server.middlewares.use(app); // Show development feedback after server is ready if (process.env.NODE_ENV === "development") { server.httpServer?.on("listening", () => { // Delay to appear after Vite's startup messages global.setTimeout(() => { if (serverFunctions.size > 0) { console.log(createDevelopmentFeedback(serverFunctions)); } }, 100); }); } }, async resolveId(source, importer, resolveOptions) { // Skip SSR resolution if (resolveOptions?.ssr) { return null; } // Handle server file imports from client code if (importer && shouldProcessFile(source, options)) { const resolvedPath = path.resolve(path.dirname(importer), source); return resolvedPath; } // Handle TypeScript imports from server files if (importer && shouldProcessFile(importer, options)) { // Check if this is a relative import if (source.startsWith(".") || source.startsWith("/")) { // Try to resolve TypeScript file const basePath = path.resolve(path.dirname(importer), source); const possiblePaths = [ basePath, `${basePath}.ts`, `${basePath}.tsx`, path.join(basePath, "index.ts"), path.join(basePath, "index.tsx"), ]; for (const possiblePath of possiblePaths) { try { const stats = await fs.stat(possiblePath); // Only return if it's a file, not a directory if (stats.isFile()) { return possiblePath; } } catch { // File doesn't exist, try next } } } } return null; }, async load(id, loadOptions) { if (shouldProcessFile(id, options)) { // Check if this is an SSR request - if so, let Vite handle the actual module if (loadOptions?.ssr) { return null; // Let Vite handle SSR loading of the actual module } try { const code = await fs.readFile(id, "utf-8"); // Sanitize the file path for security const sanitizedPath = sanitizePath(id, process.cwd()); if (!sanitizedPath) { throw new Error(`Invalid file path detected: ${id}`); } let relativePath = path.relative(process.cwd(), sanitizedPath); // Normalize path separators relativePath = relativePath.replace(/\\/g, "/").replace(/^\//, ""); // Generate module name for internal use (must be valid identifier) const moduleName = createSecureModuleName(options.moduleNameTransform(relativePath)); // Validate module name if (!isValidModuleName(moduleName)) { throw new Error(`Invalid server module name: ${moduleName}`); } // Use AST parser to extract exported functions with detailed information const exportedFunctions = extractExportedFunctions(code, id); const functions = []; const functionDetails = []; for (const fn of exportedFunctions) { // Skip default exports for now (could be supported in future) if (fn.isDefault) { console.warn( createDevelopmentWarning("Default Export Skipped", `Default exports are not currently supported`, { filePath: relativePath, suggestion: "Use named exports instead: export async function myFunction() {}", }), ); continue; } // Validate function name if (!isValidFunctionName(fn.name)) { console.warn( createDevelopmentWarning( "Invalid Function Name", `Function name '${fn.name}' is not a valid JavaScript identifier`, { filePath: relativePath, suggestion: "Function names must start with a letter, $, or _ and contain only letters, numbers, $, and _", }, ), ); continue; } // Warn about non-async functions if (!fn.isAsync) { console.warn( createDevelopmentWarning( "Non-Async Function", `Function '${fn.name}' is not async. Server actions should typically be async`, { filePath: relativePath, suggestion: "Consider changing to: export async function " + fn.name + "() {}", }, ), ); } functions.push(fn.name); functionDetails.push(fn); } // Check for duplicate function names within the same module const uniqueFunctions = [...new Set(functions)]; if (uniqueFunctions.length !== functions.length) { console.warn(`Duplicate function names detected in ${id}`); } // Store both simple function names and detailed information serverFunctions.set(moduleName, { functions: uniqueFunctions, functionDetails, id, filePath: relativePath, }); // Development-time validation and feedback if (process.env.NODE_ENV === "development") { // Validate file structure const fileWarnings = validateFileStructure(functionDetails, relativePath); fileWarnings.forEach((warning) => console.warn(warning)); // Validate individual function signatures functionDetails.forEach((func) => { const funcWarnings = validateFunctionSignature(func, relativePath); funcWarnings.forEach((warning) => console.warn(warning)); }); } // Discover schemas from module if validation is enabled (development only) // Skip TypeScript files to avoid SSR loading issues if (options.validation.enabled && process.env.NODE_ENV !== "production" && !id.endsWith(".ts")) { try { const module = await importModule(id, viteDevServer, tsModuleCache); schemaDiscovery.discoverFromModule(module, moduleName); // Validate schema attachment in development if (process.env.NODE_ENV === "development") { const schemaWarnings = validateSchemaAttachment(module, uniqueFunctions, relativePath); schemaWarnings.forEach((warning) => console.warn(warning)); } } catch (error) { const enhancedError = enhanceModuleLoadError(id, error); console.warn(enhancedError.message); if (process.env.NODE_ENV === "development" && enhancedError.suggestions) { enhancedError.suggestions.forEach((suggestion) => { console.info(` 💡 ${suggestion}`); }); } } } else if (options.validation.enabled && id.endsWith(".ts")) { // For TypeScript files, defer schema discovery to request time console.log(`[Vite Server Actions] Deferring schema discovery for TypeScript file: ${relativePath}`); } // Setup routes in development mode only if (process.env.NODE_ENV !== "production" && app) { // Normalize middleware to array (create a fresh copy to avoid mutation) const middlewares = Array.isArray(options.middleware) ? [...options.middleware] // Create a copy : options.middleware ? [options.middleware] : []; // Add validation middleware if enabled if (validationMiddleware) { middlewares.push(validationMiddleware); } uniqueFunctions.forEach((functionName) => { const routePath = options.routeTransform(relativePath, functionName); const endpoint = `${options.apiPrefix}/${routePath}`; // Create a context-aware validation middleware if validation is enabled const contextMiddlewares = [...middlewares]; if (validationMiddleware && options.validation.enabled) { // Replace the generic validation middleware with a context-aware one const lastIdx = contextMiddlewares.length - 1; if (contextMiddlewares[lastIdx] === validationMiddleware) { contextMiddlewares[lastIdx] = (req, res, next) => { // Add context to request for validation // Get the schema directly from schemaDiscovery const schema = schemaDiscovery.getSchema(moduleName, functionName); req.validationContext = { moduleName, // For error messages functionName, // For error messages schema, // Direct schema access }; return validationMiddleware(req, res, next); }; } } // Apply middleware before the handler app.post(endpoint, ...contextMiddlewares, async (req, res) => { try { const module = await importModule(id, viteDevServer, tsModuleCache); // Lazy schema discovery for TypeScript files if ( options.validation.enabled && id.endsWith(".ts") && !schemaDiscovery.hasSchema(moduleName, functionName) ) { try { schemaDiscovery.discoverFromModule(module, moduleName); } catch (err) { console.warn(`Failed to discover schemas for ${moduleName}:`, err.message); } } // Check if function exists in module if (typeof module[functionName] !== "function") { // Get available functions for better error message const availableFunctions = Object.keys(module).filter((key) => typeof module[key] === "function"); const enhancedError = enhanceFunctionNotFoundError(functionName, moduleName, availableFunctions); throw new Error(enhancedError.message); } // Validate request body is array for function arguments if (!Array.isArray(req.body)) { throw new Error("Request body must be an array of function arguments"); } const result = await module[functionName](...req.body); if (result === undefined) { res.status(204).end(); } else { res.json(result); } } catch (error) { console.error(`Error in ${functionName}: ${error.message}`); if (error.message.includes("not found") || error.message.includes("not a function")) { // Extract available functions from the error context if available const availableFunctionsMatch = error.message.match(/Available functions: ([^]+)/); const availableFunctions = availableFunctionsMatch ? availableFunctionsMatch[1].split(", ") : []; res.status(404).json( createErrorResponse(404, "Function not found", "FUNCTION_NOT_FOUND", { functionName, moduleName, availableFunctions: availableFunctions.length > 0 ? availableFunctions : undefined, suggestion: `Try one of: ${availableFunctions.join(", ") || "none available"}`, }), ); } else if (error.message.includes("Request body")) { res.status(400).json( createErrorResponse(400, error.message, "INVALID_REQUEST_BODY", { suggestion: "Send an array of arguments: [arg1, arg2, ...]", }), ); } else { res.status(500).json( createErrorResponse( 500, "Internal server error", "INTERNAL_ERROR", process.env.NODE_ENV !== "production" ? { message: error.message, stack: error.stack, suggestion: "Check server logs for more details", } : { suggestion: "Contact support if this persists" }, ), ); } } }); }); } // OpenAPI endpoints will be set up during configureServer after all modules are loaded // Use enhanced client proxy generator if we have detailed function information if (functionDetails.length > 0) { return generateEnhancedClientProxy(moduleName, functionDetails, options, relativePath); } else { // Fallback to basic proxy for backwards compatibility return generateClientProxy(moduleName, uniqueFunctions, options, relativePath); } } catch (error) { const enhancedError = enhanceParsingError(id, error); console.error(enhancedError.message); // Provide helpful suggestions in development if (process.env.NODE_ENV === "development" && enhancedError.suggestions.length > 0) { console.info("[Vite Server Actions] 💡 Suggestions:"); enhancedError.suggestions.forEach((suggestion) => { console.info(` • ${suggestion}`); }); } // Return error comment with context instead of failing the build return `// Failed to load server actions from ${id} // Error: ${error.message} // ${enhancedError.suggestions.length > 0 ? "Suggestions: " + enhancedError.suggestions.join(", ") : ""}`; } } }, transform(code, id) { // This hook is not needed since we handle the transformation in the load hook // The warning was incorrectly flagging legitimate imports that are being transformed return null; }, async generateBundle(outputOptions, bundle) { // Create a virtual entry point for all server functions const virtualEntryId = "virtual:server-actions-entry"; let virtualModuleContent = ""; for (const [moduleName, { id }] of serverFunctions) { virtualModuleContent += `import * as ${moduleName} from '${id}';\n`; } virtualModuleContent += `export { ${Array.from(serverFunctions.keys()).join(", ")} };`; // Use Rollup to bundle the virtual module const build = await rollup({ input: virtualEntryId, plugins: [ { name: "virtual", resolveId(id) { if (id === virtualEntryId) { return id; } }, load(id) { if (id === virtualEntryId) { return virtualModuleContent; } }, }, { name: "typescript-transform", async load(id) { // Handle TypeScript files if (id.endsWith(".ts")) { const code = await fs.readFile(id, "utf-8"); const result = await esbuild.transform(code, { loader: "ts", target: "node16", format: "esm", }); return result.code; } return null; }, }, { name: "external-modules", resolveId(source) { if (!shouldProcessFile(source, options) && !source.startsWith(".") && !path.isAbsolute(source)) { return { id: source, external: true }; } }, }, ], }); const { output } = await build.generate({ format: "es" }); if (output.length === 0) { throw new Error("Failed to bundle server functions"); } const bundledCode = output[0].code; // Emit the bundled server functions this.emitFile({ type: "asset", fileName: "actions.js", source: bundledCode, }); // Generate and emit TypeScript definitions const typeDefinitions = generateTypeDefinitions(serverFunctions, options); this.emitFile({ type: "asset", fileName: "actions.d.ts", source: typeDefinitions, }); // Generate OpenAPI spec if enabled let openAPISpec = null; if (options.openAPI.enabled) { // Use PORT env var for production builds, defaulting to 3000 const port = process.env.PORT || 3000; openAPISpec = openAPIGenerator.generateSpec(serverFunctions, schemaDiscovery, { apiPrefix: options.apiPrefix, routeTransform: options.routeTransform, port, }); // Emit OpenAPI spec as a separate file this.emitFile({ type: "asset", fileName: "openapi.json", source: JSON.stringify(openAPISpec, null, 2), }); } // Generate validation code if enabled const validationCode = await generateValidationCode(options, serverFunctions); // Generate server.js const serverCode = ` import express from 'express'; import * as serverActions from './actions.js'; ${options.openAPI.enabled && options.openAPI.swaggerUI ? "import swaggerUi from 'swagger-ui-express';" : ""} ${options.openAPI.enabled ? "import { readFileSync } from 'fs';\nimport { fileURLToPath } from 'url';\nimport { dirname, join } from 'path';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\nconst openAPISpec = JSON.parse(readFileSync(join(__dirname, 'openapi.json'), 'utf-8'));" : ""} ${validationCode.imports} ${validationCode.validationRuntime} const app = express(); ${validationCode.setup} ${validationCode.middlewareFactory} // Middleware // -------------------------------------------------- app.use(express.json()); app.use(express.static('dist')); // Server functions // -------------------------------------------------- ${Array.from(serverFunctions.entries()) .flatMap(([moduleName, { functions, filePath }]) => functions .map((functionName) => { const routePath = options.routeTransform(filePath, functionName); const middlewareCall = options.validation?.enabled ? `createContextualValidationMiddleware('${moduleName}', '${functionName}'), ` : ""; return ` app.post('${options.apiPrefix}/${routePath}', ${middlewareCall}async (req, res) => { try { const result = await serverActions.${moduleName}.${functionName}(...req.body); if (result === undefined) { res.status(204).end(); } else { res.json(result); } } catch (error) { console.error(\`Error in ${functionName}: \${error.message}\`); const status = error.status || 500; res.status(status).json({ error: true, status, message: status === 500 ? 'Internal server error' : error.message, code: error.code || 'SERVER_ACTION_ERROR', timestamp: new Date().toISOString(), ...(process.env.NODE_ENV !== 'production' ? { details: { message: error.message, stack: error.stack } } : {}) }); } }); `; }) .join("\n") .trim(), ) .join("\n") .trim()} ${ options.openAPI.enabled ? ` // OpenAPI endpoints // -------------------------------------------------- app.get('${options.openAPI.specPath}', (req, res) => { res.json(openAPISpec); }); ${ options.openAPI.swaggerUI ? ` // Swagger UI app.use('${options.openAPI.docsPath}', swaggerUi.serve, swaggerUi.setup(openAPISpec)); ` : "" } ` : "" } // Start server // -------------------------------------------------- const port = process.env.PORT || 3000; app.listen(port, () => { console.log(\`🚀 Server listening: http://localhost:\${port}\`); ${ options.openAPI.enabled ? ` console.log(\`📖 API Documentation: http://localhost:\${port}${options.openAPI.docsPath}\`); console.log(\`📄 OpenAPI Spec: http://localhost:\${port}${options.openAPI.specPath}\`); ` : "" } }); // List all server functions // -------------------------------------------------- `; this.emitFile({ type: "asset", fileName: "server.js", source: serverCode, }); }, }; } function generateClientProxy(moduleName, functions, options, filePath) { // Add development-only safety checks const isDev = process.env.NODE_ENV !== "production"; let clientProxy = `\n// vite-server-actions: ${moduleName}\n`; // Mark this as a legitimate client proxy module if (isDev) { clientProxy += ` // Development-only marker for client proxy module if (typeof window !== 'undefined') { window.__VITE_SERVER_ACTIONS_PROXY__ = window.__VITE_SERVER_ACTIONS_PROXY__ || {}; window.__VITE_SERVER_ACTIONS_PROXY__['${moduleName}'] = true; } `; } functions.forEach((functionName) => { const routePath = options.routeTransform(filePath, functionName); clientProxy += ` export async function ${functionName}(...args) { console.log("[Vite Server Actions] 🚀 - Executing ${functionName}"); ${ isDev ? ` // Validate arguments in development if (args.some(arg => typeof arg === 'function')) { console.warn( '[Vite Server Actions] Warning: Functions cannot be serialized and sent to the server. ' + 'Function arguments will be converted to null.' ); } ` : "" } try { const response = await fetch('${options.apiPrefix}/${routePath}', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }); if (!response.ok) { let errorData; try { errorData = await response.json(); } catch { errorData = { error: 'Unknown error', details: 'Failed to parse error response' }; } console.error("[Vite Server Actions] ❗ - Error in ${functionName}:", errorData); const error = new Error(errorData.error || 'Server request failed'); error.details = errorData.details; error.status = response.status; throw error; } console.log("[Vite Server Actions] ✅ - ${functionName} executed successfully"); // Handle 204 No Content responses (function returned undefined) if (response.status === 204) { return undefined; } const result = await response.json(); ${ isDev ? ` ` : "" } return result; } catch (error) { console.error("[Vite Server Actions] ❗ - Network or execution error in ${functionName}:", error.message); ${ isDev ? ` ` : "" } // Re-throw with more context if it's not already our custom error if (!error.details) { const networkError = new Error(\`Failed to execute server action '\${functionName}': \${error.message}\`); networkError.originalError = error; throw networkError; } throw error; } } `; }); return clientProxy; } // Export built-in middleware and validation utilities export { middleware }; export { createValidationMiddleware, ValidationAdapter, ZodAdapter, SchemaDiscovery, adapters } from "./validation.js"; export { OpenAPIGenerator, setupOpenAPIEndpoints, createSwaggerMiddleware } from "./openapi.js";