0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-24 23:21:57 -05:00

Actions experimental release (#10858)

* feat: port astro-actions poc

* feat: basic blog example

* feat: basic validationError class

* feat: standard error types and safe() wrapper

* refactor: move enhanceProps to astro:actions

* fix: throw internal server errors

* chore: refine enhance: true error message

* fix: remove FormData fallback from route

* refactor: clarify what enhance: true allows

* feat: progressively enhanced comments

* chore: changeset

* refactor: enhance -> acceptFormData

* wip: migrate actions to core

* feat: working actions demo from astro core!

* chore: changeset

* chore: delete old changeset

* fix: Function type lint

* refactor: expose defineAction from `astro:actions`

* fix: add null check to experimental

* fix: export `types/actions.d.ts`

* feat: more robust form data parsing

* feat: support formData from rpc call

* feat: remove acceptFormData flag requirement

* feat: add actions.d.ts type reference on startup

* refactor: actionNameProps -> getNameProps

* fix: actions type import

* chore: expose zod from `astro:actions`

* fix: zod export path

* feat: add explicit `accept` property

* Use zod package instead of relative path outside of src

* feat: clean up error throwing and handling flow

* fix: make `accept` optional

* docs: beef up actions experimental docs

* fix: defineAction type narrowing on `accept`

* fix: bad `getNameProps()` arg type

* refactor: move to single `error` object + `isInputError()` util

* fix: move res.json() parse to avoid double parse

* feat: support async zod schemas

* feat: serialize and expose zod properties on input error

* feat: test input error in comment example

* fix: remove ZodError import

* fix: add actions-module to files export

* fix: use workspace for test pkg versions

* refactor: default export -> server export

* fix: type inference for json vs. form

* refactor: accept form -> defineFormAction

* refactor: better callSafely signature

* feat: block action calls from the server with RFC link

* feat: move getActionResult to global

* refactor: getNameProps -> getActionProps

* refactor: body.toString()

* edit: capitAl

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* edit: highlight `actions`

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* edit: add actions file name

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* edit: not you can. You DO

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* edit: declare with feeling

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* edit: clarify what the `handler` does

* edit: schema -> input

* edit: add FormData mdn reference

* edit: add defineFormAction() explainer

* refactor: inline getDotAstroTypeRefs

* edit: yeah yeah maybe

* fix: existsSync test mock

* refactor: use callSafely in middleware

* test: upgradeFormData()

* chore: stray console log

* refactor: extract helper functions

* fix: include status in error response

* fix: return `undefined` when there's no action result

* fix: content-type

* test: e2e like button action

* test: comment e2e

* fix: existsSync mock for other sync test

* test: action dev server raw fetch

* test: build preview

* chore: fix lock

* fix: add dotAstroDir to existsSync

* chore: slim down e2e fixture

* chore: remove unneeded disabled test

* refactor: better api context error

* fix: return `false` for envDts

* refactor: defineFormAction -> defineAction with accept

* fix: check FormData on getActionProps

* edit: uppercase

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* fix: add switch default for 500

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* fix: add `toLowerCase()` on content-type check

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* chore: use VIRTUAL_MODULE_ID for plugin

* fix: remove incorrect ts-ignore

* chore: remove unneeded POST method check

* refactor: route callSafely

* refactor: error switch case to map

* chore: add link to trpc error code table

* fix: add readable error on failed json.stringify

* refactor: add param -> callerParam with comment

* feat: always return safe from getActionResult()

* refactor: move actions module to templates/

* refactor: remove unneeded existsSync on dotAstro

* fix: hasContentType util for toLowerCase()

* chore: comment on 415 code

* refactor: upgradeFormData -> formDataToObj

* fix: avoid leaking stack in production

* refactor: defineProperty with write false

* fix: revert package.json back to spaces

* edit: use config docs for changeset

* refactor: stringifiedActionsPath -> stringifiedActionsImport

* fix: avoid double-handling for route

* fix: support zero arg actions

* refactor: move actionHandler to helper fn

* fix: restore mdast deps

* docs: add `output` to config

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: bholmesdev <bholmesdev@gmail.com>
This commit is contained in:
Ben Holmes 2024-05-08 07:53:17 -04:00 committed by GitHub
parent 6382d7d238
commit c0c509b6bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2320 additions and 52 deletions

View file

@ -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 (
<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).

View file

@ -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 {

View file

@ -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);
});
});

