diff --git a/proposals/0046-actions.md b/proposals/0046-actions.md new file mode 100644 index 00000000..f053a881 --- /dev/null +++ b/proposals/0046-actions.md @@ -0,0 +1,799 @@ +Start Date: 2024-05-01 + +- Reference Issues: https://github.com/withastro/roadmap/issues/898 +- Implementation PR: https://github.com/withastro/astro/pull/10858 + +# Summary + +Astro actions make it easy to define and call backend functions with type-safety. + +# Example + +Define Astro actions in a `src/actions/index.ts` file. Create a new action with the `defineAction()` utility, specifying a backend `handler` function and type-checked arguments using an `input` schema. All actions are exported from a `server` object: + +```ts +import { defineAction, z } from "astro:actions"; + +export const server = { + like: defineAction({ + // accept json + input: z.object({ postId: z.string() }), + handler: async ({ postId }) => { + // update likes in db + + return likes; + }, + }), + comment: defineAction({ + // accept form requests + accept: "form", + input: z.object({ + postId: z.string(), + author: z.string(), + body: z.string(), + }), + handler: async ({ postId }) => { + // insert comments in db + + return comment; + }, + }), +}; +``` + +Then, call an action from your client components using the `actions` object from `astro:actions`. You can pass a type-safe object when using JSON, or a `FormData` object when using `accept: 'form'` in your action definition: + +```tsx +// src/components/blog.tsx +import { actions } from "astro:actions"; +import { useState } from "preact/hooks"; + +export function Like({ postId }: { postId: string }) { + const [likes, setLikes] = useState(0); + return ( + + ); +} + +export function Comment({ postId }: { postId: string }) { + return ( +
+ ); +} +``` + +# Background & Motivation + +Form submissions are a core building block of the web that Astro has yet to form an opinion on (pun intended). + +So far, Astro has been rewarded for waiting on the platform and the Astro community to mature before designing a primitive. By waiting on view transitions, we found a SPA-like routing solution grounded in native APIs. By waiting on libSQL, we found a data storage solution for content sites and apps alike. Now, we've waited on other major frameworks to forge new paths with form actions. This includes Remix actions, SvelteKit actions, React server actions, and more. + +At the same time, Astro just launched its database primitive: Astro DB. This is propelling the Astro community from static content to more dynamic use cases, including like counters and comment widgets. To meet our community where it's heading, Astro needs to make backend functions simple. + +# Goals + +- **You no longer need boilerplate to safely parse the request body** based on the `Content-Type`. +- **You can pass JSON or FormData payloads to an action.** +- **You no longer need boilerplate to retrieve form data values** from the request body. The action should be able to enumerate expected input names, and the handler should be able to retrieve these values without a type cast. In other words, no more bangs `!` or `as string` casts as in the example `formData.get('expected')! as string`. +- **You can call an action using a standard HTML `form` element**. +- **You can use client JavaScript to call an action** using scripts or islands. When doing so, data returned by the action is type-safe without type casting. Note: This should consider form handling in popular component frameworks, like the [`useActionState()`](https://react.dev/reference/react-dom/hooks/useFormState) and [`useFormStatus()`](https://react.dev/reference/react-dom/hooks/useFormStatus) hooks in React 19. +- **You can declare actions as an endpoint** to call from prerendered / static pages. + +# Non-Goals + +- **A solution to client-side validation.** Validation is a major piece to forms with several community libraries to choose from (ex. react-hook-form). Astro may recommend standard HTML attributes for client validation including the `required` and `type` properties on an input. +- Declaring actions within `.astro` frontmatter. Frontmatter forms a function closure, which can lead to misuse of variables within an action handler. This challenge is shared by `getStaticPaths()` and it would be best to avoid repeating this pattern in future APIs. + +# Detailed Design + +All actions are declared in a global actions handler: the `src/actions.ts` file. Similar to Astro middleware, users may switch to using `src/actions/index.ts` to store related code in a directory. + +You can define an action using the `defineAction()` utility from the `astro:actions` module. This accepts the `handler` property to define your server-side request handler. If your action accepts arguments, apply the `schema` property to validate parameters with Zod. + +This example defines a `like` action, which accepts a `postId` as a string and updates the like count on a related database entry. That action is exposed for use in your application with an exported `server` object passing `like` as a property: + +```ts +// src/actions/index.ts +import { defineAction, z } from "astro:actions"; +import { db, Likes, eq, sql } from "astro:db"; + +export const server = { + like: defineAction({ + input: z.object({ postId: z.string() }), + handler: async ({ postid }) => { + const { likes } = await db + .update(Likes) + .set({ + likes: sql`likes + 1`, + }) + .where(eq(Likes.postId, postId)) + .returning() + .get(); + return likes; + }, + }), +}; +``` + +Now, this action is callable from client components and server forms. + +> **@bholmesdev Note:** Users will call actions by importing an `actions` object from the `astro:actions` virtual module. To avoid misleading auto-import suggestions, we use the name `server` instead of `actions` for server code. + +## Call actions from a client component + +To call an action from a client component, you can import the `actions` object from `astro:actions`. This will include `like()` as a function, which accepts type-safe arguments as a JSON object. The return value will be an object containing either `data` with the action result, or `error` with any validation error or custom exception. + +Implementation: When imported on the client, the `actions` object will _not_ expose server code. It will instead expose a proxy object that calls a given action using a `fetch()` call. The object path will mirror the request URL that is fetched. For example, a call to `actions.like()` will generate a fetch to the URL `/_actions/like`. For nested objects like `actions.blog.like()`, dot chaining is used: `/_actions/blog.like`. The request is routed to your action using an injected handler at `/_actions/[...path]`. + +This example uses Preact to call the action on a button press and tracks the current likes with `useState()`: + +```tsx +// src/components/Like.tsx +import { actions } from "astro:actions"; +import { useState } from "preact/hooks"; + +export function Like({ postId }: { postId: string }) { + const [likes, setLikes] = useState(0); + return ( + + ); +} +``` + +To retrieve `data` directly and allow errors to throw, you can add `orThrow()` following the format `actions.NAME.orThrow(input)`: + +```ts +const newLikes = await actions.like.orThrow({ postId }); +setLikes(newLikes); +``` + + +## Handle form requests + +Actions are callable with JSON objects by default. For more extensive forms, you may prefer to submit fields using [the web-standard FormData object](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects). This object includes all `input` element values within a given `form`, and is the default argument using form actions in React 19. + +To accept form data from an action, define a new action with the `accept` property set to `'form'`. You can validate the action `input` using the same Zod `object()` function you would use for a JSON action. In this example, we will create a `comment` action that expects a `postId`, `author`, and `body` as string fields on a form: + +```ts +// src/actions/index.ts +import { defineAction, z } from "astro:actions"; + +export const server = { + comment: defineAction({ + accept: "form", + input: z.object({ + postId: z.string(), + author: z.string().optional(), + body: z.string(), + }), + handler: async ({ postId, author, body }) => { + // persist comment in a database + }, + }), +}; +``` + +You can also handle an untyped `FormData` object by omitting the `input` argument. + +See the [Form API complete reference](#form-api-complete-reference) for all Zod validators we support. + +### Call actions with form data from a client component + +You can call an action with `FormData` using a client component. This example uses a Preact component with a form `onSubmit` function to create a new `FormData` object. The form includes input names that match the expected properties of our `comment` action, with a hidden input for `postId`, a text input for `author`, and a `textarea` for a long-form `body`: + +```tsx +import { actions } from "astro:actions"; + +// src/components/Comment.tsx +export function Comment({ postId }: { postId: string }) { + return ( + + ); +} +``` + +### Call actions from an HTML form action + +Note: Pages must be server rendered when calling actions using a form action. [Ensure prerendering is disabled on the page](/en/guides/server-side-rendering/#opting-out-of-pre-rendering-in-hybrid-mode) before using this API. + +You may want to pass `FormData` using a standard HTML form as well. This is useful as a fallback for client forms during slow internet connections or older devices. You may also prefer to handle forms entirely from the server using an Astro component. + +To use a standard form request, add `method="POST"` as a form attribute to any ` +``` + +You can also re-render the current page with the result by passing an action function directly: + +```astro title="src/pages/index.astro" +--- +import { actions } from 'astro:actions'; +--- + + + +``` + +#### Handle form action errors + +Astro avoids redirecting to your success route when an action fails. Instead, the current page is re-rendered with `error` available via `Astro.getActionResult()`. This function receives the action you want the result for as an argument (example: `Astro.getActionResult(actions.signup)`). This return an object with either `data` or `error` when an action is called, and `undefined` otherwise. + +Note: `getActionResult()` persists data as a single-use cookie. This means `getActionResult()` will return `undefined` when refreshing the page or revisiting the form. + +You can use the `isInputError()` utility to check whether an error contains validation errors. If the check passes, `error` will contain a `fields` object containing error messages for each input value that failed to validate. These messages can then be displayed to prompt your user to correct their submission. + +This example renders an error banner under the `email` input when an invalid email is submitted: + +```astro title="src/pages/index.astro" ins={6,11} +--- +// src/pages/index.astro +import { actions, isInputError } from 'astro:actions'; + +const result = Astro.getActionResult(actions.newsletter); +const inputErrors = isInputError(result?.error) ? result.error.fields : {}; +--- + + +``` + +##### Preserve input values on error + +Inputs will be cleared whenever a form is submitted. To persist input values, you can [enable view transitions](/en/guides/view-transitions/#adding-view-transitions-to-a-page) on the page and apply the `transition:persist` directive to each input: + +```astro ins="transition:persist" + +``` + +#### Redirect to a constructed route on success + +You may need to use the result of an action to construct a redirect path. This is common when creating a product record and redirecting to that product's page (example: `/products/[id]`). + +To create a redirect, call `Astro.getActionResult()` from the Astro component that received the action call. Check if a given action has returned successfully, and if so, redirect based on the `data` the action returns. + +For example, say you have a `createProduct()` action that returns the generated product id: + +```ts title="src/actions/index.ts" mark={10} +import { defineAction, z } from 'astro:actions'; + +export const server = { + createProduct: defineAction({ + accept: 'form', + input: z.object({ /* ... */ }), + handler: async (input) => { + // persist product to database + return productId; + }, + }) +} +``` + +Retrieve this generated `productId` from the `data` property returned by `Astro.getActionResult(actions.createProduct)`. Use this value to construct an url and return a `redirect` response: + +```astro title="src/pages/products/create.astro" +--- +import { actions } from 'astro:actions'; + +const result = Astro.getActionResult(actions.createProduct); +if (result?.data?.productId) { + return Astro.redirect(`/products/${result.data.productId}`); +} +--- + + +``` + +#### Update the UI with a form action result + +The result returned by `Astro.getActionResult()` is single-use, and will reset to `undefined` whenever the page is refreshed. This is ideal for [displaying input errors](#handle-form-action-errors) and showing temporary success banners. + +Call `Astro.getActionResult()` with a given action function, and use the `data` property to render a success message. This example uses the `productName` property returned by an `addToCart` action: + +```astro title="src/pages/products/[slug].astro" +--- +import { actions } from 'astro:actions'; + +const result = Astro.getActionResult(actions.addToCart); +--- + +{result.data?.productName &&Added {result.data.productName} to cart
} + + +``` + +⚠️ Warning: Action data is passed using a persisted cookie. **This cookie is not encrypted.** In general, we recommend returning the minimum information required from your action `handler` to avoid vulnerabilities, and persist other sensitive information in a database. + +For example, you might return the name of a product in an `addToCart` action, rather than returning the entire `product` object: + +```diff title="src/actions/index.ts" +import { defineAction } from 'astro:actions'; + +export const server = { + addToCard: defineAction({ + handler: async () => { + /* ... */ +- return product; ++ return { productName: product.name }; + } + }) +} +``` + + +### Call actions using React 19 form actions + +Astro actions are fully compatible with React 19 form actions. This includes automatic enhancement to submit your form in zero-JS scenarios. To use React 19 in your Astro project, [follow the React 19 beta installation guide.](https://react.dev/blog/2024/04/25/react-19-upgrade-guide#installing) + +This example uses an Astro Action called `like()` that accepts a `postId` and returns the number of likes. With React 19, you can pass this action to the `useActionState()` hook to track the result. Apply the `experimental_withState()` function (to be renamed to `withState()` when stable) to apply progressive enhancement information: + +```tsx +// src/components/Like.tsx +import { actions } from 'astro:actions'; +import { useActionState } from 'react'; +import { experimental_withState } from '@astrojs/react/actions'; + +export function Like({ postId }: { postId: string }) { + const [{ data, error }, action, pending] = useActionState( + experimental_withState(actions.like), + { data: 0 }, // initial likes + ); + + return ( + + ); +} +``` + +Note: Chaining `orThrow()` on a function will _disable_ progressive enhancement. This is because uncaught exceptions must be handled from the client. + +Implementation: `withState()` updates an action to match the expected signature for `useActionState()`: `(state, formData) => any`. The `state` field is applied as a stringified `formData` property to retrieve in your action `handler` as-needed. `withState()` also ensures progressive enhancement metadata is applied. + +You can also access the state stored by `useActionState()` from your action `handler`. Call `experimental_getActionState()` with the API context, and optionally apply a type to the result: + +```ts +import { defineAction, type ActionError, z } from 'astro:actions'; +import { experimental_getActionState } from '@astrojs/react/actions'; + +export const server = { + like: defineAction({ + input: z.object({ + postId: z.string(), + }), + handler: async ({ postId }, ctx) => { + const state = experimental_getActionState<{ data: number } | { error: ActionError}>(ctx); + // write to database + return state.data + 1; + } + }) +} +``` + +If you don't need to track the state, you can also apply Astro Actions directly to the `action` property on any React ` + ); +} +``` + +### Handle input validation errors + +Your `input` schema gives you type safety wherever you call your action. Still, you may have further refinements in your Zod schema that can raise a validation error at runtime. + +You can check if an `error` is caused by input validation by using the `isInputError()` function. This will expose the `issues` property with a list of validation errors. It also exposes the `fields` property to get error messages by key when the input is an object. + +This example shows an error message if a comment's `body` field is invalid: + +```tsx +// src/components/Comment.tsx +import { actions, isInputError } from "astro:actions"; +import { useState } from "preact/hooks"; + +export function Comment({ postId }: { postId: string }) { + const [bodyError, setBodyError] = useState(null); + return ( + + ); +} +``` + +## Custom errors + +You may need to raise an exception from your action `handler()`. For this, `astro:actions` provides an `ActionError` object. This includes a `code` to set a human-readable status code like `'BAD_REQUEST'` or `'FORBIDDEN'`. These match the codes supplied by the [tRPC error object.](https://trpc.io/docs/server/error-handling) `ActionError` also includes an optional `message` property to pass information about the error. + +This example creates an `updateUsername` action, and raises an `ActionError` with the code `NOT_FOUND` when a given user ID is not found: + +```ts +import { defineAction, ActionError } from "astro:actions"; +import { db, User, eq } from "astro:db"; + +export const server = { + updateUsername: defineAction({ + input: z.object({ id: z.string(), name }), + handler: async ({ id, name }) => { + const result = await db.update(User).set({ name }).where(eq(User.id, id)); + if (result.rowsAffected === 0) { + throw new ActionError({ + code: "NOT_FOUND", + message: `User with id ${id} not found.`, + }); + } + return id; + }, + }), +}; +``` + +To handle this error, you can call your action and check whether an `error` is present. This property will be of type `ActionError`. + +## Access API Context + +You can access the Astro API context from the second parameter to your action `handler()`. This grants access to the base `request` object, cookies, middleware `locals` and more. + +This example gates `getUser()` requests by checking for a session cookie. If the user is unauthorized, you can raise a "bad request" error for the client to handle. + +```ts +// src/actions/index.ts +import { defineAction, ActionError, z } from "astro:actions"; +import { db, Comment, Likes, eq, sql } from "astro:db"; + +export const server = { + getUsers: defineAction({ + handler: async (_, context) => { + if (!context.cookies.has('expected-session')) { + throw new ActionError({ + code: "UNAUTHORIZED", + }); + } + // return users + }, + }), +}; +``` + +## Call actions directly from server code + +You may need to call an action handler directly from server pages and endpoints. To do so, use the `Astro.callAction()` function. Pass the function you want to call as the first parameter, and any input arguments as the second parameter: + +```astro +--- +import { actions } from 'astro:actions'; + +const result = await Astro.callAction(actions.searchPosts, { + searchTerm: Astro.url.searchParams.get('search'), +}); +--- + +{result.data && ( + {/* render the results */} +)} +``` + +## Integration actions + +Astro integrations can inject routes and middleware. We'd like to expand this pattern for actions as well. + +To inject an action, integrations can use the `injectAction()` utility on the `astro:config:setup` hook. This accepts an `entrypoint` with the path to your action handler. Use a relative path for local integrations, or the package export name (ex. `@my-integration/comments/entrypoint`) for integrations installed as a package. + +If you are creating an npm package, apply the `astro/client` types reference to an `env.d.ts` in your project. This will define types for the `astro:actions` module: + +```ts +// my-integration/src/env.d.ts + +///