diff --git a/.changeset/shaggy-moons-peel.md b/.changeset/shaggy-moons-peel.md
new file mode 100644
index 0000000000..db500d5e5c
--- /dev/null
+++ b/.changeset/shaggy-moons-peel.md
@@ -0,0 +1,95 @@
+---
+"astro": minor
+---
+
+Adds experimental support for the Actions API. Actions let you define type-safe endpoints you can query from client components with progressive enhancement built in.
+
+
+Actions help you write type-safe backend functions you can call from anywhere. Enable server rendering [using the `output` property](https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered) and add the `actions` flag to the `experimental` object:
+
+```js
+{
+ output: 'hybrid', // or 'server'
+ experimental: {
+ actions: true,
+ },
+}
+```
+
+Declare all your actions in `src/actions/index.ts`. This file is the global actions handler.
+
+Define an action using the `defineAction()` utility from the `astro:actions` module. These accept the `handler` property to define your server-side request handler. If your action accepts arguments, apply the `input` property to validate parameters with Zod.
+
+This example defines two actions: `like` and `comment`. The `like` action accepts a JSON object with a `postId` string, while the `comment` action accepts [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) with `postId`, `author`, and `body` strings. Each `handler` updates your database and return a type-safe response.
+
+```ts
+// src/actions/index.ts
+import { defineAction, z } from "astro:actions";
+
+export const server = {
+ like: defineAction({
+ input: z.object({ postId: z.string() }),
+ handler: async ({ postId }, context) => {
+ // update likes in db
+
+ return likes;
+ },
+ }),
+ comment: defineAction({
+ accept: 'form',
+ input: z.object({
+ postId: z.string(),
+ author: z.string(),
+ body: z.string(),
+ }),
+ handler: async ({ postId }, context) => {
+ // 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](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) object when using `accept: 'form'` in your action definition:
+
+```tsx "actions"
+// 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 (
+ {
+ const newLikes = await actions.like({ postId });
+ setLikes(newLikes);
+ }}
+ >
+ {likes} likes
+
+ );
+}
+
+export function Comment({ postId }: { postId: string }) {
+ return (
+
+ );
+}
+```
+
+For a complete overview, and to give feedback on this experimental API, see the [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md).
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index 9128e9dd0c..f81d652e38 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -1,5 +1,6 @@
///
///
+///
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace App {
diff --git a/packages/astro/e2e/actions-blog.test.js b/packages/astro/e2e/actions-blog.test.js
new file mode 100644
index 0000000000..b98f74143e
--- /dev/null
+++ b/packages/astro/e2e/actions-blog.test.js
@@ -0,0 +1,58 @@
+import { expect } from '@playwright/test';
+import { testFactory } from './test-utils.js';
+
+const test = testFactory({ root: './fixtures/actions-blog/' });
+
+let devServer;
+
+test.beforeAll(async ({ astro }) => {
+ devServer = await astro.startDevServer();
+});
+
+test.afterAll(async () => {
+ await devServer.stop();
+});
+
+test.describe('Astro Actions - Blog', () => {
+ test('Like action', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/blog/first-post/'));
+
+ const likeButton = page.getByLabel('Like');
+ await expect(likeButton, 'like button starts with 10 likes').toContainText('10');
+ await likeButton.click();
+ await expect(likeButton, 'like button should increment likes').toContainText('11');
+ });
+
+ test('Comment action - validation error', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/blog/first-post/'));
+
+ const authorInput = page.locator('input[name="author"]');
+ const bodyInput = page.locator('textarea[name="body"]');
+
+ await authorInput.fill('Ben');
+ await bodyInput.fill('Too short');
+
+ const submitButton = page.getByLabel('Post comment');
+ await submitButton.click();
+
+ await expect(page.locator('p[data-error="body"]')).toBeVisible();
+ });
+
+ test('Comment action - success', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/blog/first-post/'));
+
+ const authorInput = page.locator('input[name="author"]');
+ const bodyInput = page.locator('textarea[name="body"]');
+
+ const body = 'This should be long enough.';
+ await authorInput.fill('Ben');
+ await bodyInput.fill(body);
+
+ const submitButton = page.getByLabel('Post comment');
+ await submitButton.click();
+
+ const comment = await page.getByTestId('comment');
+ await expect(comment).toBeVisible();
+ await expect(comment).toContainText(body);
+ });
+});
diff --git a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
new file mode 100644
index 0000000000..acbed1768b
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
@@ -0,0 +1,17 @@
+import { defineConfig } from 'astro/config';
+import db from '@astrojs/db';
+import react from '@astrojs/react';
+import node from '@astrojs/node';
+
+// https://astro.build/config
+export default defineConfig({
+ site: 'https://example.com',
+ integrations: [db(), react()],
+ output: 'hybrid',
+ adapter: node({
+ mode: 'standalone',
+ }),
+ experimental: {
+ actions: true,
+ },
+});
diff --git a/packages/astro/e2e/fixtures/actions-blog/db/config.ts b/packages/astro/e2e/fixtures/actions-blog/db/config.ts
new file mode 100644
index 0000000000..da005471e1
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/db/config.ts
@@ -0,0 +1,21 @@
+import { column, defineDb, defineTable } from "astro:db";
+
+const Comment = defineTable({
+ columns: {
+ postId: column.text(),
+ author: column.text(),
+ body: column.text(),
+ },
+});
+
+const Likes = defineTable({
+ columns: {
+ postId: column.text(),
+ likes: column.number(),
+ },
+});
+
+// https://astro.build/db/config
+export default defineDb({
+ tables: { Comment, Likes },
+});
diff --git a/packages/astro/e2e/fixtures/actions-blog/db/seed.ts b/packages/astro/e2e/fixtures/actions-blog/db/seed.ts
new file mode 100644
index 0000000000..11dc55f7fe
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/db/seed.ts
@@ -0,0 +1,15 @@
+import { db, Likes, Comment } from "astro:db";
+
+// https://astro.build/db/seed
+export default async function seed() {
+ await db.insert(Likes).values({
+ postId: "first-post.md",
+ likes: 10,
+ });
+
+ await db.insert(Comment).values({
+ postId: "first-post.md",
+ author: "Alice",
+ body: "Great post!",
+ });
+}
diff --git a/packages/astro/e2e/fixtures/actions-blog/package.json b/packages/astro/e2e/fixtures/actions-blog/package.json
new file mode 100644
index 0000000000..0c69e9a98b
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@e2e/astro-actions-basics",
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "astro dev",
+ "start": "astro dev",
+ "build": "astro check && astro build",
+ "preview": "astro preview",
+ "astro": "astro"
+ },
+ "dependencies": {
+ "@astrojs/check": "^0.5.10",
+ "@astrojs/db": "workspace:*",
+ "@astrojs/node": "workspace:*",
+ "@astrojs/react": "workspace:*",
+ "@types/react": "^18.2.79",
+ "@types/react-dom": "^18.2.25",
+ "astro": "workspace:*",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
new file mode 100644
index 0000000000..4574caaaf5
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
@@ -0,0 +1,45 @@
+import { db, Comment, Likes, eq, sql } from 'astro:db';
+import { defineAction, z } from 'astro:actions';
+
+export const server = {
+ blog: {
+ like: defineAction({
+ input: z.object({ postId: z.string() }),
+ handler: async ({ postId }) => {
+ await new Promise((r) => setTimeout(r, 200));
+
+ const { likes } = await db
+ .update(Likes)
+ .set({
+ likes: sql`likes + 1`,
+ })
+ .where(eq(Likes.postId, postId))
+ .returning()
+ .get();
+
+ return likes;
+ },
+ }),
+
+ comment: defineAction({
+ accept: 'form',
+ input: z.object({
+ postId: z.string(),
+ author: z.string(),
+ body: z.string().min(10),
+ }),
+ handler: async ({ postId, author, body }) => {
+ const comment = await db
+ .insert(Comment)
+ .values({
+ postId,
+ body,
+ author,
+ })
+ .returning()
+ .get();
+ return comment;
+ },
+ }),
+ },
+};
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/BaseHead.astro b/packages/astro/e2e/fixtures/actions-blog/src/components/BaseHead.astro
new file mode 100644
index 0000000000..344124012b
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/BaseHead.astro
@@ -0,0 +1,47 @@
+---
+// Import the global.css file here so that it is included on
+// all pages through the use of the component.
+import '../styles/global.css';
+
+interface Props {
+ title: string;
+ description: string;
+ image?: string;
+}
+
+const canonicalURL = new URL(Astro.url.pathname, Astro.site);
+
+const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/Footer.astro b/packages/astro/e2e/fixtures/actions-blog/src/components/Footer.astro
new file mode 100644
index 0000000000..96c2fce912
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/Footer.astro
@@ -0,0 +1,62 @@
+---
+const today = new Date();
+---
+
+
+ © {today.getFullYear()} Your name here. All rights reserved.
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/FormattedDate.astro b/packages/astro/e2e/fixtures/actions-blog/src/components/FormattedDate.astro
new file mode 100644
index 0000000000..1bcce73a2b
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/FormattedDate.astro
@@ -0,0 +1,17 @@
+---
+interface Props {
+ date: Date;
+}
+
+const { date } = Astro.props;
+---
+
+
+ {
+ date.toLocaleDateString('en-us', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+ }
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/Header.astro b/packages/astro/e2e/fixtures/actions-blog/src/components/Header.astro
new file mode 100644
index 0000000000..71b8cdc55c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/Header.astro
@@ -0,0 +1,83 @@
+---
+import HeaderLink from './HeaderLink.astro';
+import { SITE_TITLE } from '../consts';
+---
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/HeaderLink.astro b/packages/astro/e2e/fixtures/actions-blog/src/components/HeaderLink.astro
new file mode 100644
index 0000000000..bb600fb65a
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/HeaderLink.astro
@@ -0,0 +1,25 @@
+---
+import type { HTMLAttributes } from 'astro/types';
+
+type Props = HTMLAttributes<'a'>;
+
+const { href, class: className, ...props } = Astro.props;
+
+const { pathname } = Astro.url;
+const subpath = pathname.match(/[^\/]+/g);
+const isActive = href === pathname || href === '/' + subpath?.[0];
+---
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/Like.tsx b/packages/astro/e2e/fixtures/actions-blog/src/components/Like.tsx
new file mode 100644
index 0000000000..7d4e6a53d1
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/Like.tsx
@@ -0,0 +1,22 @@
+import { actions } from 'astro:actions';
+import { useState } from 'react';
+
+export function Like({ postId, initial }: { postId: string; initial: number }) {
+ const [likes, setLikes] = useState(initial);
+ const [pending, setPending] = useState(false);
+
+ return (
+ {
+ setPending(true);
+ setLikes(await actions.blog.like({ postId }));
+ setPending(false);
+ }}
+ type="submit"
+ >
+ {likes} ❤️
+
+ );
+}
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx b/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx
new file mode 100644
index 0000000000..1b0d10a063
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx
@@ -0,0 +1,66 @@
+import { getActionProps, actions, isInputError } from 'astro:actions';
+import { useState } from 'react';
+
+export function PostComment({
+ postId,
+ serverBodyError,
+}: {
+ postId: string;
+ serverBodyError?: string;
+}) {
+ const [comments, setComments] = useState<{ author: string; body: string }[]>([]);
+ const [bodyError, setBodyError] = useState(serverBodyError);
+
+ return (
+ <>
+ {
+ e.preventDefault();
+ const form = e.target as HTMLFormElement;
+ const formData = new FormData(form);
+ const { data, error } = await actions.blog.comment.safe(formData);
+ if (isInputError(error)) {
+ return setBodyError(error.fields.body?.join(' '));
+ }
+ if (data) {
+ setBodyError(undefined);
+ setComments((c) => [data, ...c]);
+ }
+ form.reset();
+ }}
+ >
+
+
+
+ Author
+
+
+
+ {bodyError && (
+
+ {bodyError}
+
+ )}
+
+ Post
+
+
+ {comments.map((c) => (
+
+ {c.body}
+ {c.author}
+
+ ))}
+ >
+ );
+}
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/consts.ts b/packages/astro/e2e/fixtures/actions-blog/src/consts.ts
new file mode 100644
index 0000000000..0df8a61f4c
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/consts.ts
@@ -0,0 +1,5 @@
+// Place any global data in this file.
+// You can import this data from anywhere in your site by using the `import` keyword.
+
+export const SITE_TITLE = 'Astro Blog';
+export const SITE_DESCRIPTION = 'Welcome to my website!';
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/content/blog/first-post.md b/packages/astro/e2e/fixtures/actions-blog/src/content/blog/first-post.md
new file mode 100644
index 0000000000..ee51f15410
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/content/blog/first-post.md
@@ -0,0 +1,15 @@
+---
+title: 'First post'
+description: 'Lorem ipsum dolor sit amet'
+pubDate: 'Jul 08 2022'
+---
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
+
+Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
+
+Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
+
+Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
+
+Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/content/config.ts b/packages/astro/e2e/fixtures/actions-blog/src/content/config.ts
new file mode 100644
index 0000000000..667a31cc73
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/content/config.ts
@@ -0,0 +1,16 @@
+import { defineCollection, z } from 'astro:content';
+
+const blog = defineCollection({
+ type: 'content',
+ // Type-check frontmatter using a schema
+ schema: z.object({
+ title: z.string(),
+ description: z.string(),
+ // Transform string to Date object
+ pubDate: z.coerce.date(),
+ updatedDate: z.coerce.date().optional(),
+ heroImage: z.string().optional(),
+ }),
+});
+
+export const collections = { blog };
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/layouts/BlogPost.astro b/packages/astro/e2e/fixtures/actions-blog/src/layouts/BlogPost.astro
new file mode 100644
index 0000000000..e67b2b30f8
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/layouts/BlogPost.astro
@@ -0,0 +1,85 @@
+---
+import type { CollectionEntry } from 'astro:content';
+import BaseHead from '../components/BaseHead.astro';
+import Header from '../components/Header.astro';
+import Footer from '../components/Footer.astro';
+import FormattedDate from '../components/FormattedDate.astro';
+
+type Props = CollectionEntry<'blog'>['data'];
+
+const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
+---
+
+
+
+
+
+
+
+
+
+
+
+
+ {heroImage &&
}
+
+
+
+
+
+ {
+ updatedDate && (
+
+ Last updated on
+
+ )
+ }
+
+
{title}
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro
new file mode 100644
index 0000000000..a571a7cbe6
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro
@@ -0,0 +1,61 @@
+---
+import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
+import BlogPost from '../../layouts/BlogPost.astro';
+import { db, eq, Comment, Likes } from 'astro:db';
+import { Like } from '../../components/Like';
+import { PostComment } from '../../components/PostComment';
+import { actions } from 'astro:actions';
+import { isInputError } from 'astro:actions';
+
+export const prerender = false;
+
+export async function getStaticPaths() {
+ const posts = await getCollection('blog');
+ return posts.map((post) => ({
+ params: { slug: post.slug },
+ props: post,
+ }));
+}
+
+type Props = CollectionEntry<'blog'>;
+
+const post = await getEntry('blog', Astro.params.slug)!;
+const { Content } = await post.render();
+
+const comment = Astro.getActionResult(actions.blog.comment);
+
+const comments = await db.select().from(Comment).where(eq(Comment.postId, post.id));
+
+const initialLikes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).get();
+---
+
+
+
+
+
+
+ Comments
+
+
+ {
+ comments.map((c) => (
+
+ {c.body}
+ {c.author}
+
+ ))
+ }
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/index.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/index.astro
new file mode 100644
index 0000000000..ebbcd30427
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/index.astro
@@ -0,0 +1,111 @@
+---
+import BaseHead from '../../components/BaseHead.astro';
+import Header from '../../components/Header.astro';
+import Footer from '../../components/Footer.astro';
+import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';
+import { getCollection } from 'astro:content';
+import FormattedDate from '../../components/FormattedDate.astro';
+
+const posts = (await getCollection('blog')).sort(
+ (a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
+);
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/styles/global.css b/packages/astro/e2e/fixtures/actions-blog/src/styles/global.css
new file mode 100644
index 0000000000..757a8a07f4
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/styles/global.css
@@ -0,0 +1,140 @@
+/*
+ The CSS in this style tag is based off of Bear Blog's default CSS.
+ https://github.com/HermanMartinus/bearblog/blob/297026a877bc2ab2b3bdfbd6b9f7961c350917dd/templates/styles/blog/default.css
+ License MIT: https://github.com/HermanMartinus/bearblog/blob/master/LICENSE.md
+ */
+
+:root {
+ --accent: #2337ff;
+ --accent-dark: #000d8a;
+ --black: 15, 18, 25;
+ --gray: 96, 115, 159;
+ --gray-light: 229, 233, 240;
+ --gray-dark: 34, 41, 57;
+ --gray-gradient: rgba(var(--gray-light), 50%), #fff;
+ --box-shadow: 0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%),
+ 0 16px 32px rgba(var(--gray), 33%);
+}
+body {
+ font-family: sans-serif;
+ margin: 0;
+ padding: 0;
+ text-align: left;
+ background: linear-gradient(var(--gray-gradient)) no-repeat;
+ background-size: 100% 600px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ color: rgb(var(--gray-dark));
+ font-size: 20px;
+ line-height: 1.7;
+}
+main {
+ width: 720px;
+ max-width: calc(100% - 2em);
+ margin: auto;
+ padding: 3em 1em;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ margin: 0 0 0.5rem 0;
+ color: rgb(var(--black));
+ line-height: 1.2;
+}
+h1 {
+ font-size: 3.052em;
+}
+h2 {
+ font-size: 2.441em;
+}
+h3 {
+ font-size: 1.953em;
+}
+h4 {
+ font-size: 1.563em;
+}
+h5 {
+ font-size: 1.25em;
+}
+strong,
+b {
+ font-weight: 700;
+}
+a {
+ color: var(--accent);
+}
+a:hover {
+ color: var(--accent);
+}
+p {
+ margin-bottom: 1em;
+}
+.prose p {
+ margin-bottom: 2em;
+}
+textarea {
+ width: 100%;
+ font-size: 16px;
+}
+input {
+ font-size: 16px;
+}
+table {
+ width: 100%;
+}
+img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+}
+code {
+ padding: 2px 5px;
+ background-color: rgb(var(--gray-light));
+ border-radius: 2px;
+}
+pre {
+ padding: 1.5em;
+ border-radius: 8px;
+}
+pre > code {
+ all: unset;
+}
+blockquote {
+ border-left: 4px solid var(--accent);
+ padding: 0 0 0 20px;
+ margin: 0px;
+ font-size: 1.333em;
+}
+hr {
+ border: none;
+ border-top: 1px solid rgb(var(--gray-light));
+}
+@media (max-width: 720px) {
+ body {
+ font-size: 18px;
+ }
+ main {
+ padding: 1em;
+ }
+}
+
+.sr-only {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ position: absolute !important;
+ height: 1px;
+ width: 1px;
+ overflow: hidden;
+ /* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
+ clip: rect(1px 1px 1px 1px);
+ /* maybe deprecated but we need to support legacy browsers */
+ clip: rect(1px, 1px, 1px, 1px);
+ /* modern browsers, clip-path works inwards from each corner */
+ clip-path: inset(50%);
+ /* added line to stop words getting smushed together (as they go onto seperate lines and some screen readers do not understand line feeds as a space */
+ white-space: nowrap;
+}
diff --git a/packages/astro/e2e/fixtures/actions-blog/tsconfig.json b/packages/astro/e2e/fixtures/actions-blog/tsconfig.json
new file mode 100644
index 0000000000..6bed1f7a51
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "compilerOptions": {
+ "strictNullChecks": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "react"
+ }
+}
\ No newline at end of file
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 572d5a9863..aa5ef4a3d8 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -54,6 +54,7 @@
"./components": "./components/index.ts",
"./components/*": "./components/*",
"./toolbar": "./dist/toolbar/index.js",
+ "./actions/runtime/*": "./dist/actions/runtime/*",
"./assets": "./dist/assets/index.js",
"./assets/utils": "./dist/assets/utils/index.js",
"./assets/endpoint/*": "./dist/assets/endpoint/*.js",
@@ -85,6 +86,7 @@
"components",
"tsconfigs",
"dist",
+ "types",
"astro.js",
"index.d.ts",
"config.d.ts",
@@ -96,8 +98,8 @@
"jsx-runtime.d.ts",
"content-types.template.d.ts",
"content-module.template.mjs",
+ "templates",
"astro-jsx.d.ts",
- "types/content.d.ts",
"types.d.ts",
"README.md",
"vendor"
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index cb39ec785d..9e45bfd23c 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -46,6 +46,7 @@ import type {
} from '../transitions/events.js';
import type { DeepPartial, OmitIndexSignature, Simplify, WithRequired } from '../type-utils.js';
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
+import type { Accept, ActionClient, InputSchema } from '../actions/runtime/virtual/server.js';
export type { AstroIntegrationLogger, ToolbarServerHelpers };
@@ -239,6 +240,22 @@ export interface AstroGlobal<
response: ResponseInit & {
readonly headers: Headers;
};
+ /**
+ * Get an action result on the server when using a form POST.
+ * Expects the action function as a parameter.
+ * Returns a type-safe result with the action data when
+ * a matching POST request is received
+ * and `undefined` otherwise.
+ *
+ * Example usage:
+ *
+ * ```typescript
+ * import { actions } from 'astro:actions';
+ *
+ * const result = await Astro.getActionResult(actions.myAction);
+ * ```
+ */
+ getActionResult: AstroSharedContext['getActionResult'];
/** Redirect to another page (**SSR Only**)
*
* Example usage:
@@ -1705,6 +1722,105 @@ export interface AstroUserConfig {
*/
directRenderScript?: boolean;
+ /**
+ * @docs
+ * @name experimental.actions
+ * @type {boolean}
+ * @default `false`
+ * @version 4.7.0
+ * @description
+ *
+ * Actions help you write type-safe backend functions you can call from anywhere. Enable server rendering [using the `output` property](https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered) and add the `actions` flag to the `experimental` object:
+ *
+ * ```js
+ * {
+ * output: 'hybrid', // or 'server'
+ * experimental: {
+ * actions: true,
+ * },
+ * }
+ * ```
+ *
+ * Declare all your actions in `src/actions/index.ts`. This file is the global actions handler.
+ *
+ * Define an action using the `defineAction()` utility from the `astro:actions` module. These accept the `handler` property to define your server-side request handler. If your action accepts arguments, apply the `input` property to validate parameters with Zod.
+ *
+ * This example defines two actions: `like` and `comment`. The `like` action accepts a JSON object with a `postId` string, while the `comment` action accepts [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) with `postId`, `author`, and `body` strings. Each `handler` updates your database and return a type-safe response.
+ *
+ * ```ts
+ * // src/actions/index.ts
+ * import { defineAction, z } from "astro:actions";
+ *
+ * export const server = {
+ * like: defineAction({
+ * input: z.object({ postId: z.string() }),
+ * handler: async ({ postId }, context) => {
+ * // update likes in db
+ *
+ * return likes;
+ * },
+ * }),
+ * comment: defineAction({
+ * accept: 'form',
+ * input: z.object({
+ * postId: z.string(),
+ * author: z.string(),
+ * body: z.string(),
+ * }),
+ * handler: async ({ postId }, context) => {
+ * // 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](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) object when using `accept: 'form'` in your action definition:
+ *
+ * ```tsx "actions"
+ * // 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 (
+ * {
+ * const newLikes = await actions.like({ postId });
+ * setLikes(newLikes);
+ * }}
+ * >
+ * {likes} likes
+ *
+ * );
+ * }
+ *
+ * export function Comment({ postId }: { postId: string }) {
+ * return (
+ * {
+ * e.preventDefault();
+ * const formData = new FormData(e.target);
+ * const result = await actions.blog.comment(formData);
+ * // handle result
+ * }}
+ * >
+ *
+ * Author
+ *
+ *
+ * Post
+ *
+ * );
+ * }
+ * ```
+ *
+ * For a complete overview, and to give feedback on this experimental API, see the [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md).
+ */
+ actions?: boolean;
+
/**
* @docs
* @name experimental.contentCollectionCache
@@ -2548,6 +2664,16 @@ interface AstroSharedContext<
* A full URL object of the request URL.
*/
url: URL;
+ /**
+ * Get action result on the server when using a form POST.
+ */
+ getActionResult: <
+ TAccept extends Accept,
+ TInputSchema extends InputSchema,
+ TAction extends ActionClient,
+ >(
+ action: TAction
+ ) => Awaited> | undefined;
/**
* Route parameters for this request if this is a dynamic route.
*/
diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts
new file mode 100644
index 0000000000..ef6b87ca83
--- /dev/null
+++ b/packages/astro/src/actions/consts.ts
@@ -0,0 +1,3 @@
+export const VIRTUAL_MODULE_ID = 'astro:actions';
+export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
+export const ACTIONS_TYPES_FILE = 'actions.d.ts';
diff --git a/packages/astro/src/actions/index.ts b/packages/astro/src/actions/index.ts
new file mode 100644
index 0000000000..c2c3ebbf6c
--- /dev/null
+++ b/packages/astro/src/actions/index.ts
@@ -0,0 +1,81 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import type { AstroIntegration } from '../@types/astro.js';
+import { ACTIONS_TYPES_FILE, RESOLVED_VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID } from './consts.js';
+import type { Plugin as VitePlugin } from 'vite';
+
+export default function astroActions(): AstroIntegration {
+ return {
+ name: VIRTUAL_MODULE_ID,
+ hooks: {
+ async 'astro:config:setup'(params) {
+ const stringifiedActionsImport = JSON.stringify(
+ new URL('actions', params.config.srcDir).pathname
+ );
+ params.updateConfig({
+ vite: {
+ define: {
+ 'import.meta.env.ACTIONS_PATH': stringifiedActionsImport,
+ },
+ plugins: [vitePluginActions],
+ },
+ });
+
+ params.injectRoute({
+ pattern: '/_actions/[...path]',
+ entrypoint: 'astro/actions/runtime/route.js',
+ prerender: false,
+ });
+
+ params.addMiddleware({
+ entrypoint: 'astro/actions/runtime/middleware.js',
+ order: 'pre',
+ });
+
+ await typegen({
+ stringifiedActionsImport,
+ root: params.config.root,
+ });
+ },
+ },
+ };
+}
+
+const vitePluginActions: VitePlugin = {
+ name: VIRTUAL_MODULE_ID,
+ enforce: 'pre',
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ },
+ async load(id, opts) {
+ if (id !== RESOLVED_VIRTUAL_MODULE_ID) return;
+
+ let code = await readFile(new URL('../../templates/actions.mjs', import.meta.url), 'utf-8');
+ if (opts?.ssr) {
+ code += `\nexport * from 'astro/actions/runtime/virtual/server.js';`;
+ } else {
+ code += `\nexport * from 'astro/actions/runtime/virtual/client.js';`;
+ }
+ return code;
+ },
+};
+
+async function typegen({
+ stringifiedActionsImport,
+ root,
+}: {
+ stringifiedActionsImport: string;
+ root: URL;
+}) {
+ const content = `declare module "astro:actions" {
+ type Actions = typeof import(${stringifiedActionsImport})["server"];
+
+ export const actions: Actions;
+}`;
+
+ const dotAstroDir = new URL('.astro/', root);
+
+ await mkdir(dotAstroDir, { recursive: true });
+ await writeFile(new URL(ACTIONS_TYPES_FILE, dotAstroDir), content);
+}
diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts
new file mode 100644
index 0000000000..c0d90f26fe
--- /dev/null
+++ b/packages/astro/src/actions/runtime/middleware.ts
@@ -0,0 +1,52 @@
+import { defineMiddleware } from '../../core/middleware/index.js';
+import { ApiContextStorage } from './store.js';
+import { formContentTypes, getAction, hasContentType } from './utils.js';
+import { callSafely } from './virtual/shared.js';
+import type { APIContext, MiddlewareNext } from '../../@types/astro.js';
+
+export type Locals = {
+ _actionsInternal: {
+ getActionResult: APIContext['getActionResult'];
+ };
+};
+
+export const onRequest = defineMiddleware(async (context, next) => {
+ const locals = context.locals as Locals;
+ const { request, url } = context;
+ const contentType = request.headers.get('Content-Type');
+
+ // Avoid double-handling with middleware when calling actions directly.
+ if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, locals);
+
+ if (!contentType || !hasContentType(contentType, formContentTypes))
+ return nextWithLocalsStub(next, locals);
+
+ const formData = await request.clone().formData();
+ const actionPath = formData.get('_astroAction');
+ if (typeof actionPath !== 'string') return nextWithLocalsStub(next, locals);
+
+ const actionPathKeys = actionPath.replace('/_actions/', '').split('.');
+ const action = await getAction(actionPathKeys);
+ const result = await ApiContextStorage.run(context, () => callSafely(() => action(formData)));
+
+ const actionsInternal: Locals['_actionsInternal'] = {
+ getActionResult: (actionFn) => {
+ if (actionFn.toString() !== actionPath) return Promise.resolve(undefined);
+ // The `action` uses type `unknown` since we can't infer the user's action type.
+ // Cast to `any` to satisfy `getActionResult()` type.
+ return result as any;
+ },
+ };
+ Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
+ return next();
+});
+
+function nextWithLocalsStub(next: MiddlewareNext, locals: Locals) {
+ Object.defineProperty(locals, '_actionsInternal', {
+ writable: false,
+ value: {
+ getActionResult: () => undefined,
+ },
+ });
+ return next();
+}
diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts
new file mode 100644
index 0000000000..59b5aaf290
--- /dev/null
+++ b/packages/astro/src/actions/runtime/route.ts
@@ -0,0 +1,39 @@
+import type { APIRoute } from '../../@types/astro.js';
+import { ApiContextStorage } from './store.js';
+import { formContentTypes, getAction, hasContentType } from './utils.js';
+import { callSafely } from './virtual/shared.js';
+
+export const POST: APIRoute = async (context) => {
+ const { request, url } = context;
+ const actionPathKeys = url.pathname.replace('/_actions/', '').split('.');
+ const action = await getAction(actionPathKeys);
+ const contentType = request.headers.get('Content-Type');
+ let args: unknown;
+ if (contentType && hasContentType(contentType, formContentTypes)) {
+ args = await request.clone().formData();
+ } else if (contentType && hasContentType(contentType, ['application/json'])) {
+ args = await request.clone().json();
+ } else {
+ // 415: Unsupported media type
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415
+ return new Response(null, { status: 415 });
+ }
+ const result = await ApiContextStorage.run(context, () => callSafely(() => action(args)));
+ if (result.error) {
+ if (import.meta.env.PROD) {
+ // Avoid leaking stack trace in production
+ result.error.stack = undefined;
+ }
+ return new Response(JSON.stringify(result.error), {
+ status: result.error.status,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ }
+ return new Response(JSON.stringify(result.data), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+};
diff --git a/packages/astro/src/actions/runtime/store.ts b/packages/astro/src/actions/runtime/store.ts
new file mode 100644
index 0000000000..5961776012
--- /dev/null
+++ b/packages/astro/src/actions/runtime/store.ts
@@ -0,0 +1,18 @@
+import type { APIContext } from '../../@types/astro.js';
+import { AstroError } from '../../core/errors/errors.js';
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+export type ActionAPIContext = Omit;
+export const ApiContextStorage = new AsyncLocalStorage();
+
+export function getApiContext(): ActionAPIContext {
+ const context = ApiContextStorage.getStore();
+ if (!context) {
+ throw new AstroError({
+ name: 'AstroActionError',
+ message: 'Unable to get API context.',
+ hint: 'If you attempted to call this action from server code, trying using `Astro.getActionResult()` instead.',
+ });
+ }
+ return context;
+}
diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts
new file mode 100644
index 0000000000..8beb43a5a1
--- /dev/null
+++ b/packages/astro/src/actions/runtime/utils.ts
@@ -0,0 +1,27 @@
+export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+export function hasContentType(contentType: string, expected: string[]) {
+ // Split off parameters like charset or boundary
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
+ const type = contentType.split(';')[0].toLowerCase();
+
+ return expected.some((t) => type === t);
+}
+
+export type MaybePromise = T | Promise;
+
+export async function getAction(
+ pathKeys: string[]
+): Promise<(param: unknown) => MaybePromise> {
+ let { server: actionLookup } = await import(import.meta.env.ACTIONS_PATH);
+ for (const key of pathKeys) {
+ if (!(key in actionLookup)) {
+ throw new Error('Action not found');
+ }
+ actionLookup = actionLookup[key];
+ }
+ if (typeof actionLookup !== 'function') {
+ throw new Error('Action not found');
+ }
+ return actionLookup;
+}
diff --git a/packages/astro/src/actions/runtime/virtual/client.ts b/packages/astro/src/actions/runtime/virtual/client.ts
new file mode 100644
index 0000000000..3c81e19cb8
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/client.ts
@@ -0,0 +1,18 @@
+export * from './shared.js';
+
+export function defineAction() {
+ throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.');
+}
+
+export function getApiContext() {
+ throw new Error('[astro:action] `getApiContext()` unexpectedly used on the client.');
+}
+
+export const z = new Proxy(
+ {},
+ {
+ get() {
+ throw new Error('[astro:action] `z` unexpectedly used on the client.');
+ },
+ }
+);
diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts
new file mode 100644
index 0000000000..c9c73481c4
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/server.ts
@@ -0,0 +1,172 @@
+import { z } from 'zod';
+import { getApiContext } from '../store.js';
+import { hasContentType, type MaybePromise } from '../utils.js';
+import {
+ ActionError,
+ ActionInputError,
+ callSafely,
+ type ErrorInferenceObject,
+ type SafeResult,
+} from './shared.js';
+
+export * from './shared.js';
+
+export { z } from 'zod';
+
+export { getApiContext } from '../store.js';
+
+export type Accept = 'form' | 'json';
+export type InputSchema = T extends 'form'
+ ? z.AnyZodObject | z.ZodType
+ : z.ZodType;
+
+type Handler = TInputSchema extends z.ZodType
+ ? (input: z.infer) => MaybePromise
+ : (input?: any) => MaybePromise;
+
+export type ActionClient<
+ TOutput,
+ TAccept extends Accept,
+ TInputSchema extends InputSchema | undefined,
+> = TInputSchema extends z.ZodType
+ ? ((
+ input: TAccept extends 'form' ? FormData : z.input
+ ) => Promise>) & {
+ safe: (
+ input: TAccept extends 'form' ? FormData : z.input
+ ) => Promise<
+ SafeResult<
+ z.input extends ErrorInferenceObject
+ ? z.input
+ : ErrorInferenceObject,
+ Awaited
+ >
+ >;
+ }
+ : ((input?: any) => Promise>) & {
+ safe: (input?: any) => Promise>>;
+ };
+
+export function defineAction<
+ TOutput,
+ TAccept extends Accept = 'json',
+ TInputSchema extends InputSchema | undefined = TAccept extends 'form'
+ ? // If `input` is omitted, default to `FormData` for forms and `any` for JSON.
+ z.ZodType
+ : undefined,
+>({
+ accept,
+ input: inputSchema,
+ handler,
+}: {
+ input?: TInputSchema;
+ accept?: TAccept;
+ handler: Handler;
+}): ActionClient {
+ const serverHandler =
+ accept === 'form'
+ ? getFormServerHandler(handler, inputSchema)
+ : getJsonServerHandler(handler, inputSchema);
+
+ Object.assign(serverHandler, {
+ safe: async (unparsedInput: unknown) => {
+ return callSafely(() => serverHandler(unparsedInput));
+ },
+ });
+ return serverHandler as ActionClient;
+}
+
+function getFormServerHandler>(
+ handler: Handler,
+ inputSchema?: TInputSchema
+) {
+ return async (unparsedInput: unknown): Promise> => {
+ if (!(unparsedInput instanceof FormData)) {
+ throw new ActionError({
+ code: 'UNSUPPORTED_MEDIA_TYPE',
+ message: 'This action only accepts FormData.',
+ });
+ }
+
+ if (!(inputSchema instanceof z.ZodObject)) return await handler(unparsedInput);
+
+ const parsed = await inputSchema.safeParseAsync(formDataToObject(unparsedInput, inputSchema));
+ if (!parsed.success) {
+ throw new ActionInputError(parsed.error.issues);
+ }
+ return await handler(parsed.data);
+ };
+}
+
+function getJsonServerHandler>(
+ handler: Handler,
+ inputSchema?: TInputSchema
+) {
+ return async (unparsedInput: unknown): Promise> => {
+ const context = getApiContext();
+ const contentType = context.request.headers.get('content-type');
+ if (!contentType || !hasContentType(contentType, ['application/json'])) {
+ throw new ActionError({
+ code: 'UNSUPPORTED_MEDIA_TYPE',
+ message: 'This action only accepts JSON.',
+ });
+ }
+
+ if (!inputSchema) return await handler(unparsedInput);
+ const parsed = await inputSchema.safeParseAsync(unparsedInput);
+ if (!parsed.success) {
+ throw new ActionInputError(parsed.error.issues);
+ }
+ return await handler(parsed.data);
+ };
+}
+
+/** Transform form data to an object based on a Zod schema. */
+export function formDataToObject(
+ formData: FormData,
+ schema: T
+): Record {
+ const obj: Record = {};
+ for (const [key, baseValidator] of Object.entries(schema.shape)) {
+ let validator = baseValidator;
+ if (baseValidator instanceof z.ZodOptional || baseValidator instanceof z.ZodNullable) {
+ validator = baseValidator._def.innerType;
+ }
+ if (validator instanceof z.ZodBoolean) {
+ obj[key] = 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: string,
+ formData: FormData,
+ validator: z.ZodArray
+) {
+ 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: string,
+ formData: FormData,
+ validator: unknown,
+ baseValidator: unknown
+) {
+ const value = formData.get(key);
+ if (!value) {
+ return baseValidator instanceof z.ZodOptional ? undefined : null;
+ }
+ return validator instanceof z.ZodNumber ? Number(value) : value;
+}
diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts
new file mode 100644
index 0000000000..a8c1fc444e
--- /dev/null
+++ b/packages/astro/src/actions/runtime/virtual/shared.ts
@@ -0,0 +1,151 @@
+import type { z } from 'zod';
+import type { MaybePromise } from '../utils.js';
+
+type ActionErrorCode =
+ | 'BAD_REQUEST'
+ | 'UNAUTHORIZED'
+ | 'FORBIDDEN'
+ | 'NOT_FOUND'
+ | 'TIMEOUT'
+ | 'CONFLICT'
+ | 'PRECONDITION_FAILED'
+ | 'PAYLOAD_TOO_LARGE'
+ | 'UNSUPPORTED_MEDIA_TYPE'
+ | 'UNPROCESSABLE_CONTENT'
+ | 'TOO_MANY_REQUESTS'
+ | 'CLIENT_CLOSED_REQUEST'
+ | 'INTERNAL_SERVER_ERROR';
+
+const codeToStatusMap: Record = {
+ // Implemented from tRPC error code table
+ // https://trpc.io/docs/server/error-handling#error-codes
+ BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
+ FORBIDDEN: 403,
+ NOT_FOUND: 404,
+ TIMEOUT: 405,
+ CONFLICT: 409,
+ PRECONDITION_FAILED: 412,
+ PAYLOAD_TOO_LARGE: 413,
+ UNSUPPORTED_MEDIA_TYPE: 415,
+ UNPROCESSABLE_CONTENT: 422,
+ TOO_MANY_REQUESTS: 429,
+ CLIENT_CLOSED_REQUEST: 499,
+ INTERNAL_SERVER_ERROR: 500,
+};
+
+const statusToCodeMap: Record = Object.entries(codeToStatusMap).reduce(
+ // reverse the key-value pairs
+ (acc, [key, value]) => ({ ...acc, [value]: key }),
+ {}
+);
+
+export type ErrorInferenceObject = Record;
+
+export class ActionError extends Error {
+ type = 'AstroActionError';
+ code: ActionErrorCode = 'INTERNAL_SERVER_ERROR';
+ status = 500;
+
+ constructor(params: { message?: string; code: ActionErrorCode }) {
+ super(params.message);
+ this.code = params.code;
+ this.status = ActionError.codeToStatus(params.code);
+ }
+
+ static codeToStatus(code: ActionErrorCode): number {
+ return codeToStatusMap[code];
+ }
+
+ static statusToCode(status: number): ActionErrorCode {
+ return statusToCodeMap[status] ?? 'INTERNAL_SERVER_ERROR';
+ }
+
+ static async fromResponse(res: Response) {
+ if (
+ res.status === 400 &&
+ res.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')
+ ) {
+ const body = await res.json();
+ if (
+ typeof body === 'object' &&
+ body?.type === 'AstroActionInputError' &&
+ Array.isArray(body.issues)
+ ) {
+ return new ActionInputError(body.issues);
+ }
+ }
+ return new ActionError({
+ message: res.statusText,
+ code: this.statusToCode(res.status),
+ });
+ }
+}
+
+export function isInputError(
+ error?: ActionError
+): error is ActionInputError {
+ return error instanceof ActionInputError;
+}
+
+export type SafeResult =
+ | {
+ data: TOutput;
+ error: undefined;
+ }
+ | {
+ data: undefined;
+ error: ActionError;
+ };
+
+export class ActionInputError extends ActionError {
+ type = 'AstroActionInputError';
+
+ // We don't expose all ZodError properties.
+ // Not all properties will serialize from server to client,
+ // and we don't want to import the full ZodError object into the client.
+
+ issues: z.ZodIssue[];
+ fields: z.ZodError['formErrors']['fieldErrors'];
+
+ constructor(issues: z.ZodIssue[]) {
+ super({ message: 'Failed to validate', code: 'BAD_REQUEST' });
+ this.issues = issues;
+ this.fields = {};
+ for (const issue of issues) {
+ if (issue.path.length > 0) {
+ const key = issue.path[0].toString() as keyof typeof this.fields;
+ this.fields[key] ??= [];
+ this.fields[key]?.push(issue.message);
+ }
+ }
+ }
+}
+
+export async function callSafely(
+ handler: () => MaybePromise
+): Promise> {
+ try {
+ const data = await handler();
+ return { data, error: undefined };
+ } catch (e) {
+ if (e instanceof ActionError) {
+ return { data: undefined, error: e };
+ }
+ return {
+ data: undefined,
+ error: new ActionError({
+ message: e instanceof Error ? e.message : 'Unknown error',
+ code: 'INTERNAL_SERVER_ERROR',
+ }),
+ };
+ }
+}
+
+export function getActionProps MaybePromise>(action: T) {
+ return {
+ type: 'hidden',
+ name: '_astroAction',
+ value: action.toString(),
+ } as const;
+}
diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts
new file mode 100644
index 0000000000..a449c716b9
--- /dev/null
+++ b/packages/astro/src/actions/utils.ts
@@ -0,0 +1,20 @@
+import { AstroError } from '../core/errors/errors.js';
+import type { APIContext } from '../@types/astro.js';
+import type { Locals } from './runtime/middleware.js';
+
+function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
+ return '_actionsInternal' in locals;
+}
+
+export function createGetActionResult(locals: APIContext['locals']): APIContext['getActionResult'] {
+ return (actionFn) => {
+ if (!hasActionsInternal(locals))
+ throw new AstroError({
+ name: 'AstroActionError',
+ message: 'Experimental actions are not enabled in your project.',
+ hint: 'See https://docs.astro.build/en/reference/configuration-reference/#experimental-flags',
+ });
+
+ return locals._actionsInternal.getActionResult(actionFn);
+ };
+}
diff --git a/packages/astro/src/content/index.ts b/packages/astro/src/content/index.ts
index 815f77f2a6..2aef23e854 100644
--- a/packages/astro/src/content/index.ts
+++ b/packages/astro/src/content/index.ts
@@ -1,12 +1,7 @@
export { CONTENT_FLAG, PROPAGATED_ASSET_FLAG } from './consts.js';
export { attachContentServerListeners } from './server-listeners.js';
export { createContentTypesGenerator } from './types-generator.js';
-export {
- contentObservable,
- getContentPaths,
- getDotAstroTypeReference,
- hasAssetPropagationFlag,
-} from './utils.js';
+export { contentObservable, getContentPaths, hasAssetPropagationFlag } from './utils.js';
export { astroContentAssetPropagationPlugin } from './vite-plugin-content-assets.js';
export { astroContentImportPlugin } from './vite-plugin-content-imports.js';
export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts
index f4356ab27c..0255f65356 100644
--- a/packages/astro/src/content/utils.ts
+++ b/packages/astro/src/content/utils.ts
@@ -14,7 +14,7 @@ import type {
} from '../@types/astro.js';
import { AstroError, AstroErrorData, MarkdownError, errorMap } from '../core/errors/index.js';
import { isYAMLException } from '../core/errors/utils.js';
-import { CONTENT_FLAGS, CONTENT_TYPES_FILE, PROPAGATED_ASSET_FLAG } from './consts.js';
+import { CONTENT_FLAGS, PROPAGATED_ASSET_FLAG } from './consts.js';
import { createImage } from './runtime-assets.js';
/**
@@ -37,15 +37,6 @@ export const collectionConfigParser = z.union([
}),
]);
-export function getDotAstroTypeReference({ root, srcDir }: { root: URL; srcDir: URL }) {
- const { cacheDir } = getContentPaths({ root, srcDir });
- const contentTypesRelativeToSrcDir = normalizePath(
- path.relative(fileURLToPath(srcDir), fileURLToPath(new URL(CONTENT_TYPES_FILE, cacheDir)))
- );
-
- return `/// `;
-}
-
export const contentConfigParser = z.object({
collections: z.record(collectionConfigParser),
});
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 0fd4c58e66..b2dd496bf6 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -80,6 +80,7 @@ const ASTRO_CONFIG_DEFAULTS = {
legacy: {},
redirects: {},
experimental: {
+ actions: false,
directRenderScript: false,
contentCollectionCache: false,
contentCollectionJsonSchema: false,
@@ -494,6 +495,7 @@ export const AstroConfigSchema = z.object({
),
experimental: z
.object({
+ actions: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.actions),
directRenderScript: z
.boolean()
.optional()
diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts
index 358cf31fe6..02fad1c61c 100644
--- a/packages/astro/src/core/middleware/index.ts
+++ b/packages/astro/src/core/middleware/index.ts
@@ -1,4 +1,5 @@
import type { APIContext, MiddlewareHandler, Params, RewritePayload } from '../../@types/astro.js';
+import { createGetActionResult } from '../../actions/utils.js';
import {
computeCurrentLocale,
computePreferredLocale,
@@ -47,19 +48,18 @@ function createContext({
const route = url.pathname;
// TODO verify that this function works in an edge middleware environment
- const reroute = (_reroutePayload: RewritePayload) => {
+ const rewrite = (_reroutePayload: RewritePayload) => {
// return dummy response
return Promise.resolve(new Response(null));
};
-
- return {
+ const context: Omit = {
cookies: new AstroCookies(request),
request,
params,
site: undefined,
generator: `Astro v${ASTRO_VERSION}`,
props: {},
- rewrite: reroute,
+ rewrite,
redirect(path, status) {
return new Response(null, {
status: status || 302,
@@ -104,6 +104,9 @@ function createContext({
}
},
};
+ return Object.assign(context, {
+ getActionResult: createGetActionResult(context.locals),
+ });
}
/**
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index 279745ac19..a41665928c 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -1,3 +1,4 @@
+import { createGetActionResult } from '../actions/utils.js';
import type {
APIContext,
AstroGlobal,
@@ -9,6 +10,7 @@ import type {
RouteData,
SSRResult,
} from '../@types/astro.js';
+import type { ActionAPIContext } from '../actions/runtime/store.js';
import {
computeCurrentLocale,
computePreferredLocale,
@@ -193,6 +195,14 @@ export class RenderContext {
}
createAPIContext(props: APIContext['props']): APIContext {
+ const context = this.createActionAPIContext();
+ return Object.assign(context, {
+ props,
+ getActionResult: createGetActionResult(context.locals),
+ });
+ }
+
+ createActionAPIContext(): ActionAPIContext {
const renderContext = this;
const { cookies, params, pipeline, url } = this;
const generator = `Astro v${ASTRO_VERSION}`;
@@ -256,7 +266,6 @@ export class RenderContext {
get preferredLocaleList() {
return renderContext.computePreferredLocaleList();
},
- props,
redirect,
rewrite,
request: this.request,
@@ -434,6 +443,7 @@ export class RenderContext {
redirect,
rewrite,
request: this.request,
+ getActionResult: createGetActionResult(locals),
response,
site: pipeline.site,
url,
diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts
index ae97802eeb..0e58f7e858 100644
--- a/packages/astro/src/integrations/hooks.ts
+++ b/packages/astro/src/integrations/hooks.ts
@@ -115,6 +115,10 @@ export async function runHookConfigSetup({
if (settings.config.adapter) {
settings.config.integrations.push(settings.config.adapter);
}
+ if (settings.config.experimental?.actions) {
+ const { default: actionsIntegration } = await import('../actions/index.js');
+ settings.config.integrations.push(actionsIntegration());
+ }
let updatedConfig: AstroConfig = { ...settings.config };
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
diff --git a/packages/astro/src/vite-plugin-inject-env-ts/index.ts b/packages/astro/src/vite-plugin-inject-env-ts/index.ts
index 5c49e3b043..9ee4b54131 100644
--- a/packages/astro/src/vite-plugin-inject-env-ts/index.ts
+++ b/packages/astro/src/vite-plugin-inject-env-ts/index.ts
@@ -4,8 +4,10 @@ import { fileURLToPath } from 'node:url';
import { bold } from 'kleur/colors';
import { type Plugin, normalizePath } from 'vite';
import type { AstroSettings } from '../@types/astro.js';
-import { getContentPaths, getDotAstroTypeReference } from '../content/index.js';
+import { getContentPaths } from '../content/index.js';
import { type Logger } from '../core/logger/core.js';
+import { CONTENT_TYPES_FILE } from '../content/consts.js';
+import { ACTIONS_TYPES_FILE } from '../actions/consts.js';
export function getEnvTsPath({ srcDir }: { srcDir: URL }) {
return new URL('env.d.ts', srcDir);
@@ -42,23 +44,27 @@ export async function setUpEnvTs({
}) {
const envTsPath = getEnvTsPath(settings.config);
const dotAstroDir = getContentPaths(settings.config).cacheDir;
- const dotAstroTypeReference = getDotAstroTypeReference(settings.config);
- const envTsPathRelativetoRoot = normalizePath(
+ const dotAstroTypeReferences = getDotAstroTypeReferences({
+ root: settings.config.root,
+ srcDir: settings.config.srcDir,
+ fs,
+ });
+ const envTsPathRelativeToRoot = normalizePath(
path.relative(fileURLToPath(settings.config.root), fileURLToPath(envTsPath))
);
if (fs.existsSync(envTsPath)) {
let typesEnvContents = await fs.promises.readFile(envTsPath, 'utf-8');
- if (!fs.existsSync(dotAstroDir))
- // Add `.astro` types reference if none exists
- return;
- const expectedTypeReference = getDotAstroTypeReference(settings.config);
-
- if (!typesEnvContents.includes(expectedTypeReference)) {
- typesEnvContents = `${expectedTypeReference}\n${typesEnvContents}`;
+ let addedTypes = false;
+ for (const typeReference of dotAstroTypeReferences) {
+ if (typesEnvContents.includes(typeReference)) continue;
+ typesEnvContents = `${typeReference}\n${typesEnvContents}`;
await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8');
- logger.info('types', `Added ${bold(envTsPathRelativetoRoot)} type declarations`);
+ addedTypes = true;
+ }
+ if (addedTypes) {
+ logger.info('types', `Added ${bold(envTsPathRelativeToRoot)} type declarations`);
}
} else {
// Otherwise, inject the `env.d.ts` file
@@ -66,11 +72,35 @@ export async function setUpEnvTs({
referenceDefs.push('/// ');
if (fs.existsSync(dotAstroDir)) {
- referenceDefs.push(dotAstroTypeReference);
+ referenceDefs.push(...dotAstroTypeReferences);
}
await fs.promises.mkdir(settings.config.srcDir, { recursive: true });
await fs.promises.writeFile(envTsPath, referenceDefs.join('\n'), 'utf-8');
- logger.info('types', `Added ${bold(envTsPathRelativetoRoot)} type declarations`);
+ logger.info('types', `Added ${bold(envTsPathRelativeToRoot)} type declarations`);
}
}
+
+function getDotAstroTypeReferences({
+ fs,
+ root,
+ srcDir,
+}: {
+ fs: typeof fsMod;
+ root: URL;
+ srcDir: URL;
+}) {
+ const { cacheDir } = getContentPaths({ root, srcDir });
+ let referenceDefs: string[] = [];
+ const typesFiles = [CONTENT_TYPES_FILE, ACTIONS_TYPES_FILE];
+ for (const typesFile of typesFiles) {
+ const url = new URL(typesFile, cacheDir);
+ if (!fs.existsSync(url)) continue;
+ const typesRelativeToSrcDir = normalizePath(
+ path.relative(fileURLToPath(srcDir), fileURLToPath(url))
+ );
+ referenceDefs.push(`/// `);
+ }
+
+ return referenceDefs;
+}
diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs
new file mode 100644
index 0000000000..cd7caa714e
--- /dev/null
+++ b/packages/astro/templates/actions.mjs
@@ -0,0 +1,61 @@
+import { ActionError, callSafely } from 'astro:actions';
+
+function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
+ return new Proxy(actionCallback, {
+ get(target, objKey) {
+ if (objKey in target) {
+ return target[objKey];
+ }
+ const path = aggregatedPath + objKey.toString();
+ const action = (clientParam) => actionHandler(clientParam, path);
+ action.toString = () => path;
+ action.safe = (input) => {
+ return callSafely(() => action(input));
+ };
+ // recurse to construct queries for nested object paths
+ // ex. actions.user.admins.auth()
+ return toActionProxy(action, path + '.');
+ },
+ });
+}
+
+/**
+ * @param {*} clientParam argument passed to the action when used on the client.
+ * @param {string} path Built path to call action on the server.
+ * Usage: `actions.[name](clientParam)`.
+ */
+async function actionHandler(clientParam, path) {
+ if (import.meta.env.SSR) {
+ throw new ActionError({
+ code: 'BAD_REQUEST',
+ message:
+ 'Action unexpectedly called on the server. If this error is unexpected, share your feedback on our RFC discussion: https://github.com/withastro/roadmap/pull/912',
+ });
+ }
+ const headers = new Headers();
+ headers.set('Accept', 'application/json');
+ let body = clientParam;
+ if (!(body instanceof FormData)) {
+ try {
+ body = clientParam ? JSON.stringify(clientParam) : undefined;
+ } catch (e) {
+ throw new ActionError({
+ code: 'BAD_REQUEST',
+ message: `Failed to serialize request body to JSON. Full error: ${e.message}`,
+ });
+ }
+ headers.set('Content-Type', 'application/json');
+ }
+ const res = await fetch(path, {
+ method: 'POST',
+ body,
+ headers,
+ });
+ if (!res.ok) {
+ throw await ActionError.fromResponse(res);
+ }
+ const json = await res.json();
+ return json;
+}
+
+export const actions = toActionProxy();
diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js
new file mode 100644
index 0000000000..581777d718
--- /dev/null
+++ b/packages/astro/test/actions.test.js
@@ -0,0 +1,173 @@
+import assert from 'node:assert/strict';
+import { before, after, describe, it } from 'node:test';
+import { loadFixture } from './test-utils.js';
+import testAdapter from './test-adapter.js';
+
+describe('Astro Actions', () => {
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/actions/',
+ adapter: testAdapter(),
+ });
+ });
+
+ describe('dev', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('Exposes subscribe action', async () => {
+ const res = await fixture.fetch('/_actions/subscribe', {
+ method: 'POST',
+ body: JSON.stringify({ channel: 'bholmesdev' }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.channel, 'bholmesdev');
+ assert.equal(json.subscribeButtonState, 'smashed');
+ });
+
+ it('Exposes comment action', async () => {
+ const formData = new FormData();
+ formData.append('channel', 'bholmesdev');
+ formData.append('comment', 'Hello, World!');
+ const res = await fixture.fetch('/_actions/comment', {
+ method: 'POST',
+ body: formData,
+ });
+
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.channel, 'bholmesdev');
+ assert.equal(json.comment, 'Hello, World!');
+ });
+
+ it('Raises validation error on bad form data', async () => {
+ const formData = new FormData();
+ formData.append('channel', 'bholmesdev');
+ const res = await fixture.fetch('/_actions/comment', {
+ method: 'POST',
+ body: formData,
+ });
+
+ assert.equal(res.ok, false);
+ assert.equal(res.status, 400);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.type, 'AstroActionInputError');
+ });
+
+ it('Exposes plain formData action', async () => {
+ const formData = new FormData();
+ formData.append('channel', 'bholmesdev');
+ formData.append('comment', 'Hello, World!');
+ const res = await fixture.fetch('/_actions/commentPlainFormData', {
+ method: 'POST',
+ body: formData,
+ });
+
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.success, true);
+ assert.equal(json.isFormData, true, 'Should receive plain FormData');
+ });
+ });
+
+ describe('build', () => {
+ let app;
+
+ before(async () => {
+ await fixture.build();
+ app = await fixture.loadTestAdapterApp();
+ });
+
+ it('Exposes subscribe action', async () => {
+ const req = new Request('http://example.com/_actions/subscribe', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ channel: 'bholmesdev' }),
+ });
+ const res = await app.render(req);
+
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.channel, 'bholmesdev');
+ assert.equal(json.subscribeButtonState, 'smashed');
+ });
+
+ it('Exposes comment action', async () => {
+ const formData = new FormData();
+ formData.append('channel', 'bholmesdev');
+ formData.append('comment', 'Hello, World!');
+ const req = new Request('http://example.com/_actions/comment', {
+ method: 'POST',
+ body: formData,
+ });
+ const res = await app.render(req);
+
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.channel, 'bholmesdev');
+ assert.equal(json.comment, 'Hello, World!');
+ });
+
+ it('Raises validation error on bad form data', async () => {
+ const formData = new FormData();
+ formData.append('channel', 'bholmesdev');
+ const req = new Request('http://example.com/_actions/comment', {
+ method: 'POST',
+ body: formData,
+ });
+ const res = await app.render(req);
+
+ assert.equal(res.ok, false);
+ assert.equal(res.status, 400);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.type, 'AstroActionInputError');
+ });
+
+ it('Exposes plain formData action', async () => {
+ const formData = new FormData();
+ formData.append('channel', 'bholmesdev');
+ formData.append('comment', 'Hello, World!');
+ const req = new Request('http://example.com/_actions/commentPlainFormData', {
+ method: 'POST',
+ body: formData,
+ });
+ const res = await app.render(req);
+
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json');
+
+ const json = await res.json();
+ assert.equal(json.success, true);
+ assert.equal(json.isFormData, true, 'Should receive plain FormData');
+ });
+ });
+});
diff --git a/packages/astro/test/astro-sync.test.js b/packages/astro/test/astro-sync.test.js
index 433dfd1426..42ebaaf8dc 100644
--- a/packages/astro/test/astro-sync.test.js
+++ b/packages/astro/test/astro-sync.test.js
@@ -2,6 +2,9 @@ import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import { before, describe, it } from 'node:test';
import { loadFixture } from './test-utils.js';
+import { CONTENT_TYPES_FILE } from '../dist/content/consts.js';
+import { ACTIONS_TYPES_FILE } from '../dist/actions/consts.js';
+import { getContentPaths } from '../dist/content/utils.js';
describe('astro sync', () => {
let fixture;
@@ -34,22 +37,17 @@ describe('astro sync', () => {
it('Adds type reference to `src/env.d.ts`', async () => {
let writtenFiles = {};
- const typesEnvPath = new URL('env.d.ts', fixture.config.srcDir).href;
+ const typesEnvPath = getTypesEnvPath(fixture);
const fsMock = {
...fs,
- existsSync(path, ...args) {
- if (path.toString() === typesEnvPath) {
- return true;
- }
- return fs.existsSync(path, ...args);
- },
+ existsSync: createExistsSync(fixture, true),
promises: {
...fs.promises,
- async readFile(path, ...args) {
+ async readFile(path) {
if (path.toString() === typesEnvPath) {
return `/// `;
} else {
- return fs.promises.readFile(path, ...args);
+ throw new Error(`Tried to read unexpected path: ${path}`);
}
},
async writeFile(path, contents) {
@@ -72,15 +70,10 @@ describe('astro sync', () => {
it('Writes `src/env.d.ts` if none exists', async () => {
let writtenFiles = {};
- const typesEnvPath = new URL('env.d.ts', fixture.config.srcDir).href;
+ const typesEnvPath = getTypesEnvPath(fixture);
const fsMock = {
...fs,
- existsSync(path, ...args) {
- if (path.toString() === typesEnvPath) {
- return false;
- }
- return fs.existsSync(path, ...args);
- },
+ existsSync: createExistsSync(fixture, false),
promises: {
...fs.promises,
async writeFile(path, contents) {
@@ -105,3 +98,21 @@ describe('astro sync', () => {
);
});
});
+
+function getTypesEnvPath(fixture) {
+ return new URL('env.d.ts', fixture.config.srcDir).href;
+}
+
+function createExistsSync(fixture, envDtsExists = false) {
+ const { cacheDir } = getContentPaths(fixture.config);
+ const paths = [
+ new URL(CONTENT_TYPES_FILE, cacheDir).href,
+ new URL(ACTIONS_TYPES_FILE, cacheDir).href,
+ cacheDir.href,
+ ];
+ if (envDtsExists) {
+ paths.push(getTypesEnvPath(fixture));
+ }
+
+ return (path) => paths.includes(path.toString());
+}
diff --git a/packages/astro/test/fixtures/actions/astro.config.mjs b/packages/astro/test/fixtures/actions/astro.config.mjs
new file mode 100644
index 0000000000..fc6477578b
--- /dev/null
+++ b/packages/astro/test/fixtures/actions/astro.config.mjs
@@ -0,0 +1,9 @@
+import { defineConfig } from 'astro/config';
+
+// https://astro.build/config
+export default defineConfig({
+ output: 'server',
+ experimental: {
+ actions: true,
+ },
+});
diff --git a/packages/astro/test/fixtures/actions/package.json b/packages/astro/test/fixtures/actions/package.json
new file mode 100644
index 0000000000..18dc7fa827
--- /dev/null
+++ b/packages/astro/test/fixtures/actions/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/actions",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts
new file mode 100644
index 0000000000..8887011928
--- /dev/null
+++ b/packages/astro/test/fixtures/actions/src/actions/index.ts
@@ -0,0 +1,32 @@
+import { defineAction, z } from 'astro:actions';
+
+export const server = {
+ subscribe: defineAction({
+ input: z.object({ channel: z.string() }),
+ handler: async ({ channel }) => {
+ return {
+ channel,
+ subscribeButtonState: 'smashed',
+ };
+ },
+ }),
+ comment: defineAction({
+ accept: 'form',
+ input: z.object({ channel: z.string(), comment: z.string() }),
+ handler: async ({ channel, comment }) => {
+ return {
+ channel,
+ comment,
+ };
+ },
+ }),
+ commentPlainFormData: defineAction({
+ accept: 'form',
+ handler: async (formData) => {
+ return {
+ success: true,
+ isFormData: formData instanceof FormData,
+ };
+ },
+ }),
+};
diff --git a/packages/astro/test/units/actions/form-data-to-object.test.js b/packages/astro/test/units/actions/form-data-to-object.test.js
new file mode 100644
index 0000000000..6cce8f3d7d
--- /dev/null
+++ b/packages/astro/test/units/actions/form-data-to-object.test.js
@@ -0,0 +1,138 @@
+import * as assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { z } from 'zod';
+import { formDataToObject } from '../../../dist/actions/runtime/virtual/server.js';
+
+describe('formDataToObject', () => {
+ it('should handle strings', () => {
+ const formData = new FormData();
+ formData.set('name', 'Ben');
+ formData.set('email', 'test@test.test');
+
+ const input = z.object({
+ name: z.string(),
+ email: z.string(),
+ });
+
+ const res = formDataToObject(formData, input);
+ assert.equal(res.name, 'Ben');
+ assert.equal(res.email, 'test@test.test');
+ });
+
+ it('should handle numbers', () => {
+ const formData = new FormData();
+ formData.set('age', '25');
+
+ const input = z.object({
+ age: z.number(),
+ });
+
+ const res = formDataToObject(formData, input);
+ assert.equal(res.age, 25);
+ });
+
+ it('should pass NaN for invalid numbers', () => {
+ const formData = new FormData();
+ formData.set('age', 'twenty-five');
+
+ const input = z.object({
+ age: z.number(),
+ });
+
+ const res = formDataToObject(formData, input);
+ assert.ok(isNaN(res.age));
+ });
+
+ it('should handle boolean checks', () => {
+ const formData = new FormData();
+ formData.set('isCool', 'yes');
+
+ const input = z.object({
+ isCool: z.boolean(),
+ isNotCool: z.boolean(),
+ });
+
+ const res = formDataToObject(formData, input);
+ assert.equal(res.isCool, true);
+ assert.equal(res.isNotCool, false);
+ });
+
+ it('should handle optional values', () => {
+ const formData = new FormData();
+ formData.set('name', 'Ben');
+
+ const input = z.object({
+ name: z.string().optional(),
+ email: z.string().optional(),
+ age: z.number().optional(),
+ });
+
+ const res = formDataToObject(formData, input);
+
+ assert.equal(res.name, 'Ben');
+ assert.equal(res.email, undefined);
+ assert.equal(res.age, undefined);
+ });
+
+ it('should handle null values', () => {
+ const formData = new FormData();
+ formData.set('name', 'Ben');
+
+ const input = z.object({
+ name: z.string().nullable(),
+ email: z.string().nullable(),
+ age: z.number().nullable(),
+ });
+
+ const res = formDataToObject(formData, input);
+
+ assert.equal(res.name, 'Ben');
+ assert.equal(res.email, null);
+ assert.equal(res.age, null);
+ });
+
+ it('should handle File objects', () => {
+ const formData = new FormData();
+ formData.set('file', new File([''], 'test.txt'));
+
+ const input = z.object({
+ file: z.instanceof(File),
+ });
+
+ const res = formDataToObject(formData, input);
+
+ assert.equal(res.file instanceof File, true);
+ });
+
+ it('should handle string arrays', () => {
+ const formData = new FormData();
+ formData.append('contact', 'Ben');
+ formData.append('contact', 'Jane');
+ formData.append('contact', 'John');
+
+ const input = z.object({
+ contact: z.array(z.string()),
+ });
+
+ const res = formDataToObject(formData, input);
+
+ assert.ok(Array.isArray(res.contact), 'contact is not an array');
+ assert.deepEqual(res.contact.sort(), ['Ben', 'Jane', 'John']);
+ });
+
+ it('should handle number arrays', () => {
+ const formData = new FormData();
+ formData.append('age', '25');
+ formData.append('age', '30');
+ formData.append('age', '35');
+
+ const input = z.object({
+ age: z.array(z.number()),
+ });
+
+ const res = formDataToObject(formData, input);
+
+ assert.ok(Array.isArray(res.age), 'age is not an array');
+ assert.deepEqual(res.age.sort(), [25, 30, 35]);
+ });
+});
diff --git a/packages/astro/types/actions.d.ts b/packages/astro/types/actions.d.ts
new file mode 100644
index 0000000000..90187ebb99
--- /dev/null
+++ b/packages/astro/types/actions.d.ts
@@ -0,0 +1,3 @@
+declare module 'astro:actions' {
+ export * from 'astro/actions/runtime/virtual/server.js';
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3bb10a16b..b087e7b3d3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -856,6 +856,39 @@ importers:
specifier: workspace:*
version: link:../../../..
+ packages/astro/e2e/fixtures/actions-blog:
+ dependencies:
+ '@astrojs/check':
+ specifier: ^0.5.10
+ version: 0.5.10(prettier-plugin-astro@0.13.0)(prettier@3.2.5)(typescript@5.4.5)
+ '@astrojs/db':
+ specifier: workspace:*
+ version: link:../../../../db
+ '@astrojs/node':
+ specifier: workspace:*
+ version: link:../../../../integrations/node
+ '@astrojs/react':
+ specifier: workspace:*
+ version: link:../../../../integrations/react
+ '@types/react':
+ specifier: ^18.2.79
+ version: 18.3.1
+ '@types/react-dom':
+ specifier: ^18.2.25
+ version: 18.3.0
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+ react:
+ specifier: ^18.3.0
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.0
+ version: 18.3.1(react@18.3.1)
+ typescript:
+ specifier: ^5.4.5
+ version: 5.4.5
+
packages/astro/e2e/fixtures/astro-component:
dependencies:
'@astrojs/preact':
@@ -1726,6 +1759,12 @@ importers:
specifier: ^3.3.8
version: 3.4.21(typescript@5.4.5)
+ packages/astro/test/fixtures/actions:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
packages/astro/test/fixtures/alias:
dependencies:
'@astrojs/svelte':
@@ -8470,7 +8509,7 @@ packages:
/@types/react-dom@18.2.25:
resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==}
dependencies:
- '@types/react': 18.2.78
+ '@types/react': 18.3.1
dev: false
/@types/react-dom@18.3.0:
@@ -9618,6 +9657,7 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ requiresBuild: true
dev: false
/bcp-47-match@2.0.3:
@@ -9727,6 +9767,7 @@ packages:
/buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+ requiresBuild: true
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
@@ -10532,6 +10573,7 @@ packages:
/detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
+ requiresBuild: true
dev: false
/deterministic-object-hash@2.0.2:
@@ -12034,6 +12076,7 @@ packages:
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+ requiresBuild: true
dev: false
/ignore@5.3.1:
@@ -12078,6 +12121,7 @@ packages:
/inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ requiresBuild: true
/inline-style-parser@0.1.1:
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
@@ -13498,6 +13542,7 @@ packages:
/minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ requiresBuild: true
dev: false
/minipass@3.3.6:
@@ -13839,6 +13884,7 @@ packages:
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ requiresBuild: true
dependencies:
wrappy: 1.0.2
@@ -14839,6 +14885,7 @@ packages:
/readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
+ requiresBuild: true
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
@@ -15857,6 +15904,7 @@ packages:
/strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
dev: true
/strip-json-comments@3.1.1:
@@ -16685,6 +16733,7 @@ packages:
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ requiresBuild: true
/utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
@@ -17327,6 +17376,7 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ requiresBuild: true
/ws@8.16.0:
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}