From c9d51107d0a4b58a9ced486b28d09118f3885254 Mon Sep 17 00:00:00 2001
From: Florian Lefebvre <contact@florian-lefebvre.dev>
Date: Wed, 18 Dec 2024 15:37:52 +0100
Subject: [PATCH] feat(actions): getActionPath() (#12721)

* feat(actions): getActionPath()

* feat: take trailing slash into account

* fix

* fix

* Update wise-boxes-develop.md

* Apply suggestions from code review

Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>

* Update .changeset/wise-boxes-develop.md

Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>

---------

Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
---
 .changeset/wise-boxes-develop.md              | 44 +++++++++++++++++++
 packages/astro/src/actions/plugins.ts         | 12 ++---
 packages/astro/templates/actions.mjs          | 36 ++++++++++-----
 packages/astro/test/actions.test.js           | 17 +++++++
 .../actions/src/pages/get-action-path.astro   |  6 +++
 packages/astro/types/actions.d.ts             |  4 ++
 6 files changed, 101 insertions(+), 18 deletions(-)
 create mode 100644 .changeset/wise-boxes-develop.md
 create mode 100644 packages/astro/test/fixtures/actions/src/pages/get-action-path.astro

diff --git a/.changeset/wise-boxes-develop.md b/.changeset/wise-boxes-develop.md
new file mode 100644
index 000000000000..5b7d0825e444
--- /dev/null
+++ b/.changeset/wise-boxes-develop.md
@@ -0,0 +1,44 @@
+---
+'astro': minor
+---
+
+Adds a new `getActionPath()` helper available from `astro:actions`
+
+Astro 5.1 introduces a new helper function, `getActionPath()` to give you more flexibility when calling your action.
+
+Calling `getActionPath()` with your action returns its URL path so you can make a `fetch()` request with custom headers, or use your action with an API such as `navigator.sendBeacon()`. Then, you can [handle the custom-formatted returned data](https://docs.astro.build/en/guides/actions/#handling-returned-data) as needed, just as if you had called an action directly.
+
+This example shows how to call a defined `like` action passing the `Authorization` header and the [`keepalive`](https://developer.mozilla.org/en-US/docs/Web/API/Request/keepalive) option:
+
+```astro
+<script>
+// src/components/my-component.astro
+import { actions, getActionPath } from 'astro:actions'
+
+await fetch(getActionPath(actions.like), {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    Authorization: 'Bearer YOUR_TOKEN'
+  },
+  body: JSON.stringify({ id: 'YOUR_ID' }),
+  keepalive: true
+})
+</script>
+```
+
+This example shows how to call the same `like` action using the [`sendBeacon`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon) API:
+
+```astro
+<script>
+// src/components/my-component.astro
+import { actions, getActionPath } from 'astro:actions'
+
+navigator.sendBeacon(
+  getActionPath(actions.like),
+  new Blob([JSON.stringify({ id: 'YOUR_ID' })], {
+    type: 'application/json'
+  })
+)
+</script>
+```
diff --git a/packages/astro/src/actions/plugins.ts b/packages/astro/src/actions/plugins.ts
index f5bd074dfcca..4c1b930c3d87 100644
--- a/packages/astro/src/actions/plugins.ts
+++ b/packages/astro/src/actions/plugins.ts
@@ -85,13 +85,13 @@ export function vitePluginActions({
 				code += `\nexport * from 'astro/actions/runtime/virtual/server.js';`;
 			} else {
 				code += `\nexport * from 'astro/actions/runtime/virtual/client.js';`;
-				code = code.replace(
-					"'/** @TRAILING_SLASH@ **/'",
-					JSON.stringify(
-						shouldAppendForwardSlash(settings.config.trailingSlash, settings.config.build.format),
-					),
-				);
 			}
+			code = code.replace(
+				"'/** @TRAILING_SLASH@ **/'",
+				JSON.stringify(
+					shouldAppendForwardSlash(settings.config.trailingSlash, settings.config.build.format),
+				),
+			);
 			return code;
 		},
 	};
diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs
index 93aaa4d76262..d10b2e3b3405 100644
--- a/packages/astro/templates/actions.mjs
+++ b/packages/astro/templates/actions.mjs
@@ -1,5 +1,6 @@
 import {
 	ActionError,
+	ACTION_QUERY_PARAMS,
 	appendForwardSlash,
 	deserializeActionResult,
 	getActionQueryString,
@@ -52,6 +53,17 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') {
 	});
 }
 
