mirror of
https://github.com/withastro/astro.git
synced 2024-12-30 22:03:56 -05:00
Actions middleware (#12373)
* add manual middleware config option with getMiddlewareContext() * refactor requestInfo to action object * set action error response status from render context * update automatic middleware to plain POST handler * fix missing Locals type * test: add separate POST and cookie forwarding tests * remove actions.middleware flag * add docs on actionResultAlreadySet * test: use Astro.rewrite instead of middleware next(). TODO: fix next() * fix type errors from rebase * test: remove middleware handler * test: use cookie forwarding for 'lots of fields' * refactor: _isPrerendered -> ctx.isPrerendered * expose getOriginPathname as middleware utility * add support for handling RPC action results from middleware * test: RPC security middleware * refactor POST route handler to use getMiddlewareContext() * remove unused actionRedirect flag * changeset * test: add expectedd rewrite failure for Ema to debug * fix e2e test * nit: form -> from Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * rename getMiddlewareContext -> getActionContext * rename form-action -> form * move /_actions/ route pattern to const * move type defs to user-accessible ActionMiddlewareContext type * export action middleware context type * strip omitted fields for Action API Context * add satisfies to type for good measure * move getOriginPathname to shared ctx.originPathname * remove `next()` rewrite because it isn't supported * fix empty forms raising a 415 * fix missing async on cookie example * nit: ctx -> context * fix json parse error when content length is 0 * refactor body parsing to function * edit: migration -> updating your HTML form actions Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * update changeset to match docs v5 guide * add absolute urls to changeset links --------- Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
d63d87dcae
commit
d10f91815e
26 changed files with 484 additions and 312 deletions
62
.changeset/tall-waves-impress.md
Normal file
62
.changeset/tall-waves-impress.md
Normal file
|
@ -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();
|
||||
})
|
||||
```
|
|
@ -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}');
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)!;
|
||||
|
|
|
@ -16,26 +16,30 @@ const result = Astro.getActionResult(actions.blog.lotsOfStuff);
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form method="POST" action={actions.blog.lotsOfStuff} data-testid="lots">
|
||||
<input type="text" name="one" value="">
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.blog.lotsOfStuff + '&actionCookieForwarding=true'}
|
||||
data-testid="lots"
|
||||
>
|
||||
<input type="text" name="one" value="" />
|
||||
<span class="one error">{result?.error?.fields.one}</span>
|
||||
<input type="text" name="two" value="">
|
||||
<input type="text" name="two" value="" />
|
||||
<span class="two error">{result?.error?.fields.two}</span>
|
||||
<input type="text" name="three" value="">
|
||||
<input type="text" name="three" value="" />
|
||||
<span class="three error">{result?.error?.fields.three}</span>
|
||||
<input type="text" name="four" value="">
|
||||
<input type="text" name="four" value="" />
|
||||
<span class="four error">{result?.error?.fields.four}</span>
|
||||
<input type="text" name="five" value="">
|
||||
<input type="text" name="five" value="" />
|
||||
<span class="five error">{result?.error?.fields.five}</span>
|
||||
<input type="text" name="six" value="">
|
||||
<input type="text" name="six" value="" />
|
||||
<span class="six error">{result?.error?.fields.six}</span>
|
||||
<input type="text" name="seven" value="">
|
||||
<input type="text" name="seven" value="" />
|
||||
<span class="seven error">{result?.error?.fields.seven}</span>
|
||||
<input type="text" name="eight" value="">
|
||||
<input type="text" name="eight" value="" />
|
||||
<span class="eight error">{result?.error?.fields.eight}</span>
|
||||
<input type="text" name="nine" value="">
|
||||
<input type="text" name="nine" value="" />
|
||||
<span class="nine error">{result?.error?.fields.nine}</span>
|
||||
<input type="text" name="ten" value="">
|
||||
<input type="text" name="ten" value="" />
|
||||
<span class="ten error">{result?.error?.fields.ten}</span>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
|
|
@ -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);
|
||||
---
|
||||
|
||||
<html>
|
||||
|
@ -13,6 +12,5 @@ const result = Astro.getActionResult(actions.sum)
|
|||
<button data-testid="submit" type="submit">Sum</button>
|
||||
</form>
|
||||
<p>Form result: {JSON.stringify(result)}</p>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
return Astro.rewrite('/rewritten' + Astro.url.search);
|
||||
---
|
|
@ -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]';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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`.",
|
||||
);
|
||||
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));
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
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<any, any>;
|
||||
}) {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
@ -11,10 +11,7 @@ import type { ActionAccept, ActionClient } from './server.js';
|
|||
export async function getAction(
|
||||
path: string,
|
||||
): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
|
||||
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');
|
||||
|
||||
|
|
|
@ -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<SafeResult<any, any>>;
|
||||
};
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -161,28 +161,33 @@ export const server = {
|
|||
};
|
||||
},
|
||||
}),
|
||||
"with.dot": 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({
|
||||
'with space': defineAction({
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
return `Hello, ${input.name}!`
|
||||
}
|
||||
return `Hello, ${input.name}!`;
|
||||
},
|
||||
}),
|
||||
"with/slash": defineAction({
|
||||
'with/slash': defineAction({
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
return `Hello, ${input.name}!`
|
||||
}
|
||||
return `Hello, ${input.name}!`;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -1,8 +1,54 @@
|
|||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { defineMiddleware, sequence } from 'astro:middleware';
|
||||
import { getActionContext } from 'astro:actions';
|
||||
|
||||
export const onRequest = defineMiddleware((ctx, next) => {
|
||||
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);
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
export const onRequest = sequence(
|
||||
defineMiddleware((ctx, next) => {
|
||||
ctx.locals.user = {
|
||||
name: 'Houston',
|
||||
};
|
||||
return next();
|
||||
});
|
||||
}),
|
||||
actionCookieForwarding,
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue