UNPKG

vite-plugin-react-server

Version:
270 lines (236 loc) 7.4 kB
import { logError, toError } from "../error/index.js"; import { join } from "node:path"; import type { Logger } from "vite"; import type { ServerResponse } from "node:http"; import type { IncomingMessage } from "node:http"; export type ServerActionHandlerOptions = { projectRoot: string; verbose?: boolean; logger?: Logger; ssrLoadModule?: (path: string) => Promise<any>; }; export type ServerActionRequest = { id: string; args: unknown[]; }; /** * Parses a server action request from the request body. * Supports two formats: * 1. Direct args array: [arg1, arg2, ...] * 2. Object with id and args: { id: string, args: unknown[] } */ export function parseServerActionRequestBody(body: string, url?: string): ServerActionRequest { const parsed = JSON.parse(body); if (Array.isArray(parsed)) { // Format 1: Direct args array return { args: parsed, id: url?.split("?")[0] ?? "", }; } else if (parsed && typeof parsed === "object" && "id" in parsed) { // Format 2: Object with id and args return { id: parsed.id, args: parsed.args ?? [], }; } throw new Error("Invalid server action request format"); } export type ServerActionResponse = { type: "server-action-response"; returnValue: unknown; }; /** * Creates a server action response with the given result or error. */ export function createServerActionResponse(result?: unknown, error?: string): ServerActionResponse { return { type: "server-action-response", returnValue: error ? { success: false, error } : result }; } /** * Sets up common response headers for server actions. */ export function setupServerActionHeaders(res: ServerResponse) { res.setHeader("Content-Type", "text/x-component; charset=utf-8"); res.setHeader("Transfer-Encoding", "chunked"); res.setHeader("Connection", "keep-alive"); } /** * Parses a server action request from the request body and URL */ export async function parseServerActionRequest( req: IncomingMessage, verbose = false, logger?: Logger ): Promise<ServerActionRequest> { // Get action ID from x-rsc-action header (preferred) or URL let id = (req.headers["x-rsc-action"] as string) ?? req.url?.split("?")[0] ?? ""; if (verbose) { logger?.info(`[handleServerActionHelper] Parsing request at ${req.url}`); logger?.info(`[handleServerActionHelper] Action ID from header: ${req.headers["x-rsc-action"]}`); } // Parse the request body let args: unknown[]; try { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(chunk); } const body = Buffer.concat(chunks).toString(); if (verbose) { logger?.info(`[handleServerActionHelper] Request body length: ${body.length}`); } // Try to parse as JSON first (for backwards compatibility) try { const parsed = JSON.parse(body); if (Array.isArray(parsed)) { // Format 1: Direct args array args = parsed; if (verbose) { logger?.info(`[handleServerActionHelper] Parsed args as array`); } } else if (parsed && typeof parsed === "object" && "id" in parsed) { // Format 2: Object with id and args (legacy format) id = parsed.id; args = parsed.args ?? []; } else { throw new Error("Invalid server action request format"); } } catch { // Not JSON - assume it's React's encoded format // For now, pass the raw body to the worker which can decode it // using decodeReply from react-server-dom-esm/server if (verbose) { logger?.info(`[handleServerActionHelper] Body is not JSON, passing raw body`); } args = [body]; // Pass raw body as first arg, worker will decode } } catch (error: unknown) { throw new Error(`Failed to parse server action request`, { cause: error, }); } if (!id) { throw new Error("Server action ID is required"); } if (verbose) { logger?.info( `[handleServerActionHelper] Server action request for ${id} with args: ${JSON.stringify(args)}` ); } return { id, args }; } /** * Resolves a server action ID to file path and export name */ export function resolveServerAction( id: string, projectRoot: string, verbose = false, logger?: Logger ): { filePath: string; exportName: string; fullPath: string } { // Parse the server action ID to get the file path and export name const [filePath, exportName] = id.split("#"); if (!filePath || !exportName) { throw new Error( `Invalid server action ID format: ${id}. Expected format: "path/to/file.ts#exportName"` ); } // Convert the server action ID to a file path const actionPath = filePath.startsWith("/") ? filePath.slice(1) : filePath; const fullPath = join(projectRoot, actionPath); if (verbose) { logger?.info( `[handleServerActionHelper] Resolved file path: id=${id}, actionPath=${actionPath}, projectRoot=${projectRoot}, filePath=${fullPath}, exportName=${exportName}` ); } return { filePath: actionPath, exportName, fullPath }; } /** * Loads and validates a server action from a module */ export async function loadServerAction( fullPath: string, exportName: string, ssrLoadModule: (path: string) => Promise<any>, verbose = false, logger?: Logger ): Promise<Function> { if (verbose) { logger?.info(`[handleServerActionHelper] Loading module: ${fullPath}`); } const module = await ssrLoadModule(fullPath); if (verbose) { logger?.info( `[handleServerActionHelper] Looking for action: ${exportName} in module with exports: ${Object.keys(module).join(", ")}` ); } const action = module[exportName]; if (typeof action !== "function") { if (verbose) { logger?.info( `[handleServerActionHelper] Export ${exportName} is not a function: ${typeof action}` ); } throw new Error( `Server action ${exportName} is not a function. Found: ${typeof action}` ); } return action; } /** * Executes a server action with the given arguments */ export async function executeServerAction( action: Function, args: unknown[], verbose = false, logger?: Logger ): Promise<unknown> { if (verbose) { logger?.info(`[handleServerActionHelper] Executing action with args: ${JSON.stringify(args)}`); } const result = await action(...args); if (verbose) { logger?.info(`[handleServerActionHelper] Action executed successfully: ${JSON.stringify(result)}`); } return result; } /** * Sends a server action response */ export function sendServerActionResponse( res: ServerResponse, result: unknown, verbose = false, logger?: Logger ): void { if (verbose) { logger?.info(`[handleServerActionHelper] Sending response: ${JSON.stringify(result)}`); } // Send in RSC wire format for createFromFetch compatibility res.setHeader("Content-Type", "text/x-component"); res.end(`0:${JSON.stringify(result)}\n`); } /** * Handles server action errors */ export function handleServerActionError( error: unknown, res: ServerResponse, logger?: Logger ): void { const err = toError(error); logError(err, logger); res.statusCode = 500; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ success: false, error: err.message, stack: err.stack })); }