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