Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Actions: Allow actions to be called on the server #11088

Merged
merged 15 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved
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>

Loading