+const SHOULD_APPEND_TRAILING_SLASH = '/** @TRAILING_SLASH@ **/';
+
+/** @param {import('astro:actions').ActionClient<any, any, any>} */
+export function getActionPath(action) {
+	let path = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/_actions/${new URLSearchParams(action.toString()).get(ACTION_QUERY_PARAMS.actionName)}`;
+	if (SHOULD_APPEND_TRAILING_SLASH) {
+		path = appendForwardSlash(path);
+	}
+	return path;
+}
+
 /**
  * @param {*} param argument passed to the action when called server or client-side.
  * @param {string} path Built path to call action by path name.
@@ -88,19 +100,19 @@ async function handleAction(param, path, context) {
 			headers.set('Content-Length', '0');
 		}
 	}
+	const rawResult = await fetch(
+		getActionPath({
+			toString() {
+				return getActionQueryString(path);
+			},
+		}),
+		{
+			method: 'POST',
+			body,
+			headers,
+		},
+	);
 
-	const shouldAppendTrailingSlash = '/** @TRAILING_SLASH@ **/';
-	let actionPath = import.meta.env.BASE_URL.replace(/\/$/, '') + '/_actions/' + path;
-
-	if (shouldAppendTrailingSlash) {
-		actionPath = appendForwardSlash(actionPath);
-	}
-
-	const rawResult = await fetch(actionPath, {
-		method: 'POST',
-		body,
-		headers,
-	});
 	if (rawResult.status === 204) {
 		return deserializeActionResult({ type: 'empty', status: 204 });
 	}
diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js
index 2af8ebdd97fb..929a2d8d84a9 100644
--- a/packages/astro/test/actions.test.js
+++ b/packages/astro/test/actions.test.js
@@ -588,6 +588,23 @@ it('Should support trailing slash', async () => {
 	await devServer.stop();
 });
 
+it('getActionPath() should return the right path', async () => {
+	const fixture = await loadFixture({
+		root: './fixtures/actions/',
+		adapter: testAdapter(),
+		base: '/base',
+		trailingSlash: 'always',
+	});
+	const devServer = await fixture.startDevServer();
+	const res = await fixture.fetch('/base/get-action-path/');
+
+	assert.equal(res.ok, true);
+	const html = await res.text();
+	let $ = cheerio.load(html);
+	assert.equal($('[data-path]').text(), '/base/_actions/transformFormInput/');
+	await devServer.stop();
+});
+
 /**
  * Follow an expected redirect response.
  *
diff --git a/packages/astro/test/fixtures/actions/src/pages/get-action-path.astro b/packages/astro/test/fixtures/actions/src/pages/get-action-path.astro
new file mode 100644
index 000000000000..9fa69b0e2872
--- /dev/null
+++ b/packages/astro/test/fixtures/actions/src/pages/get-action-path.astro
@@ -0,0 +1,6 @@
+---
+import { actions, getActionPath } from "astro:actions"
+
+const path = getActionPath(actions.transformFormInput)
+---
+<p data-path>{path}</p>
\ No newline at end of file
diff --git a/packages/astro/types/actions.d.ts b/packages/astro/types/actions.d.ts
index 90187ebb9979..d30bd8bd99d0 100644
--- a/packages/astro/types/actions.d.ts
+++ b/packages/astro/types/actions.d.ts
@@ -1,3 +1,7 @@
 declare module 'astro:actions' {
 	export * from 'astro/actions/runtime/virtual/server.js';
+
+	export function getActionPath(
+		action: import('astro/actions/runtime/virtual/server.js').ActionClient<any, any, any>,
+	): string;
 }