diff options
Diffstat (limited to 'packages/astro/templates/actions.mjs')
-rw-r--r-- | packages/astro/templates/actions.mjs | 130 |
1 files changed, 130 insertions, 0 deletions
diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs new file mode 100644 index 000000000..b39991a65 --- /dev/null +++ b/packages/astro/templates/actions.mjs @@ -0,0 +1,130 @@ +import { + ACTION_QUERY_PARAMS, + ActionError, + appendForwardSlash, + astroCalledServerError, + deserializeActionResult, + getActionQueryString, +} from 'astro:actions'; + +const apiContextRoutesSymbol = Symbol.for('context.routes'); +const ENCODED_DOT = '%2E'; + +function toActionProxy(actionCallback = {}, aggregatedPath = '') { + return new Proxy(actionCallback, { + get(target, objKey) { + if (objKey in target || typeof objKey === 'symbol') { + return target[objKey]; + } + // Add the key, encoding dots so they're not interpreted as nested properties. + const path = + aggregatedPath + encodeURIComponent(objKey.toString()).replaceAll('.', ENCODED_DOT); + function action(param) { + return handleAction(param, path, this); + } + + Object.assign(action, { + queryString: getActionQueryString(path), + toString: () => action.queryString, + // Progressive enhancement info for React. + $$FORM_ACTION: function () { + const searchParams = new URLSearchParams(action.toString()); + return { + method: 'POST', + // `name` creates a hidden input. + // It's unused by Astro, but we can't turn this off. + // At least use a name that won't conflict with a user's formData. + name: '_astroAction', + action: '?' + searchParams.toString(), + }; + }, + // Note: `orThrow` does not have progressive enhancement info. + // If you want to throw exceptions, + // you must handle those exceptions with client JS. + async orThrow(param) { + const { data, error } = await handleAction(param, path, this); + if (error) throw error; + return data; + }, + }); + + // recurse to construct queries for nested object paths + // ex. actions.user.admins.auth() + return toActionProxy(action, path + '.'); + }, + }); +} + +const SHOULD_APPEND_TRAILING_SLASH = '/** @TRAILING_SLASH@ **/'; + +/** @param {import('astro:actions').ActionClient<any, any, any>} */ +export function getActionPath(action) { + let path = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${new URLSearchParams(action.toString()).get(ACTION_QUERY_PARAMS.actionName)}`; + if (SHOULD_APPEND_TRAILING_SLASH) { + path = appendForwardSlash(path); + } + return path; +} + +/** + * @param {*} param argument passed to the action when called server or client-side. + * @param {string} path Built path to call action by path name. + * @param {import('../dist/types/public/context.js').APIContext | undefined} context Injected API context when calling actions from the server. + * Usage: `actions.[name](param)`. + * @returns {Promise<import('../dist/actions/runtime/virtual/shared.js').SafeResult<any, any>>} + */ +async function handleAction(param, path, context) { + // When running server-side, import the action and call it. + if (import.meta.env.SSR && context) { + const pipeline = Reflect.get(context, apiContextRoutesSymbol); + if (!pipeline) { + throw astroCalledServerError(); + } + const action = await pipeline.getAction(path); + if (!action) throw new Error(`Action not found: ${path}`); + return action.bind(context)(param); + } + + // When running client-side, make a fetch request to the action path. + const headers = new Headers(); + headers.set('Accept', 'application/json'); + let body = param; + if (!(body instanceof FormData)) { + try { + body = JSON.stringify(param); + } catch (e) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: `Failed to serialize request body to JSON. Full error: ${e.message}`, + }); + } + if (body) { + headers.set('Content-Type', 'application/json'); + } else { + headers.set('Content-Length', '0'); + } + } + const rawResult = await fetch( + getActionPath({ + toString() { + return getActionQueryString(path); + }, + }), + { + method: 'POST', + body, + headers, + }, + ); + + if (rawResult.status === 204) { + return deserializeActionResult({ type: 'empty', status: 204 }); + } + + return deserializeActionResult({ + type: rawResult.ok ? 'data' : 'error', + body: await rawResult.text(), + }); +} + +export const actions = toActionProxy(); |