View file

@ -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,
},
});

View file

@ -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 },
});

View file

@ -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!",
});
}

View file

@ -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"
}
}

View file

@ -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;
},
}),
},
};

View file

@ -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)} />

View file

@ -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>

View file

@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

View file

@ -0,0 +1,83 @@
---
import HeaderLink from './HeaderLink.astro';
import { SITE_TITLE } from '../consts';
---
<header>
<nav>
<h2><a href="/">{SITE_TITLE}</a></h2>
<div class="internal-links">
<HeaderLink href="/blog">Blog</HeaderLink>
</div>
<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"
><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"
><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"
><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>
</nav>
</header>
<style>
header {
margin: 0;
padding: 0 1em;
background: white;
box-shadow: 0 2px 8px rgba(var(--black), 5%);
}
h2 {
margin: 0;
font-size: 1em;
}
h2 a,
h2 a.active {
text-decoration: none;
}
nav {
display: flex;
align-items: center;
justify-content: space-between;
}
nav a {
padding: 1em 0.5em;
color: var(--black);
border-bottom: 4px solid transparent;
text-decoration: none;
}
nav a.active {
text-decoration: none;
border-bottom-color: var(--accent);
}
.social-links,
.social-links a {
display: flex;
}
@media (max-width: 720px) {
.social-links {
display: none;
}
}
</style>

View file

@ -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];
---
<a href={href} class:list={[className, { active: isActive }]} {...props}>
<slot />
</a>
<style>
a {
display: inline-block;
text-decoration: none;
}
a.active {
font-weight: bolder;
text-decoration: underline;
}
</style>

View file

@ -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 (
<button
aria-label="Like"
disabled={pending}
onClick={async () => {
setPending(true);
setLikes(await actions.blog.like({ postId }));
setPending(false);
}}
type="submit"
>
{likes}
</button>
);
}

View file

@ -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<string | undefined>(serverBodyError);
return (
<>
<form
method="POST"
onSubmit={async (e) => {
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();
}}
>
<input {...getActionProps(actions.blog.comment)} />
<input type="hidden" name="postId" value={postId} />
<label className="sr-only" htmlFor="author">
Author
</label>
<input id="author" type="text" name="author" placeholder="Your name" />
<textarea rows={10} name="body"></textarea>
{bodyError && (
<p data-error="body" style={{ color: 'red' }}>
{bodyError}
</p>
)}
<button aria-label="Post comment" type="submit">
Post
</button>
</form>
{comments.map((c) => (
<article
data-testid="comment"
key={c.body}
style={{
border: '2px solid color-mix(in srgb, var(--accent), transparent 80%)',
padding: '0.3rem 1rem',
borderRadius: '0.3rem',
marginBlock: '0.3rem',
}}
>
<p>{c.body}</p>
<p>{c.author}</p>
</article>
))}
</>
);
}

View file

@ -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!';

View file

@ -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.

View file

@ -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 };

View file

