mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
Actions: Allow actions to be called on the server (#11088)
* wip: consume async local storage from `defineAction()` * fix: move async local storage to middleware. It works! * refactor: remove content-type check on JSON. Not needed * chore: remove test * feat: support server action calls * refactor: parse path keys within getAction * feat(test): server-side action call * chore: changeset * fix: reapply context on detected rewrite * feat(test): action from server with rewrite * chore: stray import change * feat(docs): add endpoints to changeset * chore: minor -> patch * fix: move rewrite check to start of middleware * fix: bad getApiContext() import --------- Co-authored-by: bholmesdev <bholmesdev@gmail.com>
This commit is contained in:
parent
e71348e138
commit
9566fa0860
12 changed files with 132 additions and 48 deletions
16
.changeset/eighty-taxis-wait.md
Normal file
16
.changeset/eighty-taxis-wait.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
"astro": patch
|
||||
---
|
||||
|
||||
Allow actions to be called on the server. This allows you to call actions as utility functions in your Astro frontmatter, endpoints, and server-side UI components.
|
||||
|
||||
Import and call directly from `astro:actions` as you would for client actions:
|
||||
|
||||
```astro
|
||||
---
|
||||
// src/pages/blog/[postId].astro
|
||||
import { actions } from 'astro:actions';
|
||||
|
||||
await actions.like({ postId: Astro.params.postId });
|
||||
---
|
||||
```
|
|
@ -13,6 +13,11 @@ test.afterAll(async () => {
|
|||
await devServer.stop();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ astro }) => {
|
||||
// Force database reset between tests
|
||||
await astro.editFile('./db/seed.ts', (original) => original);
|
||||
});
|
||||
|
||||
test.describe('Astro Actions - Blog', () => {
|
||||
test('Like action', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/blog/first-post/'));
|
||||
|
@ -23,6 +28,17 @@ test.describe('Astro Actions - Blog', () => {
|
|||
await expect(likeButton, 'like button should increment likes').toContainText('11');
|
||||
});
|
||||
|
||||
test('Like action - server-side', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/blog/first-post/'));
|
||||
|
||||
const likeButton = page.getByLabel('get-request');
|
||||
const likeCount = page.getByLabel('Like');
|
||||
|
||||
await expect(likeCount, 'like button starts with 10 likes').toContainText('10');
|
||||
await likeButton.click();
|
||||
await expect(likeCount, 'like button should increment likes').toContainText('11');
|
||||
});
|
||||
|
||||
test('Comment action - validation error', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/blog/first-post/'));
|
||||
|
||||
|
|
|
@ -17,11 +17,16 @@ export async function getStaticPaths() {
|
|||
}));
|
||||
}
|
||||
|
||||
|
||||
type Props = CollectionEntry<'blog'>;
|
||||
|
||||
const post = await getEntry('blog', Astro.params.slug)!;
|
||||
const { Content } = await post.render();
|
||||
|
||||
if (Astro.url.searchParams.has('like')) {
|
||||
await actions.blog.like({postId: post.id });
|
||||
}
|
||||
|
||||
const comment = Astro.getActionResult(actions.blog.comment);
|
||||
|
||||
const comments = await db.select().from(Comment).where(eq(Comment.postId, post.id));
|
||||
|
@ -35,6 +40,11 @@ const commentPostIdOverride = Astro.url.searchParams.get('commentPostIdOverride'
|
|||
<BlogPost {...post.data}>
|
||||
<Like postId={post.id} initial={initialLikes?.likes ?? 0} client:load />
|
||||
|
||||
<form>
|
||||
<input type="hidden" name="like" />
|
||||
<button type="submit" aria-label="get-request">Like GET request</button>
|
||||
</form>
|
||||
|
||||
<Content />
|
||||
|
||||
<h2>Comments</h2>
|
||||
|
|
|
@ -14,39 +14,37 @@ export type Locals = {
|
|||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const locals = context.locals as Locals;
|
||||
// Actions middleware may have run already after a path rewrite.
|
||||
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
|
||||
// `_actionsInternal` is the same for every page,
|
||||
// so short circuit if already defined.
|
||||
if (locals._actionsInternal) return ApiContextStorage.run(context, () => next());
|
||||
if (context.request.method === 'GET') {
|
||||
return nextWithLocalsStub(next, locals);
|
||||
return nextWithLocalsStub(next, context);
|
||||
}
|
||||
|
||||
// Heuristic: If body is null, Astro might've reset this for prerendering.
|
||||
// Stub with warning when `getActionResult()` is used.
|
||||
if (context.request.method === 'POST' && context.request.body === null) {
|
||||
return nextWithStaticStub(next, locals);
|
||||
return nextWithStaticStub(next, context);
|
||||
}
|
||||
|
||||
// Actions middleware may have run already after a path rewrite.
|
||||
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
|
||||
// `_actionsInternal` is the same for every page,
|
||||
// so short circuit if already defined.
|
||||
if (locals._actionsInternal) return next();
|
||||
|
||||
const { request, url } = context;
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
|
||||
// Avoid double-handling with middleware when calling actions directly.
|
||||
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, locals);
|
||||
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
|
||||
|
||||
if (!contentType || !hasContentType(contentType, formContentTypes)) {
|
||||
return nextWithLocalsStub(next, locals);
|
||||
return nextWithLocalsStub(next, context);
|
||||
}
|
||||
|
||||
const formData = await request.clone().formData();
|
||||
const actionPath = formData.get('_astroAction');
|
||||
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, locals);
|
||||
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, context);
|
||||
|
||||
const actionPathKeys = actionPath.replace('/_actions/', '').split('.');
|
||||
const action = await getAction(actionPathKeys);
|
||||
if (!action) return nextWithLocalsStub(next, locals);
|
||||
const action = await getAction(actionPath);
|
||||
if (!action) return nextWithLocalsStub(next, context);
|
||||
|
||||
const result = await ApiContextStorage.run(context, () => callSafely(() => action(formData)));
|
||||
|
||||
|
@ -60,19 +58,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
actionResult: result,
|
||||
};
|
||||
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
|
||||
const response = await next();
|
||||
if (result.error) {
|
||||
return new Response(response.body, {
|
||||
status: result.error.status,
|
||||
statusText: result.error.name,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
return ApiContextStorage.run(context, async () => {
|
||||
const response = await next();
|
||||
if (result.error) {
|
||||
return new Response(response.body, {
|
||||
status: result.error.status,
|
||||
statusText: result.error.name,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
});
|
||||
});
|
||||
|
||||
function nextWithStaticStub(next: MiddlewareNext, locals: Locals) {
|
||||
Object.defineProperty(locals, '_actionsInternal', {
|
||||
function nextWithStaticStub(next: MiddlewareNext, context: APIContext) {
|
||||
Object.defineProperty(context.locals, '_actionsInternal', {
|
||||
writable: false,
|
||||
value: {
|
||||
getActionResult: () => {
|
||||
|
@ -84,15 +84,15 @@ function nextWithStaticStub(next: MiddlewareNext, locals: Locals) {
|
|||
},
|
||||
},
|
||||
});
|
||||
return next();
|
||||
return ApiContextStorage.run(context, () => next());
|
||||
}
|
||||
|
||||
function nextWithLocalsStub(next: MiddlewareNext, locals: Locals) {
|
||||
Object.defineProperty(locals, '_actionsInternal', {
|
||||
function nextWithLocalsStub(next: MiddlewareNext, context: APIContext) {
|
||||
Object.defineProperty(context.locals, '_actionsInternal', {
|
||||
writable: false,
|
||||
value: {
|
||||
getActionResult: () => undefined,
|
||||
},
|
||||
});
|
||||
return next();
|
||||
return ApiContextStorage.run(context, () => next());
|
||||
}
|
||||
|
|
|
@ -5,8 +5,7 @@ import { callSafely } from './virtual/shared.js';
|
|||
|
||||
export const POST: APIRoute = async (context) => {
|
||||
const { request, url } = context;
|
||||
const actionPathKeys = url.pathname.replace('/_actions/', '').split('.');
|
||||
const action = await getAction(actionPathKeys);
|
||||
const action = await getAction(url.pathname);
|
||||
if (!action) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
|
|
@ -10,9 +10,15 @@ export function hasContentType(contentType: string, expected: string[]) {
|
|||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
/**
|
||||
* Get server-side action based on the route path.
|
||||
* Imports from `import.meta.env.ACTIONS_PATH`, which maps to
|
||||
* the user's `src/actions/index.ts` file at build-time.
|
||||
*/
|
||||
export async function getAction(
|
||||
pathKeys: string[]
|
||||
path: string
|
||||
): Promise<((param: unknown) => MaybePromise<unknown>) | undefined> {
|
||||
const pathKeys = path.replace('/_actions/', '').split('.');
|
||||
let { server: actionLookup } = await import(import.meta.env.ACTIONS_PATH);
|
||||
for (const key of pathKeys) {
|
||||
if (!(key in actionLookup)) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { type ActionAPIContext, getApiContext as _getApiContext } from '../store.js';
|
||||
import { type MaybePromise, hasContentType } from '../utils.js';
|
||||
import { type MaybePromise } from '../utils.js';
|
||||
import {
|
||||
ActionError,
|
||||
ActionInputError,
|
||||
|
@ -104,9 +104,7 @@ function getJsonServerHandler<TOutput, TInputSchema extends InputSchema<'json'>>
|
|||
inputSchema?: TInputSchema
|
||||
) {
|
||||
return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => {
|
||||
const context = getApiContext();
|
||||
const contentType = context.request.headers.get('content-type');
|
||||
if (!contentType || !hasContentType(contentType, ['application/json'])) {
|
||||
if (unparsedInput instanceof FormData) {
|
||||
throw new ActionError({
|
||||
code: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
message: 'This action only accepts JSON.',
|
||||
|
|
|
@ -7,7 +7,7 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
|
|||
return target[objKey];
|
||||
}
|
||||
const path = aggregatedPath + objKey.toString();
|
||||
const action = (clientParam) => actionHandler(clientParam, path);
|
||||
const action = (param) => actionHandler(param, path);
|
||||
action.toString = () => path;
|
||||
action.safe = (input) => {
|
||||
return callSafely(() => action(input));
|
||||
|
@ -42,24 +42,27 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {*} clientParam argument passed to the action when used on the client.
|
||||
* @param {string} path Built path to call action on the server.
|
||||
* Usage: `actions.[name](clientParam)`.
|
||||
* @param {*} param argument passed to the action when called server or client-side.
|
||||
* @param {string} path Built path to call action by path name.
|
||||
* Usage: `actions.[name](param)`.
|
||||
*/
|
||||
async function actionHandler(clientParam, path) {
|
||||
async function actionHandler(param, path) {
|
||||
// When running server-side, import the action and call it.
|
||||
if (import.meta.env.SSR) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'Action unexpectedly called on the server. If this error is unexpected, share your feedback on our RFC discussion: https://github.com/withastro/roadmap/pull/912',
|
||||
});
|
||||
const { getAction } = await import('astro/actions/runtime/utils.js');
|
||||
const action = await getAction(path);
|
||||
if (!action) throw new Error(`Action not found: ${path}`);
|
||||
|
||||
return action(param);
|
||||
}
|
||||
|
||||
// When running client-side, make a fetch request to the action path.
|
||||
const headers = new Headers();
|
||||
headers.set('Accept', 'application/json');
|
||||
let body = clientParam;
|
||||
let body = param;
|
||||
if (!(body instanceof FormData)) {
|
||||
try {
|
||||
body = clientParam ? JSON.stringify(clientParam) : undefined;
|
||||
body = param ? JSON.stringify(param) : undefined;
|
||||
} catch (e) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
|
|
|
@ -214,5 +214,16 @@ describe('Astro Actions', () => {
|
|||
const res = await app.render(req);
|
||||
assert.equal(res.status, 204);
|
||||
});
|
||||
|
||||
it('Is callable from the server with rewrite', async () => {
|
||||
const req = new Request('http://example.com/rewrite');
|
||||
const res = await app.render(req);
|
||||
assert.equal(res.ok, true);
|
||||
|
||||
const html = await res.text();
|
||||
let $ = cheerio.load(html);
|
||||
assert.equal($('[data-url]').text(), '/subscribe');
|
||||
assert.equal($('[data-channel]').text(), 'bholmesdev');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,6 +10,17 @@ export const server = {
|
|||
};
|
||||
},
|
||||
}),
|
||||
subscribeFromServer: defineAction({
|
||||
input: z.object({ channel: z.string() }),
|
||||
handler: async ({ channel }, { url }) => {
|
||||
return {
|
||||
// Returned to ensure path rewrites are respected
|
||||
url: url.pathname,
|
||||
channel,
|
||||
subscribeButtonState: 'smashed',
|
||||
};
|
||||
},
|
||||
}),
|
||||
comment: defineAction({
|
||||
accept: 'form',
|
||||
input: z.object({ channel: z.string(), comment: z.string() }),
|
||||
|
|
3
packages/astro/test/fixtures/actions/src/pages/rewrite.astro
vendored
Normal file
3
packages/astro/test/fixtures/actions/src/pages/rewrite.astro
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
return Astro.rewrite('/subscribe');
|
||||
---
|
11
packages/astro/test/fixtures/actions/src/pages/subscribe.astro
vendored
Normal file
11
packages/astro/test/fixtures/actions/src/pages/subscribe.astro
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
import { actions } from 'astro:actions';
|
||||
|
||||
const { url, channel } = await actions.subscribeFromServer({
|
||||
channel: 'bholmesdev',
|
||||
});
|
||||
---
|
||||
|
||||
<p data-url>{url}</p>
|
||||
<p data-channel>{channel}</p>
|
||||
|
Loading…
Reference in a new issue