vite-plugin-server-actions 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -3,10 +3,126 @@ import path from "path";
3
3
  import express from "express";
4
4
  import { rollup } from "rollup";
5
5
  import { minimatch } from "minimatch";
6
+ import esbuild from "esbuild";
7
+ import { createRequire } from "module";
8
+ import { fileURLToPath } from "url";
9
+ import os from "os";
6
10
  import { middleware } from "./middleware.js";
7
11
  import { defaultSchemaDiscovery, createValidationMiddleware } from "./validation.js";
8
12
  import { OpenAPIGenerator, setupOpenAPIEndpoints } from "./openapi.js";
9
13
  import { generateValidationCode } from "./build-utils.js";
14
+ import { extractExportedFunctions, isValidFunctionName } from "./ast-parser.js";
15
+ import { generateTypeDefinitions, generateEnhancedClientProxy } from "./type-generator.js";
16
+ import { sanitizePath, isValidModuleName, createSecureModuleName, createErrorResponse } from "./security.js";
17
+ import {
18
+ enhanceFunctionNotFoundError,
19
+ enhanceParsingError,
20
+ enhanceValidationError,
21
+ enhanceModuleLoadError,
22
+ createDevelopmentWarning,
23
+ } from "./error-enhancer.js";
24
+ import {
25
+ validateFunctionSignature,
26
+ validateFileStructure,
27
+ createDevelopmentFeedback,
28
+ validateSchemaAttachment,
29
+ } from "./dev-validator.js";
30
+
31
+ // Module cache moved to plugin instance to avoid cross-instance pollution
32
+
33
+ /**
34
+ * Import a module, handling TypeScript files in development
35
+ * @param {string} id - Module path
36
+ * @param {any} viteServer - Vite dev server instance
37
+ * @param {Map} cache - Module cache for this plugin instance
38
+ * @returns {Promise<any>} - Imported module
39
+ */
40
+ async function importModule(id, viteServer = null, cache = new Map()) {
41
+ // In production or for JS files, use regular import
42
+ if (process.env.NODE_ENV === "production" || !id.endsWith(".ts")) {
43
+ return import(id);
44
+ }
45
+
46
+ // Use Vite's SSR module loader if available (preferred method)
47
+ if (viteServer && viteServer.ssrLoadModule) {
48
+ try {
49
+ // Clear from cache if it exists to ensure fresh load
50
+ if (cache.has(id)) {
51
+ cache.delete(id);
52
+ }
53
+
54
+ const module = await viteServer.ssrLoadModule(id);
55
+ cache.set(id, module);
56
+ return module;
57
+ } catch (error) {
58
+ console.error(`Failed to load module ${id} via Vite SSR:`, error);
59
+ // Fall through to manual compilation
60
+ }
61
+ }
62
+
63
+ // Check cache first
64
+ if (cache.has(id)) {
65
+ return cache.get(id);
66
+ }
67
+
68
+ // Fallback: Manual TypeScript compilation (when Vite server is not available)
69
+ // Retry logic for TypeScript compilation failures
70
+ let retryCount = 0;
71
+ const maxRetries = 3;
72
+
73
+ while (retryCount < maxRetries) {
74
+ try {
75
+ // Read and transform TypeScript file
76
+ const tsCode = await fs.readFile(id, "utf-8");
77
+
78
+ // Transform imports to be relative to the original file location
79
+ const result = await esbuild.transform(tsCode, {
80
+ loader: "ts",
81
+ target: "node16",
82
+ format: "esm",
83
+ sourcefile: id,
84
+ sourcemap: "inline",
85
+ });
86
+
87
+ // Create a temporary file in the same directory as the original
88
+ // This ensures relative imports work correctly
89
+ const dir = path.dirname(id);
90
+ const basename = path.basename(id, ".ts");
91
+ const tmpFile = path.join(dir, `.${basename}.tmp.mjs`);
92
+
93
+ // Write compiled JavaScript
94
+ await fs.writeFile(tmpFile, result.code, "utf-8");
95
+
96
+ try {
97
+ // Add a small delay to ensure file is written
98
+ await new Promise((resolve) => setTimeout(resolve, 50));
99
+
100
+ // Import the compiled module with cache busting
101
+ const module = await import(`${tmpFile}?t=${Date.now()}`);
102
+
103
+ // Cache the module
104
+ cache.set(id, module);
105
+
106
+ // Clean up temp file immediately
107
+ await fs.unlink(tmpFile).catch(() => {});
108
+
109
+ return module;
110
+ } catch (importError) {
111
+ // Clean up on error
112
+ await fs.unlink(tmpFile).catch(() => {});
113
+ throw importError;
114
+ }
115
+ } catch (error) {
116
+ retryCount++;
117
+ if (retryCount >= maxRetries) {
118
+ console.error(`Failed to import TypeScript module ${id} after ${maxRetries} attempts:`, error);
119
+ throw error;
120
+ }
121
+ // Wait before retry
122
+ await new Promise((resolve) => setTimeout(resolve, 100 * retryCount));
123
+ }
124
+ }
125
+ }
10
126
 
