Skip to content

Commit

Permalink
Actions: Allow actions to be called on the server (#11088)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
bholmesdev and bholmesdev authored May 22, 2024
1 parent e71348e commit 9566fa0
Show file tree
Hide file tree
Showing 12 changed files with 132 additions and 48 deletions.
16 changes: 16 additions & 0 deletions .changeset/eighty-taxis-wait.md
Original file line number Diff line number Diff line change
@@ -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 });
---
```
16 changes: 16 additions & 0 deletions packages/astro/e2e/actions-blog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/'));
Expand All @@ -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/'));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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>
Expand Down
58 changes: 29 additions & 29 deletions packages/astro/src/actions/runtime/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Expand All @@ -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: () => {
Expand All @@ -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());
}
3 changes: 1 addition & 2 deletions packages/astro/src/actions/runtime/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
8 changes: 7 additions & 1 deletion packages/astro/src/actions/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
6 changes: 2 additions & 4 deletions packages/astro/src/actions/runtime/virtual/server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.',
Expand Down
27 changes: 15 additions & 12 deletions packages/astro/templates/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions packages/astro/test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
11 changes: 11 additions & 0 deletions packages/astro/test/fixtures/actions/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/test/fixtures/actions/src/pages/rewrite.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
return Astro.rewrite('/subscribe');
---
11 changes: 11 additions & 0 deletions packages/astro/test/fixtures/actions/src/pages/subscribe.astro
Original file line number Diff line number Diff line change
@@ -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>

0 comments on commit 9566fa0

Please sign in to comment.