Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Actions experimental release #10858

Merged
merged 110 commits into from May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
110 commits
Select commit Hold shift + click to select a range
26f4c53
feat: port astro-actions poc
bholmesdev Apr 23, 2024
af6a962
feat: basic blog example
bholmesdev Apr 23, 2024
0a99d69
feat: basic validationError class
bholmesdev Apr 23, 2024
056ecd6
feat: standard error types and safe() wrapper
bholmesdev Apr 23, 2024
eff1c18
refactor: move enhanceProps to astro:actions
bholmesdev Apr 23, 2024
ed137a3
fix: throw internal server errors
bholmesdev Apr 23, 2024
b3f654d
chore: refine enhance: true error message
bholmesdev Apr 23, 2024
7c65ccc
fix: remove FormData fallback from route
bholmesdev Apr 23, 2024
2aee27b
refactor: clarify what enhance: true allows
bholmesdev Apr 23, 2024
953d9cd
feat: progressively enhanced comments
bholmesdev Apr 23, 2024
47a8f61
chore: changeset
bholmesdev Apr 23, 2024
8d36b63
refactor: enhance -> acceptFormData
bholmesdev Apr 24, 2024
9a00ba2
wip: migrate actions to core
bholmesdev Apr 24, 2024
e867bac
feat: working actions demo from astro core!
bholmesdev Apr 25, 2024
43f4403
chore: changeset
bholmesdev Apr 25, 2024
5590280
chore: delete old changeset
bholmesdev Apr 25, 2024
bbf1bf7
fix: Function type lint
bholmesdev Apr 25, 2024
7d94c48
refactor: expose defineAction from `astro:actions`
bholmesdev Apr 25, 2024
5dde337
fix: add null check to experimental
bholmesdev Apr 25, 2024
045d615
fix: export `types/actions.d.ts`
bholmesdev Apr 25, 2024
d52488e
feat: more robust form data parsing
bholmesdev Apr 25, 2024
1284383
feat: support formData from rpc call
bholmesdev Apr 25, 2024
d8e52bf
feat: remove acceptFormData flag requirement
bholmesdev Apr 25, 2024
6625501
feat: add actions.d.ts type reference on startup
bholmesdev Apr 25, 2024
96d0a39
refactor: actionNameProps -> getNameProps
bholmesdev Apr 25, 2024
141e04a
fix: actions type import
bholmesdev Apr 25, 2024
4074356
chore: expose zod from `astro:actions`
bholmesdev Apr 25, 2024
179d523
fix: zod export path
bholmesdev Apr 25, 2024
8dd2524
feat: add explicit `accept` property
bholmesdev Apr 26, 2024
cd8efd3
Use zod package instead of relative path outside of src
bholmesdev Apr 26, 2024
cb2bd8b
feat: clean up error throwing and handling flow
bholmesdev Apr 29, 2024
b1f927f
fix: make `accept` optional
bholmesdev May 3, 2024
542af7b
docs: beef up actions experimental docs
bholmesdev May 3, 2024
a308c8d
fix: defineAction type narrowing on `accept`
bholmesdev May 3, 2024
92b82b2
fix: bad `getNameProps()` arg type
bholmesdev May 3, 2024
feab5e1
refactor: move to single `error` object + `isInputError()` util
bholmesdev May 3, 2024
63b1892
fix: move res.json() parse to avoid double parse
bholmesdev May 3, 2024
012085b
feat: support async zod schemas
bholmesdev May 3, 2024
7fb26a8
feat: serialize and expose zod properties on input error
bholmesdev May 3, 2024
e333e82
feat: test input error in comment example
bholmesdev May 3, 2024
d041807
fix: remove ZodError import
bholmesdev May 3, 2024
d042f32
fix: add actions-module to files export
bholmesdev May 3, 2024
efcd9ec
fix: use workspace for test pkg versions
bholmesdev May 3, 2024
6681a71
refactor: default export -> server export
bholmesdev May 3, 2024
c2ea11c
fix: type inference for json vs. form
bholmesdev May 3, 2024
cff5241
refactor: accept form -> defineFormAction
bholmesdev May 6, 2024
b17b0a4
refactor: better callSafely signature
bholmesdev May 6, 2024
347ba41
feat: block action calls from the server with RFC link
bholmesdev May 6, 2024
d366925
feat: move getActionResult to global
bholmesdev May 6, 2024
2672640
refactor: getNameProps -> getActionProps
bholmesdev May 6, 2024
894ed76
refactor: body.toString()
bholmesdev May 6, 2024
ab9bf88
edit: capitAl
bholmesdev May 6, 2024
21d828f
edit: highlight `actions`
bholmesdev May 6, 2024
eb75c24
edit: add actions file name
bholmesdev May 6, 2024
c0c9f3d
edit: not you can. You DO
bholmesdev May 6, 2024
24e0de2
edit: declare with feeling
bholmesdev May 6, 2024
be6ae6b
edit: clarify what the `handler` does
bholmesdev May 6, 2024
3dc82b8
edit: schema -> input
bholmesdev May 6, 2024
d9249a0
edit: add FormData mdn reference
bholmesdev May 6, 2024
2866c01
edit: add defineFormAction() explainer
bholmesdev May 6, 2024
fa68332
refactor: inline getDotAstroTypeRefs
bholmesdev May 6, 2024
84bf0d6
edit: yeah yeah maybe
bholmesdev May 6, 2024
36f9d2d
fix: existsSync test mock
bholmesdev May 6, 2024
04cc449
refactor: use callSafely in middleware
bholmesdev May 6, 2024
6183068
test: upgradeFormData()
bholmesdev May 6, 2024
01daa05
chore: stray console log
bholmesdev May 6, 2024
4fb465f
refactor: extract helper functions
bholmesdev May 6, 2024
95c5aff
fix: include status in error response
bholmesdev May 6, 2024
60a4b10
fix: return `undefined` when there's no action result
bholmesdev May 6, 2024
7302d80
fix: content-type
bholmesdev May 6, 2024
84773b8
test: e2e like button action
bholmesdev May 6, 2024
7a97673
test: comment e2e
bholmesdev May 6, 2024
cddb4fd
fix: existsSync mock for other sync test
bholmesdev May 6, 2024
2992b71
test: action dev server raw fetch
bholmesdev May 6, 2024
b34d286
test: build preview
bholmesdev May 6, 2024
b629fc5
chore: fix lock
bholmesdev May 6, 2024
b054178
fix: add dotAstroDir to existsSync
bholmesdev May 6, 2024
071cedd
chore: slim down e2e fixture
bholmesdev May 6, 2024
70166af
chore: remove unneeded disabled test
bholmesdev May 6, 2024
8475f4a
refactor: better api context error
bholmesdev May 6, 2024
b981432
fix: return `false` for envDts
bholmesdev May 6, 2024
fd5b93f
refactor: defineFormAction -> defineAction with accept
bholmesdev May 6, 2024
63a77dc
fix: check FormData on getActionProps
bholmesdev May 6, 2024
1112927
edit: uppercase
bholmesdev May 6, 2024
56b9a23
fix: add switch default for 500
bholmesdev May 7, 2024
c8aa956
fix: add `toLowerCase()` on content-type check
bholmesdev May 7, 2024
331a93a
chore: use VIRTUAL_MODULE_ID for plugin
bholmesdev May 7, 2024
4916b59
fix: remove incorrect ts-ignore
bholmesdev May 7, 2024
ba3c510
chore: remove unneeded POST method check
bholmesdev May 7, 2024
50210f4
refactor: route callSafely
bholmesdev May 7, 2024
63e2322
refactor: error switch case to map
bholmesdev May 7, 2024
b212681
chore: add link to trpc error code table
bholmesdev May 7, 2024
ec9b7bf
fix: add readable error on failed json.stringify
bholmesdev May 7, 2024
4fd7f6f
refactor: add param -> callerParam with comment
bholmesdev May 7, 2024
bb362b0
feat: always return safe from getActionResult()
bholmesdev May 7, 2024
9634999
refactor: move actions module to templates/
bholmesdev May 7, 2024
b7dd73c
refactor: remove unneeded existsSync on dotAstro
bholmesdev May 7, 2024
53463fb
fix: hasContentType util for toLowerCase()
bholmesdev May 7, 2024
6ea2483
chore: comment on 415 code
bholmesdev May 7, 2024
99ab252
refactor: upgradeFormData -> formDataToObj
bholmesdev May 7, 2024
9fb39ee
fix: avoid leaking stack in production
bholmesdev May 7, 2024
2290788
refactor: defineProperty with write false
bholmesdev May 7, 2024
87a3a28
fix: revert package.json back to spaces
bholmesdev May 7, 2024
4a5410f
edit: use config docs for changeset
bholmesdev May 7, 2024
280e279
refactor: stringifiedActionsPath -> stringifiedActionsImport
bholmesdev May 7, 2024
44b0532
fix: avoid double-handling for route
bholmesdev May 7, 2024
c6417c3
fix: support zero arg actions
bholmesdev May 7, 2024
f2677a8
refactor: move actionHandler to helper fn
bholmesdev May 7, 2024
3383e78
fix: restore mdast deps
May 8, 2024
ceff804
docs: add `output` to config
May 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
95 changes: 95 additions & 0 deletions .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.
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved


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 (
<button
onClick={async () => {
const newLikes = await actions.like({ postId });
setLikes(newLikes);
}}
>
{likes} likes
</button>
);
}