@ -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;
---
<html lang="en">
<head>
<BaseHead title={title} description={description} />
<style>
main {
width: calc(100% - 2em);
max-width: 100%;
margin: 0;
}
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
box-shadow: var(--box-shadow);
}
.prose {
width: 720px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
color: rgb(var(--gray-dark));
}
.title {
margin-bottom: 1em;
padding: 1em 0;
text-align: center;
line-height: 1;
}
.title h1 {
margin: 0 0 0.5em 0;
}
.date {
margin-bottom: 0.5em;
color: rgb(var(--gray));
}
.last-updated-on {
font-style: italic;
}
</style>
</head>
<body>
<Header />
<main>
<article>
<div class="hero-image">
{heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
</div>
<div class="prose">
<div class="title">
<div class="date">
<FormattedDate date={pubDate} />
{
updatedDate && (
<div class="last-updated-on">
Last updated on <FormattedDate date={updatedDate} />
</div>
)
}
</div>
<h1>{title}</h1>
<hr />
</div>
<slot />
</div>
</article>
</main>
<Footer />
</body>
</html>

View file

@ -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();
---
<BlogPost {...post.data}>
<Like postId={post.id} initial={initialLikes?.likes ?? 0} client:load />
<Content />
<h2>Comments</h2>
<PostComment
postId={post.id}
serverBodyError={isInputError(comment?.error)
? comment.error.fields.body?.toString()
: undefined}
client:load
/>
<div>
{
comments.map((c) => (
<article>
<p>{c.body}</p>
<p>{c.author}</p>
</article>
))
}
</div>
</BlogPost>
<style>
.error {
color: red;
}
</style>

View file

@ -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()
);
---
<!doctype html>
<html lang="en">
<head>
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
<style>
main {
width: 960px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 2rem;
list-style-type: none;
margin: 0;
padding: 0;
}
ul li {
width: calc(50% - 1rem);
}
ul li * {
text-decoration: none;
transition: 0.2s ease;
}
ul li:first-child {
width: 100%;
margin-bottom: 1rem;
text-align: center;
}
ul li:first-child img {
width: 100%;
}
ul li:first-child .title {
font-size: 2.369rem;
}
ul li img {
margin-bottom: 0.5rem;
border-radius: 12px;
}
ul li a {
display: block;
}
.title {
margin: 0;
color: rgb(var(--black));
line-height: 1;
}
.date {
margin: 0;
color: rgb(var(--gray));
}
ul li a:hover h4,
ul li a:hover .date {
color: rgb(var(--accent));
}
ul a:hover img {
box-shadow: var(--box-shadow);
}
@media (max-width: 720px) {
ul {
gap: 0.5em;
}
ul li {
width: 100%;
text-align: center;
}
ul li:first-child {
margin-bottom: 0;
}
ul li:first-child .title {
font-size: 1.563em;
}
}
</style>
</head>
<body>
<Header />
<main>
<section>
<ul>
{
posts.map((post) => (
<li>
<a href={`/blog/${post.slug}/`}>
<img width={720} height={360} src={post.data.heroImage} alt="" />
<h4 class="title">{post.data.title}</h4>
<p class="date">
<FormattedDate date={post.data.pubDate} />
</p>
</a>
</li>
))
}
</ul>
</section>
</main>
<Footer />
</body>
</html>

View file

@ -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;
}

View file

@ -0,0 +1,8 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true,
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

View file

@ -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"

View file

@ -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 (
* <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).
*/
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<TAccept>,
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
>(
action: TAction
) => Awaited<ReturnType<TAction['safe']>> | undefined;
/**
* Route parameters for this request if this is a dynamic route.
*/

View file

@ -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';

View file

@ -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);
}

View file

@ -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();
}

View file

@ -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',
},
});
};

View file

@ -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<APIContext, 'getActionResult' | 'props'>;
export const ApiContextStorage = new AsyncLocalStorage<ActionAPIContext>();
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;
}

View file

@ -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> = T | Promise<T>;
export async function getAction(
pathKeys: string[]
): Promise<(param: unknown) => MaybePromise<unknown>> {
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;
}

View file

@ -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.');
},
}
);

View file

