summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/eighty-taxis-wait.md16
-rw-r--r--packages/astro/e2e/actions-blog.test.js16
-rw-r--r--packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro10
-rw-r--r--packages/astro/src/actions/runtime/middleware.ts58
-rw-r--r--packages/astro/src/actions/runtime/route.ts3
-rw-r--r--packages/astro/src/actions/runtime/utils.ts8
-rw-r--r--packages/astro/src/actions/runtime/virtual/server.ts6
-rw-r--r--packages/astro/templates/actions.mjs27
-rw-r--r--packages/astro/test/actions.test.js11
-rw-r--r--packages/astro/test/fixtures/actions/src/actions/index.ts11
-rw-r--r--packages/astro/test/fixtures/actions/src/pages/rewrite.astro3
-rw-r--r--packages/astro/test/fixtures/actions/src/pages/subscribe.astro11
12 files changed, 132 insertions, 48 deletions
diff --git a/.changeset/eighty-taxis-wait.md b/.changeset/eighty-taxis-wait.md
new file mode 100644
index 000000000..889df3830
--- /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 d7032dded..e3a8c7cf8 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 d7eac0c4c..e864c3610 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'
<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>
diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts
index c4ad4e263..b70da4f65 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 e1521f3ad..d5bdf2f89 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 10f09665b..eac9b92cf 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> = 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)) {
diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts
index 077307a73..ce4d5f696 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<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.',
diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs
index c58ec7ec2..52906bead 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 3d632dd66..081e83bf6 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 7429006cd..62b1f01ba 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 000000000..5ae3dd90d
--- /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 000000000..e0b51e087
--- /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',
+});
+---
+
+<p data-url>{url}</p>
+<p data-channel>{channel}</p>
+