diff --git a/packages/actions/index.d.ts b/packages/actions/index.d.ts
new file mode 100644
index 0000000000..f52ed74e41
--- /dev/null
+++ b/packages/actions/index.d.ts
@@ -0,0 +1,3 @@
+///
+
+export { default } from './src/index.js';
diff --git a/packages/actions/package.json b/packages/actions/package.json
new file mode 100644
index 0000000000..d1e01e109b
--- /dev/null
+++ b/packages/actions/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@astrojs/actions",
+ "version": "0.0.1",
+ "description": "Add RPC actions to your Astro projects",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/withastro/astro.git",
+ "directory": "packages/actions"
+ },
+ "bugs": "https://github.com/withastro/astro/issues",
+ "homepage": "https://docs.astro.build/",
+ "type": "module",
+ "author": "withastro",
+ "types": "./index.d.ts",
+ "main": "./dist/index.js",
+ "exports": {
+ ".": {
+ "types": "./index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./config": {
+ "types": "./dist/runtime/config.d.ts",
+ "import": "./dist/runtime/config.js"
+ },
+ "./route.js": "./dist/runtime/route.js",
+ "./middleware.js": "./dist/runtime/middleware.js",
+ "./package.json": "./package.json"
+ },
+ "typesVersions": {
+ "*": {
+ ".": [
+ "./index.d.ts"
+ ],
+ "config": [
+ "./dist/config.d.ts"
+ ]
+ }
+ },
+ "files": [
+ "index.d.ts",
+ "virtual.d.ts",
+ "dist"
+ ],
+ "keywords": [
+ "withastro",
+ "astro-integration"
+ ],
+ "scripts": {
+ "build": "astro-scripts build \"src/**/*.ts\" && tsc",
+ "build:ci": "astro-scripts build \"src/**/*.ts\"",
+ "dev": "astro-scripts dev \"src/**/*.ts\"",
+ "test": "mocha --exit --timeout 20000 \"test/*.js\" \"test/unit/**/*.js\"",
+ "test:match": "mocha --timeout 20000 \"test/*.js\" \"test/unit/*.js\" -g"
+ },
+ "dependencies": {
+ "astro-integration-kit": "^0.11.0",
+ "zod": "^3.23.0"
+ },
+ "devDependencies": {
+ "astro": "workspace:*",
+ "astro-scripts": "workspace:*",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts
new file mode 100644
index 0000000000..46b577cd92
--- /dev/null
+++ b/packages/actions/src/index.ts
@@ -0,0 +1,60 @@
+import type { AstroIntegration } from 'astro';
+import { addDts, addVitePlugin } from 'astro-integration-kit';
+import { readFile } from 'node:fs/promises';
+
+const VIRTUAL_MODULE_ID = 'astro:actions';
+const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
+
+export default function astroActions(): AstroIntegration {
+ return {
+ name: 'astro-actions',
+ hooks: {
+ async 'astro:config:setup'(params) {
+ const stringifiedActionsPath = JSON.stringify(
+ new URL('actions', params.config.srcDir).pathname
+ );
+ params.updateConfig({
+ vite: {
+ define: {
+ 'import.meta.env.ACTIONS_PATH': stringifiedActionsPath,
+ },
+ },
+ });
+ params.injectRoute({
+ pattern: '/_actions/[...path]',
+ entrypoint: '@astrojs/actions/route.js',
+ prerender: false,
+ });
+ params.addMiddleware({
+ entrypoint: '@astrojs/actions/middleware.js',
+ order: 'pre',
+ });
+ addDts(params, {
+ name: 'astro-actions',
+ content: `declare module "astro:actions" {
+ type Actions = typeof import(${stringifiedActionsPath})["default"];
+
+ export const actions: Actions;
+}`,
+ });
+
+ addVitePlugin(params, {
+ plugin: {
+ name: 'astro-actions',
+ enforce: 'pre',
+ resolveId(id) {
+ if (id === VIRTUAL_MODULE_ID) {
+ return RESOLVED_VIRTUAL_MODULE_ID;
+ }
+ },
+ async load(id) {
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
+ return await readFile(new URL('./virtual.js', import.meta.url), 'utf-8');
+ }
+ },
+ },
+ });
+ },
+ },
+ };
+}
diff --git a/packages/actions/src/runtime/config.ts b/packages/actions/src/runtime/config.ts
new file mode 100644
index 0000000000..91ec8abc3d
--- /dev/null
+++ b/packages/actions/src/runtime/config.ts
@@ -0,0 +1,88 @@
+import type { APIContext } from 'astro';
+import { z } from 'zod';
+import { ApiContextStorage } from './utils.js';
+
+export function enhanceProps(action: T) {
+ return {
+ type: 'hidden',
+ name: '_astroAction',
+ value: action.toString(),
+ } as const;
+}
+
+type MaybePromise = T | Promise;
+
+export function defineAction({
+ input: inputSchema,
+ handler,
+ enhance,
+}: {
+ input?: TInputSchema;
+ handler: (input: z.infer, context: APIContext) => MaybePromise;
+ enhance?: boolean;
+}): (input: z.input) => Promise> {
+ return async (unparsedInput): Promise> => {
+ const context = ApiContextStorage.getStore()!;
+ const ContentType = context.request.headers.get('content-type');
+ if (!enhance && (ContentType !== 'application/json' || unparsedInput instanceof FormData)) {
+ // TODO: prettify dev server error
+ throw new Response(
+ 'This action only accepts JSON. To enhance this action to accept form data, add `enhance: true` to your `defineAction()` config.',
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ }
+ );
+ }
+
+ if (!inputSchema) return await handler(unparsedInput, context);
+
+ if (enhance && unparsedInput instanceof FormData) {
+ if (!(inputSchema instanceof z.ZodObject)) {
+ throw new Response(
+ '`input` must use a Zod object schema (z.object) when `enhance` is enabled.',
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ }
+ );
+ }
+ unparsedInput = enhanceFormData(unparsedInput, inputSchema);
+ }
+
+ const parsed = inputSchema.safeParse(unparsedInput);
+ if (!parsed.success) {
+ throw new Response(JSON.stringify(parsed.error), {
+ status: 400,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ }
+ return await handler(parsed.data, context);
+ };
+}
+
+function enhanceFormData(
+ formData: FormData,
+ schema: T
+): Record {
+ const obj: Record = {};
+ for (const [key, validator] of Object.entries(schema.shape)) {
+ // TODO: refine, unit test
+ if (validator instanceof z.ZodBoolean) {
+ obj[key] = formData.has(key);
+ } else if (validator instanceof z.ZodArray) {
+ obj[key] = Array.from(formData.getAll(key));
+ } else if (validator instanceof z.ZodNumber) {
+ obj[key] = Number(formData.get(key));
+ } else {
+ obj[key] = formData.get(key);
+ }
+ }
+ return obj;
+}
diff --git a/packages/actions/src/runtime/env.d.ts b/packages/actions/src/runtime/env.d.ts
new file mode 100644
index 0000000000..cb7f88cdeb
--- /dev/null
+++ b/packages/actions/src/runtime/env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/packages/actions/src/runtime/middleware.ts b/packages/actions/src/runtime/middleware.ts
new file mode 100644
index 0000000000..4402fb8e42
--- /dev/null
+++ b/packages/actions/src/runtime/middleware.ts
@@ -0,0 +1,30 @@
+import { defineMiddleware } from 'astro:middleware';
+import { ApiContextStorage, formContentTypes, getAction } from './utils.js';
+
+export const onRequest = defineMiddleware(async (context, next) => {
+ context.locals.getActionResult = (action) => undefined;
+
+ const { request } = context;
+ const contentType = request.headers.get('Content-Type');
+ if (!formContentTypes.some((f) => contentType?.startsWith(f))) return next();
+
+ const formData = await request.clone().formData();
+ const actionPath = formData.get('_astroAction');
+ if (typeof actionPath !== 'string') return next();
+
+ const actionPathKeys = actionPath.replace('/_actions/', '').split('.');
+ const action = await getAction(actionPathKeys);
+ let result: any;
+ try {
+ result = await ApiContextStorage.run(context, () => action(formData));
+ } catch (e) {
+ if (e instanceof Response) {
+ return e;
+ }
+ throw e;
+ }
+ context.locals.getActionResult = (action) => {
+ if (action.toString() === actionPath) return result;
+ };
+ return next();
+});
diff --git a/packages/actions/src/runtime/route.ts b/packages/actions/src/runtime/route.ts
new file mode 100644
index 0000000000..a9a6c52e15
--- /dev/null
+++ b/packages/actions/src/runtime/route.ts
@@ -0,0 +1,33 @@
+import type { APIRoute } from 'astro';
+import { ApiContextStorage, formContentTypes, getAction } from './utils.js';
+
+export const POST: APIRoute = async (context) => {
+ const { request, url, redirect } = context;
+ if (request.method !== 'POST') {
+ return new Response(null, { status: 405 });
+ }
+ const actionPathKeys = url.pathname.replace('/_actions/', '').split('.');
+ const action = await getAction(actionPathKeys);
+ const contentType = request.headers.get('Content-Type');
+ let args: any;
+ if (contentType === 'application/json') {
+ args = await request.clone().json();
+ }
+ if (formContentTypes.some((f) => contentType?.startsWith(f))) {
+ args = await request.clone().formData();
+ }
+ let result: unknown;
+ try {
+ result = await ApiContextStorage.run(context, () => action(args));
+ } catch (e) {
+ if (e instanceof Response) {
+ return e;
+ }
+ throw e;
+ }
+ return new Response(JSON.stringify(result), {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+};
diff --git a/packages/actions/src/runtime/utils.ts b/packages/actions/src/runtime/utils.ts
new file mode 100644
index 0000000000..73a2c2b1ec
--- /dev/null
+++ b/packages/actions/src/runtime/utils.ts
@@ -0,0 +1,20 @@
+import type { APIContext } from 'astro';
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+export const ApiContextStorage = new AsyncLocalStorage();
+
+export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+export async function getAction(pathKeys: string[]): Promise {
+ let { default: actionLookup } = await import(import.meta.env.ACTIONS_PATH);
+ for (const key of pathKeys) {
+ if (!(key in actionLookup)) {
+ throw new Error('Action not found');
+ }
+ actionLookup = actionLookup[key];
+ }
+ if (typeof actionLookup !== 'function') {
+ throw new Error('Action not found');
+ }
+ return actionLookup;
+}
diff --git a/packages/actions/src/virtual.ts b/packages/actions/src/virtual.ts
new file mode 100644
index 0000000000..4aeaff9141
--- /dev/null
+++ b/packages/actions/src/virtual.ts
@@ -0,0 +1,34 @@
+function toActionProxy(
+ actionCallback = {},
+ aggregatedPath = '/_actions/'
+): Record {
+ return new Proxy(actionCallback, {
+ get(target: Record, objKey) {
+ const path = aggregatedPath + objKey.toString();
+ if (objKey in target) {
+ return target[objKey];
+ }
+ async function action(param?: BodyInit) {
+ const headers = new Headers();
+ headers.set('Accept', 'application/json');
+ let body = param;
+ if (!(body instanceof FormData)) {
+ body = JSON.stringify(param);
+ headers.set('Content-Type', 'application/json');
+ }
+ const res = await fetch(path, {
+ method: 'POST',
+ body,
+ headers,
+ });
+ return res.json();
+ }
+ action.toString = () => path;
+ // recurse to construct queries for nested object paths
+ // ex. actions.user.admins.auth()
+ return toActionProxy(action, path + '.');
+ },
+ });
+}
+
+export const actions = toActionProxy();
diff --git a/packages/actions/tsconfig.json b/packages/actions/tsconfig.json
new file mode 100644
index 0000000000..93a4b3350c
--- /dev/null
+++ b/packages/actions/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "outDir": "./dist",
+ }
+}
diff --git a/packages/actions/virtual.d.ts b/packages/actions/virtual.d.ts
new file mode 100644
index 0000000000..b5db4a3cee
--- /dev/null
+++ b/packages/actions/virtual.d.ts
@@ -0,0 +1,7 @@
+declare namespace App {
+ interface Locals {
+ getActionResult: any>(
+ action: T
+ ) => Awaited> | undefined;
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 15bc13293c..2395b9991a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -501,6 +501,70 @@ importers:
specifier: ^1.5.0
version: 1.5.0(@types/node@18.19.31)
+ packages/actions:
+ dependencies:
+ astro-integration-kit:
+ specifier: ^0.11.0
+ version: 0.11.0(astro@packages+astro)
+ zod:
+ specifier: ^3.23.0
+ version: 3.23.0
+ devDependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../astro
+ astro-scripts:
+ specifier: workspace:*
+ version: link:../../scripts
+ typescript:
+ specifier: ^5.4.5
+ version: 5.4.5
+
+ packages/actions/test/fixtures/basics:
+ dependencies:
+ '@astrojs/actions':
+ specifier: workspace:*
+ version: link:../../..
+ '@astrojs/check':
+ specifier: ^0.5.10
+ version: 0.5.10(prettier-plugin-astro@0.13.0)(prettier@3.2.5)(typescript@5.4.5)
+ '@astrojs/db':
+ specifier: ^0.10.5
+ version: link:../../../../db
+ '@astrojs/mdx':
+ specifier: ^2.3.1
+ version: link:../../../../integrations/mdx
+ '@astrojs/node':
+ specifier: ^8.2.5
+ version: link:../../../../integrations/node
+ '@astrojs/react':
+ specifier: ^3.3.0
+ version: link:../../../../integrations/react
+ '@astrojs/rss':
+ specifier: ^4.0.5
+ version: link:../../../../astro-rss
+ '@astrojs/sitemap':
+ specifier: ^3.1.4
+ version: link:../../../../integrations/sitemap
+ '@types/react':
+ specifier: ^18.2.79
+ version: 18.2.79
+ '@types/react-dom':
+ specifier: ^18.2.25
+ version: 18.2.25
+ astro:
+ specifier: ^4.6.3
+ version: link:../../../../astro
+ react:
+ specifier: 18.3.0-canary-670811593-20240322
+ version: 18.3.0-canary-670811593-20240322
+ react-dom:
+ specifier: 18.3.0-canary-670811593-20240322
+ version: 18.3.0-canary-670811593-20240322(react@18.3.0-canary-670811593-20240322)
+ typescript:
+ specifier: ^5.4.5
+ version: 5.4.5
+
packages/astro:
dependencies:
'@astrojs/compiler':
@@ -8071,13 +8135,14 @@ packages:
/@types/react-dom@18.2.25:
resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==}
dependencies:
- '@types/react': 18.2.78
+ '@types/react': 18.2.79
/@types/react@18.2.78:
resolution: {integrity: sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A==}
dependencies:
'@types/prop-types': 15.7.12
csstype: 3.1.3
+ dev: false
/@types/react@18.2.79:
resolution: {integrity: sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==}
@@ -8963,6 +9028,13 @@ packages:
engines: {node: '>=12'}
dev: true
+ /ast-types@0.16.1:
+ resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
+ engines: {node: '>=4'}
+ dependencies:
+ tslib: 2.6.2
+ dev: false
+
/astring@1.8.6:
resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==}
hasBin: true
@@ -8991,6 +9063,41 @@ packages:
astro: link:packages/astro
dev: false
+ /astro-integration-kit@0.11.0(astro@packages+astro):
+ resolution: {integrity: sha512-P41igJUY63W6iWq6Rtw0FnERVUdJhkpAa9jothRQ+AXl56B/6PMes/jcYPo3Zr6KwlMPT8TOHMoasHL0CfqglQ==}
+ peerDependencies:
+ '@astrojs/db': ^0.9.0
+ '@vitejs/plugin-react': ^4.2.1
+ astro: '*'
+ preact: ^10.19.4
+ react: ^18.2.0
+ react-dom: ^18.2.0
+ solid-js: ^1.8.15
+ svelte: ^4.2.11
+ vue: ^3.4.19
+ peerDependenciesMeta:
+ '@astrojs/db':
+ optional: true
+ '@vitejs/plugin-react':
+ optional: true
+ preact:
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vue:
+ optional: true
+ dependencies:
+ astro: link:packages/astro
+ pathe: 1.1.2
+ recast: 0.23.6
+ dev: false
+
/astro-remote@0.2.4:
resolution: {integrity: sha512-iMFOuEVaLPWixNhx4acQc7h9ODMsLElb8itVpyREC/xPsUVf+124Nt80LIdFGV93lmYhIiFu2yf87cdsOxoubg==}
dependencies:
@@ -14461,6 +14568,18 @@ packages:
react: 18.2.0
scheduler: 0.23.0
+ /react-dom@18.3.0-canary-670811593-20240322(react@18.3.0-canary-670811593-20240322):
+ resolution: {integrity: sha512-AHxCnyDzZueXIHY4WA2Uba1yaL7/vbjhO3D3TWPQeruKD5MwgD0/xExZi0T104gBr6Thv6MEsLSxFjBAHhHKKg==}
+ peerDependencies:
+ react: 18.3.0-canary-670811593-20240322
+ peerDependenciesMeta:
+ react:
+ optional: true
+ dependencies:
+ react: 18.3.0-canary-670811593-20240322
+ scheduler: 0.24.0-canary-670811593-20240322
+ dev: false
+
/react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: false
@@ -14476,6 +14595,11 @@ packages:
dependencies:
loose-envify: 1.4.0
+ /react@18.3.0-canary-670811593-20240322:
+ resolution: {integrity: sha512-EI6+q3tOT+0z4OkB2sz842Ra/n/yz7b3jOJhSK1HQwi4Ng29VJzLGngWmSuxQ94YfdE3EBhpUKDfgNgzoKM9Vg==}
+ engines: {node: '>=0.10.0'}
+ dev: false
+
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
dependencies:
@@ -14528,6 +14652,17 @@ packages:
/reading-time@1.5.0:
resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==}
+ /recast@0.23.6:
+ resolution: {integrity: sha512-9FHoNjX1yjuesMwuthAmPKabxYQdOgihFYmT5ebXfYGBcnqXZf3WOVz+5foEZ8Y83P4ZY6yQD5GMmtV+pgCCAQ==}
+ engines: {node: '>= 4'}
+ dependencies:
+ ast-types: 0.16.1
+ esprima: 4.0.1
+ source-map: 0.6.1
+ tiny-invariant: 1.3.3
+ tslib: 2.6.2
+ dev: false
+
/redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -14969,6 +15104,10 @@ packages:
dependencies:
loose-envify: 1.4.0
+ /scheduler@0.24.0-canary-670811593-20240322:
+ resolution: {integrity: sha512-IGX6Fq969h1L0X7jV0sJ/EdI4fr+mRetbBNJl55nn+/RsCuQSVwgKnZG6Q3NByixDNbkRI8nRmWuhOm8NQowGQ==}
+ dev: false
+
/scslre@0.3.0:
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
engines: {node: ^14.0.0 || >=16.0.0}
@@ -15812,6 +15951,10 @@ packages:
globrex: 0.1.2
dev: true
+ /tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ dev: false
+
/tinybench@2.7.0:
resolution: {integrity: sha512-Qgayeb106x2o4hNzNjsZEfFziw8IbKqtbXBjVh7VIZfBxfD5M4gWtpyx5+YTae2gJ6Y6Dz/KLepiv16RFeQWNA==}
dev: false