vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
270 lines (236 loc) • 7.4 kB
text/typescript
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
}));
}