mirror of
https://github.com/withastro/astro.git
synced 2025-03-24 23:21:57 -05:00
Merge branch 'main' into feat/refresh-content
This commit is contained in:
commit
8fabda7e00
29 changed files with 361 additions and 418 deletions
17
.changeset/perfect-wasps-grow.md
Normal file
17
.changeset/perfect-wasps-grow.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Exposes `z` from the new `astro:schema` module. This is the new recommended import source for all Zod utilities when using Astro Actions.
|
||||
|
||||
## Migration for Astro Actions users
|
||||
|
||||
`z` will no longer be exposed from `astro:actions`. To use `z` in your actions, import it from `astro:schema` instead:
|
||||
|
||||
```diff
|
||||
import {
|
||||
defineAction,
|
||||
- z,
|
||||
} from 'astro:actions';
|
||||
+ import { z } from 'astro:schema';
|
||||
```
|
38
.changeset/spicy-suits-explode.md
Normal file
38
.changeset/spicy-suits-explode.md
Normal file
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
"astro": minor
|
||||
---
|
||||
|
||||
The Astro Actions API introduced behind a flag in [v4.8.0](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md#480) is no longer experimental and is available for general use.
|
||||
|
||||
Astro Actions allow you to define and call backend functions with type-safety, performing data fetching, JSON parsing, and input validation for you.
|
||||
|
||||
Actions can be called from client-side components and HTML forms. This gives you to flexibility to build apps using any technology: React, Svelte, HTMX, or just plain Astro components. This example calls a newsletter action and renders the result using an Astro component:
|
||||
|
||||
```astro
|
||||
---
|
||||
// src/pages/newsletter.astro
|
||||
import { actions } from 'astro:actions';
|
||||
const result = Astro.getActionResult(actions.newsletter);
|
||||
---
|
||||
{result && !result.error && <p>Thanks for signing up!</p>}
|
||||
<form method="POST" action={actions.newsletter}>
|
||||
<input type="email" name="email" />
|
||||
<button>Sign up</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
If you were previously using this feature, please remove the experimental flag from your Astro config:
|
||||
|
||||
```diff
|
||||
import { defineConfig } from 'astro'
|
||||
|
||||
export default defineConfig({
|
||||
- experimental: {
|
||||
- actions: true,
|
||||
- }
|
||||
})
|
||||
```
|
||||
|
||||
If you have been waiting for stabilization before using Actions, you can now do so.
|
||||
|
||||
For more information and usage examples, see our [brand new Actions guide](https://docs.astro.build/en/guides/actions).
|
4
packages/astro/client.d.ts
vendored
4
packages/astro/client.d.ts
vendored
|
@ -175,6 +175,10 @@ declare module 'astro:components' {
|
|||
export * from 'astro/components';
|
||||
}
|
||||
|
||||
declare module 'astro:schema' {
|
||||
export * from 'astro/zod';
|
||||
}
|
||||
|
||||
type MD = import('./dist/@types/astro.js').MarkdownInstance<Record<string, any>>;
|
||||
interface ExportedMarkdownModuleEntities {
|
||||
frontmatter: MD['frontmatter'];
|
||||
|
|
|
@ -11,7 +11,4 @@ export default defineConfig({
|
|||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
experimental: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { db, Comment, Likes, eq, sql } from 'astro:db';
|
||||
import { ActionError, defineAction, z } from 'astro:actions';
|
||||
import { ActionError, defineAction } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export const server = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getActionProps, actions, isInputError } from 'astro:actions';
|
||||
import { actions, isInputError } from 'astro:actions';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function PostComment({
|
||||
|
@ -17,6 +17,7 @@ export function PostComment({
|
|||
<form
|
||||
method="POST"
|
||||
data-testid="client"
|
||||
action={actions.blog.comment}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
|
@ -32,12 +33,13 @@ export function PostComment({
|
|||
form.reset();
|
||||
}}
|
||||
>
|
||||
{unexpectedError && <p data-error="unexpected" style={{ color: 'red' }}>{unexpectedError}</p>}
|
||||
<input {...getActionProps(actions.blog.comment)} />
|
||||
{unexpectedError && (
|
||||
<p data-error="unexpected" style={{ color: 'red' }}>
|
||||
{unexpectedError}
|
||||
</p>
|
||||
)}
|
||||
<input type="hidden" name="postId" value={postId} />
|
||||
<label htmlFor="author">
|
||||
Author
|
||||
</label>
|
||||
<label htmlFor="author">Author</label>
|
||||
<input id="author" type="text" name="author" placeholder="Your name" />
|
||||
<textarea rows={10} name="body"></textarea>
|
||||
{bodyError && (
|
||||
|
@ -45,25 +47,23 @@ export function PostComment({
|
|||
{bodyError}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit">
|
||||
Post
|
||||
</button>
|
||||
<button type="submit">Post</button>
|
||||
</form>
|
||||
<div data-testid="client-comments">
|
||||
{comments.map((c) => (
|
||||
<article
|
||||
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>
|
||||
))}
|
||||
{comments.map((c) => (
|
||||
<article
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,4 @@ export default defineConfig({
|
|||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
experimental: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { db, Likes, eq, sql } from 'astro:db';
|
||||
import { defineAction, z, type SafeResult } from 'astro:actions';
|
||||
import { defineAction, type SafeResult } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
import { experimental_getActionState } from '@astrojs/react/actions';
|
||||
|
||||
export const server = {
|
||||
|
|
|
@ -7,7 +7,10 @@ process.stdout.isTTY = false;
|
|||
|
||||
export default defineConfig({
|
||||
testMatch: 'e2e/*.test.js',
|
||||
timeout: 40000,
|
||||
timeout: 40_000,
|
||||
expect: {
|
||||
timeout: 6_000,
|
||||
},
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
|
|
@ -8,7 +8,10 @@ process.stdout.isTTY = false;
|
|||
export default defineConfig({
|
||||
// TODO: add more tests like view transitions and audits, and fix them. Some of them are failing.
|
||||
testMatch: ['e2e/css.test.js', 'e2e/prefetch.test.js', 'e2e/view-transitions.test.js'],
|
||||
timeout: 40000,
|
||||
timeout: 40_000,
|
||||
expect: {
|
||||
timeout: 6_000,
|
||||
},
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
|
|
@ -1833,107 +1833,6 @@ export interface AstroUserConfig {
|
|||
*/
|
||||
directRenderScript?: boolean;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name experimental.actions
|
||||
* @type {boolean}
|
||||
* @default `false`
|
||||
* @version 4.8.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. An action accepts 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 }) => {
|
||||
* // 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 }) => {
|
||||
* // 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.
|
||||
*
|
||||
* This example calls the `like` and `comment` actions from a React component:
|
||||
*
|
||||
* ```tsx "actions"
|
||||
* // src/components/blog.tsx
|
||||
* import { actions } from "astro:actions";
|
||||
* import { useState } from "react";
|
||||
*
|
||||
* 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 as HTMLFormElement);
|
||||
* const result = await actions.blog.comment(formData);
|
||||
* // handle result
|
||||
* }}
|
||||
* >
|
||||
* <input type="hidden" name="postId" value={postId} />
|
||||
* <label htmlFor="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
|
||||
|
|
|
@ -1,125 +0,0 @@
|
|||
import fsMod from 'node:fs';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type { AstroIntegration, AstroSettings } from '../@types/astro.js';
|
||||
import { ActionsWithoutServerOutputError } from '../core/errors/errors-data.js';
|
||||
import { AstroError } from '../core/errors/errors.js';
|
||||
import { isServerLikeOutput, viteID } from '../core/util.js';
|
||||
import {
|
||||
ACTIONS_TYPES_FILE,
|
||||
NOOP_ACTIONS,
|
||||
RESOLVED_VIRTUAL_INTERNAL_MODULE_ID,
|
||||
RESOLVED_VIRTUAL_MODULE_ID,
|
||||
VIRTUAL_INTERNAL_MODULE_ID,
|
||||
VIRTUAL_MODULE_ID,
|
||||
} from './consts.js';
|
||||
|
||||
export default function astroActions({
|
||||
fs = fsMod,
|
||||
settings,
|
||||
}: {
|
||||
fs?: typeof fsMod;
|
||||
settings: AstroSettings;
|
||||
}): AstroIntegration {
|
||||
return {
|
||||
name: VIRTUAL_MODULE_ID,
|
||||
hooks: {
|
||||
async 'astro:config:setup'(params) {
|
||||
if (!isServerLikeOutput(params.config)) {
|
||||
const error = new AstroError(ActionsWithoutServerOutputError);
|
||||
error.stack = undefined;
|
||||
throw error;
|
||||
}
|
||||
|
||||
params.updateConfig({
|
||||
vite: {
|
||||
plugins: [vitePluginUserActions({ settings }), vitePluginActions(fs)],
|
||||
},
|
||||
});
|
||||
|
||||
params.injectRoute({
|
||||
pattern: '/_actions/[...path]',
|
||||
entrypoint: 'astro/actions/runtime/route.js',
|
||||
prerender: false,
|
||||
});
|
||||
|
||||
params.addMiddleware({
|
||||
entrypoint: 'astro/actions/runtime/middleware.js',
|
||||
order: 'post',
|
||||
});
|
||||
},
|
||||
'astro:config:done': (params) => {
|
||||
const stringifiedActionsImport = JSON.stringify(
|
||||
viteID(new URL('./actions', params.config.srcDir)),
|
||||
);
|
||||
settings.injectedTypes.push({
|
||||
filename: ACTIONS_TYPES_FILE,
|
||||
content: `declare module "astro:actions" {
|
||||
type Actions = typeof import(${stringifiedActionsImport})["server"];
|
||||
|
||||
export const actions: Actions;
|
||||
}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin is responsible to load the known file `actions/index.js` / `actions.js`
|
||||
* If the file doesn't exist, it returns an empty object.
|
||||
* @param settings
|
||||
*/
|
||||
export function vitePluginUserActions({ settings }: { settings: AstroSettings }): VitePlugin {
|
||||
let resolvedActionsId: string;
|
||||
return {
|
||||
name: '@astro/plugin-actions',
|
||||
async resolveId(id) {
|
||||
if (id === NOOP_ACTIONS) {
|
||||
return NOOP_ACTIONS;
|
||||
}
|
||||
if (id === VIRTUAL_INTERNAL_MODULE_ID) {
|
||||
const resolvedModule = await this.resolve(
|
||||
`${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`,
|
||||
);
|
||||
|
||||
if (!resolvedModule) {
|
||||
return NOOP_ACTIONS;
|
||||
}
|
||||
resolvedActionsId = resolvedModule.id;
|
||||
return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === NOOP_ACTIONS) {
|
||||
return 'export const server = {}';
|
||||
} else if (id === RESOLVED_VIRTUAL_INTERNAL_MODULE_ID) {
|
||||
return `export { server } from '${resolvedActionsId}';`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const vitePluginActions = (fs: typeof fsMod): 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 fs.promises.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;
|
||||
},
|
||||
});
|
52
packages/astro/src/actions/integration.ts
Normal file
52
packages/astro/src/actions/integration.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import type { AstroIntegration, AstroSettings } from '../@types/astro.js';
|
||||
import { ActionsWithoutServerOutputError } from '../core/errors/errors-data.js';
|
||||
import { AstroError } from '../core/errors/errors.js';
|
||||
import { isServerLikeOutput, viteID } from '../core/util.js';
|
||||
import { ACTIONS_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js';
|
||||
|
||||
/**
|
||||
* This integration is applied when the user is using Actions in their project.
|
||||
* It will inject the necessary routes and middlewares to handle actions.
|
||||
*/
|
||||
export default function astroIntegrationActionsRouteHandler({
|
||||
settings,
|
||||
}: {
|
||||
settings: AstroSettings;
|
||||
}): AstroIntegration {
|
||||
return {
|
||||
name: VIRTUAL_MODULE_ID,
|
||||
hooks: {
|
||||
async 'astro:config:setup'(params) {
|
||||
params.injectRoute({
|
||||
pattern: '/_actions/[...path]',
|
||||
entrypoint: 'astro/actions/runtime/route.js',
|
||||
prerender: false,
|
||||
});
|
||||
|
||||
params.addMiddleware({
|
||||
entrypoint: 'astro/actions/runtime/middleware.js',
|
||||
order: 'post',
|
||||
});
|
||||
},
|
||||
'astro:config:done': async (params) => {
|
||||
if (!isServerLikeOutput(params.config)) {
|
||||
const error = new AstroError(ActionsWithoutServerOutputError);
|
||||
error.stack = undefined;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stringifiedActionsImport = JSON.stringify(
|
||||
viteID(new URL('./actions', params.config.srcDir)),
|
||||
);
|
||||
settings.injectedTypes.push({
|
||||
filename: ACTIONS_TYPES_FILE,
|
||||
content: `declare module "astro:actions" {
|
||||
type Actions = typeof import(${stringifiedActionsImport})["server"];
|
||||
|
||||
export const actions: Actions;
|
||||
}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
91
packages/astro/src/actions/plugins.ts
Normal file
91
packages/astro/src/actions/plugins.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import type fsMod from 'node:fs';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
import type { AstroSettings } from '../@types/astro.js';
|
||||
import {
|
||||
NOOP_ACTIONS,
|
||||
RESOLVED_VIRTUAL_INTERNAL_MODULE_ID,
|
||||
RESOLVED_VIRTUAL_MODULE_ID,
|
||||
VIRTUAL_INTERNAL_MODULE_ID,
|
||||
VIRTUAL_MODULE_ID,
|
||||
} from './consts.js';
|
||||
import { isActionsFilePresent } from './utils.js';
|
||||
|
||||
/**
|
||||
* This plugin is responsible to load the known file `actions/index.js` / `actions.js`
|
||||
* If the file doesn't exist, it returns an empty object.
|
||||
* @param settings
|
||||
*/
|
||||
export function vitePluginUserActions({ settings }: { settings: AstroSettings }): VitePlugin {
|
||||
let resolvedActionsId: string;
|
||||
return {
|
||||
name: '@astro/plugin-actions',
|
||||
async resolveId(id) {
|
||||
if (id === NOOP_ACTIONS) {
|
||||
return NOOP_ACTIONS;
|
||||
}
|
||||
if (id === VIRTUAL_INTERNAL_MODULE_ID) {
|
||||
const resolvedModule = await this.resolve(
|
||||
`${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`,
|
||||
);
|
||||
|
||||
if (!resolvedModule) {
|
||||
return NOOP_ACTIONS;
|
||||
}
|
||||
resolvedActionsId = resolvedModule.id;
|
||||
return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === NOOP_ACTIONS) {
|
||||
return 'export const server = {}';
|
||||
} else if (id === RESOLVED_VIRTUAL_INTERNAL_MODULE_ID) {
|
||||
return `export { server } from '${resolvedActionsId}';`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function vitePluginActions({
|
||||
fs,
|
||||
settings,
|
||||
}: {
|
||||
fs: typeof fsMod;
|
||||
settings: AstroSettings;
|
||||
}): VitePlugin {
|
||||
return {
|
||||
name: VIRTUAL_MODULE_ID,
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
async configureServer(server) {
|
||||
const filePresentOnStartup = await isActionsFilePresent(fs, settings.config.srcDir);
|
||||
// Watch for the actions file to be created.
|
||||
async function watcherCallback() {
|
||||
const filePresent = await isActionsFilePresent(fs, settings.config.srcDir);
|
||||
if (filePresentOnStartup !== filePresent) {
|
||||
server.restart();
|
||||
}
|
||||
}
|
||||
server.watcher.on('add', watcherCallback);
|
||||
server.watcher.on('change', watcherCallback);
|
||||
},
|
||||
async load(id, opts) {
|
||||
if (id !== RESOLVED_VIRTUAL_MODULE_ID) return;
|
||||
|
||||
let code = await fs.promises.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;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
import { yellow } from 'kleur/colors';
|
||||
import type { APIContext, MiddlewareNext } from '../../@types/astro.js';
|
||||
import { ActionQueryStringInvalidError } from '../../core/errors/errors-data.js';
|
||||
import { AstroError } from '../../core/errors/errors.js';
|
||||
import { defineMiddleware } from '../../core/middleware/index.js';
|
||||
import { ACTION_QUERY_PARAMS } from '../consts.js';
|
||||
import { formContentTypes, hasContentType } from './utils.js';
|
||||
|
@ -54,10 +52,6 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
return handlePost({ context, next, actionName });
|
||||
}
|
||||
|
||||
if (context.request.method === 'POST') {
|
||||
return handlePostLegacy({ context, next });
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
|
@ -98,14 +92,7 @@ async function handlePost({
|
|||
actionName: string;
|
||||
}) {
|
||||
const { request } = context;
|
||||
|
||||
const baseAction = await getAction(actionName);
|
||||
if (!baseAction) {
|
||||
throw new AstroError({
|
||||
...ActionQueryStringInvalidError,
|
||||
message: ActionQueryStringInvalidError.message(actionName),
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
let formData: FormData | undefined;
|
||||
|
@ -153,38 +140,6 @@ async function redirectWithResult({
|
|||
return context.redirect(context.url.pathname);
|
||||
}
|
||||
|
||||
async function handlePostLegacy({ context, next }: { context: APIContext; next: MiddlewareNext }) {
|
||||
const { request } = context;
|
||||
|
||||
// We should not run a middleware handler for fetch()
|
||||
// requests directly to the /_actions URL.
|
||||
// Otherwise, we may handle the result twice.
|
||||
if (context.url.pathname.startsWith('/_actions')) return next();
|
||||
|
||||
const contentType = request.headers.get('content-type');
|
||||
let formData: FormData | undefined;
|
||||
if (contentType && hasContentType(contentType, formContentTypes)) {
|
||||
formData = await request.clone().formData();
|
||||
}
|
||||
|
||||
if (!formData) return next();
|
||||
|
||||
const actionName = formData.get(ACTION_QUERY_PARAMS.actionName) as string;
|
||||
if (!actionName) return next();
|
||||
|
||||
const baseAction = await getAction(actionName);
|
||||
if (!baseAction) {
|
||||
throw new AstroError({
|
||||
...ActionQueryStringInvalidError,
|
||||
message: ActionQueryStringInvalidError.message(actionName),
|
||||
});
|
||||
}
|
||||
|
||||
const action = baseAction.bind(context);
|
||||
const actionResult = await action(formData);
|
||||
return redirectWithResult({ context, actionName, actionResult });
|
||||
}
|
||||
|
||||
function isActionPayload(json: unknown): json is ActionPayload {
|
||||
if (typeof json !== 'object' || json == null) return false;
|
||||
|
||||
|
|
|
@ -5,9 +5,14 @@ import { serializeActionResult } from './virtual/shared.js';
|
|||
|
||||
export const POST: APIRoute = async (context) => {
|
||||
const { request, url } = context;
|
||||
const baseAction = await getAction(url.pathname);
|
||||
if (!baseAction) {
|
||||
return new Response(null, { status: 404 });
|
||||
let baseAction;
|
||||
try {
|
||||
baseAction = await getAction(url.pathname);
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) throw e;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
return new Response(e instanceof Error ? e.message : null, { status: 404 });
|
||||
}
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
const contentLength = request.headers.get('Content-Length');
|
||||
|
|
|
@ -3,12 +3,3 @@ export * from './shared.js';
|
|||
export function defineAction() {
|
||||
throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.');
|
||||
}
|
||||
|
||||
export const z = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('[astro:action] `z` unexpectedly used on the client.');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import type { ZodType } from 'zod';
|
||||
import { ActionNotFoundError } from '../../../core/errors/errors-data.js';
|
||||
import { AstroError } from '../../../core/errors/errors.js';
|
||||
import type { ActionAccept, ActionClient } from './server.js';
|
||||
|
||||
/**
|
||||
|
@ -8,19 +10,30 @@ import type { ActionAccept, ActionClient } from './server.js';
|
|||
*/
|
||||
export async function getAction(
|
||||
path: string,
|
||||
): Promise<ActionClient<unknown, ActionAccept, ZodType> | undefined> {
|
||||
): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
|
||||
const pathKeys = path.replace('/_actions/', '').split('.');
|
||||
// @ts-expect-error virtual module
|
||||
let { server: actionLookup } = await import('astro:internal-actions');
|
||||
|
||||
if (actionLookup == null || !(typeof actionLookup === 'object')) {
|
||||
throw new TypeError(
|
||||
`Expected \`server\` export in actions file to be an object. Received ${typeof actionLookup}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of pathKeys) {
|
||||
if (!(key in actionLookup)) {
|
||||
return undefined;
|
||||
throw new AstroError({
|
||||
...ActionNotFoundError,
|
||||
message: ActionNotFoundError.message(pathKeys.join('.')),
|
||||
});
|
||||
}
|
||||
actionLookup = actionLookup[key];
|
||||
}
|
||||
if (typeof actionLookup !== 'function') {
|
||||
return undefined;
|
||||
throw new TypeError(
|
||||
`Expected handler for action ${pathKeys.join('.')} to be a function. Received ${typeof actionLookup}.`,
|
||||
);
|
||||
}
|
||||
return actionLookup;
|
||||
}
|
||||
|
|
|
@ -6,8 +6,6 @@ import { ActionError, ActionInputError, type SafeResult, callSafely } from './sh
|
|||
|
||||
export * from './shared.js';
|
||||
|
||||
export { z } from 'zod';
|
||||
|
||||
export type ActionAccept = 'form' | 'json';
|
||||
|
||||
export type ActionHandler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
|
||||
|
|
|
@ -181,26 +181,6 @@ export function getActionQueryString(name: string) {
|
|||
return `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated You can now pass action functions
|
||||
* directly to the `action` attribute on a form.
|
||||
*
|
||||
* Example: `<form action={actions.like} />`
|
||||
*/
|
||||
export function getActionProps<T extends (args: FormData) => MaybePromise<unknown>>(action: T) {
|
||||
const params = new URLSearchParams(action.toString());
|
||||
const actionName = params.get('_astroAction');
|
||||
if (!actionName) {
|
||||
// No need for AstroError. `getActionProps()` will be removed for stable.
|
||||
throw new Error('Invalid actions function was passed to getActionProps()');
|
||||
}
|
||||
return {
|
||||
type: 'hidden',
|
||||
name: '_astroAction',
|
||||
value: actionName,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export type SerializedActionResult =
|
||||
| {
|
||||
type: 'data';
|
||||
|
@ -269,10 +249,22 @@ export function serializeActionResult(res: SafeResult<any, any>): SerializedActi
|
|||
|
||||
export function deserializeActionResult(res: SerializedActionResult): SafeResult<any, any> {
|
||||
if (res.type === 'error') {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(res.body);
|
||||
} catch {
|
||||
return {
|
||||
data: undefined,
|
||||
error: new ActionError({
|
||||
message: res.body,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (import.meta.env?.PROD) {
|
||||
return { error: ActionError.fromJson(JSON.parse(res.body)), data: undefined };
|
||||
return { error: ActionError.fromJson(json), data: undefined };
|
||||
} else {
|
||||
const error = ActionError.fromJson(JSON.parse(res.body));
|
||||
const error = ActionError.fromJson(json);
|
||||
error.stack = actionResultErrorStack.get();
|
||||
return {
|
||||
error,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import type fsMod from 'node:fs';
|
||||
import * as eslexer from 'es-module-lexer';
|
||||
import type { APIContext } from '../@types/astro.js';
|
||||
import type { Locals } from './runtime/middleware.js';
|
||||
import type { ActionAPIContext } from './runtime/utils.js';
|
||||
|
@ -25,3 +27,53 @@ export function createCallAction(context: ActionAPIContext): APIContext['callAct
|
|||
return action(input) as any;
|
||||
};
|
||||
}
|
||||
|
||||
let didInitLexer = false;
|
||||
|
||||
/**
|
||||
* Check whether the Actions config file is present.
|
||||
*/
|
||||
export async function isActionsFilePresent(fs: typeof fsMod, srcDir: URL) {
|
||||
if (!didInitLexer) await eslexer.init;
|
||||
|
||||
const actionsFile = search(fs, srcDir);
|
||||
if (!actionsFile) return false;
|
||||
|
||||
let contents: string;
|
||||
try {
|
||||
contents = fs.readFileSync(actionsFile, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if `server` export is present.
|
||||
// If not, the user may have an empty `actions` file,
|
||||
// or may be using the `actions` file for another purpose
|
||||
// (possible since actions are non-breaking for v4.X).
|
||||
const [, exports] = eslexer.parse(contents, actionsFile.pathname);
|
||||
for (const exp of exports) {
|
||||
if (exp.n === 'server') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function search(fs: typeof fsMod, srcDir: URL) {
|
||||
const paths = [
|
||||
'actions.mjs',
|
||||
'actions.js',
|
||||
'actions.mts',
|
||||
'actions.ts',
|
||||
'actions/index.mjs',
|
||||
'actions/index.js',
|
||||
'actions/index.mts',
|
||||
'actions/index.ts',
|
||||
].map((p) => new URL(p, srcDir));
|
||||
for (const file of paths) {
|
||||
if (fs.existsSync(file)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -83,7 +83,6 @@ export const ASTRO_CONFIG_DEFAULTS = {
|
|||
redirects: {},
|
||||
security: {},
|
||||
experimental: {
|
||||
actions: false,
|
||||
directRenderScript: false,
|
||||
contentCollectionCache: false,
|
||||
clientPrerender: false,
|
||||
|
@ -510,7 +509,6 @@ export const AstroConfigSchema = z.object({
|
|||
.default(ASTRO_CONFIG_DEFAULTS.security),
|
||||
experimental: z
|
||||
.object({
|
||||
actions: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.actions),
|
||||
directRenderScript: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
|
|
@ -4,6 +4,7 @@ import glob from 'fast-glob';
|
|||
import * as vite from 'vite';
|
||||
import { crawlFrameworkPkgs } from 'vitefu';
|
||||
import type { AstroSettings } from '../@types/astro.js';
|
||||
import { vitePluginActions, vitePluginUserActions } from '../actions/plugins.js';
|
||||
import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js';
|
||||
import astroAssetsPlugin from '../assets/vite-plugin-assets.js';
|
||||
import astroContainer from '../container/vite-plugin-container.js';
|
||||
|
@ -153,6 +154,8 @@ export async function createVite(
|
|||
astroDevToolbar({ settings, logger }),
|
||||
vitePluginFileURL(),
|
||||
astroInternationalization({ settings }),
|
||||
vitePluginActions({ fs, settings }),
|
||||
vitePluginUserActions({ settings }),
|
||||
settings.config.experimental.serverIslands && vitePluginServerIslands({ settings }),
|
||||
astroContainer(),
|
||||
],
|
||||
|
@ -191,6 +194,10 @@ export async function createVite(
|
|||
find: 'astro:middleware',
|
||||
replacement: 'astro/virtual-modules/middleware.js',
|
||||
},
|
||||
{
|
||||
find: 'astro:schema',
|
||||
replacement: 'astro/zod',
|
||||
},
|
||||
{
|
||||
find: 'astro:components',
|
||||
replacement: 'astro/components',
|
||||
|
|
|
@ -1667,23 +1667,7 @@ export const ActionsWithoutServerOutputError = {
|
|||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
|
||||
* @description
|
||||
* Action was called from a form using a GET request, but only POST requests are supported. This often occurs if `method="POST"` is missing on the form.
|
||||
* @deprecated Deprecated since version 4.13.2.
|
||||
*/
|
||||
export const ActionsUsedWithForGetError = {
|
||||
name: 'ActionsUsedWithForGetError',
|
||||
title: 'An invalid Action query string was passed by a form.',
|
||||
message: (actionName: string) =>
|
||||
`Action ${actionName} was called from a form using a GET request, but only POST requests are supported. This often occurs if \`method="POST"\` is missing on the form.`,
|
||||
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
|
||||
* - [Actions handler reference](https://docs.astro.build/en/reference/api-reference/#handler-property)
|
||||
* @description
|
||||
* Action handler returned invalid data. Handlers should return serializable data types, and cannot return a Response object.
|
||||
*/
|
||||
|
@ -1697,29 +1681,30 @@ export const ActionsReturnedInvalidDataError = {
|
|||
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md)
|
||||
* @description
|
||||
* The server received the query string `?_astroAction=name`, but could not find an action with that name. Use the action function's `.queryString` property to retrieve the form `action` URL.
|
||||
* The server received a request for an action but could not find a match with the same name.
|
||||
*/
|
||||
export const ActionQueryStringInvalidError = {
|
||||
name: 'ActionQueryStringInvalidError',
|
||||
title: 'An invalid Action query string was passed by a form.',
|
||||
export const ActionNotFoundError = {
|
||||
name: 'ActionNotFoundError',
|
||||
title: 'Action not found.',
|
||||
message: (actionName: string) =>
|
||||
`The server received the query string \`?_astroAction=${actionName}\`, but could not find an action with that name. If you changed an action's name in development, remove this query param from your URL and refresh.`,
|
||||
hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md',
|
||||
`The server received a request for an action named \`${actionName}\` but could not find a match. If you renamed an action, check that you've updated your \`actions/index\` file and your calling code to match.`,
|
||||
hint: 'You can run `astro check` to detect type errors caused by mismatched action names.',
|
||||
} satisfies ErrorData;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @see
|
||||
* - [`Astro.callAction()` reference](https://docs.astro.build/en/reference/api-reference/#astrocallaction)
|
||||
* @description
|
||||
* Action called from a server page or endpoint without using `Astro.callAction()`.
|
||||
*/
|
||||
export const ActionCalledFromServerError = {
|
||||
name: 'ActionCalledFromServerError',
|
||||
title: 'Action unexpected called from the server.',
|
||||
message: 'Action called from a server page or endpoint without using `Astro.callAction()`.',
|
||||
hint: 'See the RFC section on server calls for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md#call-actions-directly-from-server-code',
|
||||
message:
|
||||
'Action called from a server page or endpoint without using `Astro.callAction()`. This wrapper must be used to call actions from server code.',
|
||||
hint: 'See the `Astro.callAction()` reference for usage examples: https://docs.astro.build/en/reference/api-reference/#astrocallaction',
|
||||
} satisfies ErrorData;
|
||||
|
||||
// Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip.
|
||||
|
|
|
@ -17,6 +17,8 @@ import type {
|
|||
RouteOptions,
|
||||
} from '../@types/astro.js';
|
||||
import { globalContentLayer } from '../content/content-layer.js';
|
||||
import astroIntegrationActionsRouteHandler from '../actions/integration.js';
|
||||
import { isActionsFilePresent } from '../actions/utils.js';
|
||||
import type { SerializedSSRManifest } from '../core/app/types.js';
|
||||
import type { PageBuildData } from '../core/build/types.js';
|
||||
import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js';
|
||||
|
@ -132,9 +134,8 @@ 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({ fs, settings }));
|
||||
if (await isActionsFilePresent(fs, settings.config.srcDir)) {
|
||||
settings.config.integrations.push(astroIntegrationActionsRouteHandler({ settings }));
|
||||
}
|
||||
|
||||
let updatedConfig: AstroConfig = { ...settings.config };
|
||||
|
|
|
@ -306,45 +306,6 @@ describe('Astro Actions', () => {
|
|||
assert.equal(data?.age, '42');
|
||||
});
|
||||
|
||||
describe('legacy', () => {
|
||||
it('Response middleware fallback', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('_astroAction', 'getUser');
|
||||
const req = new Request('http://example.com/user', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Referer: 'http://example.com/user',
|
||||
},
|
||||
});
|
||||
const res = await followExpectedRedirect(req, app);
|
||||
assert.equal(res.ok, true);
|
||||
|
||||
const html = await res.text();
|
||||
let $ = cheerio.load(html);
|
||||
assert.equal($('#user').text(), 'Houston');
|
||||
});
|
||||
|
||||
it('Respects custom errors', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('_astroAction', 'getUserOrThrow');
|
||||
const req = new Request('http://example.com/user-or-throw', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Referer: 'http://example.com/user-or-throw',
|
||||
},
|
||||
});
|
||||
const res = await followExpectedRedirect(req, app);
|
||||
assert.equal(res.status, 401);
|
||||
|
||||
const html = await res.text();
|
||||
let $ = cheerio.load(html);
|
||||
assert.equal($('#error-message').text(), 'Not logged in');
|
||||
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
|
||||
});
|
||||
});
|
||||
|
||||
it('Sets status to 204 when content-length is 0', async () => {
|
||||
const req = new Request('http://example.com/_actions/fireAndForget', {
|
||||
method: 'POST',
|
||||
|
|
|
@ -3,7 +3,4 @@ import { defineConfig } from 'astro/config';
|
|||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
experimental: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { defineAction, ActionError, z } from 'astro:actions';
|
||||
import { defineAction, ActionError } from 'astro:actions';
|
||||
import { z } from 'astro:schema';
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
|
|
|
@ -8,10 +8,16 @@ import {
|
|||
} from '../../../dist/integrations/hooks.js';
|
||||
import { defaultLogger } from '../test-utils.js';
|
||||
|
||||
const defaultConfig = {
|
||||
root: new URL('./', import.meta.url),
|
||||
srcDir: new URL('src/', import.meta.url),
|
||||
};
|
||||
|
||||
describe('Integration API', () => {
|
||||
it('runHookBuildSetup should work', async () => {
|
||||
const updatedViteConfig = await runHookBuildSetup({
|
||||
config: {
|
||||
...defaultConfig,
|
||||
integrations: [
|
||||
{
|
||||
name: 'test',
|
||||
|
@ -39,6 +45,7 @@ describe('Integration API', () => {
|
|||
let updatedInternalConfig;
|
||||
const updatedViteConfig = await runHookBuildSetup({
|
||||
config: {
|
||||
...defaultConfig,
|
||||
integrations: [
|
||||
{
|
||||
name: 'test',
|
||||
|
@ -68,6 +75,7 @@ describe('Integration API', () => {
|
|||
logger: defaultLogger,
|
||||
settings: {
|
||||
config: {
|
||||
...defaultConfig,
|
||||
integrations: [
|
||||
{
|
||||
name: 'test',
|
||||
|
@ -90,6 +98,7 @@ describe('Integration API', () => {
|
|||
logger: defaultLogger,
|
||||
settings: {
|
||||
config: {
|
||||
...defaultConfig,
|
||||
integrations: [
|
||||
{
|
||||
name: 'test',
|
||||
|
|
Loading…
Add table
Reference in a new issue