Skip to content

Commit 9566fa0

Browse files
authoredMay 22, 2024··
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>
1 parent e71348e commit 9566fa0

File tree

12 files changed

+132
-48
lines changed

12 files changed

+132
-48
lines changed
 

‎.changeset/eighty-taxis-wait.md

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"astro": patch
3+
---
4+
5+
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.
6+
7+
Import and call directly from `astro:actions` as you would for client actions:
8+
9+
```astro
10+
---
11+
// src/pages/blog/[postId].astro
12+
import { actions } from 'astro:actions';
13+
14+
await actions.like({ postId: Astro.params.postId });
15+
---
16+
```

‎packages/astro/e2e/actions-blog.test.js

+16
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ test.afterAll(async () => {
1313
await devServer.stop();
1414
});
1515

16+
test.afterEach(async ({ astro }) => {
17+
// Force database reset between tests
18+
await astro.editFile('./db/seed.ts', (original) => original);
19+
});
20+
1621
test.describe('Astro Actions - Blog', () => {
1722
test('Like action', async ({ page, astro }) => {
1823
await page.goto(astro.resolveUrl('/blog/first-post/'));
@@ -23,6 +28,17 @@ test.describe('Astro Actions - Blog', () => {
2328
await expect(likeButton, 'like button should increment likes').toContainText('11');
2429
});
2530

31+
test('Like action - server-side', async ({ page, astro }) => {
32+
await page.goto(astro.resolveUrl('/blog/first-post/'));
33+
34+
const likeButton = page.getByLabel('get-request');
35+
const likeCount = page.getByLabel('Like');
36+
37+
await expect(likeCount, 'like button starts with 10 likes').toContainText('10');
38+
await likeButton.click();
39+
await expect(likeCount, 'like button should increment likes').toContainText('11');
40+
});
41+
2642
test('Comment action - validation error', async ({ page, astro }) => {
2743
await page.goto(astro.resolveUrl('/blog/first-post/'));
2844

‎packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro

+10
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ export async function getStaticPaths() {
1717
}));
1818
}
1919
20+
2021
type Props = CollectionEntry<'blog'>;
2122
2223
const post = await getEntry('blog', Astro.params.slug)!;
2324
const { Content } = await post.render();
2425
26+
if (Astro.url.searchParams.has('like')) {
27+
await actions.blog.like({postId: post.id });
28+
}
29+
2530
const comment = Astro.getActionResult(actions.blog.comment);
2631
2732
const comments = await db.select().from(Comment).where(eq(Comment.postId, post.id));
@@ -35,6 +40,11 @@ const commentPostIdOverride = Astro.url.searchParams.get('commentPostIdOverride'
3540
<BlogPost {...post.data}>
3641
<Like postId={post.id} initial={initialLikes?.likes ?? 0} client:load />
3742

43+
<form>
44+
<input type="hidden" name="like" />
45+
<button type="submit" aria-label="get-request">Like GET request</button>
46+
</form>
47+
3848
<Content />
3949

4050
<h2>Comments</h2>

‎packages/astro/src/actions/runtime/middleware.ts

+29-29
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,37 @@ export type Locals = {
1414

1515
export const onRequest = defineMiddleware(async (context, next) => {
1616
const locals = context.locals as Locals;
17+
// Actions middleware may have run already after a path rewrite.
18+
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
19+
// `_actionsInternal` is the same for every page,
20+
// so short circuit if already defined.
21+
if (locals._actionsInternal) return ApiContextStorage.run(context, () => next());
1722
if (context.request.method === 'GET') {
18-
return nextWithLocalsStub(next, locals);
23+
return nextWithLocalsStub(next, context);
1924
}
2025

2126
// Heuristic: If body is null, Astro might've reset this for prerendering.
2227
// Stub with warning when `getActionResult()` is used.
2328
if (context.request.method === 'POST' && context.request.body === null) {
24-
return nextWithStaticStub(next, locals);
29+
return nextWithStaticStub(next, context);
2530
}
2631

27-
// Actions middleware may have run already after a path rewrite.
28-
// See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite
29-
// `_actionsInternal` is the same for every page,
30-
// so short circuit if already defined.
31-
if (locals._actionsInternal) return next();
32-
3332
const { request, url } = context;
3433
const contentType = request.headers.get('Content-Type');
3534

3635
// Avoid double-handling with middleware when calling actions directly.
37-
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, locals);
36+
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, context);
3837

3938
if (!contentType || !hasContentType(contentType, formContentTypes)) {
40-
return nextWithLocalsStub(next, locals);
39+
return nextWithLocalsStub(next, context);
4140
}
4241

4342
const formData = await request.clone().formData();
4443
const actionPath = formData.get('_astroAction');
45-
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, locals);
44+
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, context);
4645

47-
const actionPathKeys = actionPath.replace('/_actions/', '').split('.');
48-
const action = await getAction(actionPathKeys);
49-
if (!action) return nextWithLocalsStub(next, locals);
46+
const action = await getAction(actionPath);
47+
if (!action) return nextWithLocalsStub(next, context);
5048

5149
const result = await ApiContextStorage.run(context, () => callSafely(() => action(formData)));
5250

@@ -60,19 +58,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
6058
actionResult: result,
6159
};
6260
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
63-
const response = await next();
64-
if (result.error) {
65-
return new Response(response.body, {
66-
status: result.error.status,
67-
statusText: result.error.name,
68-
headers: response.headers,
69-
});
70-
}
71-
return response;
61+
return ApiContextStorage.run(context, async () => {
62+
const response = await next();
63+
if (result.error) {
64+
return new Response(response.body, {
65+
status: result.error.status,
66+
statusText: result.error.name,
67+
headers: response.headers,
68+
});
69+
}
70+
return response;
71+
});
7272
});
7373

