import { z } from "zod"; import { shouldAppendForwardSlash } from "../../core/build/util.js"; import { AstroError } from "../../core/errors/errors.js"; import { ActionCalledFromServerError, ActionNotFoundError } from "../../core/errors/errors-data.js"; import { removeTrailingForwardSlash } from "../../core/path.js"; import { apiContextRoutesSymbol } from "../../core/render-context.js"; import { ACTION_RPC_ROUTE_PATTERN } from "../consts.js"; import { ACTION_QUERY_PARAMS, ActionError, ActionInputError, callSafely, deserializeActionResult, serializeActionResult } from "./shared.js"; import { ACTION_API_CONTEXT_SYMBOL, formContentTypes, hasContentType, isActionAPIContext } from "./utils.js"; export * from "./shared.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 baseSchema = unwrapBaseObjectSchema(inputSchema, unparsedInput); const parsed = await inputSchema.safeParseAsync( baseSchema instanceof z.ZodObject ? formDataToObject(unparsedInput, baseSchema) : unparsedInput ); if (!parsed.success) { throw new ActionInputError(parsed.error.issues); } return await handler(parsed.data, context); }; } 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 inputSchema.safeParseAsync(unparsedInput); if (!parsed.success) { throw new ActionInputError(parsed.error.issues); } return await handler(parsed.data, context); }; } function formDataToObject(formData, schema) { const obj = schema._def.unknownKeys === "passthrough" ? Object.fromEntries(formData.entries()) : {}; for (const [key, baseValidator] of Object.entries(schema.shape)) { let validator = baseValidator; while (validator instanceof z.ZodOptional || validator instanceof z.ZodNullable || validator instanceof z.ZodDefault) { if (validator instanceof z.ZodDefault && !formData.has(key)) { obj[key] = validator._def.defaultValue(); } validator = validator._def.innerType; } if (!formData.has(key) && key in obj) { continue; } else if (validator instanceof z.ZodBoolean) { const val = formData.get(key); obj[key] = val === "true" ? true : val === "false" ? false : formData.has(key); } else if (validator instanceof z.ZodArray) { obj[key] = handleFormDataGetAll(key, formData, validator); } else { obj[key] = handleFormDataGet(key, formData, validator, baseValidator); } } return obj; } function handleFormDataGetAll(key, formData, validator) { const entries = Array.from(formData.getAll(key)); const elementValidator = validator._def.type; 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 unwrapBaseObjectSchema(schema, unparsedInput) { while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) { if (schema instanceof z.ZodEffects) { schema = schema._def.schema; } if (schema instanceof z.ZodPipeline) { schema = schema._def.in; } } if (schema instanceof z.ZodDiscriminatedUnion) { const typeKey = schema._def.discriminator; const typeValue = unparsedInput.get(typeKey); if (typeof typeValue !== "string") return schema; const objSchema = schema._def.optionsMap.get(typeValue); if (!objSchema) return schema; return objSchema; } return schema; } 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, apiContextRoutesSymbol); 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` }); } if (hasContentType(contentType, formContentTypes)) { if (!hasContentLength) { const body = await readRequestBodyWithLimit(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 readRequestBodyWithLimit(request.clone(), bodySizeLimit); if (body.byteLength === 0) return void 0; return JSON.parse(new TextDecoder().decode(body)); } return await request.clone().json(); } throw new TypeError("Unsupported content type"); } async function readRequestBodyWithLimit(request, limit) { if (!request.body) return new Uint8Array(); const reader = request.body.getReader(); const chunks = []; let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { received += value.byteLength; if (received > limit) { throw new ActionError({ code: "CONTENT_TOO_LARGE", message: `Request body exceeds ${limit} bytes` }); } chunks.push(value); } } const buffer = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { buffer.set(chunk, offset); offset += chunk.byteLength; } return buffer; } function toArrayBuffer(buffer) { const copy = new Uint8Array(buffer.byteLength); copy.set(buffer); return copy.buffer; } export { defineAction, formDataToObject, getActionContext };