diff --git a/.changeset/tall-waves-impress.md b/.changeset/tall-waves-impress.md
new file mode 100644
index 0000000000..aecf307e02
--- /dev/null
+++ b/.changeset/tall-waves-impress.md
@@ -0,0 +1,62 @@
+---
+'astro': minor
+---
+
+Changes the default behavior for Astro Action form requests to a standard POST submission.
+
+In Astro 4.x, actions called from an HTML form would trigger a redirect with the result forwarded using cookies. This caused issues for large form errors and return values that exceeded the 4 KB limit of cookie-based storage.
+
+Astro 5.0 now renders the result of an action as a POST result without any forwarding. This will introduce a "confirm form resubmission?" dialog when a user attempts to refresh the page, though it no longer imposes a 4 KB limit on action return value.
+
+## Customize form submission behavior
+
+If you prefer to address the "confirm form resubmission?" dialog on refresh, or to preserve action results across sessions, you can now [customize action result handling from middleware](https://5-0-0-beta.docs.astro.build/en/guides/actions/#advanced-persist-action-results-with-a-session).
+
+We recommend using a session storage provider [as described in our Netlify Blob example](https://5-0-0-beta.docs.astro.build/en/guides/actions/#advanced-persist-action-results-with-a-session). However, if you prefer the cookie forwarding behavior from 4.X and accept the 4 KB size limit, you can implement the pattern as shown in this sample snippet:
+
+```ts
+// src/middleware.ts
+import { defineMiddleware } from 'astro:middleware';
+import { getActionContext } from 'astro:actions';
+
+export const onRequest = defineMiddleware(async (context, next) => {
+ // Skip requests for prerendered pages
+ if (context.isPrerendered) return next();
+
+ const { action, setActionResult, serializeActionResult } = getActionContext(context);
+
+ // If an action result was forwarded as a cookie, set the result
+ // to be accessible from `Astro.getActionResult()`
+ const payload = context.cookies.get('ACTION_PAYLOAD');
+ if (payload) {
+ const { actionName, actionResult } = payload.json();
+ setActionResult(actionName, actionResult);
+ context.cookies.delete('ACTION_PAYLOAD');
+ return next();
+ }
+
+ // If an action was called from an HTML form action,
+ // call the action handler and redirect with the result as a cookie.
+ if (action?.calledFrom === 'form') {
+ const actionResult = await action.handler();
+
+ context.cookies.set('ACTION_PAYLOAD', {
+ actionName: action.name,
+ actionResult: serializeActionResult(actionResult),
+ });
+
+ if (actionResult.error) {
+ // Redirect back to the previous page on error
+ const referer = context.request.headers.get('Referer');
+ if (!referer) {
+ throw new Error('Internal: Referer unexpectedly missing from Action POST request.');
+ }
+ return context.redirect(referer);
+ }
+ // Redirect to the destination page on success
+ return context.redirect(context.originPathname);
+ }
+
+ return next();
+})
+```
diff --git a/packages/astro/e2e/actions-blog.test.js b/packages/astro/e2e/actions-blog.test.js
index a8d9a7fc67..7d362334e3 100644
--- a/packages/astro/e2e/actions-blog.test.js
+++ b/packages/astro/e2e/actions-blog.test.js
@@ -155,14 +155,14 @@ test.describe('Astro Actions - Blog', () => {
await expect(page).toHaveURL(astro.resolveUrl('/blog/'));
});
- test('Should redirect to the origin pathname when there is a rewrite', async ({
+ test('Should redirect to the origin pathname when there is a rewrite from an Astro page', async ({
page,
astro,
}) => {
await page.goto(astro.resolveUrl('/sum'));
const submitButton = page.getByTestId('submit');
await submitButton.click();
- await expect(page).toHaveURL(astro.resolveUrl('/sum'));
+ await expect(page).toHaveURL(astro.resolveUrl('/sum?_astroAction=sum'));
const p = page.locator('p').nth(0);
await expect(p).toContainText('Form result: {"data":3}');
});
diff --git a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
index 35f7481f23..c00c4da70d 100644
--- a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
+++ b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
@@ -7,7 +7,7 @@ import node from '@astrojs/node';
export default defineConfig({
site: 'https://example.com',
integrations: [db(), react()],
- output: 'static',
+ output: 'server',
adapter: node({
mode: 'standalone',
}),
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
index f3e1f248e5..01b479b2b9 100644
--- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
+++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
@@ -68,21 +68,21 @@ export const server = {
seven: z.string().min(3),
eight: z.string().min(3),
nine: z.string().min(3),
- ten: z.string().min(3)
+ ten: z.string().min(3),
}),
handler(form) {
return form;
- }
- })
+ },
+ }),
},
sum: defineAction({
- accept: "form",
+ accept: 'form',
input: z.object({
a: z.number(),
b: z.number(),
}),
async handler({ a, b }) {
- return a + b
+ return a + b;
},
- })
+ }),
};
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts b/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts
index 53bb8235ac..9e259f4ee9 100644
--- a/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts
+++ b/packages/astro/e2e/fixtures/actions-blog/src/middleware.ts
@@ -1,9 +1,38 @@
-import { defineMiddleware } from "astro:middleware";
+import { defineMiddleware } from 'astro:middleware';
+import { getActionContext } from 'astro:actions';
-export const onRequest = defineMiddleware((ctx, next) => {
- if (ctx.request.method === "GET" && ctx.url.pathname === "/sum") {
- return next("/rewritten")
+const actionCookieForwarding = defineMiddleware(async (ctx, next) => {
+ if (ctx.isPrerendered) return next();
+
+ const { action, setActionResult, serializeActionResult } = getActionContext(ctx);
+
+ const payload = ctx.cookies.get('ACTION_PAYLOAD');
+ if (payload) {
+ const { actionName, actionResult } = payload.json();
+ setActionResult(actionName, actionResult);
+ ctx.cookies.delete('ACTION_PAYLOAD');
+ return next();
}
-
- return next()
-})
+
+ if (action?.calledFrom === 'form' && ctx.url.searchParams.has('actionCookieForwarding')) {
+ const actionResult = await action.handler();
+
+ ctx.cookies.set('ACTION_PAYLOAD', {
+ actionName: action.name,
+ actionResult: serializeActionResult(actionResult),
+ });
+
+ if (actionResult.error) {
+ const referer = ctx.request.headers.get('Referer');
+ if (!referer) {
+ throw new Error('Internal: Referer unexpectedly missing from Action POST request.');
+ }
+ return ctx.redirect(referer);
+ }
+ return ctx.redirect(ctx.originPathname);
+ }
+
+ return next();
+});
+
+export const onRequest = actionCookieForwarding;
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro
index ad4aea521a..9dc5404d64 100644
--- a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro
+++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro
@@ -1,5 +1,5 @@
---
-import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
+import { type CollectionEntry, getEntry } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import { Logout } from '../../components/Logout';
import { db, eq, Comment, Likes } from 'astro:db';
@@ -8,16 +8,6 @@ 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)!;
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro
index 2b78aee1b8..e260e3b395 100644
--- a/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro
+++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/lots-of-fields.astro
@@ -6,38 +6,42 @@ const result = Astro.getActionResult(actions.blog.lotsOfStuff);
---
-
- Actions
-
-
-
-
-
+
+ Actions
+
+
+
+
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro
index 72eebf1bb0..0a41c1a8f0 100644
--- a/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro
+++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/rewritten.astro
@@ -1,8 +1,7 @@
---
-import { actions } from "astro:actions";
-
-const result = Astro.getActionResult(actions.sum)
+import { actions } from 'astro:actions';
+const result = Astro.getActionResult(actions.sum);
---
@@ -13,6 +12,5 @@ const result = Astro.getActionResult(actions.sum)
Form result: {JSON.stringify(result)}
-
-
+
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro
new file mode 100644
index 0000000000..46856b4ff0
--- /dev/null
+++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/sum.astro
@@ -0,0 +1,3 @@
+---
+return Astro.rewrite('/rewritten' + Astro.url.search);
+---
diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts
index 6a55386d86..35137df59a 100644
--- a/packages/astro/src/actions/consts.ts
+++ b/packages/astro/src/actions/consts.ts
@@ -8,5 +8,6 @@ export const NOOP_ACTIONS = '\0noop-actions';
export const ACTION_QUERY_PARAMS = {
actionName: '_astroAction',
actionPayload: '_astroActionPayload',
- actionRedirect: '_astroActionRedirect',
};
+
+export const ACTION_RPC_ROUTE_PATTERN = '/_actions/[...path]';
diff --git a/packages/astro/src/actions/integration.ts b/packages/astro/src/actions/integration.ts
index 830420836a..23fbd904a5 100644
--- a/packages/astro/src/actions/integration.ts
+++ b/packages/astro/src/actions/integration.ts
@@ -3,7 +3,7 @@ import { AstroError } from '../core/errors/errors.js';
import { viteID } from '../core/util.js';
import type { AstroSettings } from '../types/astro.js';
import type { AstroIntegration } from '../types/public/integrations.js';
-import { ACTIONS_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js';
+import { ACTIONS_TYPES_FILE, VIRTUAL_MODULE_ID, ACTION_RPC_ROUTE_PATTERN } from './consts.js';
/**
* This integration is applied when the user is using Actions in their project.
@@ -19,7 +19,7 @@ export default function astroIntegrationActionsRouteHandler({
hooks: {
async 'astro:config:setup'(params) {
params.injectRoute({
- pattern: '/_actions/[...path]',
+ pattern: ACTION_RPC_ROUTE_PATTERN,
entrypoint: 'astro/actions/runtime/route.js',
prerender: false,
});
diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts
index 881169cb47..47adc29454 100644
--- a/packages/astro/src/actions/runtime/middleware.ts
+++ b/packages/astro/src/actions/runtime/middleware.ts
@@ -1,166 +1,13 @@
-import { decodeBase64, encodeBase64 } from '@oslojs/encoding';
-import { yellow } from 'kleur/colors';
-import { defineMiddleware } from '../../core/middleware/index.js';
-import { getOriginPathname } from '../../core/routing/rewrite.js';
-import type { MiddlewareNext } from '../../types/public/common.js';
-import type { APIContext } from '../../types/public/context.js';
-import { ACTION_QUERY_PARAMS } from '../consts.js';
-import { formContentTypes, hasContentType } from './utils.js';
-import { getAction } from './virtual/get-action.js';
-import {
- type SafeResult,
- type SerializedActionResult,
- serializeActionResult,
-} from './virtual/shared.js';
-
-export type ActionPayload = {
- actionResult: SerializedActionResult;
- actionName: string;
-};
-
-export type Locals = {
- _actionPayload: ActionPayload;
-};
-
-const decoder = new TextDecoder();
-const encoder = new TextEncoder();
+import { defineMiddleware } from '../../virtual-modules/middleware.js';
+import { getActionContext } from './virtual/server.js';
export const onRequest = defineMiddleware(async (context, next) => {
- if (context.isPrerendered) {
- if (context.request.method === 'POST') {
- console.warn(
- yellow('[astro:actions]'),
- "POST requests should not be sent to prerendered pages. If you're using Actions, disable prerendering with `export const prerender = false`.",
- );
- }
- return next();
+ if (context.isPrerendered) return next();
+ const { action, setActionResult, serializeActionResult } = getActionContext(context);
+
+ if (action?.calledFrom === 'form') {
+ const actionResult = await action.handler();
+ setActionResult(action.name, serializeActionResult(actionResult));
}
-
- const locals = context.locals as Locals;
- // Actions middleware may have run already after a path rewrite.
- // See https://github.com/withastro/roadmap/blob/main/proposals/0048-rerouting.md#ctxrewrite
- // `_actionPayload` is the same for every page,
- // so short circuit if already defined.
- if (locals._actionPayload) return next();
-
- const actionPayloadCookie = context.cookies.get(ACTION_QUERY_PARAMS.actionPayload)?.value;
- if (actionPayloadCookie) {
- const actionPayload = JSON.parse(decoder.decode(decodeBase64(actionPayloadCookie)));
-
- if (!isActionPayload(actionPayload)) {
- throw new Error('Internal: Invalid action payload in cookie.');
- }
- return renderResult({ context, next, ...actionPayload });
- }
-
- const actionName = context.url.searchParams.get(ACTION_QUERY_PARAMS.actionName);
-
- if (context.request.method === 'POST' && actionName) {
- return handlePost({ context, next, actionName });
- }
-
return next();
});
-
-async function renderResult({
- context,
- next,
- actionResult,
- actionName,
-}: {
- context: APIContext;
- next: MiddlewareNext;
- actionResult: SerializedActionResult;
- actionName: string;
-}) {
- const locals = context.locals as Locals;
-
- locals._actionPayload = { actionResult, actionName };
- const response = await next();
- context.cookies.delete(ACTION_QUERY_PARAMS.actionPayload);
-
- if (actionResult.type === 'error') {
- return new Response(response.body, {
- status: actionResult.status,
- statusText: actionResult.type,
- headers: response.headers,
- });
- }
- return response;
-}
-
-async function handlePost({
- context,
- next,
- actionName,
-}: {
- context: APIContext;
- next: MiddlewareNext;
- actionName: string;
-}) {
- const { request } = context;
- const baseAction = await getAction(actionName);
-
- const contentType = request.headers.get('content-type');
- let formData: FormData | undefined;
- if (contentType && hasContentType(contentType, formContentTypes)) {
- formData = await request.clone().formData();
- }
- const { getActionResult, callAction, props, redirect, ...actionAPIContext } = context;
- const action = baseAction.bind(actionAPIContext);
- const actionResult = await action(formData);
-
- if (context.url.searchParams.get(ACTION_QUERY_PARAMS.actionRedirect) === 'false') {
- return renderResult({
- context,
- next,
- actionName,
- actionResult: serializeActionResult(actionResult),
- });
- }
-
- return redirectWithResult({ context, actionName, actionResult });
-}
-
-async function redirectWithResult({
- context,
- actionName,
- actionResult,
-}: {
- context: APIContext;
- actionName: string;
- actionResult: SafeResult;
-}) {
- const cookieValue = encodeBase64(
- encoder.encode(
- JSON.stringify({
- actionName: actionName,
- actionResult: serializeActionResult(actionResult),
- }),
- ),
- );
- context.cookies.set(ACTION_QUERY_PARAMS.actionPayload, cookieValue);
-
- if (actionResult.error) {
- const referer = context.request.headers.get('Referer');
- if (!referer) {
- throw new Error('Internal: Referer unexpectedly missing from Action POST request.');
- }
- return context.redirect(referer);
- }
-
- const referer = getOriginPathname(context.request);
- if (referer) {
- return context.redirect(referer);
- }
-
- return context.redirect(context.url.pathname);
-}
-
-function isActionPayload(json: unknown): json is ActionPayload {
- if (typeof json !== 'object' || json == null) return false;
-
- if (!('actionResult' in json) || typeof json.actionResult !== 'object') return false;
- if (!('actionName' in json) || typeof json.actionName !== 'string') return false;
- return true;
-}
diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts
index 07e06ee9e6..c7522328d2 100644
--- a/packages/astro/src/actions/runtime/route.ts
+++ b/packages/astro/src/actions/runtime/route.ts
@@ -1,35 +1,14 @@
import type { APIRoute } from '../../types/public/common.js';
-import { formContentTypes, hasContentType } from './utils.js';
-import { getAction } from './virtual/get-action.js';
-import { serializeActionResult } from './virtual/shared.js';
+import { getActionContext } from './virtual/server.js';
export const POST: APIRoute = async (context) => {
- const { request, url } = context;
- let baseAction;
- try {
- baseAction = await getAction(url.pathname);
- } catch (e) {
- if (import.meta.env.DEV) throw e;
- console.error(e);
- return new Response(e instanceof Error ? e.message : null, { status: 404 });
+ const { action, serializeActionResult } = getActionContext(context);
+
+ if (action?.calledFrom !== 'rpc') {
+ return new Response('Not found', { status: 404 });
}
- const contentType = request.headers.get('Content-Type');
- const contentLength = request.headers.get('Content-Length');
- let args: unknown;
- if (!contentType || contentLength === '0') {
- args = undefined;
- } else 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 { getActionResult, callAction, props, redirect, ...actionAPIContext } = context;
- const action = baseAction.bind(actionAPIContext);
- const result = await action(args);
+
+ const result = await action.handler();
const serialized = serializeActionResult(result);
if (serialized.type === 'empty') {
diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts
index d8b339a093..7d6a217305 100644
--- a/packages/astro/src/actions/runtime/utils.ts
+++ b/packages/astro/src/actions/runtime/utils.ts
@@ -1,4 +1,14 @@
import type { APIContext } from '../../types/public/context.js';
+import type { SerializedActionResult } from './virtual/shared.js';
+
+export type ActionPayload = {
+ actionResult: SerializedActionResult;
+ actionName: string;
+};
+
+export type Locals = {
+ _actionPayload: ActionPayload;
+};
export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
diff --git a/packages/astro/src/actions/runtime/virtual/client.ts b/packages/astro/src/actions/runtime/virtual/client.ts
index c80e6778ae..68407f4cbf 100644
--- a/packages/astro/src/actions/runtime/virtual/client.ts
+++ b/packages/astro/src/actions/runtime/virtual/client.ts
@@ -3,3 +3,7 @@ export * from './shared.js';
export function defineAction() {
throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.');
}
+
+export function getActionContext() {
+ throw new Error('[astro:action] `getActionContext()` unexpectedly used on the client.');
+}
diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts
index 7cd260f866..a11e72fc48 100644
--- a/packages/astro/src/actions/runtime/virtual/get-action.ts
+++ b/packages/astro/src/actions/runtime/virtual/get-action.ts
@@ -11,10 +11,7 @@ import type { ActionAccept, ActionClient } from './server.js';
export async function getAction(
path: string,
): Promise> {
- const pathKeys = path
- .replace(/^.*\/_actions\//, '')
- .split('.')
- .map((key) => decodeURIComponent(key));
+ const pathKeys = path.split('.').map((key) => decodeURIComponent(key));
// @ts-expect-error virtual module
let { server: actionLookup } = await import('astro:internal-actions');
diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts
index 8e5e6bb4f1..f8fac557a0 100644
--- a/packages/astro/src/actions/runtime/virtual/server.ts
+++ b/packages/astro/src/actions/runtime/virtual/server.ts
@@ -1,8 +1,27 @@
import { z } from 'zod';
import { ActionCalledFromServerError } from '../../../core/errors/errors-data.js';
import { AstroError } from '../../../core/errors/errors.js';
-import type { ActionAPIContext, ErrorInferenceObject, MaybePromise } from '../utils.js';
-import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js';
+import {
+ formContentTypes,
+ hasContentType,
+ type ActionAPIContext,
+ type ErrorInferenceObject,
+ type MaybePromise,
+} from '../utils.js';
+import {
+ ACTION_QUERY_PARAMS,
+ ActionError,
+ ActionInputError,
+ type SafeResult,
+ type SerializedActionResult,
+ callSafely,
+ deserializeActionResult,
+ serializeActionResult,
+} from './shared.js';
+import type { Locals } from '../utils.js';
+import { getAction } from './get-action.js';
+import type { APIContext } from '../../../types/public/index.js';
+import { ACTION_RPC_ROUTE_PATTERN } from '../../consts.js';
export * from './shared.js';
@@ -212,3 +231,109 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
}
return schema;
}
+
+export type ActionMiddlewareContext = {
+ /** Information about an incoming action request. */
+ action?: {
+ /** Whether an action was called using an RPC function or by using an HTML form action. */
+ calledFrom: 'rpc' | 'form';
+ /** The name of the action. Useful to track the source of an action result during a redirect. */
+ name: string;
+ /** Programatically call the action to get the result. */
+ handler: () => Promise>;
+ };
+ /**
+ * Manually set the action result accessed via `getActionResult()`.
+ * Calling this function from middleware will disable Astro's own action result handling.
+ */
+ setActionResult(actionName: string, actionResult: SerializedActionResult): void;
+ /**
+ * Serialize an action result to stored in a cookie or session.
+ * Also used to pass a result to Astro templates via `setActionResult()`.
+ */
+ serializeActionResult: typeof serializeActionResult;
+ /**
+ * Deserialize an action result to access data and error objects.
+ */
+ deserializeActionResult: typeof deserializeActionResult;
+};
+
+/**
+ * Access information about Action requests from middleware.
+ */
+export function getActionContext(context: APIContext): ActionMiddlewareContext {
+ const callerInfo = getCallerInfo(context);
+
+ // Prevents action results from being handled on a rewrite.
+ // Also prevents our *own* fallback middleware from running
+ // if the user's middleware has already handled the result.
+ const actionResultAlreadySet = Boolean((context.locals as Locals)._actionPayload);
+
+ let action: ActionMiddlewareContext['action'] = undefined;
+
+ if (callerInfo && context.request.method === 'POST' && !actionResultAlreadySet) {
+ action = {
+ calledFrom: callerInfo.from,
+ name: callerInfo.name,
+ handler: async () => {
+ const baseAction = await getAction(callerInfo.name);
+ let input;
+ try {
+ input = await parseRequestBody(context.request);
+ } catch (e) {
+ if (e instanceof TypeError) {
+ return { data: undefined, error: new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE' }) };
+ }
+ throw e;
+ }
+ const {
+ props: _props,
+ getActionResult: _getActionResult,
+ callAction: _callAction,
+ redirect: _redirect,
+ ...actionAPIContext
+ } = context;
+ const handler = baseAction.bind(actionAPIContext satisfies ActionAPIContext);
+ return handler(input);
+ },
+ };
+ }
+
+ function setActionResult(actionName: string, actionResult: SerializedActionResult) {
+ (context.locals as Locals)._actionPayload = {
+ actionResult,
+ actionName,
+ };
+ }
+ return {
+ action,
+ setActionResult,
+ serializeActionResult,
+ deserializeActionResult,
+ };
+}
+
+function getCallerInfo(ctx: APIContext) {
+ if (ctx.routePattern === ACTION_RPC_ROUTE_PATTERN) {
+ return { from: 'rpc', name: ctx.url.pathname.replace(/^.*\/_actions\//, '') } as const;
+ }
+ const queryParam = ctx.url.searchParams.get(ACTION_QUERY_PARAMS.actionName);
+ if (queryParam) {
+ return { from: 'form', name: queryParam } as const;
+ }
+ return undefined;
+}
+
+async function parseRequestBody(request: Request) {
+ const contentType = request.headers.get('content-type');
+ const contentLength = request.headers.get('Content-Length');
+
+ if (!contentType) return undefined;
+ if (hasContentType(contentType, formContentTypes)) {
+ return await request.clone().formData();
+ }
+ if (hasContentType(contentType, ['application/json'])) {
+ return contentLength === '0' ? undefined : await request.clone().json();
+ }
+ throw new TypeError('Unsupported content type');
+}
diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts
index 3f2f45bfe6..dc0fa4b148 100644
--- a/packages/astro/src/actions/utils.ts
+++ b/packages/astro/src/actions/utils.ts
@@ -1,8 +1,7 @@
import type fsMod from 'node:fs';
import * as eslexer from 'es-module-lexer';
import type { APIContext } from '../types/public/context.js';
-import type { Locals } from './runtime/middleware.js';
-import type { ActionAPIContext } from './runtime/utils.js';
+import type { ActionAPIContext, Locals } from './runtime/utils.js';
import { deserializeActionResult, getActionQueryString } from './runtime/virtual/shared.js';
export function hasActionPayload(locals: APIContext['locals']): locals is Locals {
diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts
index 9e27434dc9..c7ed6e6479 100644
--- a/packages/astro/src/core/middleware/index.ts
+++ b/packages/astro/src/core/middleware/index.ts
@@ -6,10 +6,11 @@ import {
} from '../../i18n/utils.js';
import type { MiddlewareHandler, Params, RewritePayload } from '../../types/public/common.js';
import type { APIContext } from '../../types/public/context.js';
-import { ASTRO_VERSION, clientAddressSymbol, clientLocalsSymbol } from '../constants.js';
+import { ASTRO_VERSION, clientLocalsSymbol } from '../constants.js';
import { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { getClientIpAddress } from '../routing/request.js';
+import { getOriginPathname } from '../routing/rewrite.js';
import { sequence } from './sequence.js';
function defineMiddleware(fn: MiddlewareHandler) {
@@ -89,6 +90,9 @@ function createContext({
return (currentLocale ??= computeCurrentLocale(route, userDefinedLocales, defaultLocale));
},
url,
+ get originPathname() {
+ return getOriginPathname(request);
+ },
get clientAddress() {
if (clientIpAddress) {
return clientIpAddress;
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index 880d1f6187..49f174c33f 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -28,7 +28,7 @@ import { callMiddleware } from './middleware/callMiddleware.js';
import { sequence } from './middleware/index.js';
import { renderRedirect } from './redirects/render.js';
import { type Pipeline, Slots, getParams, getProps } from './render/index.js';
-import { copyRequest, setOriginPathname } from './routing/rewrite.js';
+import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js';
import { SERVER_ISLAND_COMPONENT } from './server-islands/endpoint.js';
export const apiContextRoutesSymbol = Symbol.for('context.routes');
@@ -299,6 +299,9 @@ export class RenderContext {
request: this.request,
site: pipeline.site,
url,
+ get originPathname() {
+ return getOriginPathname(renderContext.request);
+ },
};
}
@@ -311,9 +314,12 @@ export class RenderContext {
(await pipeline.componentMetadata(routeData)) ?? manifest.componentMetadata;
const headers = new Headers({ 'Content-Type': 'text/html' });
const partial = typeof this.partial === 'boolean' ? this.partial : Boolean(mod.partial);
+ const actionResult = hasActionPayload(this.locals)
+ ? deserializeActionResult(this.locals._actionPayload.actionResult)
+ : undefined;
const response = {
- status,
- statusText: 'OK',
+ status: actionResult?.error ? actionResult?.error.status : status,
+ statusText: actionResult?.error ? actionResult?.error.type : 'OK',
get headers() {
return headers;
},
@@ -323,10 +329,6 @@ export class RenderContext {
},
} satisfies AstroGlobal['response'];
- const actionResult = hasActionPayload(this.locals)
- ? deserializeActionResult(this.locals._actionPayload.actionResult)
- : undefined;
-
// Create the result object that will be passed into the renderPage function.
// This object starts here as an empty shell (not yet the result) but then
// calling the render() function will populate the object with scripts, styles, etc.
@@ -478,6 +480,9 @@ export class RenderContext {
return createCallAction(this);
},
url,
+ get originPathname() {
+ return getOriginPathname(renderContext.request);
+ },
};
}
diff --git a/packages/astro/src/core/routing/rewrite.ts b/packages/astro/src/core/routing/rewrite.ts
index cb23060374..57709892ea 100644
--- a/packages/astro/src/core/routing/rewrite.ts
+++ b/packages/astro/src/core/routing/rewrite.ts
@@ -108,10 +108,10 @@ export function setOriginPathname(request: Request, pathname: string): void {
Reflect.set(request, originPathnameSymbol, encodeURIComponent(pathname));
}
-export function getOriginPathname(request: Request): string | undefined {
+export function getOriginPathname(request: Request): string {
const origin = Reflect.get(request, originPathnameSymbol);
if (origin) {
return decodeURIComponent(origin);
}
- return undefined;
+ return new URL(request.url).pathname;
}
diff --git a/packages/astro/src/types/public/context.ts b/packages/astro/src/types/public/context.ts
index 8d7c4fb307..7a6f3b6be0 100644
--- a/packages/astro/src/types/public/context.ts
+++ b/packages/astro/src/types/public/context.ts
@@ -268,6 +268,11 @@ interface AstroSharedContext<
* A full URL object of the request URL.
*/
url: URL;
+ /**
+ * The origin pathname of the request URL.
+ * Useful to track the original URL before rewrites were applied.
+ */
+ originPathname: string;
/**
* Get action result on the server when using a form POST.
*/
diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs
index 0aa22f5084..82a287448a 100644
--- a/packages/astro/templates/actions.mjs
+++ b/packages/astro/templates/actions.mjs
@@ -1,9 +1,4 @@
-import {
- ACTION_QUERY_PARAMS,
- ActionError,
- deserializeActionResult,
- getActionQueryString,
-} from 'astro:actions';
+import { ActionError, deserializeActionResult, getActionQueryString } from 'astro:actions';
const ENCODED_DOT = '%2E';
@@ -26,10 +21,6 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
// Progressive enhancement info for React.
$$FORM_ACTION: function () {
const searchParams = new URLSearchParams(action.toString());
- // Astro will redirect with a GET request by default.
- // Disable this behavior to preserve form state
- // for React's progressive enhancement.
- searchParams.set(ACTION_QUERY_PARAMS.actionRedirect, 'false');
return {
method: 'POST',
// `name` creates a hidden input.
diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js
index 0ed98db935..c34b91a7d1 100644
--- a/packages/astro/test/actions.test.js
+++ b/packages/astro/test/actions.test.js
@@ -213,7 +213,7 @@ describe('Astro Actions', () => {
assert.equal(data.isFormData, true, 'Should receive plain FormData');
});
- it('Response middleware fallback', async () => {
+ it('Response middleware fallback - POST', async () => {
const req = new Request('http://example.com/user?_astroAction=getUser', {
method: 'POST',
body: new FormData(),
@@ -221,6 +221,25 @@ describe('Astro Actions', () => {
Referer: 'http://example.com/user',
},
});
+ const res = await app.render(req);
+ assert.equal(res.ok, true);
+
+ const html = await res.text();
+ let $ = cheerio.load(html);
+ assert.equal($('#user').text(), 'Houston');
+ });
+
+ it('Response middleware fallback - cookie forwarding', async () => {
+ const req = new Request(
+ 'http://example.com/user?_astroAction=getUser&actionCookieForwarding=true',
+ {
+ method: 'POST',
+ body: new FormData(),
+ headers: {
+ Referer: 'http://example.com/user',
+ },
+ },
+ );
const res = await followExpectedRedirect(req, app);
assert.equal(res.ok, true);
@@ -229,7 +248,7 @@ describe('Astro Actions', () => {
assert.equal($('#user').text(), 'Houston');
});
- it('Respects custom errors', async () => {
+ it('Respects custom errors - POST', async () => {
const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', {
method: 'POST',
body: new FormData(),
@@ -237,6 +256,26 @@ describe('Astro Actions', () => {
Referer: 'http://example.com/user-or-throw',
},
});
+ const res = await app.render(req);
+ 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('Respects custom errors - cookie forwarding', async () => {
+ const req = new Request(
+ 'http://example.com/user-or-throw?_astroAction=getUserOrThrow&actionCookieForwarding=true',
+ {
+ method: 'POST',
+ body: new FormData(),
+ headers: {
+ Referer: 'http://example.com/user-or-throw',
+ },
+ },
+ );
const res = await followExpectedRedirect(req, app);
assert.equal(res.status, 401);
@@ -246,6 +285,35 @@ describe('Astro Actions', () => {
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
});
+ it('Respects RPC middleware handling - locked', async () => {
+ const req = new Request('http://example.com/_actions/locked', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: '{}',
+ });
+ const res = await app.render(req);
+ assert.equal(res.status, 401);
+ });
+
+ it('Respects RPC middleware handling - cookie present', async () => {
+ const req = new Request('http://example.com/_actions/locked', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Cookie: 'actionCookie=1234',
+ },
+ body: '{}',
+ });
+ const res = await app.render(req);
+ assert.equal(res.ok, true);
+ assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
+
+ const data = devalue.parse(await res.text());
+ assert.equal('safe' in data, true);
+ });
+
it('Ignores `_astroAction` name for GET requests', async () => {
const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', {
method: 'GET',
diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts
index 78cc39620b..d3cd1b1bba 100644
--- a/packages/astro/test/fixtures/actions/src/actions/index.ts
+++ b/packages/astro/test/fixtures/actions/src/actions/index.ts
@@ -161,28 +161,33 @@ export const server = {
};
},
}),
- "with.dot": defineAction({
- input: z.object({
- name: z.string(),
- }),
- handler: async (input) => {
- return `Hello, ${input.name}!`
- }
- }),
- "with space": defineAction({
- input: z.object({
- name: z.string(),
- }),
- handler: async (input) => {
- return `Hello, ${input.name}!`
- }
- }),
- "with/slash": defineAction({
+ locked: defineAction({
+ handler: async () => {
+ return { safe: true };
+ },
+ }),
+ 'with.dot': defineAction({
input: z.object({
name: z.string(),
}),
handler: async (input) => {
- return `Hello, ${input.name}!`
- }
+ return `Hello, ${input.name}!`;
+ },
+ }),
+ 'with space': defineAction({
+ input: z.object({
+ name: z.string(),
+ }),
+ handler: async (input) => {
+ return `Hello, ${input.name}!`;
+ },
+ }),
+ 'with/slash': defineAction({
+ input: z.object({
+ name: z.string(),
+ }),
+ handler: async (input) => {
+ return `Hello, ${input.name}!`;
+ },
}),
};
diff --git a/packages/astro/test/fixtures/actions/src/middleware.ts b/packages/astro/test/fixtures/actions/src/middleware.ts
index e630dfb730..0730b7e7be 100644
--- a/packages/astro/test/fixtures/actions/src/middleware.ts
+++ b/packages/astro/test/fixtures/actions/src/middleware.ts
@@ -1,8 +1,54 @@
-import { defineMiddleware } from 'astro:middleware';
+import { defineMiddleware, sequence } from 'astro:middleware';
+import { getActionContext } from 'astro:actions';
+
+const actionCookieForwarding = defineMiddleware(async (ctx, next) => {
+ if (ctx.isPrerendered) return next();
+
+ const { action, setActionResult, serializeActionResult } = getActionContext(ctx);
+
+ const payload = ctx.cookies.get('ACTION_PAYLOAD');
+ if (payload) {
+ const { actionName, actionResult } = payload.json();
+ setActionResult(actionName, actionResult);
+ ctx.cookies.delete('ACTION_PAYLOAD');
+ return next();
+ }
+
+ if (
+ action?.calledFrom === 'rpc' &&
+ action.name === 'locked' &&
+ !ctx.cookies.has('actionCookie')
+ ) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ if (action?.calledFrom === 'form' && ctx.url.searchParams.has('actionCookieForwarding')) {
+ const actionResult = await action.handler();
+
+ ctx.cookies.set('ACTION_PAYLOAD', {
+ actionName: action.name,
+ actionResult: serializeActionResult(actionResult),
+ });
+
+ if (actionResult.error) {
+ const referer = ctx.request.headers.get('Referer');
+ if (!referer) {
+ throw new Error('Internal: Referer unexpectedly missing from Action POST request.');
+ }
+ return ctx.redirect(referer);
+ }
+ return ctx.redirect(ctx.originPathname);
+ }
-export const onRequest = defineMiddleware((ctx, next) => {
- ctx.locals.user = {
- name: 'Houston',
- };
return next();
});
+
+export const onRequest = sequence(
+ defineMiddleware((ctx, next) => {
+ ctx.locals.user = {
+ name: 'Houston',
+ };
+ return next();
+ }),
+ actionCookieForwarding,
+);