0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-30 22:03:56 -05:00

feat: port astro-actions poc

This commit is contained in:
bholmesdev 2024-04-23 14:27:58 -04:00
parent 10c5b039f9
commit deb38c8cf2
12 changed files with 493 additions and 1 deletions

3
packages/actions/index.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference path="./virtual.d.ts" />
export { default } from './src/index.js';

View file

@ -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"
}
}

View file

@ -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');
}
},
},
});
},
},
};
}

View file

@ -0,0 +1,88 @@
import type { APIContext } from 'astro';
import { z } from 'zod';
import { ApiContextStorage } from './utils.js';
export function enhanceProps<T extends Function>(action: T) {
return {
type: 'hidden',
name: '_astroAction',
value: action.toString(),
} as const;
}
type MaybePromise<T> = T | Promise<T>;
export function defineAction<TOutput, TInputSchema extends z.ZodType>({
input: inputSchema,
handler,
enhance,
}: {
input?: TInputSchema;
handler: (input: z.infer<TInputSchema>, context: APIContext) => MaybePromise<TOutput>;
enhance?: boolean;
}): (input: z.input<TInputSchema>) => Promise<Awaited<TOutput>> {
return async (unparsedInput): Promise<Awaited<TOutput>> => {
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<T extends z.AnyZodObject>(
formData: FormData,
schema: T
): Record<string, unknown> {
const obj: Record<string, unknown> = {};
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;
}

2
packages/actions/src/runtime/env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="astro/client" />
/// <reference path="../../virtual.d.ts" />

View file

@ -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();
});

View file

@ -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',
},
});
};

View file

@ -0,0 +1,20 @@
import type { APIContext } from 'astro';
import { AsyncLocalStorage } from 'node:async_hooks';
export const ApiContextStorage = new AsyncLocalStorage<APIContext>();
export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
export async function getAction(pathKeys: string[]): Promise<Function> {
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;
}

View file

@ -0,0 +1,34 @@
function toActionProxy(
actionCallback = {},
aggregatedPath = '/_actions/'
): Record<string | symbol, any> {
return new Proxy(actionCallback, {
get(target: Record<string | symbol, any>, 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();

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist",
}
}

7
packages/actions/virtual.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
declare namespace App {
interface Locals {
getActionResult: <T extends (...args: any) => any>(
action: T
) => Awaited<ReturnType<T>> | undefined;
}
}

View file

@ -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