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/'));
|
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,
|
page,
|
||||||
astro,
|
astro,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(astro.resolveUrl('/sum'));
|
await page.goto(astro.resolveUrl('/sum'));
|
||||||
const submitButton = page.getByTestId('submit');
|
const submitButton = page.getByTestId('submit');
|
||||||
await submitButton.click();
|
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);
|
const p = page.locator('p').nth(0);
|
||||||
await expect(p).toContainText('Form result: {"data":3}');
|
await expect(p).toContainText('Form result: {"data":3}');
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,7 @@ import node from '@astrojs/node';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://example.com',
|
site: 'https://example.com',
|
||||||
integrations: [db(), react()],
|
integrations: [db(), react()],
|
||||||
output: 'static',
|
output: 'server',
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: 'standalone',
|
mode: 'standalone',
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -68,21 +68,21 @@ export const server = {
|
||||||
seven: z.string().min(3),
|
seven: z.string().min(3),
|
||||||
eight: z.string().min(3),
|
eight: z.string().min(3),
|
||||||
nine: z.string().min(3),
|
nine: z.string().min(3),
|
||||||
ten: z.string().min(3)
|
ten: z.string().min(3),
|
||||||
}),
|
}),
|
||||||
handler(form) {
|
handler(form) {
|
||||||
return form;
|
return form;
|
||||||
}
|
},
|
||||||
})
|
}),
|
||||||
},
|
},
|
||||||
sum: defineAction({
|
sum: defineAction({
|
||||||
accept: "form",
|
accept: 'form',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
a: z.number(),
|
a: z.number(),
|
||||||
b: z.number(),
|
b: z.number(),
|
||||||
}),
|
}),
|
||||||
async handler({ a, b }) {
|
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) => {
|
const actionCookieForwarding = defineMiddleware(async (ctx, next) => {
|
||||||
if (ctx.request.method === "GET" && ctx.url.pathname === "/sum") {
|
if (ctx.isPrerendered) return next();
|
||||||
return next("/rewritten")
|
|
||||||
|
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 BlogPost from '../../layouts/BlogPost.astro';
|
||||||
import { Logout } from '../../components/Logout';
|
import { Logout } from '../../components/Logout';
|
||||||
import { db, eq, Comment, Likes } from 'astro:db';
|
import { db, eq, Comment, Likes } from 'astro:db';
|
||||||
|
@ -8,16 +8,6 @@ import { PostComment } from '../../components/PostComment';
|
||||||
import { actions } from 'astro:actions';
|
import { actions } from 'astro:actions';
|
||||||
import { isInputError } 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'>;
|
type Props = CollectionEntry<'blog'>;
|
||||||
|
|
||||||
const post = await getEntry('blog', Astro.params.slug)!;
|
const post = await getEntry('blog', Astro.params.slug)!;
|
||||||
|
|
|
@ -6,38 +6,42 @@ const result = Astro.getActionResult(actions.blog.lotsOfStuff);
|
||||||
---
|
---
|
||||||
|
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Actions</title>
|
<title>Actions</title>
|
||||||
<style>
|
<style>
|
||||||
form {
|
form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-row-gap: 10px;
|
grid-row-gap: 10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<form method="POST" action={actions.blog.lotsOfStuff} data-testid="lots">
|
<form
|
||||||
<input type="text" name="one" value="">
|
method="POST"
|
||||||
<span class="one error">{result?.error?.fields.one}</span>
|
action={actions.blog.lotsOfStuff + '&actionCookieForwarding=true'}
|
||||||
<input type="text" name="two" value="">
|
data-testid="lots"
|
||||||
<span class="two error">{result?.error?.fields.two}</span>
|
>
|
||||||
<input type="text" name="three" value="">
|
<input type="text" name="one" value="" />
|
||||||
<span class="three error">{result?.error?.fields.three}</span>
|
<span class="one error">{result?.error?.fields.one}</span>
|
||||||
<input type="text" name="four" value="">
|
<input type="text" name="two" value="" />
|
||||||
<span class="four error">{result?.error?.fields.four}</span>
|
<span class="two error">{result?.error?.fields.two}</span>
|
||||||
<input type="text" name="five" value="">
|
<input type="text" name="three" value="" />
|
||||||
<span class="five error">{result?.error?.fields.five}</span>
|
<span class="three error">{result?.error?.fields.three}</span>
|
||||||
<input type="text" name="six" value="">
|
<input type="text" name="four" value="" />
|
||||||
<span class="six error">{result?.error?.fields.six}</span>
|
<span class="four error">{result?.error?.fields.four}</span>
|
||||||
<input type="text" name="seven" value="">
|
<input type="text" name="five" value="" />
|
||||||
<span class="seven error">{result?.error?.fields.seven}</span>
|
<span class="five error">{result?.error?.fields.five}</span>
|
||||||
<input type="text" name="eight" value="">
|
<input type="text" name="six" value="" />
|
||||||
<span class="eight error">{result?.error?.fields.eight}</span>
|
<span class="six error">{result?.error?.fields.six}</span>
|
||||||
<input type="text" name="nine" value="">
|
<input type="text" name="seven" value="" />
|
||||||
<span class="nine error">{result?.error?.fields.nine}</span>
|
<span class="seven error">{result?.error?.fields.seven}</span>
|
||||||
<input type="text" name="ten" value="">
|
<input type="text" name="eight" value="" />
|
||||||
<span class="ten error">{result?.error?.fields.ten}</span>
|
<span class="eight error">{result?.error?.fields.eight}</span>
|
||||||
<button type="submit">Submit</button>
|
<input type="text" name="nine" value="" />
|
||||||
</form>
|
<span class="nine error">{result?.error?.fields.nine}</span>
|
||||||
</body>
|
<input type="text" name="ten" value="" />
|
||||||
|
<span class="ten error">{result?.error?.fields.ten}</span>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
---
|
---
|
||||||
import { actions } from "astro:actions";
|
import { actions } from 'astro:actions';
|
||||||
|
|
||||||
const result = Astro.getActionResult(actions.sum)
|
|
||||||
|
|
||||||
|
const result = Astro.getActionResult(actions.sum);
|
||||||
---
|
---
|
||||||
|
|
||||||
<html>
|
<html>
|
||||||
|
@ -13,6 +12,5 @@ const result = Astro.getActionResult(actions.sum)
|
||||||
<button data-testid="submit" type="submit">Sum</button>
|
<button data-testid="submit" type="submit">Sum</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Form result: {JSON.stringify(result)}</p>
|
<p>Form result: {JSON.stringify(result)}</p>
|
||||||
|
</body>
|
||||||
<body>
|
|
||||||
</html>
|
</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 = {
|
export const ACTION_QUERY_PARAMS = {
|
||||||
actionName: '_astroAction',
|
actionName: '_astroAction',
|
||||||
actionPayload: '_astroActionPayload',
|
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 { viteID } from '../core/util.js';
|
||||||
import type { AstroSettings } from '../types/astro.js';
|
import type { AstroSettings } from '../types/astro.js';
|
||||||
import type { AstroIntegration } from '../types/public/integrations.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.
|
* This integration is applied when the user is using Actions in their project.
|
||||||
|
@ -19,7 +19,7 @@ export default function astroIntegrationActionsRouteHandler({
|
||||||
hooks: {
|
hooks: {
|
||||||
async 'astro:config:setup'(params) {
|
async 'astro:config:setup'(params) {
|
||||||
params.injectRoute({
|
params.injectRoute({
|
||||||
pattern: '/_actions/[...path]',
|
pattern: ACTION_RPC_ROUTE_PATTERN,
|
||||||
entrypoint: 'astro/actions/runtime/route.js',
|
entrypoint: 'astro/actions/runtime/route.js',
|
||||||
prerender: false,
|
prerender: false,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,166 +1,13 @@
|
||||||
import { decodeBase64, encodeBase64 } from '@oslojs/encoding';
|
import { defineMiddleware } from '../../virtual-modules/middleware.js';
|
||||||
import { yellow } from 'kleur/colors';
|
import { getActionContext } from './virtual/server.js';
|
||||||
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();
|
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async (context, next) => {
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
if (context.isPrerendered) {
|
if (context.isPrerendered) return next();
|
||||||
if (context.request.method === 'POST') {
|
const { action, setActionResult, serializeActionResult } = getActionContext(context);
|
||||||
console.warn(
|
|
||||||
yellow('[astro:actions]'),
|
if (action?.calledFrom === 'form') {
|
||||||
"POST requests should not be sent to prerendered pages. If you're using Actions, disable prerendering with `export const prerender = false`.",
|
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();
|
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 type { APIRoute } from '../../types/public/common.js';
|
||||||
import { formContentTypes, hasContentType } from './utils.js';
|
import { getActionContext } from './virtual/server.js';
|
||||||
import { getAction } from './virtual/get-action.js';
|
|
||||||
import { serializeActionResult } from './virtual/shared.js';
|
|
||||||
|
|
||||||
export const POST: APIRoute = async (context) => {
|
export const POST: APIRoute = async (context) => {
|
||||||
const { request, url } = context;
|
const { action, serializeActionResult } = getActionContext(context);
|
||||||
let baseAction;
|
|
||||||
try {
|
if (action?.calledFrom !== 'rpc') {
|
||||||
baseAction = await getAction(url.pathname);
|
return new Response('Not found', { status: 404 });
|
||||||
} catch (e) {
|
|
||||||
if (import.meta.env.DEV) throw e;
|
|
||||||
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');
|
const result = await action.handler();
|
||||||
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 serialized = serializeActionResult(result);
|
const serialized = serializeActionResult(result);
|
||||||
|
|
||||||
if (serialized.type === 'empty') {
|
if (serialized.type === 'empty') {
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
import type { APIContext } from '../../types/public/context.js';
|
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'];
|
export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
|
||||||
|
|
||||||
|
|
|
@ -3,3 +3,7 @@ export * from './shared.js';
|
||||||
export function defineAction() {
|
export function defineAction() {
|
||||||
throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.');
|
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(
|
export async function getAction(
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
|
): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
|
||||||
const pathKeys = path
|
const pathKeys = path.split('.').map((key) => decodeURIComponent(key));
|
||||||
.replace(/^.*\/_actions\//, '')
|
|
||||||
.split('.')
|
|
||||||
.map((key) => decodeURIComponent(key));
|
|
||||||
// @ts-expect-error virtual module
|
// @ts-expect-error virtual module
|
||||||
let { server: actionLookup } = await import('astro:internal-actions');
|
let { server: actionLookup } = await import('astro:internal-actions');
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,27 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ActionCalledFromServerError } from '../../../core/errors/errors-data.js';
|
import { ActionCalledFromServerError } from '../../../core/errors/errors-data.js';
|
||||||
import { AstroError } from '../../../core/errors/errors.js';
|
import { AstroError } from '../../../core/errors/errors.js';
|
||||||
import type { ActionAPIContext, ErrorInferenceObject, MaybePromise } from '../utils.js';
|
import {
|
||||||
import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js';
|
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';
|
export * from './shared.js';
|
||||||
|
|
||||||
|
@ -212,3 +231,109 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
|
||||||
}
|
}
|
||||||
return schema;
|
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 type fsMod from 'node:fs';
|
||||||
import * as eslexer from 'es-module-lexer';
|
import * as eslexer from 'es-module-lexer';
|
||||||
import type { APIContext } from '../types/public/context.js';
|
import type { APIContext } from '../types/public/context.js';
|
||||||
import type { Locals } from './runtime/middleware.js';
|
import type { ActionAPIContext, Locals } from './runtime/utils.js';
|
||||||
import type { ActionAPIContext } from './runtime/utils.js';
|
|
||||||
import { deserializeActionResult, getActionQueryString } from './runtime/virtual/shared.js';
|
import { deserializeActionResult, getActionQueryString } from './runtime/virtual/shared.js';
|
||||||
|
|
||||||
export function hasActionPayload(locals: APIContext['locals']): locals is Locals {
|
export function hasActionPayload(locals: APIContext['locals']): locals is Locals {
|
||||||
|
|
|
@ -6,10 +6,11 @@ import {
|
||||||
} from '../../i18n/utils.js';
|
} from '../../i18n/utils.js';
|
||||||
import type { MiddlewareHandler, Params, RewritePayload } from '../../types/public/common.js';
|
import type { MiddlewareHandler, Params, RewritePayload } from '../../types/public/common.js';
|
||||||
import type { APIContext } from '../../types/public/context.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 { AstroCookies } from '../cookies/index.js';
|
||||||
import { AstroError, AstroErrorData } from '../errors/index.js';
|
import { AstroError, AstroErrorData } from '../errors/index.js';
|
||||||
import { getClientIpAddress } from '../routing/request.js';
|
import { getClientIpAddress } from '../routing/request.js';
|
||||||
|
import { getOriginPathname } from '../routing/rewrite.js';
|
||||||
import { sequence } from './sequence.js';
|
import { sequence } from './sequence.js';
|
||||||
|
|
||||||
function defineMiddleware(fn: MiddlewareHandler) {
|
function defineMiddleware(fn: MiddlewareHandler) {
|
||||||
|
@ -89,6 +90,9 @@ function createContext({
|
||||||
return (currentLocale ??= computeCurrentLocale(route, userDefinedLocales, defaultLocale));
|
return (currentLocale ??= computeCurrentLocale(route, userDefinedLocales, defaultLocale));
|
||||||
},
|
},
|
||||||
url,
|
url,
|
||||||
|
get originPathname() {
|
||||||
|
return getOriginPathname(request);
|
||||||
|
},
|
||||||
get clientAddress() {
|
get clientAddress() {
|
||||||
if (clientIpAddress) {
|
if (clientIpAddress) {
|
||||||
return clientIpAddress;
|
return clientIpAddress;
|
||||||
|
|
|
@ -28,7 +28,7 @@ import { callMiddleware } from './middleware/callMiddleware.js';
|
||||||
import { sequence } from './middleware/index.js';
|
import { sequence } from './middleware/index.js';
|
||||||
import { renderRedirect } from './redirects/render.js';
|
import { renderRedirect } from './redirects/render.js';
|
||||||
import { type Pipeline, Slots, getParams, getProps } from './render/index.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';
|
import { SERVER_ISLAND_COMPONENT } from './server-islands/endpoint.js';
|
||||||
|
|
||||||
export const apiContextRoutesSymbol = Symbol.for('context.routes');
|
export const apiContextRoutesSymbol = Symbol.for('context.routes');
|
||||||
|
@ -299,6 +299,9 @@ export class RenderContext {
|
||||||
request: this.request,
|
request: this.request,
|
||||||
site: pipeline.site,
|
site: pipeline.site,
|
||||||
url,
|
url,
|
||||||
|
get originPathname() {
|
||||||
|
return getOriginPathname(renderContext.request);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,9 +314,12 @@ export class RenderContext {
|
||||||
(await pipeline.componentMetadata(routeData)) ?? manifest.componentMetadata;
|
(await pipeline.componentMetadata(routeData)) ?? manifest.componentMetadata;
|
||||||
const headers = new Headers({ 'Content-Type': 'text/html' });
|
const headers = new Headers({ 'Content-Type': 'text/html' });
|
||||||
const partial = typeof this.partial === 'boolean' ? this.partial : Boolean(mod.partial);
|
const partial = typeof this.partial === 'boolean' ? this.partial : Boolean(mod.partial);
|
||||||
|
const actionResult = hasActionPayload(this.locals)
|
||||||
|
? deserializeActionResult(this.locals._actionPayload.actionResult)
|
||||||
|
: undefined;
|
||||||
const response = {
|
const response = {
|
||||||
status,
|
status: actionResult?.error ? actionResult?.error.status : status,
|
||||||
statusText: 'OK',
|
statusText: actionResult?.error ? actionResult?.error.type : 'OK',
|
||||||
get headers() {
|
get headers() {
|
||||||
return headers;
|
return headers;
|
||||||
},
|
},
|
||||||
|
@ -323,10 +329,6 @@ export class RenderContext {
|
||||||
},
|
},
|
||||||
} satisfies AstroGlobal['response'];
|
} 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.
|
// 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
|
// 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.
|
// calling the render() function will populate the object with scripts, styles, etc.
|
||||||
|
@ -478,6 +480,9 @@ export class RenderContext {
|
||||||
return createCallAction(this);
|
return createCallAction(this);
|
||||||
},
|
},
|
||||||
url,
|
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));
|
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);
|
const origin = Reflect.get(request, originPathnameSymbol);
|
||||||
if (origin) {
|
if (origin) {
|
||||||
return decodeURIComponent(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.
|
* A full URL object of the request URL.
|
||||||
*/
|
*/
|
||||||
url: 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.
|
* Get action result on the server when using a form POST.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
import {
|
import { ActionError, deserializeActionResult, getActionQueryString } from 'astro:actions';
|
||||||
ACTION_QUERY_PARAMS,
|
|
||||||
ActionError,
|
|
||||||
deserializeActionResult,
|
|
||||||
getActionQueryString,
|
|
||||||
} from 'astro:actions';
|
|
||||||
|
|
||||||
const ENCODED_DOT = '%2E';
|
const ENCODED_DOT = '%2E';
|
||||||
|
|
||||||
|
@ -26,10 +21,6 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
|
||||||
// Progressive enhancement info for React.
|
// Progressive enhancement info for React.
|
||||||
$$FORM_ACTION: function () {
|
$$FORM_ACTION: function () {
|
||||||
const searchParams = new URLSearchParams(action.toString());
|
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 {
|
return {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
// `name` creates a hidden input.
|
// `name` creates a hidden input.
|
||||||
|
|
|
@ -213,7 +213,7 @@ describe('Astro Actions', () => {
|
||||||
assert.equal(data.isFormData, true, 'Should receive plain FormData');
|
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', {
|
const req = new Request('http://example.com/user?_astroAction=getUser', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: new FormData(),
|
body: new FormData(),
|
||||||
|
@ -221,6 +221,25 @@ describe('Astro Actions', () => {
|
||||||
Referer: 'http://example.com/user',
|
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);
|
const res = await followExpectedRedirect(req, app);
|
||||||
assert.equal(res.ok, true);
|
assert.equal(res.ok, true);
|
||||||
|
|
||||||
|
@ -229,7 +248,7 @@ describe('Astro Actions', () => {
|
||||||
assert.equal($('#user').text(), 'Houston');
|
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', {
|
const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: new FormData(),
|
body: new FormData(),
|
||||||
|
@ -237,6 +256,26 @@ describe('Astro Actions', () => {
|
||||||
Referer: 'http://example.com/user-or-throw',
|
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);
|
const res = await followExpectedRedirect(req, app);
|
||||||
assert.equal(res.status, 401);
|
assert.equal(res.status, 401);
|
||||||
|
|
||||||
|
@ -246,6 +285,35 @@ describe('Astro Actions', () => {
|
||||||
assert.equal($('#error-code').text(), 'UNAUTHORIZED');
|
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 () => {
|
it('Ignores `_astroAction` name for GET requests', async () => {
|
||||||
const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', {
|
const req = new Request('http://example.com/user-or-throw?_astroAction=getUserOrThrow', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
@ -161,28 +161,33 @@ export const server = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
"with.dot": defineAction({
|
locked: defineAction({
|
||||||
input: z.object({
|
handler: async () => {
|
||||||
name: z.string(),
|
return { safe: true };
|
||||||
}),
|
},
|
||||||
handler: async (input) => {
|
}),
|
||||||
return `Hello, ${input.name}!`
|
'with.dot': defineAction({
|
||||||
}
|
|
||||||
}),
|
|
||||||
"with space": defineAction({
|
|
||||||
input: z.object({
|
|
||||||
name: z.string(),
|
|
||||||
}),
|
|
||||||
handler: async (input) => {
|
|
||||||
return `Hello, ${input.name}!`
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
"with/slash": defineAction({
|
|
||||||
input: z.object({
|
input: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
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}!`;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const onRequest = sequence(
|
||||||
|
defineMiddleware((ctx, next) => {
|
||||||
|
ctx.locals.user = {
|
||||||
|
name: 'Houston',
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}),
|
||||||
|
actionCookieForwarding,
|
||||||
|
);
|
||||||
|
|
Loading…
Reference in a new issue