11
127
  // Utility functions for path transformation
12
128
  export const pathUtils = {
@@ -19,7 +135,7 @@ export const pathUtils = {
19
135
  return filePath
20
136
  .replace(/\//g, "_") // Replace slashes with underscores
21
137
  .replace(/\./g, "_") // Replace dots with underscores
22
- .replace(/_server_js$/, ""); // Remove .server.js extension
138
+ .replace(/_server_(js|ts)$/, ""); // Remove .server.js or .server.ts extension
23
139
  },
24
140
 
25
141
  /**
@@ -31,7 +147,7 @@ export const pathUtils = {
31
147
  createCleanRoute: (filePath, functionName) => {
32
148
  const cleanPath = filePath
33
149
  .replace(/^src\//, "") // Remove src/ prefix
34
- .replace(/\.server\.js$/, ""); // Remove .server.js suffix
150
+ .replace(/\.server\.(js|ts)$/, ""); // Remove .server.js or .server.ts suffix
35
151
  return `${cleanPath}/${functionName}`;
36
152
  },
37
153
 
@@ -44,7 +160,7 @@ export const pathUtils = {
44
160
  createLegacyRoute: (filePath, functionName) => {
45
161
  const legacyPath = filePath
46
162
  .replace(/\//g, "_") // Replace slashes with underscores
47
- .replace(/\.server\.js$/, ""); // Remove .server.js extension
163
+ .replace(/\.server\.(js|ts)$/, ""); // Remove .server.js or .server.ts extension
48
164
  return `${legacyPath}/${functionName}`;
49
165
  },
50
166
 
@@ -55,14 +171,14 @@ export const pathUtils = {
55
171
  * @returns {string} - Minimal route (e.g., "actions/todo.server/create")
56
172
  */
57
173
  createMinimalRoute: (filePath, functionName) => {
58
- const minimalPath = filePath.replace(/\.js$/, ""); // Just remove .js
174
+ const minimalPath = filePath.replace(/\.(js|ts)$/, ""); // Just remove .js or .ts
59
175
  return `${minimalPath}/${functionName}`;
60
176
  },
61
177
  };
62
178
 
63
179
  const DEFAULT_OPTIONS = {
64
180
  apiPrefix: "/api",
65
- include: ["**/*.server.js"],
181
+ include: ["**/*.server.js", "**/*.server.ts"],
66
182
  exclude: [],
67
183
  middleware: [],
68
184
  moduleNameTransform: pathUtils.createModuleName,
@@ -70,7 +186,7 @@ const DEFAULT_OPTIONS = {
70
186
  // Default to clean hierarchical paths: /api/actions/todo/create
71
187
  const cleanPath = filePath
72
188
  .replace(/^src\//, "") // Remove src/ prefix
73
- .replace(/\.server\.js$/, ""); // Remove .server.js suffix
189
+ .replace(/\.server\.(js|ts)$/, ""); // Remove .server.js or .server.ts suffix
74
190
  return `${cleanPath}/${functionName}`;
75
191
  },
76
192
  validation: {
@@ -114,10 +230,12 @@ export default function serverActions(userOptions = {}) {
114
230
 
115
231
  const serverFunctions = new Map();
116
232
  const schemaDiscovery = defaultSchemaDiscovery;
233
+ const tsModuleCache = new Map(); // Per-instance cache for TypeScript modules
117
234
  let app;
118
235
  let openAPIGenerator;
119
236
  let validationMiddleware = null;
120
237
  let viteConfig = null;
238
+ let viteDevServer = null;
121
239
 
122
240
  // Initialize OpenAPI generator if enabled
123
241
  if (options.openAPI.enabled) {
@@ -142,16 +260,41 @@ export default function serverActions(userOptions = {}) {
142
260
  },
143
261
 
144
262
  configureServer(server) {
263
+ viteDevServer = server;
145
264
  app = express();
146
265
  app.use(express.json());
147
266
 
267
+ // Clean up on HMR
268
+ if (server.watcher) {
269
+ server.watcher.on("change", (file) => {
270
+ // If a server file changed, remove it from the map
271
+ if (shouldProcessFile(file, options)) {
272
+ // Clear TypeScript cache for this file
273
+ if (file.endsWith(".ts")) {
274
+ tsModuleCache.delete(file);
275
+ }
276
+
277
+ for (const [moduleName, moduleInfo] of serverFunctions.entries()) {
278
+ if (moduleInfo.id === file) {
279
+ serverFunctions.delete(moduleName);
280
+ schemaDiscovery.clear(); // Clear associated schemas
281
+ console.log(`[HMR] Cleaned up server module: ${moduleName}`);
282
+ }
283
+ }
284
+ }
285
+ });
286
+ }
287
+
148
288
  // Setup dynamic OpenAPI endpoints in development
149
289
  if (process.env.NODE_ENV !== "production" && options.openAPI.enabled && openAPIGenerator) {
150
290
  // OpenAPI spec endpoint - generates spec dynamically from current serverFunctions
151
291
  app.get(options.openAPI.specPath, (req, res) => {
292
+ // Get the actual port from the request
293
+ const port = req.get("host")?.split(":")[1] || viteConfig.server?.port || 5173;
152
294
  const openAPISpec = openAPIGenerator.generateSpec(serverFunctions, schemaDiscovery, {
153
295
  apiPrefix: options.apiPrefix,
154
296
  routeTransform: options.routeTransform,
297
+ port,
155
298
  });
156
299
 
157
300
  // Add a note if no functions are found
@@ -185,7 +328,7 @@ export default function serverActions(userOptions = {}) {
185
328
  // Wait for server to start and get the actual port, then log URLs
186
329
  server.httpServer?.on("listening", () => {
187
330
  const address = server.httpServer.address();
188
- const port = address?.port || 5173;
331
+ const port = address?.port || viteConfig.server?.port || 5173;
189
332
  // Always use localhost for consistent display
190
333
  const host = "localhost";
191
334
 
@@ -211,52 +354,140 @@ export default function serverActions(userOptions = {}) {
211
354
  }
212
355
 
213
356
  server.middlewares.use(app);
357
+
358
+ // Show development feedback after server is ready
359
+ if (process.env.NODE_ENV === "development") {
360
+ server.httpServer?.on("listening", () => {
361
+ // Delay to appear after Vite's startup messages
362
+ global.setTimeout(() => {
363
+ if (serverFunctions.size > 0) {
364
+ console.log(createDevelopmentFeedback(serverFunctions));
365
+ }
366
+ }, 100);
367
+ });
368
+ }
214
369
  },
215
370
 
216
- async resolveId(source, importer) {
371
+ async resolveId(source, importer, resolveOptions) {
372
+ // Skip SSR resolution
373
+ if (resolveOptions?.ssr) {
374
+ return null;
375
+ }
376
+
377
+ // Handle server file imports from client code
217
378
  if (importer && shouldProcessFile(source, options)) {
218
379
  const resolvedPath = path.resolve(path.dirname(importer), source);
219
380
  return resolvedPath;
220
381
  }
382
+
383
+ // Handle TypeScript imports from server files
384
+ if (importer && shouldProcessFile(importer, options)) {
385
+ // Check if this is a relative import
386
+ if (source.startsWith(".") || source.startsWith("/")) {
387
+ // Try to resolve TypeScript file
388
+ const basePath = path.resolve(path.dirname(importer), source);
389
+ const possiblePaths = [
390
+ basePath,
391
+ `${basePath}.ts`,
392
+ `${basePath}.tsx`,
393
+ path.join(basePath, "index.ts"),
394
+ path.join(basePath, "index.tsx"),
395
+ ];
396
+
397
+ for (const possiblePath of possiblePaths) {
398
+ try {
399
+ const stats = await fs.stat(possiblePath);
400
+ // Only return if it's a file, not a directory
401
+ if (stats.isFile()) {
402
+ return possiblePath;
403
+ }
404
+ } catch {
405
+ // File doesn't exist, try next
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ return null;
221
412
  },
222
413
 
223
- async load(id) {
414
+ async load(id, loadOptions) {
224
415
  if (shouldProcessFile(id, options)) {
416
+ // Check if this is an SSR request - if so, let Vite handle the actual module
417
+ if (loadOptions?.ssr) {
418
+ return null; // Let Vite handle SSR loading of the actual module
419
+ }
225
420
  try {
226
421
  const code = await fs.readFile(id, "utf-8");
227
422
 
228
- let relativePath = path.relative(process.cwd(), id);
229
-
230
- // If the file is outside the project root, use the absolute path
231
- if (relativePath.startsWith("..")) {
232
- relativePath = id;
423
+ // Sanitize the file path for security
424
+ const sanitizedPath = sanitizePath(id, process.cwd());
425
+ if (!sanitizedPath) {
426
+ throw new Error(`Invalid file path detected: ${id}`);
233
427
  }
234
428
 
429
+ let relativePath = path.relative(process.cwd(), sanitizedPath);
430
+
235
431
  // Normalize path separators
236
432
  relativePath = relativePath.replace(/\\/g, "/").replace(/^\//, "");
237
433
 
238
434
  // Generate module name for internal use (must be valid identifier)
239
- const moduleName = options.moduleNameTransform(relativePath);
435
+ const moduleName = createSecureModuleName(options.moduleNameTransform(relativePath));
240
436
 
241
437
  // Validate module name
242
- if (!moduleName || moduleName.includes("..")) {
438
+ if (!isValidModuleName(moduleName)) {
243
439
  throw new Error(`Invalid server module name: ${moduleName}`);
244
440
  }
245
441
 
246
- const exportRegex = /export\s+(async\s+)?function\s+(\w+)/g;
442
+ // Use AST parser to extract exported functions with detailed information
443
+ const exportedFunctions = extractExportedFunctions(code, id);
247
444
  const functions = [];
248
- let match;
249
-
250
- while ((match = exportRegex.exec(code)) !== null) {
251
- const functionName = match[2];
445
+ const functionDetails = [];
446
+
447
+ for (const fn of exportedFunctions) {
448
+ // Skip default exports for now (could be supported in future)
449
+ if (fn.isDefault) {
450
+ console.warn(
451
+ createDevelopmentWarning("Default Export Skipped", `Default exports are not currently supported`, {
452
+ filePath: relativePath,
453
+ suggestion: "Use named exports instead: export async function myFunction() {}",
454
+ }),
455
+ );
456
+ continue;
457
+ }
252
458
 
253
459
  // Validate function name
254
- if (!functionName || !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(functionName)) {
255
- console.warn(`Skipping invalid function name: ${functionName} in ${id}`);
460
+ if (!isValidFunctionName(fn.name)) {
461
+ console.warn(
462
+ createDevelopmentWarning(
463
+ "Invalid Function Name",
464
+ `Function name '${fn.name}' is not a valid JavaScript identifier`,
465
+ {
466
+ filePath: relativePath,
467
+ suggestion:
468
+ "Function names must start with a letter, $, or _ and contain only letters, numbers, $, and _",
469
+ },
470
+ ),
471
+ );
256
472
  continue;
257
473
  }
258
474
 
259
- functions.push(functionName);
475
+ // Warn about non-async functions
476
+ if (!fn.isAsync) {
477
+ console.warn(
478
+ createDevelopmentWarning(
479
+ "Non-Async Function",
480
+ `Function '${fn.name}' is not async. Server actions should typically be async`,
481
+ {
482
+ filePath: relativePath,
483
+ suggestion: "Consider changing to: export async function " + fn.name + "() {}",
484
+ },
485
+ ),
486
+ );
487
+ }
488
+
489
+ functions.push(fn.name);
490
+ functionDetails.push(fn);
260
491
  }
261
492
 
262
493
  // Check for duplicate function names within the same module
@@ -265,16 +496,52 @@ export default function serverActions(userOptions = {}) {
265
496
  console.warn(`Duplicate function names detected in ${id}`);
266
497
  }
267
498
 
268
- serverFunctions.set(moduleName, { functions: uniqueFunctions, id, filePath: relativePath });
499
+ // Store both simple function names and detailed information
500
+ serverFunctions.set(moduleName, {
501
+ functions: uniqueFunctions,
502
+ functionDetails,
503
+ id,
504
+ filePath: relativePath,
505
+ });
506
+
507
+ // Development-time validation and feedback
508
+ if (process.env.NODE_ENV === "development") {
509
+ // Validate file structure
510
+ const fileWarnings = validateFileStructure(functionDetails, relativePath);
511
+ fileWarnings.forEach((warning) => console.warn(warning));
512
+
513
+ // Validate individual function signatures
514
+ functionDetails.forEach((func) => {
515
+ const funcWarnings = validateFunctionSignature(func, relativePath);
516
+ funcWarnings.forEach((warning) => console.warn(warning));
517
+ });
518
+ }
269
519
 
270
520
  // Discover schemas from module if validation is enabled (development only)
271
- if (options.validation.enabled && process.env.NODE_ENV !== "production") {
521
+ // Skip TypeScript files to avoid SSR loading issues
522
+ if (options.validation.enabled && process.env.NODE_ENV !== "production" && !id.endsWith(".ts")) {
272
523
  try {
273
- const module = await import(id);
524
+ const module = await importModule(id, viteDevServer, tsModuleCache);
274
525
  schemaDiscovery.discoverFromModule(module, moduleName);
526
+
527
+ // Validate schema attachment in development
528
+ if (process.env.NODE_ENV === "development") {
529
+ const schemaWarnings = validateSchemaAttachment(module, uniqueFunctions, relativePath);
530
+ schemaWarnings.forEach((warning) => console.warn(warning));
531
+ }
275
532
  } catch (error) {
276
- console.warn(`Failed to discover schemas from ${id}: ${error.message}`);
533
+ const enhancedError = enhanceModuleLoadError(id, error);
534
+ console.warn(enhancedError.message);
535
+
536
+ if (process.env.NODE_ENV === "development" && enhancedError.suggestions) {
537
+ enhancedError.suggestions.forEach((suggestion) => {
538
+ console.info(` 💡 ${suggestion}`);
539
+ });
540
+ }
277
541
  }
542
+ } else if (options.validation.enabled && id.endsWith(".ts")) {
543
+ // For TypeScript files, defer schema discovery to request time
544
+ console.log(`[Vite Server Actions] Deferring schema discovery for TypeScript file: ${relativePath}`);
278
545
  }
279
546
 
280
547
  // Setup routes in development mode only
@@ -318,11 +585,29 @@ export default function serverActions(userOptions = {}) {
318
585
  // Apply middleware before the handler
319
586
  app.post(endpoint, ...contextMiddlewares, async (req, res) => {
320
587
  try {
321
- const module = await import(id);
588
+ const module = await importModule(id, viteDevServer, tsModuleCache);
589
+
590
+ // Lazy schema discovery for TypeScript files
591
+ if (
592
+ options.validation.enabled &&
593
+ id.endsWith(".ts") &&
594
+ !schemaDiscovery.hasSchema(moduleName, functionName)
595
+ ) {
596
+ try {
597
+ schemaDiscovery.discoverFromModule(module, moduleName);
598
+ } catch (err) {
599
+ console.warn(`Failed to discover schemas for ${moduleName}:`, err.message);
600
+ }
601
+ }
322
602
 
323
603
  // Check if function exists in module
324
604
  if (typeof module[functionName] !== "function") {
325
- throw new Error(`Function ${functionName} not found or not a function`);
605
+ // Get available functions for better error message
606
+ const availableFunctions = Object.keys(module).filter((key) => typeof module[key] === "function");
607
+
608
+ const enhancedError = enhanceFunctionNotFoundError(functionName, moduleName, availableFunctions);
609
+
610
+ throw new Error(enhancedError.message);
326
611
  }
327
612
 
328
613
  // Validate request body is array for function arguments
@@ -331,25 +616,48 @@ export default function serverActions(userOptions = {}) {
331
616
  }
332
617
 
333
618
  const result = await module[functionName](...req.body);
334
- res.json(result || "* No response *");
619
+ if (result === undefined) {
620
+ res.status(204).end();
621
+ } else {
622
+ res.json(result);
623
+ }
335
624
  } catch (error) {
336
625
  console.error(`Error in ${functionName}: ${error.message}`);
337
626
 
338
627
  if (error.message.includes("not found") || error.message.includes("not a function")) {
339
- res.status(404).json({
340
- error: "Function not found",
341
- details: error.message,
342
- });
628
+ // Extract available functions from the error context if available
629
+ const availableFunctionsMatch = error.message.match(/Available functions: ([^]+)/);
630
+ const availableFunctions = availableFunctionsMatch ? availableFunctionsMatch[1].split(", ") : [];
631
+
632
+ res.status(404).json(
633
+ createErrorResponse(404, "Function not found", "FUNCTION_NOT_FOUND", {
634
+ functionName,
635
+ moduleName,
636
+ availableFunctions: availableFunctions.length > 0 ? availableFunctions : undefined,
637
+ suggestion: `Try one of: ${availableFunctions.join(", ") || "none available"}`,
638
+ }),
639
+ );
343
640
  } else if (error.message.includes("Request body")) {
344
- res.status(400).json({
345
- error: "Bad request",
346
- details: error.message,
347
- });
641
+ res.status(400).json(
642
+ createErrorResponse(400, error.message, "INVALID_REQUEST_BODY", {
643
+ suggestion: "Send an array of arguments: [arg1, arg2, ...]",
644
+ }),
645
+ );
348
646
  } else {
349
- res.status(500).json({
350
- error: "Internal server error",
351
- details: error.message,
352
- });
647
+ res.status(500).json(
648
+ createErrorResponse(
649
+ 500,
650
+ "Internal server error",
651
+ "INTERNAL_ERROR",
652
+ process.env.NODE_ENV !== "production"
653
+ ? {
654
+ message: error.message,
655
+ stack: error.stack,
656
+ suggestion: "Check server logs for more details",
657
+ }
658
+ : { suggestion: "Contact support if this persists" },
659
+ ),
660
+ );
353
661
  }
354
662
  }
355
663
  });
@@ -357,11 +665,29 @@ export default function serverActions(userOptions = {}) {
357
665
  }
358
666
  // OpenAPI endpoints will be set up during configureServer after all modules are loaded
359
667
 
360
- return generateClientProxy(moduleName, uniqueFunctions, options, relativePath);
668
+ // Use enhanced client proxy generator if we have detailed function information
669
+ if (functionDetails.length > 0) {
670
+ return generateEnhancedClientProxy(moduleName, functionDetails, options, relativePath);
671
+ } else {
672
+ // Fallback to basic proxy for backwards compatibility
673
+ return generateClientProxy(moduleName, uniqueFunctions, options, relativePath);
674
+ }
361
675
  } catch (error) {
362
- console.error(`Failed to process server file ${id}: ${error.message}`);
363
- // Return empty proxy instead of failing the build
364
- return `// Failed to load server actions from ${id}: ${error.message}`;
676
+ const enhancedError = enhanceParsingError(id, error);
677
+ console.error(enhancedError.message);
678
+
679
+ // Provide helpful suggestions in development
680
+ if (process.env.NODE_ENV === "development" && enhancedError.suggestions.length > 0) {
681
+ console.info("[Vite Server Actions] 💡 Suggestions:");
682
+ enhancedError.suggestions.forEach((suggestion) => {
683
+ console.info(` • ${suggestion}`);
684
+ });
685
+ }
686
+
687
+ // Return error comment with context instead of failing the build
688
+ return `// Failed to load server actions from ${id}
689
+ // Error: ${error.message}
690
+ // ${enhancedError.suggestions.length > 0 ? "Suggestions: " + enhancedError.suggestions.join(", ") : ""}`;
365
691
  }
366
692
  }
367
693
  },
@@ -398,6 +724,22 @@ export default function serverActions(userOptions = {}) {
398
724
  }
399
725
  },
400
726
  },
727
+ {
728
+ name: "typescript-transform",
729
+ async load(id) {
730
+ // Handle TypeScript files
731
+ if (id.endsWith(".ts")) {
732
+ const code = await fs.readFile(id, "utf-8");
733
+ const result = await esbuild.transform(code, {
734
+ loader: "ts",
735
+ target: "node16",
736
+ format: "esm",
737
+ });
738
+ return result.code;
739
+ }
740
+ return null;
741
+ },
742
+ },
401
743
  {
402
744
  name: "external-modules",
403
745
  resolveId(source) {
@@ -424,12 +766,23 @@ export default function serverActions(userOptions = {}) {
424
766
  source: bundledCode,
425
767
  });
426
768
 
769
+ // Generate and emit TypeScript definitions
770
+ const typeDefinitions = generateTypeDefinitions(serverFunctions, options);
771
+ this.emitFile({
772
+ type: "asset",
773
+ fileName: "actions.d.ts",
774
+ source: typeDefinitions,
775
+ });
776
+
427
777
  // Generate OpenAPI spec if enabled
428
778
  let openAPISpec = null;
429
779
  if (options.openAPI.enabled) {
780
+ // Use PORT env var for production builds, defaulting to 3000
781
+ const port = process.env.PORT || 3000;
430
782
  openAPISpec = openAPIGenerator.generateSpec(serverFunctions, schemaDiscovery, {
431
783
  apiPrefix: options.apiPrefix,
432
784
  routeTransform: options.routeTransform,
785
+ port,
433
786
  });
434
787
 
435
788
  // Emit OpenAPI spec as a separate file
@@ -441,7 +794,7 @@ export default function serverActions(userOptions = {}) {
441
794
  }
442
795
 
443
796
  // Generate validation code if enabled
444
- const validationCode = generateValidationCode(options, serverFunctions);
797
+ const validationCode = await generateValidationCode(options, serverFunctions);
445
798
 
446
799
  // Generate server.js
447
800
  const serverCode = `
@@ -450,6 +803,7 @@ export default function serverActions(userOptions = {}) {
450
803
  ${options.openAPI.enabled && options.openAPI.swaggerUI ? "import swaggerUi from 'swagger-ui-express';" : ""}
451
804
  ${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'));" : ""}
452
805
  ${validationCode.imports}
806
+ ${validationCode.validationRuntime}
453
807
 
454
808
  const app = express();
455
809
  ${validationCode.setup}
@@ -474,10 +828,22 @@ export default function serverActions(userOptions = {}) {
474
828
  app.post('${options.apiPrefix}/${routePath}', ${middlewareCall}async (req, res) => {
475
829
  try {
476
830
  const result = await serverActions.${moduleName}.${functionName}(...req.body);
477
- res.json(result || "* No response *");
831
+ if (result === undefined) {
832
+ res.status(204).end();
833
+ } else {
834
+ res.json(result);
835
+ }
478
836
  } catch (error) {
479
837
  console.error(\`Error in ${functionName}: \${error.message}\`);
480
- res.status(500).json({ error: error.message });
838
+ const status = error.status || 500;
839
+ res.status(status).json({
840
+ error: true,
841
+ status,
842
+ message: status === 500 ? 'Internal server error' : error.message,
843
+ code: error.code || 'SERVER_ACTION_ERROR',
844
+ timestamp: new Date().toISOString(),
845
+ ...(process.env.NODE_ENV !== 'production' ? { details: { message: error.message, stack: error.stack } } : {})
846
+ });
481
847
  }
482
848
  });
483
849
  `;
@@ -543,24 +909,13 @@ function generateClientProxy(moduleName, functions, options, filePath) {
543
909
 
544
910
  let clientProxy = `\n// vite-server-actions: ${moduleName}\n`;
545
911
 
546
- // Add a guard to prevent direct imports of server code
912
+ // Mark this as a legitimate client proxy module
547
913
  if (isDev) {
548
914
  clientProxy += `
549
- // Development-only safety check
915
+ // Development-only marker for client proxy module
550
916
  if (typeof window !== 'undefined') {
551
- // This code is running in the browser
552
- const serverFileError = new Error(
553
- '[Vite Server Actions] SECURITY WARNING: Server file "${moduleName}" is being imported in client code! ' +
554
- 'This could expose server-side code to the browser. Only import server actions through the plugin.'
555
- );
556
- serverFileError.name = 'ServerCodeInClientError';
557
-
558
- // Check if we're in a server action proxy context
559
- if (!window.__VITE_SERVER_ACTIONS_PROXY__) {
560
- console.error(serverFileError);
561
- // In development, we'll warn but not throw to avoid breaking HMR
562
- console.error('Stack trace:', serverFileError.stack);
563
- }
917
+ window.__VITE_SERVER_ACTIONS_PROXY__ = window.__VITE_SERVER_ACTIONS_PROXY__ || {};
918
+ window.__VITE_SERVER_ACTIONS_PROXY__['${moduleName}'] = true;
564
919
  }
565
920
  `;
566
921
  }
@@ -575,11 +930,6 @@ if (typeof window !== 'undefined') {
575
930
  ${
576
931
  isDev
577
932
  ? `
578
- // Development-only: Mark that we're in a valid proxy context
579
- if (typeof window !== 'undefined') {
580
- window.__VITE_SERVER_ACTIONS_PROXY__ = true;
581
- }
582
-
583
933
  // Validate arguments in development
584
934
  if (args.some(arg => typeof arg === 'function')) {
585
935
  console.warn(
@@ -615,16 +965,18 @@ if (typeof window !== 'undefined') {
615
965
  }
616
966
 
617
967
  console.log("[Vite Server Actions] ✅ - ${functionName} executed successfully");
968
+
969
+ // Handle 204 No Content responses (function returned undefined)
970
+ if (response.status === 204) {
971
+ return undefined;
972
+ }
973
+
618
974
  const result = await response.json();
619
975
 
620
976
  ${
621
977
  isDev
622
978
  ? `
623
- // Development-only: Clear the proxy context
624
- if (typeof window !== 'undefined') {
625
- window.__VITE_SERVER_ACTIONS_PROXY__ = false;
626
- }
627
- `
979
+ `
628
980
  : ""
629
981
  }
630
982
 
@@ -636,11 +988,7 @@ if (typeof window !== 'undefined') {
636
988
  ${
637
989
  isDev
638
990
  ? `
639
- // Development-only: Clear the proxy context on error
640
- if (typeof window !== 'undefined') {
641
- window.__VITE_SERVER_ACTIONS_PROXY__ = false;
642
- }
643
- `
991
+ `
644
992
  : ""
645
993
  }
646
994