From 5b4070efef877a77247bb05a4806b75f22e557c8 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Thu, 29 Aug 2024 06:13:49 -0400 Subject: [PATCH 1/2] Actions stable release (#11843) * feat: baseline experimental actions * feat(test): remove experimental config * feat: remove getActionProps() * feat: make actions file non-break * feat: detect actions usage * chore: changeset * feat: improve actions usage check * refactor: remove define action symbol now that we check server exp * fix: remove old import * chore: move actionsIntegration to top import * fix: warn only when actions are used * fix: srcDir check * refactor: split out action plugins to simplify integration * feat: new integration and plugins * chore: update error hints * fix(test): pass default src dir * feat: add ActionNotFoundError * fix: handle json parse errors in deserializer * chore: unused import * 500 -> 404 * New `astro:schema` module (#11810) * feat: expose zod from astro:schema * chore: changeset * chore: update release strategy in changeset * fix: move deprecated notice to type def * fix: update config doc reference * chore: remove z from astro:actions * edit: changeset with minor release note remove * wip: increase button click timeouts * Revert "wip: increase button click timeouts" This reverts commit a870bc2dc4bdfd77b8b4fc54b62d8bde01d20c14. * chore: remove content collections disclaimer * fix: undo biome change * agh tabs * agh newlines * fix: bad docs merge * wip: add back timeout extension * fix(test): astro schema import * refactor: move static output error to config done * refactor: usesActions -> isActionsFilePresent * fix: check whether startup and current value disagree * chore: unused import * edit: sell actions a little more * changeset nit --------- Co-authored-by: Sarah Rainsberger --- .changeset/perfect-wasps-grow.md | 17 +++ .changeset/spicy-suits-explode.md | 38 ++++++ packages/astro/client.d.ts | 4 + .../fixtures/actions-blog/astro.config.mjs | 3 - .../actions-blog/src/actions/index.ts | 3 +- .../src/components/PostComment.tsx | 46 +++---- .../actions-react-19/astro.config.mjs | 3 - .../actions-react-19/src/actions/index.ts | 3 +- packages/astro/playwright.config.js | 5 +- packages/astro/playwright.firefox.config.js | 5 +- packages/astro/src/@types/astro.ts | 101 -------------- packages/astro/src/actions/index.ts | 125 ------------------ packages/astro/src/actions/integration.ts | 52 ++++++++ packages/astro/src/actions/plugins.ts | 91 +++++++++++++ .../astro/src/actions/runtime/middleware.ts | 45 ------- packages/astro/src/actions/runtime/route.ts | 11 +- .../src/actions/runtime/virtual/client.ts | 9 -- .../src/actions/runtime/virtual/get-action.ts | 19 ++- .../src/actions/runtime/virtual/server.ts | 2 - .../src/actions/runtime/virtual/shared.ts | 36 ++--- packages/astro/src/actions/utils.ts | 52 ++++++++ packages/astro/src/core/config/schema.ts | 2 - packages/astro/src/core/create-vite.ts | 7 + packages/astro/src/core/errors/errors-data.ts | 39 ++---- packages/astro/src/integrations/hooks.ts | 7 +- packages/astro/test/actions.test.js | 39 ------ .../test/fixtures/actions/astro.config.mjs | 3 - .../fixtures/actions/src/actions/index.ts | 3 +- .../astro/test/units/integrations/api.test.js | 9 ++ 29 files changed, 361 insertions(+), 418 deletions(-) create mode 100644 .changeset/perfect-wasps-grow.md create mode 100644 .changeset/spicy-suits-explode.md delete mode 100644 packages/astro/src/actions/index.ts create mode 100644 packages/astro/src/actions/integration.ts create mode 100644 packages/astro/src/actions/plugins.ts diff --git a/.changeset/perfect-wasps-grow.md b/.changeset/perfect-wasps-grow.md new file mode 100644 index 0000000000..855c709837 --- /dev/null +++ b/.changeset/perfect-wasps-grow.md @@ -0,0 +1,17 @@ +--- +'astro': minor +--- + +Exposes `z` from the new `astro:schema` module. This is the new recommended import source for all Zod utilities when using Astro Actions. + +## Migration for Astro Actions users + +`z` will no longer be exposed from `astro:actions`. To use `z` in your actions, import it from `astro:schema` instead: + +```diff +import { + defineAction, +- z, +} from 'astro:actions'; ++ import { z } from 'astro:schema'; +``` diff --git a/.changeset/spicy-suits-explode.md b/.changeset/spicy-suits-explode.md new file mode 100644 index 0000000000..1e43ca4a2b --- /dev/null +++ b/.changeset/spicy-suits-explode.md @@ -0,0 +1,38 @@ +--- +"astro": minor +--- + +The Astro Actions API introduced behind a flag in [v4.8.0](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md#480) is no longer experimental and is available for general use. + +Astro Actions allow you to define and call backend functions with type-safety, performing data fetching, JSON parsing, and input validation for you. + +Actions can be called from client-side components and HTML forms. This gives you to flexibility to build apps using any technology: React, Svelte, HTMX, or just plain Astro components. This example calls a newsletter action and renders the result using an Astro component: + +```astro +--- +// src/pages/newsletter.astro +import { actions } from 'astro:actions'; +const result = Astro.getActionResult(actions.newsletter); +--- +{result && !result.error &&

Thanks for signing up!

} +
+ + +
+``` + +If you were previously using this feature, please remove the experimental flag from your Astro config: + +```diff +import { defineConfig } from 'astro' + +export default defineConfig({ +- experimental: { +- actions: true, +- } +}) +``` + +If you have been waiting for stabilization before using Actions, you can now do so. + +For more information and usage examples, see our [brand new Actions guide](https://docs.astro.build/en/guides/actions). diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 64b488a16d..4d4b031432 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -175,6 +175,10 @@ declare module 'astro:components' { export * from 'astro/components'; } +declare module 'astro:schema' { + export * from 'astro/zod'; +} + type MD = import('./dist/@types/astro.js').MarkdownInstance>; interface ExportedMarkdownModuleEntities { frontmatter: MD['frontmatter']; diff --git a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs index acbed1768b..8f9f0e3539 100644 --- a/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs +++ b/packages/astro/e2e/fixtures/actions-blog/astro.config.mjs @@ -11,7 +11,4 @@ export default defineConfig({ adapter: node({ mode: 'standalone', }), - experimental: { - actions: true, - }, }); diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts index 7b640be516..43ffb43d42 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts @@ -1,5 +1,6 @@ import { db, Comment, Likes, eq, sql } from 'astro:db'; -import { ActionError, defineAction, z } from 'astro:actions'; +import { ActionError, defineAction } from 'astro:actions'; +import { z } from 'astro:schema'; import { getCollection } from 'astro:content'; export const server = { diff --git a/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx b/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx index b6b6bcea1c..781206b84a 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx +++ b/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx @@ -1,4 +1,4 @@ -import { getActionProps, actions, isInputError } from 'astro:actions'; +import { actions, isInputError } from 'astro:actions'; import { useState } from 'react'; export function PostComment({ @@ -17,6 +17,7 @@ export function PostComment({
{ e.preventDefault(); const form = e.target as HTMLFormElement; @@ -32,12 +33,13 @@ export function PostComment({ form.reset(); }} > - {unexpectedError &&

{unexpectedError}

} - + {unexpectedError && ( +

+ {unexpectedError} +

+ )} - + {bodyError && ( @@ -45,25 +47,23 @@ export function PostComment({ {bodyError}

)} - +
- {comments.map((c) => ( -
-

{c.body}

-

{c.author}

-
- ))} + {comments.map((c) => ( +
+

{c.body}

+

{c.author}

+
+ ))}
); diff --git a/packages/astro/e2e/fixtures/actions-react-19/astro.config.mjs b/packages/astro/e2e/fixtures/actions-react-19/astro.config.mjs index acbed1768b..8f9f0e3539 100644 --- a/packages/astro/e2e/fixtures/actions-react-19/astro.config.mjs +++ b/packages/astro/e2e/fixtures/actions-react-19/astro.config.mjs @@ -11,7 +11,4 @@ export default defineConfig({ adapter: node({ mode: 'standalone', }), - experimental: { - actions: true, - }, }); diff --git a/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts index cd42207729..754db0171e 100644 --- a/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts @@ -1,5 +1,6 @@ import { db, Likes, eq, sql } from 'astro:db'; -import { defineAction, z, type SafeResult } from 'astro:actions'; +import { defineAction, type SafeResult } from 'astro:actions'; +import { z } from 'astro:schema'; import { experimental_getActionState } from '@astrojs/react/actions'; export const server = { diff --git a/packages/astro/playwright.config.js b/packages/astro/playwright.config.js index 26572c66c8..d9ea2c4691 100644 --- a/packages/astro/playwright.config.js +++ b/packages/astro/playwright.config.js @@ -7,7 +7,10 @@ process.stdout.isTTY = false; export default defineConfig({ testMatch: 'e2e/*.test.js', - timeout: 40000, + timeout: 40_000, + expect: { + timeout: 6_000, + }, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, diff --git a/packages/astro/playwright.firefox.config.js b/packages/astro/playwright.firefox.config.js index 00b82d9997..140f288f9e 100644 --- a/packages/astro/playwright.firefox.config.js +++ b/packages/astro/playwright.firefox.config.js @@ -8,7 +8,10 @@ process.stdout.isTTY = false; export default defineConfig({ // TODO: add more tests like view transitions and audits, and fix them. Some of them are failing. testMatch: ['e2e/css.test.js', 'e2e/prefetch.test.js', 'e2e/view-transitions.test.js'], - timeout: 40000, + timeout: 40_000, + expect: { + timeout: 6_000, + }, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 52a9af058a..5aeb6d112c 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1833,107 +1833,6 @@ export interface AstroUserConfig { */ directRenderScript?: boolean; - /** - * @docs - * @name experimental.actions - * @type {boolean} - * @default `false` - * @version 4.8.0 - * @description - * - * Actions help you write type-safe backend functions you can call from anywhere. Enable server rendering [using the `output` property](https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered) and add the `actions` flag to the `experimental` object: - * - * ```js - * { - * output: 'hybrid', // or 'server' - * experimental: { - * actions: true, - * }, - * } - * ``` - * - * Declare all your actions in `src/actions/index.ts`. This file is the global actions handler. - * - * Define an action using the `defineAction()` utility from the `astro:actions` module. An action accepts the `handler` property to define your server-side request handler. If your action accepts arguments, apply the `input` property to validate parameters with Zod. - * - * This example defines two actions: `like` and `comment`. The `like` action accepts a JSON object with a `postId` string, while the `comment` action accepts [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) with `postId`, `author`, and `body` strings. Each `handler` updates your database and return a type-safe response. - * - * ```ts - * // src/actions/index.ts - * import { defineAction, z } from "astro:actions"; - * - * export const server = { - * like: defineAction({ - * input: z.object({ postId: z.string() }), - * handler: async ({ postId }) => { - * // update likes in db - * - * return likes; - * }, - * }), - * comment: defineAction({ - * accept: 'form', - * input: z.object({ - * postId: z.string(), - * author: z.string(), - * body: z.string(), - * }), - * handler: async ({ postId }) => { - * // insert comments in db - * - * return comment; - * }, - * }), - * }; - * ``` - * - * Then, call an action from your client components using the `actions` object from `astro:actions`. You can pass a type-safe object when using JSON, or a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) object when using `accept: 'form'` in your action definition. - * - * This example calls the `like` and `comment` actions from a React component: - * - * ```tsx "actions" - * // src/components/blog.tsx - * import { actions } from "astro:actions"; - * import { useState } from "react"; - * - * export function Like({ postId }: { postId: string }) { - * const [likes, setLikes] = useState(0); - * return ( - * - * ); - * } - * - * export function Comment({ postId }: { postId: string }) { - * return ( - *
{ - * e.preventDefault(); - * const formData = new FormData(e.target as HTMLFormElement); - * const result = await actions.blog.comment(formData); - * // handle result - * }} - * > - * - * - * - * - * - *
- * ); - * } - * ``` - * - * For a complete overview, and to give feedback on this experimental API, see the [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md). - */ - actions?: boolean; - /** * @docs * @name experimental.contentCollectionCache diff --git a/packages/astro/src/actions/index.ts b/packages/astro/src/actions/index.ts deleted file mode 100644 index 2423b7017d..0000000000 --- a/packages/astro/src/actions/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import fsMod from 'node:fs'; -import type { Plugin as VitePlugin } from 'vite'; -import type { AstroIntegration, AstroSettings } from '../@types/astro.js'; -import { ActionsWithoutServerOutputError } from '../core/errors/errors-data.js'; -import { AstroError } from '../core/errors/errors.js'; -import { isServerLikeOutput, viteID } from '../core/util.js'; -import { - ACTIONS_TYPES_FILE, - NOOP_ACTIONS, - RESOLVED_VIRTUAL_INTERNAL_MODULE_ID, - RESOLVED_VIRTUAL_MODULE_ID, - VIRTUAL_INTERNAL_MODULE_ID, - VIRTUAL_MODULE_ID, -} from './consts.js'; - -export default function astroActions({ - fs = fsMod, - settings, -}: { - fs?: typeof fsMod; - settings: AstroSettings; -}): AstroIntegration { - return { - name: VIRTUAL_MODULE_ID, - hooks: { - async 'astro:config:setup'(params) { - if (!isServerLikeOutput(params.config)) { - const error = new AstroError(ActionsWithoutServerOutputError); - error.stack = undefined; - throw error; - } - - params.updateConfig({ - vite: { - plugins: [vitePluginUserActions({ settings }), vitePluginActions(fs)], - }, - }); - - params.injectRoute({ - pattern: '/_actions/[...path]', - entrypoint: 'astro/actions/runtime/route.js', - prerender: false, - }); - - params.addMiddleware({ - entrypoint: 'astro/actions/runtime/middleware.js', - order: 'post', - }); - }, - 'astro:config:done': (params) => { - const stringifiedActionsImport = JSON.stringify( - viteID(new URL('./actions', params.config.srcDir)), - ); - settings.injectedTypes.push({ - filename: ACTIONS_TYPES_FILE, - content: `declare module "astro:actions" { - type Actions = typeof import(${stringifiedActionsImport})["server"]; - - export const actions: Actions; -}`, - }); - }, - }, - }; -} - -/** - * This plugin is responsible to load the known file `actions/index.js` / `actions.js` - * If the file doesn't exist, it returns an empty object. - * @param settings - */ -export function vitePluginUserActions({ settings }: { settings: AstroSettings }): VitePlugin { - let resolvedActionsId: string; - return { - name: '@astro/plugin-actions', - async resolveId(id) { - if (id === NOOP_ACTIONS) { - return NOOP_ACTIONS; - } - if (id === VIRTUAL_INTERNAL_MODULE_ID) { - const resolvedModule = await this.resolve( - `${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`, - ); - - if (!resolvedModule) { - return NOOP_ACTIONS; - } - resolvedActionsId = resolvedModule.id; - return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID; - } - }, - - load(id) { - if (id === NOOP_ACTIONS) { - return 'export const server = {}'; - } else if (id === RESOLVED_VIRTUAL_INTERNAL_MODULE_ID) { - return `export { server } from '${resolvedActionsId}';`; - } - }, - }; -} - -const vitePluginActions = (fs: typeof fsMod): VitePlugin => ({ - name: VIRTUAL_MODULE_ID, - enforce: 'pre', - resolveId(id) { - if (id === VIRTUAL_MODULE_ID) { - return RESOLVED_VIRTUAL_MODULE_ID; - } - }, - async load(id, opts) { - if (id !== RESOLVED_VIRTUAL_MODULE_ID) return; - - let code = await fs.promises.readFile( - new URL('../../templates/actions.mjs', import.meta.url), - 'utf-8', - ); - if (opts?.ssr) { - code += `\nexport * from 'astro/actions/runtime/virtual/server.js';`; - } else { - code += `\nexport * from 'astro/actions/runtime/virtual/client.js';`; - } - return code; - }, -}); diff --git a/packages/astro/src/actions/integration.ts b/packages/astro/src/actions/integration.ts new file mode 100644 index 0000000000..624535b3e9 --- /dev/null +++ b/packages/astro/src/actions/integration.ts @@ -0,0 +1,52 @@ +import type { AstroIntegration, AstroSettings } from '../@types/astro.js'; +import { ActionsWithoutServerOutputError } from '../core/errors/errors-data.js'; +import { AstroError } from '../core/errors/errors.js'; +import { isServerLikeOutput, viteID } from '../core/util.js'; +import { ACTIONS_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js'; + +/** + * This integration is applied when the user is using Actions in their project. + * It will inject the necessary routes and middlewares to handle actions. + */ +export default function astroIntegrationActionsRouteHandler({ + settings, +}: { + settings: AstroSettings; +}): AstroIntegration { + return { + name: VIRTUAL_MODULE_ID, + hooks: { + async 'astro:config:setup'(params) { + params.injectRoute({ + pattern: '/_actions/[...path]', + entrypoint: 'astro/actions/runtime/route.js', + prerender: false, + }); + + params.addMiddleware({ + entrypoint: 'astro/actions/runtime/middleware.js', + order: 'post', + }); + }, + 'astro:config:done': async (params) => { + if (!isServerLikeOutput(params.config)) { + const error = new AstroError(ActionsWithoutServerOutputError); + error.stack = undefined; + throw error; + } + + const stringifiedActionsImport = JSON.stringify( + viteID(new URL('./actions', params.config.srcDir)), + ); + settings.injectedTypes.push({ + filename: ACTIONS_TYPES_FILE, + content: `declare module "astro:actions" { + type Actions = typeof import(${stringifiedActionsImport})["server"]; + + export const actions: Actions; +}`, + }); + }, + }, + }; +} diff --git a/packages/astro/src/actions/plugins.ts b/packages/astro/src/actions/plugins.ts new file mode 100644 index 0000000000..1323eec626 --- /dev/null +++ b/packages/astro/src/actions/plugins.ts @@ -0,0 +1,91 @@ +import type fsMod from 'node:fs'; +import type { Plugin as VitePlugin } from 'vite'; +import type { AstroSettings } from '../@types/astro.js'; +import { + NOOP_ACTIONS, + RESOLVED_VIRTUAL_INTERNAL_MODULE_ID, + RESOLVED_VIRTUAL_MODULE_ID, + VIRTUAL_INTERNAL_MODULE_ID, + VIRTUAL_MODULE_ID, +} from './consts.js'; +import { isActionsFilePresent } from './utils.js'; + +/** + * This plugin is responsible to load the known file `actions/index.js` / `actions.js` + * If the file doesn't exist, it returns an empty object. + * @param settings + */ +export function vitePluginUserActions({ settings }: { settings: AstroSettings }): VitePlugin { + let resolvedActionsId: string; + return { + name: '@astro/plugin-actions', + async resolveId(id) { + if (id === NOOP_ACTIONS) { + return NOOP_ACTIONS; + } + if (id === VIRTUAL_INTERNAL_MODULE_ID) { + const resolvedModule = await this.resolve( + `${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`, + ); + + if (!resolvedModule) { + return NOOP_ACTIONS; + } + resolvedActionsId = resolvedModule.id; + return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID; + } + }, + + load(id) { + if (id === NOOP_ACTIONS) { + return 'export const server = {}'; + } else if (id === RESOLVED_VIRTUAL_INTERNAL_MODULE_ID) { + return `export { server } from '${resolvedActionsId}';`; + } + }, + }; +} + +export function vitePluginActions({ + fs, + settings, +}: { + fs: typeof fsMod; + settings: AstroSettings; +}): VitePlugin { + return { + name: VIRTUAL_MODULE_ID, + enforce: 'pre', + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + }, + async configureServer(server) { + const filePresentOnStartup = await isActionsFilePresent(fs, settings.config.srcDir); + // Watch for the actions file to be created. + async function watcherCallback() { + const filePresent = await isActionsFilePresent(fs, settings.config.srcDir); + if (filePresentOnStartup !== filePresent) { + server.restart(); + } + } + server.watcher.on('add', watcherCallback); + server.watcher.on('change', watcherCallback); + }, + async load(id, opts) { + if (id !== RESOLVED_VIRTUAL_MODULE_ID) return; + + let code = await fs.promises.readFile( + new URL('../../templates/actions.mjs', import.meta.url), + 'utf-8', + ); + if (opts?.ssr) { + code += `\nexport * from 'astro/actions/runtime/virtual/server.js';`; + } else { + code += `\nexport * from 'astro/actions/runtime/virtual/client.js';`; + } + return code; + }, + }; +} diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts index 4f0a732b6c..c12b64b6d5 100644 --- a/packages/astro/src/actions/runtime/middleware.ts +++ b/packages/astro/src/actions/runtime/middleware.ts @@ -1,7 +1,5 @@ import { yellow } from 'kleur/colors'; import type { APIContext, MiddlewareNext } from '../../@types/astro.js'; -import { ActionQueryStringInvalidError } from '../../core/errors/errors-data.js'; -import { AstroError } from '../../core/errors/errors.js'; import { defineMiddleware } from '../../core/middleware/index.js'; import { ACTION_QUERY_PARAMS } from '../consts.js'; import { formContentTypes, hasContentType } from './utils.js'; @@ -54,10 +52,6 @@ export const onRequest = defineMiddleware(async (context, next) => { return handlePost({ context, next, actionName }); } - if (context.request.method === 'POST') { - return handlePostLegacy({ context, next }); - } - return next(); }); @@ -98,14 +92,7 @@ async function handlePost({ actionName: string; }) { const { request } = context; - const baseAction = await getAction(actionName); - if (!baseAction) { - throw new AstroError({ - ...ActionQueryStringInvalidError, - message: ActionQueryStringInvalidError.message(actionName), - }); - } const contentType = request.headers.get('content-type'); let formData: FormData | undefined; @@ -153,38 +140,6 @@ async function redirectWithResult({ return context.redirect(context.url.pathname); } -async function handlePostLegacy({ context, next }: { context: APIContext; next: MiddlewareNext }) { - const { request } = context; - - // We should not run a middleware handler for fetch() - // requests directly to the /_actions URL. - // Otherwise, we may handle the result twice. - if (context.url.pathname.startsWith('/_actions')) return next(); - - const contentType = request.headers.get('content-type'); - let formData: FormData | undefined; - if (contentType && hasContentType(contentType, formContentTypes)) { - formData = await request.clone().formData(); - } - - if (!formData) return next(); - - const actionName = formData.get(ACTION_QUERY_PARAMS.actionName) as string; - if (!actionName) return next(); - - const baseAction = await getAction(actionName); - if (!baseAction) { - throw new AstroError({ - ...ActionQueryStringInvalidError, - message: ActionQueryStringInvalidError.message(actionName), - }); - } - - const action = baseAction.bind(context); - const actionResult = await action(formData); - return redirectWithResult({ context, actionName, actionResult }); -} - function isActionPayload(json: unknown): json is ActionPayload { if (typeof json !== 'object' || json == null) return false; diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts index e4e2ad1ce5..16b53f945b 100644 --- a/packages/astro/src/actions/runtime/route.ts +++ b/packages/astro/src/actions/runtime/route.ts @@ -5,9 +5,14 @@ import { serializeActionResult } from './virtual/shared.js'; export const POST: APIRoute = async (context) => { const { request, url } = context; - const baseAction = await getAction(url.pathname); - if (!baseAction) { - return new Response(null, { status: 404 }); + let baseAction; + try { + baseAction = await getAction(url.pathname); + } catch (e) { + if (import.meta.env.DEV) throw e; + // eslint-disable-next-line no-console + console.error(e); + return new Response(e instanceof Error ? e.message : null, { status: 404 }); } const contentType = request.headers.get('Content-Type'); const contentLength = request.headers.get('Content-Length'); diff --git a/packages/astro/src/actions/runtime/virtual/client.ts b/packages/astro/src/actions/runtime/virtual/client.ts index 424552a9fe..c80e6778ae 100644 --- a/packages/astro/src/actions/runtime/virtual/client.ts +++ b/packages/astro/src/actions/runtime/virtual/client.ts @@ -3,12 +3,3 @@ export * from './shared.js'; export function defineAction() { throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.'); } - -export const z = new Proxy( - {}, - { - get() { - throw new Error('[astro:action] `z` unexpectedly used on the client.'); - }, - }, -); diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts index cb9addd9dd..a0906732b1 100644 --- a/packages/astro/src/actions/runtime/virtual/get-action.ts +++ b/packages/astro/src/actions/runtime/virtual/get-action.ts @@ -1,5 +1,7 @@ import type { ZodType } from 'zod'; import type { ActionAccept, ActionClient } from './server.js'; +import { ActionNotFoundError } from '../../../core/errors/errors-data.js'; +import { AstroError } from '../../../core/errors/errors.js'; /** * Get server-side action based on the route path. @@ -8,19 +10,30 @@ import type { ActionAccept, ActionClient } from './server.js'; */ export async function getAction( path: string, -): Promise | undefined> { +): Promise> { const pathKeys = path.replace('/_actions/', '').split('.'); // @ts-expect-error virtual module let { server: actionLookup } = await import('astro:internal-actions'); + if (actionLookup == null || !(typeof actionLookup === 'object')) { + throw new TypeError( + `Expected \`server\` export in actions file to be an object. Received ${typeof actionLookup}.`, + ); + } + for (const key of pathKeys) { if (!(key in actionLookup)) { - return undefined; + throw new AstroError({ + ...ActionNotFoundError, + message: ActionNotFoundError.message(pathKeys.join('.')), + }); } actionLookup = actionLookup[key]; } if (typeof actionLookup !== 'function') { - return undefined; + throw new TypeError( + `Expected handler for action ${pathKeys.join('.')} to be a function. Received ${typeof actionLookup}.`, + ); } return actionLookup; } diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index fcb0dc6030..cd1b4269ed 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -6,8 +6,6 @@ import { ActionError, ActionInputError, type SafeResult, callSafely } from './sh export * from './shared.js'; -export { z } from 'zod'; - export type ActionAccept = 'form' | 'json'; export type ActionHandler = TInputSchema extends z.ZodType diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 01f9bd4e68..8367710b94 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -181,26 +181,6 @@ export function getActionQueryString(name: string) { return `?${searchParams.toString()}`; } -/** - * @deprecated You can now pass action functions - * directly to the `action` attribute on a form. - * - * Example: `
` - */ -export function getActionProps MaybePromise>(action: T) { - const params = new URLSearchParams(action.toString()); - const actionName = params.get('_astroAction'); - if (!actionName) { - // No need for AstroError. `getActionProps()` will be removed for stable. - throw new Error('Invalid actions function was passed to getActionProps()'); - } - return { - type: 'hidden', - name: '_astroAction', - value: actionName, - } as const; -} - export type SerializedActionResult = | { type: 'data'; @@ -269,10 +249,22 @@ export function serializeActionResult(res: SafeResult): SerializedActi export function deserializeActionResult(res: SerializedActionResult): SafeResult { if (res.type === 'error') { + let json; + try { + json = JSON.parse(res.body); + } catch { + return { + data: undefined, + error: new ActionError({ + message: res.body, + code: 'INTERNAL_SERVER_ERROR', + }), + }; + } if (import.meta.env?.PROD) { - return { error: ActionError.fromJson(JSON.parse(res.body)), data: undefined }; + return { error: ActionError.fromJson(json), data: undefined }; } else { - const error = ActionError.fromJson(JSON.parse(res.body)); + const error = ActionError.fromJson(json); error.stack = actionResultErrorStack.get(); return { error, diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts index eddac615b7..e9673d618c 100644 --- a/packages/astro/src/actions/utils.ts +++ b/packages/astro/src/actions/utils.ts @@ -1,3 +1,5 @@ +import * as eslexer from 'es-module-lexer'; +import type fsMod from 'node:fs'; import type { APIContext } from '../@types/astro.js'; import type { Locals } from './runtime/middleware.js'; import type { ActionAPIContext } from './runtime/utils.js'; @@ -25,3 +27,53 @@ export function createCallAction(context: ActionAPIContext): APIContext['callAct return action(input) as any; }; } + +let didInitLexer = false; + +/** + * Check whether the Actions config file is present. + */ +export async function isActionsFilePresent(fs: typeof fsMod, srcDir: URL) { + if (!didInitLexer) await eslexer.init; + + const actionsFile = search(fs, srcDir); + if (!actionsFile) return false; + + let contents: string; + try { + contents = fs.readFileSync(actionsFile, 'utf-8'); + } catch { + return false; + } + + // Check if `server` export is present. + // If not, the user may have an empty `actions` file, + // or may be using the `actions` file for another purpose + // (possible since actions are non-breaking for v4.X). + const [, exports] = eslexer.parse(contents, actionsFile.pathname); + for (const exp of exports) { + if (exp.n === 'server') { + return true; + } + } + return false; +} + +function search(fs: typeof fsMod, srcDir: URL) { + const paths = [ + 'actions.mjs', + 'actions.js', + 'actions.mts', + 'actions.ts', + 'actions/index.mjs', + 'actions/index.js', + 'actions/index.mts', + 'actions/index.ts', + ].map((p) => new URL(p, srcDir)); + for (const file of paths) { + if (fs.existsSync(file)) { + return file; + } + } + return undefined; +} diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 8634e0c0f0..ad10f725ac 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -83,7 +83,6 @@ export const ASTRO_CONFIG_DEFAULTS = { redirects: {}, security: {}, experimental: { - actions: false, directRenderScript: false, contentCollectionCache: false, clientPrerender: false, @@ -510,7 +509,6 @@ export const AstroConfigSchema = z.object({ .default(ASTRO_CONFIG_DEFAULTS.security), experimental: z .object({ - actions: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.actions), directRenderScript: z .boolean() .optional() diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 7fbfe60ddd..28ce9810aa 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -39,6 +39,7 @@ import { vitePluginMiddleware } from './middleware/vite-plugin.js'; import { joinPaths } from './path.js'; import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js'; import { isObject } from './util.js'; +import { vitePluginActions, vitePluginUserActions } from '../actions/plugins.js'; interface CreateViteOptions { settings: AstroSettings; @@ -153,6 +154,8 @@ export async function createVite( astroDevToolbar({ settings, logger }), vitePluginFileURL(), astroInternationalization({ settings }), + vitePluginActions({ fs, settings }), + vitePluginUserActions({ settings }), settings.config.experimental.serverIslands && vitePluginServerIslands({ settings }), astroContainer(), ], @@ -191,6 +194,10 @@ export async function createVite( find: 'astro:middleware', replacement: 'astro/virtual-modules/middleware.js', }, + { + find: 'astro:schema', + replacement: 'astro/zod', + }, { find: 'astro:components', replacement: 'astro/components', diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 1abd7ba1b1..a7e44c682d 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1667,23 +1667,7 @@ export const ActionsWithoutServerOutputError = { /** * @docs * @see - * - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md) - * @description - * Action was called from a form using a GET request, but only POST requests are supported. This often occurs if `method="POST"` is missing on the form. - * @deprecated Deprecated since version 4.13.2. - */ -export const ActionsUsedWithForGetError = { - name: 'ActionsUsedWithForGetError', - title: 'An invalid Action query string was passed by a form.', - message: (actionName: string) => - `Action ${actionName} was called from a form using a GET request, but only POST requests are supported. This often occurs if \`method="POST"\` is missing on the form.`, - hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md', -} satisfies ErrorData; - -/** - * @docs - * @see - * - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md) + * - [Actions handler reference](https://docs.astro.build/en/reference/api-reference/#handler-property) * @description * Action handler returned invalid data. Handlers should return serializable data types, and cannot return a Response object. */ @@ -1697,29 +1681,30 @@ export const ActionsReturnedInvalidDataError = { /** * @docs - * @see - * - [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md) * @description - * The server received the query string `?_astroAction=name`, but could not find an action with that name. Use the action function's `.queryString` property to retrieve the form `action` URL. + * The server received a request for an action but could not find a match with the same name. */ -export const ActionQueryStringInvalidError = { - name: 'ActionQueryStringInvalidError', - title: 'An invalid Action query string was passed by a form.', +export const ActionNotFoundError = { + name: 'ActionNotFoundError', + title: 'Action not found.', message: (actionName: string) => - `The server received the query string \`?_astroAction=${actionName}\`, but could not find an action with that name. If you changed an action's name in development, remove this query param from your URL and refresh.`, - hint: 'Actions are experimental. Visit the RFC for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md', + `The server received a request for an action named \`${actionName}\` but could not find a match. If you renamed an action, check that you've updated your \`actions/index\` file and your calling code to match.`, + hint: 'You can run `astro check` to detect type errors caused by mismatched action names.', } satisfies ErrorData; /** * @docs + * @see + * - [`Astro.callAction()` reference](https://docs.astro.build/en/reference/api-reference/#astrocallaction) * @description * Action called from a server page or endpoint without using `Astro.callAction()`. */ export const ActionCalledFromServerError = { name: 'ActionCalledFromServerError', title: 'Action unexpected called from the server.', - message: 'Action called from a server page or endpoint without using `Astro.callAction()`.', - hint: 'See the RFC section on server calls for usage instructions: https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md#call-actions-directly-from-server-code', + message: + 'Action called from a server page or endpoint without using `Astro.callAction()`. This wrapper must be used to call actions from server code.', + hint: 'See the `Astro.callAction()` reference for usage examples: https://docs.astro.build/en/reference/api-reference/#astrocallaction', } satisfies ErrorData; // Generic catch-all - Only use this in extreme cases, like if there was a cosmic ray bit flip. diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index 3b15e0d97e..a8f1293835 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -22,6 +22,8 @@ import { mergeConfig } from '../core/config/index.js'; import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js'; import { isServerLikeOutput } from '../core/util.js'; import { validateSupportedFeatures } from './features-validation.js'; +import { isActionsFilePresent } from '../actions/utils.js'; +import astroIntegrationActionsRouteHandler from '../actions/integration.js'; async function withTakingALongTimeMsg({ name, @@ -130,9 +132,8 @@ export async function runHookConfigSetup({ if (settings.config.adapter) { settings.config.integrations.push(settings.config.adapter); } - if (settings.config.experimental?.actions) { - const { default: actionsIntegration } = await import('../actions/index.js'); - settings.config.integrations.push(actionsIntegration({ fs, settings })); + if (await isActionsFilePresent(fs, settings.config.srcDir)) { + settings.config.integrations.push(astroIntegrationActionsRouteHandler({ settings })); } let updatedConfig: AstroConfig = { ...settings.config }; diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 3c803972ce..334e07a173 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -306,45 +306,6 @@ describe('Astro Actions', () => { assert.equal(data?.age, '42'); }); - describe('legacy', () => { - it('Response middleware fallback', async () => { - const formData = new FormData(); - formData.append('_astroAction', 'getUser'); - const req = new Request('http://example.com/user', { - method: 'POST', - body: formData, - headers: { - Referer: 'http://example.com/user', - }, - }); - const res = await followExpectedRedirect(req, app); - assert.equal(res.ok, true); - - const html = await res.text(); - let $ = cheerio.load(html); - assert.equal($('#user').text(), 'Houston'); - }); - - it('Respects custom errors', async () => { - const formData = new FormData(); - formData.append('_astroAction', 'getUserOrThrow'); - const req = new Request('http://example.com/user-or-throw', { - method: 'POST', - body: formData, - headers: { - Referer: 'http://example.com/user-or-throw', - }, - }); - const res = await followExpectedRedirect(req, app); - assert.equal(res.status, 401); - - const html = await res.text(); - let $ = cheerio.load(html); - assert.equal($('#error-message').text(), 'Not logged in'); - assert.equal($('#error-code').text(), 'UNAUTHORIZED'); - }); - }); - it('Sets status to 204 when content-length is 0', async () => { const req = new Request('http://example.com/_actions/fireAndForget', { method: 'POST', diff --git a/packages/astro/test/fixtures/actions/astro.config.mjs b/packages/astro/test/fixtures/actions/astro.config.mjs index fc6477578b..2f849f7f15 100644 --- a/packages/astro/test/fixtures/actions/astro.config.mjs +++ b/packages/astro/test/fixtures/actions/astro.config.mjs @@ -3,7 +3,4 @@ import { defineConfig } from 'astro/config'; // https://astro.build/config export default defineConfig({ output: 'server', - experimental: { - actions: true, - }, }); diff --git a/packages/astro/test/fixtures/actions/src/actions/index.ts b/packages/astro/test/fixtures/actions/src/actions/index.ts index 881656994f..ed76927993 100644 --- a/packages/astro/test/fixtures/actions/src/actions/index.ts +++ b/packages/astro/test/fixtures/actions/src/actions/index.ts @@ -1,4 +1,5 @@ -import { defineAction, ActionError, z } from 'astro:actions'; +import { defineAction, ActionError } from 'astro:actions'; +import { z } from 'astro:schema'; const passwordSchema = z .string() diff --git a/packages/astro/test/units/integrations/api.test.js b/packages/astro/test/units/integrations/api.test.js index 6122ba6408..6f2438ae32 100644 --- a/packages/astro/test/units/integrations/api.test.js +++ b/packages/astro/test/units/integrations/api.test.js @@ -8,10 +8,16 @@ import { } from '../../../dist/integrations/hooks.js'; import { defaultLogger } from '../test-utils.js'; +const defaultConfig = { + root: new URL('./', import.meta.url), + srcDir: new URL('src/', import.meta.url), +}; + describe('Integration API', () => { it('runHookBuildSetup should work', async () => { const updatedViteConfig = await runHookBuildSetup({ config: { + ...defaultConfig, integrations: [ { name: 'test', @@ -39,6 +45,7 @@ describe('Integration API', () => { let updatedInternalConfig; const updatedViteConfig = await runHookBuildSetup({ config: { + ...defaultConfig, integrations: [ { name: 'test', @@ -68,6 +75,7 @@ describe('Integration API', () => { logger: defaultLogger, settings: { config: { + ...defaultConfig, integrations: [ { name: 'test', @@ -90,6 +98,7 @@ describe('Integration API', () => { logger: defaultLogger, settings: { config: { + ...defaultConfig, integrations: [ { name: 'test', From 4215d6ef673d4c73635ce9cf0d64f23dbb046c90 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Thu, 29 Aug 2024 10:14:41 +0000 Subject: [PATCH 2/2] [ci] format --- packages/astro/src/actions/runtime/virtual/get-action.ts | 2 +- packages/astro/src/actions/utils.ts | 2 +- packages/astro/src/core/create-vite.ts | 2 +- packages/astro/src/integrations/hooks.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts index a0906732b1..b547b57c10 100644 --- a/packages/astro/src/actions/runtime/virtual/get-action.ts +++ b/packages/astro/src/actions/runtime/virtual/get-action.ts @@ -1,7 +1,7 @@ import type { ZodType } from 'zod'; -import type { ActionAccept, ActionClient } from './server.js'; import { ActionNotFoundError } from '../../../core/errors/errors-data.js'; import { AstroError } from '../../../core/errors/errors.js'; +import type { ActionAccept, ActionClient } from './server.js'; /** * Get server-side action based on the route path. diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts index e9673d618c..0e7c6fb621 100644 --- a/packages/astro/src/actions/utils.ts +++ b/packages/astro/src/actions/utils.ts @@ -1,5 +1,5 @@ -import * as eslexer from 'es-module-lexer'; import type fsMod from 'node:fs'; +import * as eslexer from 'es-module-lexer'; import type { APIContext } from '../@types/astro.js'; import type { Locals } from './runtime/middleware.js'; import type { ActionAPIContext } from './runtime/utils.js'; diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 28ce9810aa..f3174b5d32 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -4,6 +4,7 @@ import glob from 'fast-glob'; import * as vite from 'vite'; import { crawlFrameworkPkgs } from 'vitefu'; import type { AstroSettings } from '../@types/astro.js'; +import { vitePluginActions, vitePluginUserActions } from '../actions/plugins.js'; import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js'; import astroAssetsPlugin from '../assets/vite-plugin-assets.js'; import astroContainer from '../container/vite-plugin-container.js'; @@ -39,7 +40,6 @@ import { vitePluginMiddleware } from './middleware/vite-plugin.js'; import { joinPaths } from './path.js'; import { vitePluginServerIslands } from './server-islands/vite-plugin-server-islands.js'; import { isObject } from './util.js'; -import { vitePluginActions, vitePluginUserActions } from '../actions/plugins.js'; interface CreateViteOptions { settings: AstroSettings; diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index a8f1293835..d7e37b5fc5 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -15,6 +15,8 @@ import type { RouteData, RouteOptions, } from '../@types/astro.js'; +import astroIntegrationActionsRouteHandler from '../actions/integration.js'; +import { isActionsFilePresent } from '../actions/utils.js'; import type { SerializedSSRManifest } from '../core/app/types.js'; import type { PageBuildData } from '../core/build/types.js'; import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.js'; @@ -22,8 +24,6 @@ import { mergeConfig } from '../core/config/index.js'; import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js'; import { isServerLikeOutput } from '../core/util.js'; import { validateSupportedFeatures } from './features-validation.js'; -import { isActionsFilePresent } from '../actions/utils.js'; -import astroIntegrationActionsRouteHandler from '../actions/integration.js'; async function withTakingALongTimeMsg({ name,