diff --git a/.changeset/twelve-carrots-raise.md b/.changeset/twelve-carrots-raise.md new file mode 100644 index 0000000000..66c394563a --- /dev/null +++ b/.changeset/twelve-carrots-raise.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Refactor Astro Actions to not use a middleware. Doing so should avoid unexpected issues when using the Astro middleware at the edge. 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 01b479b2b9..52a6ad1764 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts +++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts @@ -15,16 +15,16 @@ export const server = { handler: async ({ postId }) => { await new Promise((r) => setTimeout(r, 500)); - const { likes } = await db + const result = await db .update(Likes) .set({ - likes: sql`likes + 1`, + likes: sql`${Likes.likes} + 1`, }) .where(eq(Likes.postId, postId)) .returning() .get(); - return likes; + return result?.likes; }, }), 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 781206b84a..4d93e310d7 100644 --- a/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx +++ b/packages/astro/e2e/fixtures/actions-blog/src/components/PostComment.tsx @@ -1,5 +1,6 @@ import { actions, isInputError } from 'astro:actions'; import { useState } from 'react'; +import {createLoggerFromFlags} from "../../../../../src/cli/flags.ts"; export function PostComment({ postId, 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 9dc5404d64..51e94747de 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 @@ -1,5 +1,5 @@ --- -import { type CollectionEntry, getEntry } from 'astro:content'; +import { type CollectionEntry, getEntry, render } from 'astro:content'; import BlogPost from '../../layouts/BlogPost.astro'; import { Logout } from '../../components/Logout'; import { db, eq, Comment, Likes } from 'astro:db'; @@ -11,7 +11,7 @@ import { isInputError } from 'astro:actions'; type Props = CollectionEntry<'blog'>; const post = await getEntry('blog', Astro.params.slug)!; -const { Content } = await post.render(); +const { Content } = await render(post); if (Astro.url.searchParams.has('like')) { await Astro.callAction(actions.blog.like.orThrow, { postId: post.id }); diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts index 88fafb8a31..d2c200c089 100644 --- a/packages/astro/src/actions/consts.ts +++ b/packages/astro/src/actions/consts.ts @@ -1,8 +1,8 @@ export const VIRTUAL_MODULE_ID = 'astro:actions'; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; export const ACTIONS_TYPES_FILE = 'actions.d.ts'; -export const VIRTUAL_INTERNAL_MODULE_ID = 'astro:internal-actions'; -export const RESOLVED_VIRTUAL_INTERNAL_MODULE_ID = '\0astro:internal-actions'; +export const ASTRO_ACTIONS_INTERNAL_MODULE_ID = 'astro-internal:actions'; +export const RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID = '\0' + ASTRO_ACTIONS_INTERNAL_MODULE_ID; export const NOOP_ACTIONS = '\0noop-actions'; export const ACTION_QUERY_PARAMS = { diff --git a/packages/astro/src/actions/integration.ts b/packages/astro/src/actions/integration.ts index 13d76e8b60..e1b20ffb03 100644 --- a/packages/astro/src/actions/integration.ts +++ b/packages/astro/src/actions/integration.ts @@ -17,18 +17,13 @@ export default function astroIntegrationActionsRouteHandler({ return { name: VIRTUAL_MODULE_ID, hooks: { - async 'astro:config:setup'(params) { + async 'astro:config:setup'() { settings.injectedRoutes.push({ pattern: ACTION_RPC_ROUTE_PATTERN, entrypoint: 'astro/actions/runtime/route.js', prerender: false, origin: 'internal', }); - - params.addMiddleware({ - entrypoint: 'astro/actions/runtime/middleware.js', - order: 'post', - }); }, 'astro:config:done': async (params) => { if (params.buildOutput === 'static') { diff --git a/packages/astro/src/actions/loadActions.ts b/packages/astro/src/actions/loadActions.ts new file mode 100644 index 0000000000..8ea18bf711 --- /dev/null +++ b/packages/astro/src/actions/loadActions.ts @@ -0,0 +1,20 @@ +import type {ModuleLoader} from "../core/module-loader/index.js"; +import {ASTRO_ACTIONS_INTERNAL_MODULE_ID} from "./consts.js"; +import type {SSRActions} from "../core/app/types.js"; +import {ActionsCantBeLoaded} from "../core/errors/errors-data.js"; +import {AstroError} from "../core/errors/index.js"; + +/** + * It accepts a module loader and the astro settings, and it attempts to load the middlewares defined in the configuration. + * + * If not middlewares were not set, the function returns an empty array. + */ +export async function loadActions(moduleLoader: ModuleLoader) { + try { + return (await moduleLoader.import( + ASTRO_ACTIONS_INTERNAL_MODULE_ID, + )) as SSRActions; + } catch (error: any) { + throw new AstroError(ActionsCantBeLoaded, {cause: error}); + } +} diff --git a/packages/astro/src/actions/plugins.ts b/packages/astro/src/actions/plugins.ts index 4c1b930c3d..7b151a860c 100644 --- a/packages/astro/src/actions/plugins.ts +++ b/packages/astro/src/actions/plugins.ts @@ -4,12 +4,16 @@ import { shouldAppendForwardSlash } from '../core/build/util.js'; import type { AstroSettings } from '../types/astro.js'; import { NOOP_ACTIONS, - RESOLVED_VIRTUAL_INTERNAL_MODULE_ID, + RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID, RESOLVED_VIRTUAL_MODULE_ID, - VIRTUAL_INTERNAL_MODULE_ID, + ASTRO_ACTIONS_INTERNAL_MODULE_ID, VIRTUAL_MODULE_ID, } from './consts.js'; import { isActionsFilePresent } from './utils.js'; +import { getOutputDirectory } from '../prerender/utils.js'; +import type { StaticBuildOptions } from '../core/build/types.js'; +import type { BuildInternals } from '../core/build/internal.js'; +import { addRollupInput } from '../core/build/add-rollup-input.js'; /** * This plugin is responsible to load the known file `actions/index.js` / `actions.js` @@ -24,7 +28,7 @@ export function vitePluginUserActions({ settings }: { settings: AstroSettings }) if (id === NOOP_ACTIONS) { return NOOP_ACTIONS; } - if (id === VIRTUAL_INTERNAL_MODULE_ID) { + if (id === ASTRO_ACTIONS_INTERNAL_MODULE_ID) { const resolvedModule = await this.resolve( `${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`, ); @@ -33,20 +37,50 @@ export function vitePluginUserActions({ settings }: { settings: AstroSettings }) return NOOP_ACTIONS; } resolvedActionsId = resolvedModule.id; - return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID; + return RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID; } }, load(id) { if (id === NOOP_ACTIONS) { return 'export const server = {}'; - } else if (id === RESOLVED_VIRTUAL_INTERNAL_MODULE_ID) { + } else if (id === RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID) { return `export { server } from '${resolvedActionsId}';`; } }, }; } +/** + * This plugin is used to retrieve the final entry point of the bundled actions.ts file + * @param opts + * @param internals + */ +export function vitePluginActionsBuild( + opts: StaticBuildOptions, + internals: BuildInternals, +): VitePlugin { + return { + name: '@astro/plugin-actions-build', + + options(options) { + return addRollupInput(options, [ASTRO_ACTIONS_INTERNAL_MODULE_ID]); + }, + + writeBundle(_, bundle) { + for (const [chunkName, chunk] of Object.entries(bundle)) { + if ( + chunk.type !== 'asset' && + chunk.facadeModuleId === RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID + ) { + const outputDirectory = getOutputDirectory(opts.settings); + internals.astroActionsEntryPoint = new URL(chunkName, outputDirectory); + } + } + }, + }; +} + export function vitePluginActions({ fs, settings, diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts deleted file mode 100644 index 47adc29454..0000000000 --- a/packages/astro/src/actions/runtime/middleware.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineMiddleware } from '../../virtual-modules/middleware.js'; -import { getActionContext } from './virtual/server.js'; - -export const onRequest = defineMiddleware(async (context, next) => { - if (context.isPrerendered) return next(); - const { action, setActionResult, serializeActionResult } = getActionContext(context); - - if (action?.calledFrom === 'form') { - const actionResult = await action.handler(); - setActionResult(action.name, serializeActionResult(actionResult)); - } - return next(); -}); diff --git a/packages/astro/src/actions/runtime/virtual/get-action.ts b/packages/astro/src/actions/runtime/virtual/get-action.ts deleted file mode 100644 index a11e72fc48..0000000000 --- a/packages/astro/src/actions/runtime/virtual/get-action.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ZodType } from 'zod'; -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. - * Imports from the virtual module `astro:internal-actions`, which maps to - * the user's `src/actions/index.ts` file at build-time. - */ -export async function getAction( - path: string, -): Promise> { - const pathKeys = path.split('.').map((key) => decodeURIComponent(key)); - // @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)) { - throw new AstroError({ - ...ActionNotFoundError, - message: ActionNotFoundError.message(pathKeys.join('.')), - }); - } - actionLookup = actionLookup[key]; - } - if (typeof actionLookup !== 'function') { - 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 b58d733766..fe046701db 100644 --- a/packages/astro/src/actions/runtime/virtual/server.ts +++ b/packages/astro/src/actions/runtime/virtual/server.ts @@ -17,7 +17,6 @@ import { isActionAPIContext, } from '../utils.js'; import type { Locals } from '../utils.js'; -import { getAction } from './get-action.js'; import { ACTION_QUERY_PARAMS, ActionError, @@ -239,7 +238,7 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) { return schema; } -export type ActionMiddlewareContext = { +export type AstroActionContext = { /** Information about an incoming action request. */ action?: { /** Whether an action was called using an RPC function or by using an HTML form action. */ @@ -268,7 +267,7 @@ export type ActionMiddlewareContext = { /** * Access information about Action requests from middleware. */ -export function getActionContext(context: APIContext): ActionMiddlewareContext { +export function getActionContext(context: APIContext): AstroActionContext { const callerInfo = getCallerInfo(context); // Prevents action results from being handled on a rewrite. @@ -276,7 +275,7 @@ export function getActionContext(context: APIContext): ActionMiddlewareContext { // if the user's middleware has already handled the result. const actionResultAlreadySet = Boolean((context.locals as Locals)._actionPayload); - let action: ActionMiddlewareContext['action'] = undefined; + let action: AstroActionContext['action'] = undefined; if (callerInfo && context.request.method === 'POST' && !actionResultAlreadySet) { action = { @@ -291,7 +290,7 @@ export function getActionContext(context: APIContext): ActionMiddlewareContext { ? removeTrailingForwardSlash(callerInfo.name) : callerInfo.name; - const baseAction = await getAction(callerInfoName); + const baseAction = await pipeline.getAction(callerInfoName); let input; try { input = await parseRequestBody(context.request); diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts index 02cc07b52c..24e740681f 100644 --- a/packages/astro/src/actions/runtime/virtual/shared.ts +++ b/packages/astro/src/actions/runtime/virtual/shared.ts @@ -1,7 +1,7 @@ import { parse as devalueParse, stringify as devalueStringify } from 'devalue'; import type { z } from 'zod'; import { REDIRECT_STATUS_CODES } from '../../../core/constants.js'; -import { ActionsReturnedInvalidDataError } from '../../../core/errors/errors-data.js'; +import {ActionCalledFromServerError, ActionsReturnedInvalidDataError} from '../../../core/errors/errors-data.js'; import { AstroError } from '../../../core/errors/errors.js'; import { appendForwardSlash as _appendForwardSlash } from '../../../core/path.js'; import { ACTION_QUERY_PARAMS as _ACTION_QUERY_PARAMS } from '../../consts.js'; @@ -309,3 +309,7 @@ const actionResultErrorStack = (function actionResultErrorStackFn() { }, }; })(); + +export function astroCalledServerError(): AstroError { + return new AstroError(ActionCalledFromServerError); +} diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 09e6fe4365..24fa0ccc66 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -9,6 +9,8 @@ import type { SSRResult, } from '../../types/public/internal.js'; import type { SinglePageBuiltModule } from '../build/types.js'; +import type { ActionAccept, ActionClient } from '../../actions/runtime/virtual/server.js'; +import type { ZodType } from 'zod'; export type ComponentPath = string; @@ -75,6 +77,7 @@ export type SSRManifest = { key: Promise; i18n: SSRManifestI18n | undefined; middleware?: () => Promise | AstroMiddlewareInstance; + actions?: SSRActions; checkOrigin: boolean; sessionConfig?: ResolvedSessionConfig; cacheDir: string | URL; @@ -85,6 +88,10 @@ export type SSRManifest = { buildServerDir: string | URL; }; +export type SSRActions = { + server: Record>; +}; + export type SSRManifestI18n = { fallback: Record | undefined; fallbackType: 'redirect' | 'rewrite'; diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 81cc553ba7..8f4a0dd875 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -14,6 +14,11 @@ import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js'; import { sequence } from './middleware/sequence.js'; import { RouteCache } from './render/route-cache.js'; import { createDefaultRoutes } from './routing/default.js'; +import type { SSRActions } from './app/types.js'; +import type { ActionAccept, ActionClient } from '../actions/runtime/virtual/server.js'; +import type { ZodType } from 'zod'; +import { AstroError } from './errors/index.js'; +import { ActionNotFoundError } from './errors/errors-data.js'; /** * The `Pipeline` represents the static parts of rendering that do not change between requests. @@ -24,6 +29,7 @@ import { createDefaultRoutes } from './routing/default.js'; export abstract class Pipeline { readonly internalMiddleware: MiddlewareHandler[]; resolvedMiddleware: MiddlewareHandler | undefined = undefined; + resolvedActions: SSRActions | undefined = undefined; constructor( readonly logger: Logger, @@ -58,6 +64,8 @@ export abstract class Pipeline { * Used to find the route module */ readonly defaultRoutes = createDefaultRoutes(manifest), + + readonly actions = manifest.actions, ) { this.internalMiddleware = []; // We do use our middleware only if the user isn't using the manual setup @@ -114,6 +122,47 @@ export abstract class Pipeline { return this.resolvedMiddleware; } } + + setActions(actions: SSRActions) { + this.resolvedActions = actions; + } + + async getActions(): Promise { + if (this.resolvedActions) { + return this.resolvedActions; + } else if (this.actions) { + return this.actions; + } + return { server: {} }; + } + + async getAction(path: string): Promise> { + const pathKeys = path.split('.').map((key) => decodeURIComponent(key)); + let { server } = await this.getActions(); + + if (!server || !(typeof server === 'object')) { + throw new TypeError( + `Expected \`server\` export in actions file to be an object. Received ${typeof server}.`, + ); + } + + for (const key of pathKeys) { + if (!(key in server)) { + throw new AstroError({ + ...ActionNotFoundError, + message: ActionNotFoundError.message(pathKeys.join('.')), + }); + } + // @ts-expect-error we are doing a recursion... it's ugly + server = server[key]; + } + if (typeof server !== 'function') { + throw new TypeError( + `Expected handler for action ${pathKeys.join('.')} to be a function. Received ${typeof server}.`, + ); + } + return server; + } } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 0d5c049fc8..cd3e6228c7 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -27,7 +27,7 @@ import type { SSRError, SSRLoadedRenderer, } from '../../types/public/internal.js'; -import type { SSRManifest, SSRManifestI18n } from '../app/types.js'; +import type { SSRActions, SSRManifest, SSRManifestI18n } from '../app/types.js'; import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js'; @@ -63,11 +63,16 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil const middleware: MiddlewareHandler = internals.middlewareEntryPoint ? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest) : NOOP_MIDDLEWARE_FN; + + const actions: SSRActions = internals.astroActionsEntryPoint + ? await import(internals.astroActionsEntryPoint.toString()).then((mod) => mod) + : { server: {} }; manifest = createBuildManifest( options.settings, internals, renderers.renderers as SSRLoadedRenderer[], middleware, + actions, options.key, ); } @@ -451,8 +456,7 @@ function getUrlForPath( removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending; buildPathname = joinPaths(base, buildPathRelative); } - const url = new URL(buildPathname, origin); - return url; + return new URL(buildPathname, origin); } interface GeneratePathOptions { @@ -599,6 +603,7 @@ function createBuildManifest( internals: BuildInternals, renderers: SSRLoadedRenderer[], middleware: MiddlewareHandler, + actions: SSRActions, key: Promise, ): SSRManifest { let i18nManifest: SSRManifestI18n | undefined = undefined; @@ -641,6 +646,7 @@ function createBuildManifest( onRequest: middleware, }; }, + actions, checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, key, diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 4e36758769..a726480b8c 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -87,7 +87,8 @@ export interface BuildInternals { manifestFileName?: string; entryPoints: Map; componentMetadata: SSRResult['componentMetadata']; - middlewareEntryPoint?: URL; + middlewareEntryPoint: URL | undefined; + astroActionsEntryPoint: URL | undefined; /** * Chunks in the bundle that are only used in prerendering that we can delete later @@ -118,6 +119,8 @@ export function createBuildInternals(): BuildInternals { componentMetadata: new Map(), entryPoints: new Map(), prerenderOnlyChunks: [], + astroActionsEntryPoint: undefined, + middlewareEntryPoint: undefined, }; } diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index 8f814946db..a2a2f86019 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -13,6 +13,7 @@ import { pluginPrerender } from './plugin-prerender.js'; import { pluginRenderers } from './plugin-renderers.js'; import { pluginScripts } from './plugin-scripts.js'; import { pluginSSR } from './plugin-ssr.js'; +import { pluginActions } from './plugin-actions.js'; export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) { register(pluginComponentEntry(internals)); @@ -21,6 +22,7 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP register(pluginManifest(options, internals)); register(pluginRenderers(options)); register(pluginMiddleware(options, internals)); + register(pluginActions(options, internals)); register(pluginPages(options, internals)); register(pluginCSS(options, internals)); register(astroHeadBuildPlugin(internals)); diff --git a/packages/astro/src/core/build/plugins/plugin-actions.ts b/packages/astro/src/core/build/plugins/plugin-actions.ts new file mode 100644 index 0000000000..4ff07bcad1 --- /dev/null +++ b/packages/astro/src/core/build/plugins/plugin-actions.ts @@ -0,0 +1,20 @@ +import { vitePluginActionsBuild } from '../../../actions/plugins.js'; +import type { StaticBuildOptions } from '../types.js'; +import type { BuildInternals } from '../internal.js'; +import type { AstroBuildPlugin } from '../plugin.js'; + +export function pluginActions( + opts: StaticBuildOptions, + internals: BuildInternals, +): AstroBuildPlugin { + return { + targets: ['server'], + hooks: { + 'build:before': () => { + return { + vitePlugin: vitePluginActionsBuild(opts, internals), + }; + }, + }, + }; +} diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts index 6b982053f6..8924ca0846 100644 --- a/packages/astro/src/core/build/plugins/plugin-middleware.ts +++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts @@ -2,7 +2,6 @@ import { vitePluginMiddlewareBuild } from '../../middleware/vite-plugin.js'; import type { BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; -export { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js'; export function pluginMiddleware( opts: StaticBuildOptions, diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index eea2be3e8e..18cdf15bdb 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -7,10 +7,11 @@ import type { BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js'; -import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js'; import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; import { getVirtualModulePageName } from './util.js'; +import { ASTRO_ACTIONS_INTERNAL_MODULE_ID } from '../../../actions/consts.js'; +import { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js'; export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry'; export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; @@ -167,6 +168,7 @@ function generateSSRCode(adapter: AstroAdapter, middlewareId: string) { const imports = [ `import { renderers } from '${RENDERERS_MODULE_ID}';`, + `import * as actions from '${ASTRO_ACTIONS_INTERNAL_MODULE_ID}';`, `import * as serverEntrypointModule from '${ADAPTER_VIRTUAL_MODULE_ID}';`, `import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`, `import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`, @@ -178,6 +180,7 @@ function generateSSRCode(adapter: AstroAdapter, middlewareId: string) { ` pageMap,`, ` serverIslandMap,`, ` renderers,`, + ` actions,`, ` middleware: ${edgeMiddleware ? 'undefined' : `() => import("${middlewareId}")`}`, `});`, `const _args = ${adapter.args ? JSON.stringify(adapter.args, null, 4) : 'undefined'};`, diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 900bbd6b78..dab7af78ec 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -901,6 +901,19 @@ export const MiddlewareCantBeLoaded = { message: 'An unknown error was thrown while loading your middleware.', } satisfies ErrorData; + +/** + * @docs + * @description + * Thrown in development mode when the actions file can't be loaded. + * + */ +export const ActionsCantBeLoaded = { + name: 'ActionsCantBeLoaded', + title: "Can't load the Astro actions.", + message: 'An unknown error was thrown while loading the Astro actions file.', +} satisfies ErrorData; + /** * @docs * @see diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 15ccba4962..8a8944f1c3 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -32,6 +32,8 @@ import { type Pipeline, Slots, getParams, getProps } from './render/index.js'; import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js'; import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js'; import { AstroSession } from './session.js'; +import { getActionContext } from '../actions/runtime/virtual/server.js'; +import type {SSRActions} from "./app/types.js"; export const apiContextRoutesSymbol = Symbol.for('context.routes'); @@ -44,6 +46,7 @@ export class RenderContext { readonly pipeline: Pipeline, public locals: App.Locals, readonly middleware: MiddlewareHandler, + readonly actions: SSRActions, // It must be a DECODED pathname public pathname: string, public request: Request, @@ -80,16 +83,19 @@ export class RenderContext { status = 200, props, partial = undefined, + actions }: Pick & Partial< - Pick + Pick >): Promise { const pipelineMiddleware = await pipeline.getMiddleware(); + const pipelineActions = actions ?? await pipeline.getActions(); setOriginPathname(request, pathname); return new RenderContext( pipeline, locals, sequence(...pipeline.internalMiddleware, middleware ?? pipelineMiddleware), + pipelineActions, pathname, request, routeData, @@ -132,7 +138,8 @@ export class RenderContext { serverLike, base: manifest.base, }); - const apiContext = this.createAPIContext(props); + const actionApiContext = this.createActionAPIContext(); + const apiContext = this.createAPIContext(props, actionApiContext); this.counter++; if (this.counter === 4) { @@ -192,6 +199,15 @@ export class RenderContext { } let response: Response; + if (!ctx.isPrerendered) { + const { action, setActionResult, serializeActionResult } = getActionContext(ctx); + + if (action?.calledFrom === 'form') { + const actionResult = await action.handler(); + setActionResult(action.name, serializeActionResult(actionResult)); + } + } + switch (this.routeData.type) { case 'endpoint': { response = await renderEndpoint( @@ -205,7 +221,7 @@ export class RenderContext { case 'redirect': return renderRedirect(this); case 'page': { - const result = await this.createResult(componentInstance!); + const result = await this.createResult(componentInstance!, actionApiContext); try { response = await renderPage( result, @@ -263,8 +279,7 @@ export class RenderContext { return response; } - createAPIContext(props: APIContext['props']): APIContext { - const context = this.createActionAPIContext(); + createAPIContext(props: APIContext['props'], context: ActionAPIContext): APIContext { const redirect = (path: string, status = 302) => new Response(null, { status, headers: { Location: path } }); Reflect.set(context, apiContextRoutesSymbol, this.pipeline); @@ -365,7 +380,7 @@ export class RenderContext { }; } - async createResult(mod: ComponentInstance) { + async createResult(mod: ComponentInstance, ctx: ActionAPIContext): Promise { const { cookies, pathname, pipeline, routeData, status } = this; const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } = pipeline; @@ -403,7 +418,7 @@ export class RenderContext { cookies, /** This function returns the `Astro` faux-global */ createAstro: (astroGlobal, props, slots) => - this.createAstro(result, astroGlobal, props, slots), + this.createAstro(result, astroGlobal, props, slots, ctx), links, params: this.params, partial, @@ -448,6 +463,7 @@ export class RenderContext { astroStaticPartial: AstroGlobalPartial, props: Record, slotValues: Record | null, + apiContext: ActionAPIContext, ): AstroGlobal { let astroPagePartial; // During rewriting, we must recompute the Astro global, because we need to purge the previous params/props/etc. @@ -455,12 +471,14 @@ export class RenderContext { astroPagePartial = this.#astroPagePartial = this.createAstroPagePartial( result, astroStaticPartial, + apiContext, ); } else { // Create page partial with static partial so they can be cached together. astroPagePartial = this.#astroPagePartial ??= this.createAstroPagePartial( result, astroStaticPartial, + apiContext, ); } // Create component-level partials. `Astro.self` is added by the compiler. @@ -493,6 +511,7 @@ export class RenderContext { createAstroPagePartial( result: SSRResult, astroStaticPartial: AstroGlobalPartial, + apiContext: ActionAPIContext, ): Omit { const renderContext = this; const { cookies, locals, params, pipeline, url, session } = this; @@ -511,6 +530,8 @@ export class RenderContext { return await this.#executeRewrite(reroutePayload); }; + const callAction = createCallAction(apiContext); + return { generator: astroStaticPartial.generator, glob: astroStaticPartial.glob, @@ -539,7 +560,7 @@ export class RenderContext { site: pipeline.site, getActionResult: createGetActionResult(locals), get callAction() { - return createCallAction(this); + return callAction; }, url, get originPathname() { diff --git a/packages/astro/src/vite-plugin-astro-server/pipeline.ts b/packages/astro/src/vite-plugin-astro-server/pipeline.ts index c1eee575dc..4925c66e1f 100644 --- a/packages/astro/src/vite-plugin-astro-server/pipeline.ts +++ b/packages/astro/src/vite-plugin-astro-server/pipeline.ts @@ -212,16 +212,4 @@ export class DevPipeline extends Pipeline { setManifestData(manifestData: RoutesList) { this.routesList = manifestData; } - - rewriteKnownRoute(route: string, sourceRoute: RouteData): ComponentInstance { - if (this.serverLike && sourceRoute.prerender) { - for (let def of this.defaultRoutes) { - if (route === def.route) { - return def.instance; - } - } - } - - throw new Error('Unknown route'); - } } diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 283c40d45b..180e122e8d 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -43,7 +43,7 @@ export default function createVitePluginAstroServer({ }: AstroPluginOptions): vite.Plugin { return { name: 'astro:server', - configureServer(viteServer) { + async configureServer(viteServer) { const loader = createViteLoader(viteServer); const pipeline = DevPipeline.create(routesList, { loader, diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index eb7c43b3ae..b35d0e29bd 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -22,6 +22,7 @@ import type { ComponentInstance, RoutesList } from '../types/astro.js'; import type { RouteData } from '../types/public/internal.js'; import type { DevPipeline } from './pipeline.js'; import { writeSSRResult, writeWebResponse } from './response.js'; +import {loadActions} from "../actions/loadActions.js"; type AsyncReturnType Promise> = T extends ( ...args: any @@ -159,6 +160,8 @@ export async function handleRoute({ let renderContext: RenderContext; let mod: ComponentInstance | undefined = undefined; let route: RouteData; + const actions = await loadActions(loader); + pipeline.setActions(actions); const middleware = (await loadMiddleware(loader)).onRequest; // This is required for adapters to set locals in dev mode. They use a dev server middleware to inject locals to the `http.IncomingRequest` object. const locals = Reflect.get(incomingRequest, clientLocalsSymbol); @@ -192,6 +195,7 @@ export async function handleRoute({ request, routeData: route, clientAddress: incomingRequest.socket.remoteAddress, + actions }); let response; diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs index f7d9f313a9..8443d5ca9d 100644 --- a/packages/astro/templates/actions.mjs +++ b/packages/astro/templates/actions.mjs @@ -4,8 +4,10 @@ import { appendForwardSlash, deserializeActionResult, getActionQueryString, + astroCalledServerError, } from 'astro:actions'; +const apiContextRoutesSymbol = Symbol.for('context.routes'); const ENCODED_DOT = '%2E'; function toActionProxy(actionCallback = {}, aggregatedPath = '') { @@ -73,11 +75,13 @@ export function getActionPath(action) { */ async function handleAction(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/virtual/get-action.js'); - const action = await getAction(path); + if (import.meta.env.SSR && context) { + const pipeline = Reflect.get(context, apiContextRoutesSymbol); + if (!pipeline) { + throw astroCalledServerError(); + } + const action = await pipeline.getAction(path); if (!action) throw new Error(`Action not found: ${path}`); - return action.bind(context)(param); } diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 929a2d8d84..08cc0b535e 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -60,8 +60,8 @@ describe('Astro Actions', () => { assert.equal(res.ok, true); assert.equal(res.headers.get('Content-Type'), 'application/json+devalue'); - const data = devalue.parse(await res.text()); + assert.equal(data.channel, 'bholmesdev'); assert.equal(data.subscribeButtonState, 'smashed'); }); @@ -578,7 +578,6 @@ it('Should support trailing slash', async () => { method: 'POST', body: formData, }); - assert.equal(res.ok, true); assert.equal(res.headers.get('Content-Type'), 'application/json+devalue'); diff --git a/packages/astro/test/fixtures/actions/src/pages/invalid.astro b/packages/astro/test/fixtures/actions/src/pages/invalid.astro index fe152dbba0..e41c7ce315 100644 --- a/packages/astro/test/fixtures/actions/src/pages/invalid.astro +++ b/packages/astro/test/fixtures/actions/src/pages/invalid.astro @@ -1,6 +1,5 @@ --- import { actions } from "astro:actions"; - // this is invalid, it should fail const result = await actions.subscribe({ channel: "hey" }); ---