diff options
-rw-r--r-- | .changeset/many-mayflies-enjoy.md | 60 | ||||
-rw-r--r-- | packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro | 2 | ||||
-rw-r--r-- | packages/astro/src/@types/astro.ts | 32 | ||||
-rw-r--r-- | packages/astro/src/actions/runtime/middleware.ts | 58 | ||||
-rw-r--r-- | packages/astro/src/actions/runtime/route.ts | 8 | ||||
-rw-r--r-- | packages/astro/src/actions/runtime/store.ts | 18 | ||||
-rw-r--r-- | packages/astro/src/actions/runtime/utils.ts | 2 | ||||
-rw-r--r-- | packages/astro/src/actions/runtime/virtual/client.ts | 4 | ||||
-rw-r--r-- | packages/astro/src/actions/runtime/virtual/server.ts | 36 | ||||
-rw-r--r-- | packages/astro/src/actions/utils.ts | 13 | ||||
-rw-r--r-- | packages/astro/src/core/errors/errors-data.ts | 30 | ||||
-rw-r--r-- | packages/astro/src/core/middleware/index.ts | 5 | ||||
-rw-r--r-- | packages/astro/src/core/render-context.ts | 6 | ||||
-rw-r--r-- | packages/astro/templates/actions.mjs | 13 | ||||
-rw-r--r-- | packages/astro/test/fixtures/actions/src/pages/subscribe.astro | 2 | ||||
-rw-r--r-- | packages/astro/test/types/call-action.ts | 51 |
16 files changed, 256 insertions, 84 deletions
diff --git a/.changeset/many-mayflies-enjoy.md b/.changeset/many-mayflies-enjoy.md new file mode 100644 index 000000000..a1da048d7 --- /dev/null +++ b/.changeset/many-mayflies-enjoy.md @@ -0,0 +1,60 @@ +--- +'astro': patch +--- + +Removes async local storage dependency from Astro Actions. This allows Actions to run in Cloudflare and Stackblitz without opt-in flags or other configuration. + +This also introduces a new convention for calling actions from server code. Instead of calling actions directly, you must wrap function calls with the new `Astro.callAction()` utility. + +> `callAction()` is meant to _trigger_ an action from server code. `getActionResult()` usage with form submissions remains unchanged. + +```astro +--- +import { actions } from 'astro:actions'; + +const result = await Astro.callAction(actions.searchPosts, { + searchTerm: Astro.url.searchParams.get('search'), +}); +--- + +{result.data && ( + {/* render the results */} +)} +``` + +## Migration + +If you call actions directly from server code, update function calls to use the `Astro.callAction()` wrapper for pages and `context.callAction()` for endpoints: + +```diff +--- +import { actions } from 'astro:actions'; + +- const result = await actions.searchPosts({ searchTerm: 'test' }); ++ const result = await Astro.callAction(actions.searchPosts, { searchTerm: 'test' }); +--- +``` + +If you deploy with Cloudflare and added [the `nodejs_compat` or `nodejs_als` flags](https://developers.cloudflare.com/workers/runtime-apis/nodejs) for Actions, we recommend removing these: + +```diff +compatibility_flags = [ +- "nodejs_compat", +- "nodejs_als" +] +``` + +You can also remove `node:async_hooks` from the `vite.ssr.external` option in your `astro.config` file: + +```diff +// astro.config.mjs +import { defineConfig } from 'astro/config'; + +export default defineConfig({ +- vite: { +- ssr: { +- external: ["node:async_hooks"] +- } +- } +}) +``` diff --git a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro index 64deb7a46..fe97a8de1 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro +++ b/packages/astro/e2e/fixtures/actions-blog/src/pages/blog/[...slug].astro @@ -24,7 +24,7 @@ const post = await getEntry('blog', Astro.params.slug)!; const { Content } = await post.render(); if (Astro.url.searchParams.has('like')) { - await actions.blog.like({postId: post.id }); + await Astro.callAction(actions.blog.like.orThrow, {postId: post.id}); } const comment = Astro.getActionResult(actions.blog.comment); diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index f83c44463..32d1cd30b 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -15,6 +15,7 @@ import type { ActionAccept, ActionClient, ActionInputSchema, + ActionReturnType, } from '../actions/runtime/virtual/server.js'; import type { RemotePattern } from '../assets/utils/remotePattern.js'; import type { AssetsPrefix, SSRManifest, SerializedSSRManifest } from '../core/app/types.js'; @@ -262,6 +263,21 @@ export interface AstroGlobal< * ``` */ getActionResult: AstroSharedContext['getActionResult']; + /** + * Call an Action directly from an Astro page or API endpoint. + * Expects the action function as the first parameter, + * and the type-safe action input as the second parameter. + * Returns a Promise with the action result. + * + * Example usage: + * + * ```typescript + * import { actions } from 'astro:actions'; + * + * const result = await Astro.callAction(actions.getPost, { postId: 'test' }); + * ``` + */ + callAction: AstroSharedContext['callAction']; /** Redirect to another page * * Example usage: @@ -2741,7 +2757,21 @@ interface AstroSharedContext< TAction extends ActionClient<unknown, TAccept, TInputSchema>, >( action: TAction - ) => Awaited<ReturnType<TAction>> | undefined; + ) => ActionReturnType<TAction> | undefined; + /** + * Call action handler from the server. + */ + callAction: < + TAccept extends ActionAccept, + TInputSchema extends ActionInputSchema<TAccept>, + TOutput, + TAction extends + | ActionClient<TOutput, TAccept, TInputSchema> + | ActionClient<TOutput, TAccept, TInputSchema>['orThrow'], + >( + action: TAction, + input: Parameters<TAction>[0] + ) => Promise<ActionReturnType<TAction>>; /** * Route parameters for this request if this is a dynamic route. */ diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts index f916691b8..1e5231218 100644 --- a/packages/astro/src/actions/runtime/middleware.ts +++ b/packages/astro/src/actions/runtime/middleware.ts @@ -6,13 +6,13 @@ import { } from '../../core/errors/errors-data.js'; import { AstroError } from '../../core/errors/errors.js'; import { defineMiddleware } from '../../core/middleware/index.js'; -import { ApiContextStorage } from './store.js'; import { formContentTypes, getAction, hasContentType } from './utils.js'; import { getActionQueryString } from './virtual/shared.js'; export type Locals = { _actionsInternal: { getActionResult: APIContext['getActionResult']; + callAction: APIContext['callAction']; actionResult?: ReturnType<APIContext['getActionResult']>; }; }; @@ -24,7 +24,11 @@ export const onRequest = defineMiddleware(async (context, next) => { // See https://github.com/withastro/roadmap/blob/feat/reroute/proposals/0047-rerouting.md#ctxrewrite // `_actionsInternal` is the same for every page, // so short circuit if already defined. - if (locals._actionsInternal) return ApiContextStorage.run(context, () => next()); + if (locals._actionsInternal) { + // Re-bind `callAction` with the new API context + locals._actionsInternal.callAction = createCallAction(context); + return next(); + } // Heuristic: If body is null, Astro might've reset this for prerendering. // Stub with warning when `getActionResult()` is used. @@ -59,8 +63,8 @@ async function handlePost({ }: { context: APIContext; next: MiddlewareNext; actionName: string }) { const { request } = context; - const action = await getAction(actionName); - if (!action) { + const baseAction = await getAction(actionName); + if (!baseAction) { throw new AstroError({ ...ActionQueryStringInvalidError, message: ActionQueryStringInvalidError.message(actionName), @@ -72,12 +76,13 @@ async function handlePost({ if (contentType && hasContentType(contentType, formContentTypes)) { formData = await request.clone().formData(); } - const actionResult = await ApiContextStorage.run(context, () => action(formData)); + const action = baseAction.bind(context); + const actionResult = await action(formData); return handleResult({ context, next, actionName, actionResult }); } -function handleResult({ +async function handleResult({ context, next, actionName, @@ -90,22 +95,21 @@ function handleResult({ } return actionResult; }, + callAction: createCallAction(context), actionResult, }; const locals = context.locals as Locals; Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal }); - return ApiContextStorage.run(context, async () => { - const response = await next(); - if (actionResult.error) { - return new Response(response.body, { - status: actionResult.error.status, - statusText: actionResult.error.type, - headers: response.headers, - }); - } - return response; - }); + const response = await next(); + if (actionResult.error) { + return new Response(response.body, { + status: actionResult.error.status, + statusText: actionResult.error.type, + headers: response.headers, + }); + } + return response; } async function handlePostLegacy({ context, next }: { context: APIContext; next: MiddlewareNext }) { @@ -127,15 +131,16 @@ async function handlePostLegacy({ context, next }: { context: APIContext; next: const actionName = formData.get('_astroAction') as string; if (!actionName) return nextWithLocalsStub(next, context); - const action = await getAction(actionName); - if (!action) { + const baseAction = await getAction(actionName); + if (!baseAction) { throw new AstroError({ ...ActionQueryStringInvalidError, message: ActionQueryStringInvalidError.message(actionName), }); } - const actionResult = await ApiContextStorage.run(context, () => action(formData)); + const action = baseAction.bind(context); + const actionResult = await action(formData); return handleResult({ context, next, actionName, actionResult }); } @@ -150,9 +155,10 @@ function nextWithStaticStub(next: MiddlewareNext, context: APIContext) { ); return undefined; }, + callAction: createCallAction(context), }, }); - return ApiContextStorage.run(context, () => next()); + return next(); } function nextWithLocalsStub(next: MiddlewareNext, context: APIContext) { @@ -160,7 +166,15 @@ function nextWithLocalsStub(next: MiddlewareNext, context: APIContext) { writable: false, value: { getActionResult: () => undefined, + callAction: createCallAction(context), }, }); - return ApiContextStorage.run(context, () => next()); + return next(); +} + +function createCallAction(context: APIContext): APIContext['callAction'] { + return (baseAction, input) => { + const action = baseAction.bind(context); + return action(input) as any; + }; } diff --git a/packages/astro/src/actions/runtime/route.ts b/packages/astro/src/actions/runtime/route.ts index a1e711b18..33467a4b7 100644 --- a/packages/astro/src/actions/runtime/route.ts +++ b/packages/astro/src/actions/runtime/route.ts @@ -1,11 +1,10 @@ import type { APIRoute } from '../../@types/astro.js'; -import { ApiContextStorage } from './store.js'; import { formContentTypes, getAction, hasContentType } from './utils.js'; export const POST: APIRoute = async (context) => { const { request, url } = context; - const action = await getAction(url.pathname); - if (!action) { + const baseAction = await getAction(url.pathname); + if (!baseAction) { return new Response(null, { status: 404 }); } const contentType = request.headers.get('Content-Type'); @@ -22,7 +21,8 @@ export const POST: APIRoute = async (context) => { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415 return new Response(null, { status: 415 }); } - const result = await ApiContextStorage.run(context, () => action(args)); + const action = baseAction.bind(context); + const result = await action(args); if (result.error) { return new Response( JSON.stringify({ diff --git a/packages/astro/src/actions/runtime/store.ts b/packages/astro/src/actions/runtime/store.ts deleted file mode 100644 index a1110cd70..000000000 --- a/packages/astro/src/actions/runtime/store.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { APIContext } from '../../@types/astro.js'; -import { AstroError } from '../../core/errors/errors.js'; - -export type ActionAPIContext = Omit<APIContext, 'getActionResult' | 'props'>; -export const ApiContextStorage = new AsyncLocalStorage<ActionAPIContext>(); - -export function getApiContext(): ActionAPIContext { - const context = ApiContextStorage.getStore(); - if (!context) { - throw new AstroError({ - name: 'AstroActionError', - message: 'Unable to get API context.', - hint: 'If you attempted to call this action from server code, trying using `Astro.getActionResult()` instead.', - }); - } - return context; -} diff --git a/packages/astro/src/actions/runtime/utils.ts b/packages/astro/src/actions/runtime/utils.ts index 45f6cdc61..4c1555cbd 100644 --- a/packages/astro/src/actions/runtime/utils.ts +++ b/packages/astro/src/actions/runtime/utils.ts @@ -1,4 +1,5 @@ import type { ZodType } from 'zod'; +import type { APIContext } from '../../@types/astro.js'; import type { ActionAccept, ActionClient } from './virtual/server.js'; export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data']; @@ -11,6 +12,7 @@ export function hasContentType(contentType: string, expected: string[]) { return expected.some((t) => type === t); } +export type ActionAPIContext = Omit<APIContext, 'getActionResult' | 'callAction' | 'props'>; export type MaybePromise<T> = T | Promise<T>; /** diff --git a/packages/astro/src/actions/runtime/virtual/client.ts b/packages/astro/src/actions/runtime/virtual/client.ts index 3c81e19cb..ddeba9a75 100644 --- a/packages/astro/src/actions/runtime/virtual/client.ts +++ b/packages/astro/src/actions/runtime/virtual/client.ts @@ -4,10 +4,6 @@ export function defineAction() { throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.'); } -export function getApiContext() { - throw new Error('[astro:action] `getApiContext()` unexpectedly used on the client.'); -} - export const z = new Proxy( {}, { diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts index fb6a36c92..d0f70c40f 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -1,15 +1,13 @@ import { z } from 'zod'; -import { type ActionAPIContext, getApiContext as _getApiContext } from '../store.js'; -import type { ErrorInferenceObject, MaybePromise } from '../utils.js'; +import type { ErrorInferenceObject, MaybePromise, ActionAPIContext } from '../utils.js'; import { ActionError, ActionInputError, type SafeResult, callSafely } from './shared.js'; +import { AstroError } from '../../../core/errors/errors.js'; +import { ActionCalledFromServerError } from '../../../core/errors/errors-data.js'; export * from './shared.js'; export { z } from 'zod'; -/** @deprecated Access context from the second `handler()` parameter. */ -export const getApiContext = _getApiContext; - export type ActionAccept = 'form' | 'json'; export type ActionInputSchema<T extends ActionAccept | undefined> = T extends 'form' ? z.AnyZodObject | z.ZodType<FormData> @@ -66,12 +64,20 @@ export function defineAction< ? getFormServerHandler(handler, inputSchema) : getJsonServerHandler(handler, inputSchema); - const safeServerHandler = async (unparsedInput: unknown) => { - return callSafely(() => serverHandler(unparsedInput)); - }; + async function safeServerHandler(this: ActionAPIContext, unparsedInput: unknown) { + if (typeof this === 'function') { + throw new AstroError(ActionCalledFromServerError); + } + return callSafely(() => serverHandler(unparsedInput, this)); + } Object.assign(safeServerHandler, { - orThrow: serverHandler, + orThrow(this: ActionAPIContext, unparsedInput: unknown) { + if (typeof this === 'function') { + throw new AstroError(ActionCalledFromServerError); + } + return serverHandler(unparsedInput, this); + }, }); return safeServerHandler as ActionClient<TOutput, TAccept, TInputSchema> & string; @@ -81,7 +87,7 @@ function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'f handler: ActionHandler<TInputSchema, TOutput>, inputSchema?: TInputSchema ) { - return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => { + return async (unparsedInput: unknown, context: ActionAPIContext): Promise<Awaited<TOutput>> => { if (!(unparsedInput instanceof FormData)) { throw new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE', @@ -89,13 +95,13 @@ function getFormServerHandler<TOutput, TInputSchema extends ActionInputSchema<'f }); } - if (!(inputSchema instanceof z.ZodObject)) return await handler(unparsedInput, getApiContext()); + if (!(inputSchema instanceof z.ZodObject)) return await handler(unparsedInput, context); const parsed = await inputSchema.safeParseAsync(formDataToObject(unparsedInput, inputSchema)); if (!parsed.success) { throw new ActionInputError(parsed.error.issues); } - return await handler(parsed.data, getApiContext()); + return await handler(parsed.data, context); }; } @@ -103,7 +109,7 @@ function getJsonServerHandler<TOutput, TInputSchema extends ActionInputSchema<'j handler: ActionHandler<TInputSchema, TOutput>, inputSchema?: TInputSchema ) { - return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => { + return async (unparsedInput: unknown, context: ActionAPIContext): Promise<Awaited<TOutput>> => { if (unparsedInput instanceof FormData) { throw new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE', @@ -111,12 +117,12 @@ function getJsonServerHandler<TOutput, TInputSchema extends ActionInputSchema<'j }); } - if (!inputSchema) return await handler(unparsedInput, getApiContext()); + if (!inputSchema) return await handler(unparsedInput, context); const parsed = await inputSchema.safeParseAsync(unparsedInput); if (!parsed.success) { throw new ActionInputError(parsed.error.issues); } - return await handler(parsed.data, getApiContext()); + return await handler(parsed.data, context); }; } diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts index 833cd6dac..cb00e4b79 100644 --- a/packages/astro/src/actions/utils.ts +++ b/packages/astro/src/actions/utils.ts @@ -18,3 +18,16 @@ export function createGetActionResult(locals: APIContext['locals']): APIContext[ return locals._actionsInternal.getActionResult(actionFn); }; } + +export function createCallAction(locals: APIContext['locals']): APIContext['callAction'] { + return (actionFn, input) => { + if (!hasActionsInternal(locals)) + throw new AstroError({ + name: 'AstroActionError', + message: 'Experimental actions are not enabled in your project.', + hint: 'See https://docs.astro.build/en/reference/configuration-reference/#experimental-flags', + }); + + return locals._actionsInternal.callAction(actionFn, input); + }; +} diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index c94a710bb..398026bc7 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1606,6 +1606,21 @@ export const DuplicateContentEntrySlugError = { /** * @docs * @see + * - [devalue library](https://github.com/rich-harris/devalue) + * @description + * `transform()` functions in your content config must return valid JSON, or data types compatible with the devalue library (including Dates, Maps, and Sets). + */ +export const UnsupportedConfigTransformError = { + name: 'UnsupportedConfigTransformError', + title: 'Unsupported transform in content config.', + message: (parseError: string) => + `\`transform()\` functions in your content config must return valid JSON, or data types compatible with the devalue library (including Dates, Maps, and Sets).\nFull error: ${parseError}`, + hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue', +} satisfies ErrorData; + +/** + * @docs + * @see * - [On-demand rendering](https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered) * @description * Your project must have a server output to create backend functions with Actions. @@ -1650,17 +1665,14 @@ export const ActionQueryStringInvalidError = { /** * @docs - * @see - * - [devalue library](https://github.com/rich-harris/devalue) * @description - * `transform()` functions in your content config must return valid JSON, or data types compatible with the devalue library (including Dates, Maps, and Sets). + * Action called from a server page or endpoint without using `Astro.callAction()`. */ -export const UnsupportedConfigTransformError = { - name: 'UnsupportedConfigTransformError', - title: 'Unsupported transform in content config.', - message: (parseError: string) => - `\`transform()\` functions in your content config must return valid JSON, or data types compatible with the devalue library (including Dates, Maps, and Sets).\nFull error: ${parseError}`, - hint: 'See the devalue library for all supported types: https://github.com/rich-harris/devalue', +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', } 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/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index 02fad1c61..5499232b1 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -1,5 +1,5 @@ import type { APIContext, MiddlewareHandler, Params, RewritePayload } from '../../@types/astro.js'; -import { createGetActionResult } from '../../actions/utils.js'; +import { createCallAction, createGetActionResult } from '../../actions/utils.js'; import { computeCurrentLocale, computePreferredLocale, @@ -52,7 +52,7 @@ function createContext({ // return dummy response return Promise.resolve(new Response(null)); }; - const context: Omit<APIContext, 'getActionResult'> = { + const context: Omit<APIContext, 'getActionResult' | 'callAction'> = { cookies: new AstroCookies(request), request, params, @@ -106,6 +106,7 @@ function createContext({ }; return Object.assign(context, { getActionResult: createGetActionResult(context.locals), + callAction: createCallAction(context.locals), }); } diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index c55abc381..f8d805b09 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -9,8 +9,8 @@ import type { RouteData, SSRResult, } from '../@types/astro.js'; -import type { ActionAPIContext } from '../actions/runtime/store.js'; -import { createGetActionResult, hasActionsInternal } from '../actions/utils.js'; +import type { ActionAPIContext } from '../actions/runtime/utils.js'; +import { createCallAction, createGetActionResult, hasActionsInternal } from '../actions/utils.js'; import { computeCurrentLocale, computePreferredLocale, @@ -216,6 +216,7 @@ export class RenderContext { return Object.assign(context, { props, getActionResult: createGetActionResult(context.locals), + callAction: createCallAction(context.locals), }); } @@ -458,6 +459,7 @@ export class RenderContext { rewrite, request: this.request, getActionResult: createGetActionResult(locals), + callAction: createCallAction(locals), response, site: pipeline.site, url, diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index fbd89d75d..73ee8396d 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -7,7 +7,9 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') { return target[objKey]; } const path = aggregatedPath + objKey.toString(); - const action = (param) => callSafely(() => handleActionOrThrow(param, path)); + function action(param) { + return callSafely(() => handleActionOrThrow(param, path, this)); + } Object.assign(action, { queryString: getActionQueryString(path), @@ -26,8 +28,8 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') { // Note: `orThrow` does not have progressive enhancement info. // If you want to throw exceptions, // you must handle those exceptions with client JS. - orThrow: (param) => { - return handleActionOrThrow(param, path); + orThrow(param) { + return handleActionOrThrow(param, path, this); }, }); @@ -41,16 +43,17 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '') { /** * @param {*} param argument passed to the action when called server or client-side. * @param {string} path Built path to call action by path name. + * @param {import('../src/@types/astro.d.ts').APIContext | undefined} context Injected API context when calling actions from the server. * Usage: `actions.[name](param)`. */ -async function handleActionOrThrow(param, path) { +async function handleActionOrThrow(param, path, context) { // When running server-side, import the action and call it. if (import.meta.env.SSR) { const { getAction } = await import('astro/actions/runtime/utils.js'); const action = await getAction(path); if (!action) throw new Error(`Action not found: ${path}`); - return action.orThrow(param); + return action.orThrow.bind(context)(param); } // When running client-side, make a fetch request to the action path. diff --git a/packages/astro/test/fixtures/actions/src/pages/subscribe.astro b/packages/astro/test/fixtures/actions/src/pages/subscribe.astro index a5cadb179..be38adbce 100644 --- a/packages/astro/test/fixtures/actions/src/pages/subscribe.astro +++ b/packages/astro/test/fixtures/actions/src/pages/subscribe.astro @@ -1,7 +1,7 @@ --- import { actions } from 'astro:actions'; -const res = await actions.subscribeFromServer.orThrow({ +const res = await Astro.callAction(actions.subscribeFromServer.orThrow, { channel: 'bholmesdev', }); --- diff --git a/packages/astro/test/types/call-action.ts b/packages/astro/test/types/call-action.ts new file mode 100644 index 000000000..9a74802fc --- /dev/null +++ b/packages/astro/test/types/call-action.ts @@ -0,0 +1,51 @@ +import { describe, it } from 'node:test'; +import { expectTypeOf } from 'expect-type'; +import { type ActionReturnType, defineAction } from '../../dist/actions/runtime/virtual/server.js'; +import { z } from '../../zod.mjs'; +import type { APIContext } from '../../dist/@types/astro.js'; + +describe('Astro.callAction', () => { + it('Infers JSON action result on callAction', async () => { + const context: APIContext = {} as any; + const action = defineAction({ + input: z.object({ + name: z.string(), + }), + handler: async ({ name }) => { + return { name }; + }, + }); + const result = await context.callAction(action, { name: 'Ben' }); + expectTypeOf<typeof result>().toEqualTypeOf<ActionReturnType<typeof action>>(); + }); + + it('Infers form action result on callAction', async () => { + const context: APIContext = {} as any; + const action = defineAction({ + accept: 'form', + input: z.object({ + name: z.string(), + }), + handler: async ({ name }) => { + return { name }; + }, + }); + const result = await context.callAction(action, new FormData()); + expectTypeOf<typeof result>().toEqualTypeOf<ActionReturnType<typeof action>>(); + }); + + it('Infers orThrow action result on callAction', async () => { + const context: APIContext = {} as any; + const action = defineAction({ + accept: 'form', + input: z.object({ + name: z.string(), + }), + handler: async ({ name }) => { + return { name }; + }, + }); + const result = await context.callAction(action.orThrow, new FormData()); + expectTypeOf<typeof result>().toEqualTypeOf<ActionReturnType<(typeof action)['orThrow']>>(); + }); +}); |