export function Comment({ postId }: { postId: string }) {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const result = await actions.blog.comment(formData);
// handle result
}}
>
<input type="hidden" name="postId" value={postId} />
<label for="author">Author</label>
<input id="author" type="text" name="author" />
<textarea rows={10} name="body"></textarea>
<button type="submit">Post</button>
</form>
);
}
```

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).
1 change: 1 addition & 0 deletions packages/astro/client.d.ts
@@ -1,5 +1,6 @@
/// <reference types="vite/types/import-meta.d.ts" />
/// <reference path="./types/content.d.ts" />
/// <reference path="./types/actions.d.ts" />

// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace App {
Expand Down
58 changes: 58 additions & 0 deletions 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);
});
});
17 changes: 17 additions & 0 deletions 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,
},
});
21 changes: 21 additions & 0 deletions 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 },
});
15 changes: 15 additions & 0 deletions 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!",
});
}
24 changes: 24 additions & 0 deletions 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"
}
}
45 changes: 45 additions & 0 deletions 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;
},
}),
},
};
@@ -0,0 +1,47 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of the <BaseHead /> 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;
---

<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />

<!-- Font preloads -->
<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin />
<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin />

<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />

<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />

<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />
@@ -0,0 +1,62 @@
---
const today = new Date();
---

<footer>
&copy; {today.getFullYear()} Your name here. All rights reserved.
<div class="social-links">
<a href="https://m.webtoo.ls/@astro" target="_blank">
<span class="sr-only">Follow Astro on Mastodon</span>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
width="32"
height="32"
astro-icon="social/mastodon"
><path
fill="currentColor"
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
></path></svg
>
</a>
<a href="https://twitter.com/astrodotbuild" target="_blank">
<span class="sr-only">Follow Astro on Twitter</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/twitter"
><path
fill="currentColor"
d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"
></path></svg
>
</a>
<a href="https://github.com/withastro/astro" target="_blank">
<span class="sr-only">Go to Astro's GitHub repo</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/github"
><path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
></path></svg
>
</a>
</div>
</footer>
<style>
footer {
padding: 2em 1em 6em 1em;
background: linear-gradient(var(--gray-gradient)) no-repeat;
color: rgb(var(--gray));
text-align: center;
}
.social-links {
display: flex;
justify-content: center;
gap: 1em;
margin-top: 1em;
}
.social-links a {
text-decoration: none;
color: rgb(var(--gray));
}
.social-links a:hover {
color: rgb(var(--gray-dark));
}
</style>