@ -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 Accept> = T extends 'form'
? z.AnyZodObject | z.ZodType<FormData>
: z.ZodType;
type Handler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>) => MaybePromise<TOutput>
: (input?: any) => MaybePromise<TOutput>;
export type ActionClient<
TOutput,
TAccept extends Accept,
TInputSchema extends InputSchema<TAccept> | undefined,
> = TInputSchema extends z.ZodType
? ((
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
) => Promise<Awaited<TOutput>>) & {
safe: (
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
) => Promise<
SafeResult<
z.input<TInputSchema> extends ErrorInferenceObject
? z.input<TInputSchema>
: ErrorInferenceObject,
Awaited<TOutput>
>
>;
}
: ((input?: any) => Promise<Awaited<TOutput>>) & {
safe: (input?: any) => Promise<SafeResult<never, Awaited<TOutput>>>;
};
export function defineAction<
TOutput,
TAccept extends Accept = 'json',
TInputSchema extends InputSchema<Accept> | undefined = TAccept extends 'form'
? // If `input` is omitted, default to `FormData` for forms and `any` for JSON.
z.ZodType<FormData>
: undefined,
>({
accept,
input: inputSchema,
handler,
}: {
input?: TInputSchema;
accept?: TAccept;
handler: Handler<TInputSchema, TOutput>;
}): ActionClient<TOutput, TAccept, TInputSchema> {
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<TOutput, TAccept, TInputSchema>;
}
function getFormServerHandler<TOutput, TInputSchema extends InputSchema<'form'>>(
handler: Handler<TInputSchema, TOutput>,
inputSchema?: TInputSchema
) {
return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => {
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<TOutput, TInputSchema extends InputSchema<'json'>>(
handler: Handler<TInputSchema, TOutput>,
inputSchema?: TInputSchema
) {
return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => {
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<T extends z.AnyZodObject>(
formData: FormData,
schema: T
): Record<string, unknown> {
const obj: Record<string, unknown> = {};
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<z.ZodUnknown>
) {
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;
}

View file

@ -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<ActionErrorCode, number> = {
// 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<number, ActionErrorCode> = Object.entries(codeToStatusMap).reduce(
// reverse the key-value pairs
(acc, [key, value]) => ({ ...acc, [value]: key }),
{}
);
export type ErrorInferenceObject = Record<string, any>;
export class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject> 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<T extends ErrorInferenceObject>(
error?: ActionError<T>
): error is ActionInputError<T> {
return error instanceof ActionInputError;
}
export type SafeResult<TInput extends ErrorInferenceObject, TOutput> =
| {
data: TOutput;
error: undefined;
}
| {
data: undefined;
error: ActionError<TInput>;
};
export class ActionInputError<T extends ErrorInferenceObject> 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<T>['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<TOutput>(
handler: () => MaybePromise<TOutput>
): Promise<SafeResult<z.ZodType, TOutput>> {
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<T extends (args: FormData) => MaybePromise<unknown>>(action: T) {
return {
type: 'hidden',
name: '_astroAction',
value: action.toString(),
} as const;
}

View file

@ -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);
};
}

View file

@ -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';

View file

@ -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 `/// <reference path=${JSON.stringify(contentTypesRelativeToSrcDir)} />`;
}
export const contentConfigParser = z.object({
collections: z.record(collectionConfigParser),
});

View file

@ -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()

View file

@ -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<APIContext, 'getActionResult'> = {
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),
});
}
/**

View file

@ -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,

View file

@ -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 };

View file

@ -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('/// <reference types="astro/client" />');
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(`/// <reference path=${JSON.stringify(typesRelativeToSrcDir)} />`);
}
return referenceDefs;
}

View file

@ -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();

View file

@ -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');
});
});
});

View file

@ -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 `/// <reference path="astro/client" />`;
} 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());
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
output: 'server',
experimental: {
actions: true,
},
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/actions",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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,
};
},
}),
};

View file

@ -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]);
});
});

3
packages/astro/types/actions.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module 'astro:actions' {
export * from 'astro/actions/runtime/virtual/server.js';
}

52
pnpm-lock.yaml generated
View file

@ -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==}