astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
407 lines (406 loc) • 14.8 kB
JavaScript
import { stringify as devalueStringify } from "devalue";
import * as z from "zod/v4/core";
import { shouldAppendForwardSlash } from "../../core/build/util.js";
import { REDIRECT_STATUS_CODES } from "../../core/constants.js";
import { getFetchStateFromAPIContext } from "../../core/fetch/fetch-state.js";
import { getAction } from "../load.js";
import { ActionCalledFromServerError, ActionNotFoundError, ActionsReturnedInvalidDataError } from "../../core/errors/errors-data.js";
import { AstroError } from "../../core/errors/errors.js";
import { removeTrailingForwardSlash } from "../../core/path.js";
import { BodySizeLimitError, readBodyWithLimit } from "../../core/request-body.js";
import { ACTION_QUERY_PARAMS, ACTION_RPC_ROUTE_PATTERN } from "../consts.js";
import { ActionError, ActionInputError, actionResultErrorStack, deserializeActionResult } from "./client.js";
export function defineAction({ accept, input: inputSchema, handler }) {
const serverHandler = accept === "form" ? getFormServerHandler(handler, inputSchema) : getJsonServerHandler(handler, inputSchema);
async function safeServerHandler(unparsedInput) {
// The ActionAPIContext should always contain the `params` property
if (typeof this === "function" || !isActionAPIContext(this)) {
throw new AstroError(ActionCalledFromServerError);
}
return callSafely(() => serverHandler(unparsedInput, this));
}
Object.assign(safeServerHandler, { orThrow(unparsedInput) {
if (typeof this === "function") {
throw new AstroError(ActionCalledFromServerError);
}
return serverHandler(unparsedInput, this);
} });
return safeServerHandler;
}
function getFormServerHandler(handler, inputSchema) {
return async (unparsedInput, context) => {
if (!(unparsedInput instanceof FormData)) {
throw new ActionError({
code: "UNSUPPORTED_MEDIA_TYPE",
message: "This action only accepts FormData."
});
}
if (!inputSchema) return await handler(unparsedInput, context);
const parsed = await parseFormInput(inputSchema, unparsedInput);
if (!parsed.success) {
throw new ActionInputError(parsed.error.issues);
}
return await handler(parsed.data, context);
};
}
async function parseFormInput(inputSchema, unparsedInput) {
const baseSchema = unwrapBaseZ4ObjectSchema(inputSchema, unparsedInput);
const input = baseSchema instanceof z.$ZodObject ? formDataToObject(unparsedInput, baseSchema) : unparsedInput;
const parsed = await z.safeParseAsync(inputSchema, input);
return parsed;
}
function getJsonServerHandler(handler, inputSchema) {
return async (unparsedInput, context) => {
if (unparsedInput instanceof FormData) {
throw new ActionError({
code: "UNSUPPORTED_MEDIA_TYPE",
message: "This action only accepts JSON."
});
}
if (!inputSchema) return await handler(unparsedInput, context);
const parsed = await z.safeParseAsync(inputSchema, unparsedInput);
if (!parsed.success) {
throw new ActionInputError(parsed.error.issues);
}
return await handler(parsed.data, context);
};
}
/**
* Access information about Action requests from middleware.
*/
export function getActionContext(context) {
const callerInfo = getCallerInfo(context);
// Prevents action results from being handled on a rewrite.
// Also prevents our *own* fallback middleware from running
// if the user's middleware has already handled the result.
const actionResultAlreadySet = Boolean(context.locals._actionPayload);
let action = undefined;
if (callerInfo && context.request.method === "POST" && !actionResultAlreadySet) {
action = {
calledFrom: callerInfo.from,
name: callerInfo.name,
handler: async () => {
const { manifest } = getFetchStateFromAPIContext(context);
const callerInfoName = shouldAppendForwardSlash(manifest.trailingSlash, manifest.buildFormat) ? removeTrailingForwardSlash(callerInfo.name) : callerInfo.name;
let baseAction;
try {
baseAction = await getAction(manifest, callerInfoName);
} catch (error) {
// Check if this is an ActionNotFoundError by comparing the name property
// We use this approach instead of instanceof because the error might be
// a different instance of the AstroError class depending on the environment
if (error instanceof Error && "name" in error && typeof error.name === "string" && error.name === ActionNotFoundError.name) {
return {
data: undefined,
error: new ActionError({ code: "NOT_FOUND" })
};
}
// decodeURIComponent in getAction throws URIError for malformed
// percent-encoded action names (e.g. a lone "%").
if (error instanceof URIError) {
return {
data: undefined,
error: new ActionError({ code: "NOT_FOUND" })
};
}
throw error;
}
const bodySizeLimit = manifest.actionBodySizeLimit;
let input;
try {
input = await parseRequestBody(context.request, bodySizeLimit);
} catch (e) {
if (e instanceof ActionError) {
return {
data: undefined,
error: e
};
}
if (e instanceof TypeError) {
return {
data: undefined,
error: new ActionError({ code: "UNSUPPORTED_MEDIA_TYPE" })
};
}
if (e instanceof SyntaxError) {
return {
data: undefined,
error: new ActionError({
code: "BAD_REQUEST",
message: "Invalid JSON request body"
})
};
}
throw e;
}
const omitKeys = [
"props",
"getActionResult",
"callAction",
"redirect"
];
// Clones the context, preserving accessors and methods but omitting
// the properties that are not needed in the action handler.
const actionAPIContext = Object.create(Object.getPrototypeOf(context), Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(context)).filter(([key]) => !omitKeys.includes(key))));
Reflect.set(actionAPIContext, ACTION_API_CONTEXT_SYMBOL, true);
const handler = baseAction.bind(actionAPIContext);
return handler(input);
}
};
}
function setActionResult(actionName, actionResult) {
context.locals._actionPayload = {
actionResult,
actionName
};
}
return {
action,
setActionResult,
serializeActionResult,
deserializeActionResult
};
}
function getCallerInfo(ctx) {
if (ctx.routePattern === ACTION_RPC_ROUTE_PATTERN) {
return {
from: "rpc",
name: ctx.url.pathname.replace(/^.*\/_actions\//, "")
};
}
const queryParam = ctx.url.searchParams.get(ACTION_QUERY_PARAMS.actionName);
if (queryParam) {
return {
from: "form",
name: queryParam
};
}
return undefined;
}
async function parseRequestBody(request, bodySizeLimit) {
const contentType = request.headers.get("content-type");
const contentLengthHeader = request.headers.get("content-length");
const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : undefined;
const hasContentLength = typeof contentLength === "number" && Number.isFinite(contentLength);
if (!contentType) return undefined;
if (hasContentLength && contentLength > bodySizeLimit) {
throw new ActionError({
code: "CONTENT_TOO_LARGE",
message: `Request body exceeds ${bodySizeLimit} bytes`
});
}
try {
if (hasContentType(contentType, formContentTypes)) {
if (!hasContentLength) {
const body = await readBodyWithLimit(request.clone(), bodySizeLimit);
const formRequest = new Request(request.url, {
method: request.method,
headers: request.headers,
body: toArrayBuffer(body)
});
return await formRequest.formData();
}
return await request.clone().formData();
}
if (hasContentType(contentType, ["application/json"])) {
if (contentLength === 0) return undefined;
if (!hasContentLength) {
const body = await readBodyWithLimit(request.clone(), bodySizeLimit);
if (body.byteLength === 0) return undefined;
return JSON.parse(new TextDecoder().decode(body));
}
return await request.clone().json();
}
} catch (e) {
if (e instanceof BodySizeLimitError) {
throw new ActionError({
code: "CONTENT_TOO_LARGE",
message: `Request body exceeds ${bodySizeLimit} bytes`
});
}
throw e;
}
throw new TypeError("Unsupported content type");
}
export const ACTION_API_CONTEXT_SYMBOL = Symbol.for("astro.actionAPIContext");
const formContentTypes = ["application/x-www-form-urlencoded", "multipart/form-data"];
function hasContentType(contentType, expected) {
// Split off parameters like charset or boundary
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
const type = contentType.split(";")[0].toLowerCase();
return expected.some((t) => type === t);
}
function isActionAPIContext(ctx) {
const symbol = Reflect.get(ctx, ACTION_API_CONTEXT_SYMBOL);
return symbol === true;
}
/** Transform form data to an object based on a Zod schema. */
export function formDataToObject(formData, schema, prefix = "") {
const formKeys = [...formData.keys()];
const obj = schema._zod.def.catchall ? Object.fromEntries([...formData.entries()].filter(([k]) => k.startsWith(prefix)).map(([k, v]) => [k.slice(prefix.length), v])) : {};
for (const [key, baseValidator] of Object.entries(schema._zod.def.shape)) {
const prefixedKey = prefix + key;
let validator = baseValidator;
while (validator instanceof z.$ZodOptional || validator instanceof z.$ZodNullable || validator instanceof z.$ZodDefault) {
// use default value when key is undefined
if (validator instanceof z.$ZodDefault && !formDataHasKeyOrPrefix(formKeys, prefixedKey)) {
obj[key] = validator._zod.def.defaultValue instanceof Function ? validator._zod.def.defaultValue() : validator._zod.def.defaultValue;
}
validator = validator._zod.def.innerType;
}
// Unwrap pipe (from .transform() / .pipe()) to find nested objects
while (validator instanceof z.$ZodPipe) {
validator = validator._zod.def.in;
}
// Resolve nested discriminatedUnion to the matching variant
if (validator instanceof z.$ZodDiscriminatedUnion) {
const typeKey = validator._zod.def.discriminator;
const typeValue = formData.get(prefixedKey + "." + typeKey);
if (typeof typeValue === "string") {
const match = validator._zod.def.options.find((option) => option.def.shape[typeKey].values.has(typeValue));
if (match) {
validator = match;
}
}
}
if (validator instanceof z.$ZodObject) {
const nestedPrefix = prefixedKey + ".";
const hasNestedKeys = formKeys.some((k) => k.startsWith(nestedPrefix));
if (hasNestedKeys) {
obj[key] = formDataToObject(formData, validator, nestedPrefix);
} else if (!(key in obj)) {
// No nested keys and no default was set — respect optional/nullable
obj[key] = baseValidator instanceof z.$ZodNullable ? null : undefined;
}
} else if (!formData.has(prefixedKey) && key in obj) {
// continue loop if form input is not found and default value is set
continue;
} else if (validator instanceof z.$ZodBoolean) {
const val = formData.get(prefixedKey);
obj[key] = val === "true" ? true : val === "false" ? false : formData.has(prefixedKey);
} else if (validator instanceof z.$ZodArray) {
obj[key] = handleFormDataGetAll(prefixedKey, formData, validator);
} else {
obj[key] = handleFormDataGet(prefixedKey, formData, validator, baseValidator);
}
}
return obj;
}
/** Check if formKeys contains an exact key or any keys with the given prefix (for nested objects). */
function formDataHasKeyOrPrefix(formKeys, key) {
const prefix = key + ".";
return formKeys.some((k) => k === key || k.startsWith(prefix));
}
function handleFormDataGetAll(key, formData, validator) {
const entries = Array.from(formData.getAll(key));
const elementValidator = validator._zod.def.element;
if (elementValidator instanceof z.$ZodNumber) {
return entries.map(Number);
} else if (elementValidator instanceof z.$ZodBoolean) {
return entries.map((v) => v === "true" ? true : v === "false" ? false : Boolean(v));
}
return entries;
}
function handleFormDataGet(key, formData, validator, baseValidator) {
const value = formData.get(key);
if (!value) {
return baseValidator instanceof z.$ZodOptional ? undefined : null;
}
return validator instanceof z.$ZodNumber ? Number(value) : value;
}
function unwrapBaseZ4ObjectSchema(schema, unparsedInput) {
if (schema instanceof z.$ZodPipe) {
return unwrapBaseZ4ObjectSchema(schema._zod.def.in, unparsedInput);
}
if (schema instanceof z.$ZodDiscriminatedUnion) {
const typeKey = schema._zod.def.discriminator;
const typeValue = unparsedInput.get(typeKey);
if (typeof typeValue !== "string") return schema;
const objSchema = schema._zod.def.options.find((option) => option.def.shape[typeKey].values.has(typeValue));
if (!objSchema) return schema;
return objSchema;
}
return schema;
}
async function callSafely(handler) {
try {
const data = await handler();
return {
data,
error: undefined
};
} catch (e) {
if (e instanceof ActionError) {
return {
data: undefined,
error: e
};
}
return {
data: undefined,
error: new ActionError({
message: e instanceof Error ? e.message : "Unknown error",
code: "INTERNAL_SERVER_ERROR"
})
};
}
}
export function serializeActionResult(res) {
if (res.error) {
if (import.meta.env?.DEV) {
actionResultErrorStack.set(res.error.stack);
}
let body;
if (res.error instanceof ActionInputError) {
body = {
type: res.error.type,
issues: res.error.issues,
fields: res.error.fields
};
} else {
body = {
...res.error,
message: res.error.message
};
}
return {
type: "error",
status: res.error.status,
contentType: "application/json",
body: JSON.stringify(body)
};
}
if (res.data === undefined) {
return {
type: "empty",
status: 204
};
}
let body;
try {
body = devalueStringify(res.data, {
// Add support for URL objects
URL: (value) => value instanceof URL && value.href });
} catch (e) {
let hint = ActionsReturnedInvalidDataError.hint;
if (res.data instanceof Response) {
hint = REDIRECT_STATUS_CODES.includes(res.data.status) ? "If you need to redirect when the action succeeds, trigger a redirect where the action is called. See the Actions guide for server and client redirect examples: https://docs.astro.build/en/guides/actions." : "If you need to return a Response object, try using a server endpoint instead. See https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes";
}
throw new AstroError({
...ActionsReturnedInvalidDataError,
message: ActionsReturnedInvalidDataError.message(String(e)),
hint
});
}
return {
type: "data",
status: 200,
contentType: "application/json+devalue",
body
};
}
function toArrayBuffer(buffer) {
const copy = new Uint8Array(buffer.byteLength);
copy.set(buffer);
return copy.buffer;
}