0
Fork 0
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:
Matt Kane 2024-08-29 12:16:45 +01:00
commit 8fabda7e00
29 changed files with 361 additions and 418 deletions

View 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';
```

View 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).

View file

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

View file

@ -11,7 +11,4 @@ export default defineConfig({
adapter: node({
mode: 'standalone',
}),
experimental: {
actions: true,
},
});

View file

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

View file

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

View file

@ -11,7 +11,4 @@ export default defineConfig({
adapter: node({
mode: 'standalone',
}),
experimental: {
actions: true,
},
});

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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;
},
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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