UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

217 lines (216 loc) • 7.62 kB
import { z } from "zod"; import { shouldAppendForwardSlash } from "../../../core/build/util.js"; import { ActionCalledFromServerError } from "../../../core/errors/errors-data.js"; import { AstroError } from "../../../core/errors/errors.js"; import { removeTrailingForwardSlash } from "../../../core/path.js"; import { apiContextRoutesSymbol } from "../../../core/render-context.js"; import { ACTION_RPC_ROUTE_PATTERN } from "../../consts.js"; import { ACTION_API_CONTEXT_SYMBOL, formContentTypes, hasContentType, isActionAPIContext } from "../utils.js"; import { getAction } from "./get-action.js"; import { ACTION_QUERY_PARAMS, ActionError, ActionInputError, callSafely, deserializeActionResult, serializeActionResult } from "./shared.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; const baseAction = await getAction(callerInfoName); let input; try { input = await parseRequestBody(context.request); } catch (e) { if (e instanceof TypeError) { return { data: void 0, error: new ActionError({ code: "UNSUPPORTED_MEDIA_TYPE" }) }; } throw e; } const { props: _props, getActionResult: _getActionResult, callAction: _callAction, redirect: _redirect, ...actionAPIContext } = context; 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) { const contentType = request.headers.get("content-type"); const contentLength = request.headers.get("Content-Length"); if (!contentType) return void 0; if (hasContentType(contentType, formContentTypes)) { return await request.clone().formData(); } if (hasContentType(contentType, ["application/json"])) { return contentLength === "0" ? void 0 : await request.clone().json(); } throw new TypeError("Unsupported content type"); } export { defineAction, formDataToObject, getActionContext };