74-
function nextWithStaticStub(next: MiddlewareNext, locals: Locals) {
75-
Object.defineProperty(locals, '_actionsInternal', {
74+
function nextWithStaticStub(next: MiddlewareNext, context: APIContext) {
75+
Object.defineProperty(context.locals, '_actionsInternal', {
7676
writable: false,
7777
value: {
7878
getActionResult: () => {
@@ -84,15 +84,15 @@ function nextWithStaticStub(next: MiddlewareNext, locals: Locals) {
8484
},
8585
},
8686
});
87-
return next();
87+
return ApiContextStorage.run(context, () => next());
8888
}
8989

90-
function nextWithLocalsStub(next: MiddlewareNext, locals: Locals) {
91-
Object.defineProperty(locals, '_actionsInternal', {
90+
function nextWithLocalsStub(next: MiddlewareNext, context: APIContext) {
91+
Object.defineProperty(context.locals, '_actionsInternal', {
9292
writable: false,
9393
value: {
9494
getActionResult: () => undefined,
9595
},
9696
});
97-
return next();
97+
return ApiContextStorage.run(context, () => next());
9898
}

‎packages/astro/src/actions/runtime/route.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { callSafely } from './virtual/shared.js';
55

66
export const POST: APIRoute = async (context) => {
77
const { request, url } = context;
8-
const actionPathKeys = url.pathname.replace('/_actions/', '').split('.');
9-
const action = await getAction(actionPathKeys);
8+
const action = await getAction(url.pathname);
109
if (!action) {
1110
return new Response(null, { status: 404 });
1211
}

‎packages/astro/src/actions/runtime/utils.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@ export function hasContentType(contentType: string, expected: string[]) {
1010

1111
export type MaybePromise<T> = T | Promise<T>;
1212

13+
/**
14+
* Get server-side action based on the route path.
15+
* Imports from `import.meta.env.ACTIONS_PATH`, which maps to
16+
* the user's `src/actions/index.ts` file at build-time.
17+
*/
1318
export async function getAction(
14-
pathKeys: string[]
19+
path: string
1520
): Promise<((param: unknown) => MaybePromise<unknown>) | undefined> {
21+
const pathKeys = path.replace('/_actions/', '').split('.');
1622
let { server: actionLookup } = await import(import.meta.env.ACTIONS_PATH);
1723
for (const key of pathKeys) {
1824
if (!(key in actionLookup)) {

‎packages/astro/src/actions/runtime/virtual/server.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from 'zod';
22
import { type ActionAPIContext, getApiContext as _getApiContext } from '../store.js';
3-
import { type MaybePromise, hasContentType } from '../utils.js';
3+
import { type MaybePromise } from '../utils.js';
44
import {
55
ActionError,
66
ActionInputError,
@@ -104,9 +104,7 @@ function getJsonServerHandler<TOutput, TInputSchema extends InputSchema<'json'>>
104104
inputSchema?: TInputSchema
105105
) {
106106
return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => {
107-
const context = getApiContext();
108-
const contentType = context.request.headers.get('content-type');
109-
if (!contentType || !hasContentType(contentType, ['application/json'])) {
107+
if (unparsedInput instanceof FormData) {
110108
throw new ActionError({
111109
code: 'UNSUPPORTED_MEDIA_TYPE',
112110
message: 'This action only accepts JSON.',

‎packages/astro/templates/actions.mjs

+15-12
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
77
return target[objKey];
88
}
99
const path = aggregatedPath + objKey.toString();
10-
const action = (clientParam) => actionHandler(clientParam, path);
10+
const action = (param) => actionHandler(param, path);
1111
action.toString = () => path;
1212
action.safe = (input) => {
1313
return callSafely(() => action(input));
@@ -42,24 +42,27 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
4242
}
4343

4444
/**
45-
* @param {*} clientParam argument passed to the action when used on the client.
46-
* @param {string} path Built path to call action on the server.
47-
* Usage: `actions.[name](clientParam)`.
45+
* @param {*} param argument passed to the action when called server or client-side.
46+
* @param {string} path Built path to call action by path name.
47+
* Usage: `actions.[name](param)`.
4848
*/
49-
async function actionHandler(clientParam, path) {
49+
async function actionHandler(param, path) {
50+
// When running server-side, import the action and call it.
5051
if (import.meta.env.SSR) {
51-
throw new ActionError({
52-
code: 'BAD_REQUEST',
53-
message:
54-
'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',
55-
});
52+
const { getAction } = await import('astro/actions/runtime/utils.js');
53+
const action = await getAction(path);
54+
if (!action) throw new Error(`Action not found: ${path}`);
55+
56+
return action(param);
5657
}
58+
59+
// When running client-side, make a fetch request to the action path.
5760
const headers = new Headers();
5861
headers.set('Accept', 'application/json');
59-
let body = clientParam;
62+
let body = param;
6063
if (!(body instanceof FormData)) {
6164
try {
62-
body = clientParam ? JSON.stringify(clientParam) : undefined;
65+
body = param ? JSON.stringify(param) : undefined;
6366
} catch (e) {
6467
throw new ActionError({
6568
code: 'BAD_REQUEST',

‎packages/astro/test/actions.test.js

+11
Original file line numberDiff line numberDiff line change
@@ -214,5 +214,16 @@ describe('Astro Actions', () => {
214214
const res = await app.render(req);
215215
assert.equal(res.status, 204);
216216
});
217+
218+
it('Is callable from the server with rewrite', async () => {
219+
const req = new Request('http://example.com/rewrite');
220+
const res = await app.render(req);
221+
assert.equal(res.ok, true);
222+
223+
const html = await res.text();
224+
let $ = cheerio.load(html);
225+
assert.equal($('[data-url]').text(), '/subscribe');
226+
assert.equal($('[data-channel]').text(), 'bholmesdev');
227+
});
217228
});
218229
});

‎packages/astro/test/fixtures/actions/src/actions/index.ts

+11
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ export const server = {
1010
};
1111
},
1212
}),
13+
subscribeFromServer: defineAction({
14+
input: z.object({ channel: z.string() }),
15+
handler: async ({ channel }, { url }) => {
16+
return {
17+
// Returned to ensure path rewrites are respected
18+
url: url.pathname,
19+
channel,
20+
subscribeButtonState: 'smashed',
21+
};
22+
},
23+
}),
1324
comment: defineAction({
1425
accept: 'form',
1526
input: z.object({ channel: z.string(), comment: z.string() }),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
---
2+
return Astro.rewrite('/subscribe');
3+
---
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
import { actions } from 'astro:actions';
3+
4+
const { url, channel } = await actions.subscribeFromServer({
5+
channel: 'bholmesdev',
6+
});
7+
---
8+
9+
<p data-url>{url}</p>
10+
<p data-channel>{channel}</p>
11+

0 commit comments

Comments
 (0)
Please sign in to comment.