From 9566fa08608be766df355be17d72a39ea7b99ed0 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 22 May 2024 12:46:49 -0400 Subject: [PATCH] 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 --- .changeset/eighty-taxis-wait.md | 16 +++++ packages/astro/e2e/actions-blog.test.js | 16 +++++ .../src/pages/blog/[...slug].astro | 10 ++++ .../astro/src/actions/runtime/middleware.ts | 58 +++++++++---------- packages/astro/src/actions/runtime/route.ts | 3 +- packages/astro/src/actions/runtime/utils.ts | 8 ++- .../src/actions/runtime/virtual/server.ts | 6 +- packages/astro/templates/actions.mjs | 27 +++++---- packages/astro/test/actions.test.js | 11 ++++ .../fixtures/actions/src/actions/index.ts | 11 ++++ .../fixtures/actions/src/pages/rewrite.astro | 3 + .../actions/src/pages/subscribe.astro | 11 ++++ 12 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 .changeset/eighty-taxis-wait.md create mode 100644 packages/astro/test/fixtures/actions/src/pages/rewrite.astro create mode 100644 packages/astro/test/fixtures/actions/src/pages/subscribe.astro diff --git a/.changeset/eighty-taxis-wait.md b/.changeset/eighty-taxis-wait.md new file mode 100644 index 0000000000..889df38309 --- /dev/null +++ b/.changeset/eighty-taxis-wait.md @@ -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 }); +--- +``` diff --git a/packages/astro/e2e/actions-blog.test.js b/packages/astro/e2e/actions-blog.test.js index d7032dded5..e3a8c7cf84 100644 --- a/packages/astro/e2e/actions-blog.test.js +++ b/packages/astro/e2e/actions-blog.test.js @@ -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/')); diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro index d7eac0c4cd..e864c36101 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro +++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro @@ -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' +
+ + +
+

Comments

diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts index c4ad4e2636..b70da4f65d 100644 --- a/packages/astro/src/actions/runtime/middleware.ts +++ b/packages/astro/src/actions/runtime/middleware.ts @@ -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()); } diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts index e1521f3ad9..d5bdf2f897 100644 --- a/packages/astro/src/actions/runtime/route.ts +++ b/packages/astro/src/actions/runtime/route.ts @@ -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 }); } diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index 10f09665b1..eac9b92cf9 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -10,9 +10,15 @@ export function hasContentType(contentType: string, expected: string[]) { export type MaybePromise = T | Promise; +/** + * 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) | 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)) { diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index 077307a738..ce4d5f6966 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -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> inputSchema?: TInputSchema ) { return async (unparsedInput: unknown): Promise> => { - 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.', diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index c58ec7ec20..52906beada 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -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', diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 3d632dd66f..081e83bf6d 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -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'); + }); }); }); diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts index 7429006cdb..62b1f01ba1 100644 --- a/packages/astro/test/fixtures/actions/src/actions/index.ts +++ b/packages/astro/test/fixtures/actions/src/actions/index.ts @@ -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() }), diff --git a/packages/astro/test/fixtures/actions/src/pages/rewrite.astro b/packages/astro/test/fixtures/actions/src/pages/rewrite.astro new file mode 100644 index 0000000000..5ae3dd90d4 --- /dev/null +++ b/packages/astro/test/fixtures/actions/src/pages/rewrite.astro @@ -0,0 +1,3 @@ +--- +return Astro.rewrite('/subscribe'); +--- diff --git a/packages/astro/test/fixtures/actions/src/pages/subscribe.astro b/packages/astro/test/fixtures/actions/src/pages/subscribe.astro new file mode 100644 index 0000000000..e0b51e087e --- /dev/null +++ b/packages/astro/test/fixtures/actions/src/pages/subscribe.astro @@ -0,0 +1,11 @@ +--- +import { actions } from 'astro:actions'; + +const { url, channel } = await actions.subscribeFromServer({ + channel: 'bholmesdev', +}); +--- + +

{url}

+

{channel}

+