import { stringify as devalueStringify } from "devalue"; import * as z from "zod/v4/core"; import { shouldAppendForwardSlash } from "../../core/build/util.js"; import { pipelineSymbol, REDIRECT_STATUS_CODES } from "../../core/constants.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"; function defineAction({ accept, input: inputSchema, handler }) { const serverHandler = accept === "form" ? getFormServerHandler(handler, inputSchema) : getJsonServerHandler(handler, inputSchema); async function safeServerHandler(unparsedInput) { 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); }; } function getActionContext(context) { const callerInfo = getCallerInfo(context); const actionResultAlreadySet = Boolean(context.locals._actionPayload); let action = void 0; if (callerInfo && context.request.method === "POST" && !actionResultAlreadySet) { action = { calledFrom: callerInfo.from, name: callerInfo.name, handler: async () => { const pipeline = Reflect.get(context, pipelineSymbol); const callerInfoName = shouldAppendForwardSlash( pipeline.manifest.trailingSlash, pipeline.manifest.buildFormat ) ? removeTrailingForwardSlash(callerInfo.name) : callerInfo.name; let baseAction; try { baseAction = await pipeline.getAction(callerInfoName); } catch (error) { if (error instanceof Error && "name" in error && typeof error.name === "string" && error.name === ActionNotFoundError.name) { return { data: void 0, error: new ActionError({ code: "NOT_FOUND" }) }; } throw error; } const bodySizeLimit = pipeline.manifest.actionBodySizeLimit; let input; try { input = await parseRequestBody(context.request, bodySizeLimit); } catch (e) { if (e instanceof ActionError) { return { data: void 0, error: e }; } if (e instanceof TypeError) { return { data: void 0, error: new ActionError({ code: "UNSUPPORTED_MEDIA_TYPE" }) }; } throw e; } const omitKeys = ["props", "getActionResult", "callAction", "redirect"]; 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 void 0; } 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) : void 0; const hasContentLength = typeof contentLength === "number" && Number.isFinite(contentLength); if (!contentType) return void 0; 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 void 0; if (!hasContentLength) { const body = await readBodyWithLimit(request.clone(), bodySizeLimit); if (body.byteLength === 0) return void 0; 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"); } const ACTION_API_CONTEXT_SYMBOL = /* @__PURE__ */ Symbol.for("astro.actionAPIContext"); const formContentTypes = ["application/x-www-form-urlencoded", "multipart/form-data"]; function hasContentType(contentType, expected) { 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; } 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) { 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; } while (validator instanceof z.$ZodPipe) { validator = validator._zod.def.in; } 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)) { obj[key] = baseValidator instanceof z.$ZodNullable ? null : void 0; } } else if (!formData.has(prefixedKey) && key in obj) { 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; } 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(Boolean); } return entries; } function handleFormDataGet(key, formData, validator, baseValidator) { const value = formData.get(key); if (!value) { return baseValidator instanceof z.$ZodOptional ? void 0 : 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: void 0 }; } catch (e) { if (e instanceof ActionError) { return { data: void 0, error: e }; } return { data: void 0, error: new ActionError({ message: e instanceof Error ? e.message : "Unknown error", code: "INTERNAL_SERVER_ERROR" }) }; } } function serializeActionResult(res) { if (res.error) { if (import.meta.env?.DEV) { actionResultErrorStack.set(res.error.stack); } let body2; if (res.error instanceof ActionInputError) { body2 = { type: res.error.type, issues: res.error.issues, fields: res.error.fields }; } else { body2 = { ...res.error, message: res.error.message }; } return { type: "error", status: res.error.status, contentType: "application/json", body: JSON.stringify(body2) }; } if (res.data === void 0) { 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; } export { ACTION_API_CONTEXT_SYMBOL, defineAction, formDataToObject, getActionContext, serializeActionResult };