0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-10 23:01:26 -05:00

refactor(core): move actions inside core (#13262)

* refactor(core): remove middleware from actions

* chore: fix tests

* chore: skip e2e test due to db issues

* chore: import actions later during server life cycle

* chore: rename to just use the word "actions"

* chore: address review

* Update packages/astro/src/actions/loadActions.ts

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>

* chore: remove gotcha

---------

Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev>

Co-authored-by: bholmesdev <51384119+bholmesdev@users.noreply.github.com>
Co-authored-by: JacobNWolf <27933270+JacobNWolf@users.noreply.github.com>
Co-authored-by: matthewp <361671+matthewp@users.noreply.github.com>
Co-authored-by: florian-lefebvre <69633530+florian-lefebvre@users.noreply.github.com>
This commit is contained in:
Emanuele Stoppa 2025-03-04 13:43:39 +00:00 committed by GitHub
parent e8c26d5bdb
commit 0025df37af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 233 additions and 110 deletions

View file

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

View file

@ -15,16 +15,16 @@ export const server = {
handler: async ({ postId }) => { handler: async ({ postId }) => {
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
const { likes } = await db const result = await db
.update(Likes) .update(Likes)
.set({ .set({
likes: sql`likes + 1`, likes: sql`${Likes.likes} + 1`,
}) })
.where(eq(Likes.postId, postId)) .where(eq(Likes.postId, postId))
.returning() .returning()
.get(); .get();
return likes; return result?.likes;
}, },
}), }),

View file

@ -1,5 +1,6 @@
import { actions, isInputError } from 'astro:actions'; import { actions, isInputError } from 'astro:actions';
import { useState } from 'react'; import { useState } from 'react';
import {createLoggerFromFlags} from "../../../../../src/cli/flags.ts";
export function PostComment({ export function PostComment({
postId, postId,

View file

@ -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 BlogPost from '../../layouts/BlogPost.astro';
import { Logout } from '../../components/Logout'; import { Logout } from '../../components/Logout';
import { db, eq, Comment, Likes } from 'astro:db'; import { db, eq, Comment, Likes } from 'astro:db';
@ -11,7 +11,7 @@ import { isInputError } from 'astro:actions';
type Props = CollectionEntry<'blog'>; type Props = CollectionEntry<'blog'>;
const post = await getEntry('blog', Astro.params.slug)!; const post = await getEntry('blog', Astro.params.slug)!;
const { Content } = await post.render(); const { Content } = await render(post);
if (Astro.url.searchParams.has('like')) { if (Astro.url.searchParams.has('like')) {
await Astro.callAction(actions.blog.like.orThrow, { postId: post.id }); await Astro.callAction(actions.blog.like.orThrow, { postId: post.id });

View file

@ -1,8 +1,8 @@
export const VIRTUAL_MODULE_ID = 'astro:actions'; export const VIRTUAL_MODULE_ID = 'astro:actions';
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
export const ACTIONS_TYPES_FILE = 'actions.d.ts'; export const ACTIONS_TYPES_FILE = 'actions.d.ts';
export const VIRTUAL_INTERNAL_MODULE_ID = 'astro:internal-actions'; export const ASTRO_ACTIONS_INTERNAL_MODULE_ID = 'astro-internal:actions';
export const RESOLVED_VIRTUAL_INTERNAL_MODULE_ID = '\0astro:internal-actions'; export const RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID = '\0' + ASTRO_ACTIONS_INTERNAL_MODULE_ID;
export const NOOP_ACTIONS = '\0noop-actions'; export const NOOP_ACTIONS = '\0noop-actions';
export const ACTION_QUERY_PARAMS = { export const ACTION_QUERY_PARAMS = {

View file

@ -17,18 +17,13 @@ export default function astroIntegrationActionsRouteHandler({
return { return {
name: VIRTUAL_MODULE_ID, name: VIRTUAL_MODULE_ID,
hooks: { hooks: {
async 'astro:config:setup'(params) { async 'astro:config:setup'() {
settings.injectedRoutes.push({ settings.injectedRoutes.push({
pattern: ACTION_RPC_ROUTE_PATTERN, pattern: ACTION_RPC_ROUTE_PATTERN,
entrypoint: 'astro/actions/runtime/route.js', entrypoint: 'astro/actions/runtime/route.js',
prerender: false, prerender: false,
origin: 'internal', origin: 'internal',
}); });
params.addMiddleware({
entrypoint: 'astro/actions/runtime/middleware.js',
order: 'post',
});
}, },
'astro:config:done': async (params) => { 'astro:config:done': async (params) => {
if (params.buildOutput === 'static') { if (params.buildOutput === 'static') {

View file

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

View file

@ -4,12 +4,16 @@ import { shouldAppendForwardSlash } from '../core/build/util.js';
import type { AstroSettings } from '../types/astro.js'; import type { AstroSettings } from '../types/astro.js';
import { import {
NOOP_ACTIONS, NOOP_ACTIONS,
RESOLVED_VIRTUAL_INTERNAL_MODULE_ID, RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID,
RESOLVED_VIRTUAL_MODULE_ID, RESOLVED_VIRTUAL_MODULE_ID,
VIRTUAL_INTERNAL_MODULE_ID, ASTRO_ACTIONS_INTERNAL_MODULE_ID,
VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID,
} from './consts.js'; } from './consts.js';
import { isActionsFilePresent } from './utils.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` * 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) { if (id === NOOP_ACTIONS) {
return NOOP_ACTIONS; return NOOP_ACTIONS;
} }
if (id === VIRTUAL_INTERNAL_MODULE_ID) { if (id === ASTRO_ACTIONS_INTERNAL_MODULE_ID) {
const resolvedModule = await this.resolve( const resolvedModule = await this.resolve(
`${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`, `${decodeURI(new URL('actions', settings.config.srcDir).pathname)}`,
); );
@ -33,20 +37,50 @@ export function vitePluginUserActions({ settings }: { settings: AstroSettings })
return NOOP_ACTIONS; return NOOP_ACTIONS;
} }
resolvedActionsId = resolvedModule.id; resolvedActionsId = resolvedModule.id;
return RESOLVED_VIRTUAL_INTERNAL_MODULE_ID; return RESOLVED_ASTRO_ACTIONS_INTERNAL_MODULE_ID;
} }
}, },
load(id) { load(id) {
if (id === NOOP_ACTIONS) { if (id === NOOP_ACTIONS) {
return 'export const server = {}'; 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}';`; 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({ export function vitePluginActions({
fs, fs,
settings, settings,

View file

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

View file

@ -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<ActionClient<unknown, ActionAccept, ZodType>> {
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;
}

View file

@ -17,7 +17,6 @@ import {
isActionAPIContext, isActionAPIContext,
} from '../utils.js'; } from '../utils.js';
import type { Locals } from '../utils.js'; import type { Locals } from '../utils.js';
import { getAction } from './get-action.js';
import { import {
ACTION_QUERY_PARAMS, ACTION_QUERY_PARAMS,
ActionError, ActionError,
@ -239,7 +238,7 @@ function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
return schema; return schema;
} }
export type ActionMiddlewareContext = { export type AstroActionContext = {
/** Information about an incoming action request. */ /** Information about an incoming action request. */
action?: { action?: {
/** Whether an action was called using an RPC function or by using an HTML form 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. * Access information about Action requests from middleware.
*/ */
export function getActionContext(context: APIContext): ActionMiddlewareContext { export function getActionContext(context: APIContext): AstroActionContext {
const callerInfo = getCallerInfo(context); const callerInfo = getCallerInfo(context);
// Prevents action results from being handled on a rewrite. // 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. // if the user's middleware has already handled the result.
const actionResultAlreadySet = Boolean((context.locals as Locals)._actionPayload); 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) { if (callerInfo && context.request.method === 'POST' && !actionResultAlreadySet) {
action = { action = {
@ -291,7 +290,7 @@ export function getActionContext(context: APIContext): ActionMiddlewareContext {
? removeTrailingForwardSlash(callerInfo.name) ? removeTrailingForwardSlash(callerInfo.name)
: callerInfo.name; : callerInfo.name;
const baseAction = await getAction(callerInfoName); const baseAction = await pipeline.getAction(callerInfoName);
let input; let input;
try { try {
input = await parseRequestBody(context.request); input = await parseRequestBody(context.request);

View file

@ -1,7 +1,7 @@
import { parse as devalueParse, stringify as devalueStringify } from 'devalue'; import { parse as devalueParse, stringify as devalueStringify } from 'devalue';
import type { z } from 'zod'; import type { z } from 'zod';
import { REDIRECT_STATUS_CODES } from '../../../core/constants.js'; 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 { AstroError } from '../../../core/errors/errors.js';
import { appendForwardSlash as _appendForwardSlash } from '../../../core/path.js'; import { appendForwardSlash as _appendForwardSlash } from '../../../core/path.js';
import { ACTION_QUERY_PARAMS as _ACTION_QUERY_PARAMS } from '../../consts.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);
}

View file

@ -9,6 +9,8 @@ import type {
SSRResult, SSRResult,
} from '../../types/public/internal.js'; } from '../../types/public/internal.js';
import type { SinglePageBuiltModule } from '../build/types.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; export type ComponentPath = string;
@ -75,6 +77,7 @@ export type SSRManifest = {
key: Promise<CryptoKey>; key: Promise<CryptoKey>;
i18n: SSRManifestI18n | undefined; i18n: SSRManifestI18n | undefined;
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance; middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
actions?: SSRActions;
checkOrigin: boolean; checkOrigin: boolean;
sessionConfig?: ResolvedSessionConfig<any>; sessionConfig?: ResolvedSessionConfig<any>;
cacheDir: string | URL; cacheDir: string | URL;
@ -85,6 +88,10 @@ export type SSRManifest = {
buildServerDir: string | URL; buildServerDir: string | URL;
}; };
export type SSRActions = {
server: Record<string, ActionClient<unknown, ActionAccept, ZodType>>;
};
export type SSRManifestI18n = { export type SSRManifestI18n = {
fallback: Record<string, string> | undefined; fallback: Record<string, string> | undefined;
fallbackType: 'redirect' | 'rewrite'; fallbackType: 'redirect' | 'rewrite';

View file

@ -14,6 +14,11 @@ import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js';
import { sequence } from './middleware/sequence.js'; import { sequence } from './middleware/sequence.js';
import { RouteCache } from './render/route-cache.js'; import { RouteCache } from './render/route-cache.js';
import { createDefaultRoutes } from './routing/default.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. * 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 { export abstract class Pipeline {
readonly internalMiddleware: MiddlewareHandler[]; readonly internalMiddleware: MiddlewareHandler[];
resolvedMiddleware: MiddlewareHandler | undefined = undefined; resolvedMiddleware: MiddlewareHandler | undefined = undefined;
resolvedActions: SSRActions | undefined = undefined;
constructor( constructor(
readonly logger: Logger, readonly logger: Logger,
@ -58,6 +64,8 @@ export abstract class Pipeline {
* Used to find the route module * Used to find the route module
*/ */
readonly defaultRoutes = createDefaultRoutes(manifest), readonly defaultRoutes = createDefaultRoutes(manifest),
readonly actions = manifest.actions,
) { ) {
this.internalMiddleware = []; this.internalMiddleware = [];
// We do use our middleware only if the user isn't using the manual setup // 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; return this.resolvedMiddleware;
} }
} }
setActions(actions: SSRActions) {
this.resolvedActions = actions;
}
async getActions(): Promise<SSRActions> {
if (this.resolvedActions) {
return this.resolvedActions;
} else if (this.actions) {
return this.actions;
}
return { server: {} };
}
async getAction(path: string): Promise<ActionClient<unknown, ActionAccept, ZodType>> {
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 // eslint-disable-next-line @typescript-eslint/no-empty-object-type

View file

@ -27,7 +27,7 @@ import type {
SSRError, SSRError,
SSRLoadedRenderer, SSRLoadedRenderer,
} from '../../types/public/internal.js'; } 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 { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js';
import { AstroError, AstroErrorData } from '../errors/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js';
import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.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 const middleware: MiddlewareHandler = internals.middlewareEntryPoint
? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest) ? await import(internals.middlewareEntryPoint.toString()).then((mod) => mod.onRequest)
: NOOP_MIDDLEWARE_FN; : NOOP_MIDDLEWARE_FN;
const actions: SSRActions = internals.astroActionsEntryPoint
? await import(internals.astroActionsEntryPoint.toString()).then((mod) => mod)
: { server: {} };
manifest = createBuildManifest( manifest = createBuildManifest(
options.settings, options.settings,
internals, internals,
renderers.renderers as SSRLoadedRenderer[], renderers.renderers as SSRLoadedRenderer[],
middleware, middleware,
actions,
options.key, options.key,
); );
} }
@ -451,8 +456,7 @@ function getUrlForPath(
removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending; removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending;
buildPathname = joinPaths(base, buildPathRelative); buildPathname = joinPaths(base, buildPathRelative);
} }
const url = new URL(buildPathname, origin); return new URL(buildPathname, origin);
return url;
} }
interface GeneratePathOptions { interface GeneratePathOptions {
@ -599,6 +603,7 @@ function createBuildManifest(
internals: BuildInternals, internals: BuildInternals,
renderers: SSRLoadedRenderer[], renderers: SSRLoadedRenderer[],
middleware: MiddlewareHandler, middleware: MiddlewareHandler,
actions: SSRActions,
key: Promise<CryptoKey>, key: Promise<CryptoKey>,
): SSRManifest { ): SSRManifest {
let i18nManifest: SSRManifestI18n | undefined = undefined; let i18nManifest: SSRManifestI18n | undefined = undefined;
@ -641,6 +646,7 @@ function createBuildManifest(
onRequest: middleware, onRequest: middleware,
}; };
}, },
actions,
checkOrigin: checkOrigin:
(settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false,
key, key,

View file

@ -87,7 +87,8 @@ export interface BuildInternals {
manifestFileName?: string; manifestFileName?: string;
entryPoints: Map<RouteData, URL>; entryPoints: Map<RouteData, URL>;
componentMetadata: SSRResult['componentMetadata']; 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 * 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(), componentMetadata: new Map(),
entryPoints: new Map(), entryPoints: new Map(),
prerenderOnlyChunks: [], prerenderOnlyChunks: [],
astroActionsEntryPoint: undefined,
middlewareEntryPoint: undefined,
}; };
} }

View file

@ -13,6 +13,7 @@ import { pluginPrerender } from './plugin-prerender.js';
import { pluginRenderers } from './plugin-renderers.js'; import { pluginRenderers } from './plugin-renderers.js';
import { pluginScripts } from './plugin-scripts.js'; import { pluginScripts } from './plugin-scripts.js';
import { pluginSSR } from './plugin-ssr.js'; import { pluginSSR } from './plugin-ssr.js';
import { pluginActions } from './plugin-actions.js';
export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) { export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) {
register(pluginComponentEntry(internals)); register(pluginComponentEntry(internals));
@ -21,6 +22,7 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP
register(pluginManifest(options, internals)); register(pluginManifest(options, internals));
register(pluginRenderers(options)); register(pluginRenderers(options));
register(pluginMiddleware(options, internals)); register(pluginMiddleware(options, internals));
register(pluginActions(options, internals));
register(pluginPages(options, internals)); register(pluginPages(options, internals));
register(pluginCSS(options, internals)); register(pluginCSS(options, internals));
register(astroHeadBuildPlugin(internals)); register(astroHeadBuildPlugin(internals));

View file

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

View file

@ -2,7 +2,6 @@ import { vitePluginMiddlewareBuild } from '../../middleware/vite-plugin.js';
import type { BuildInternals } from '../internal.js'; import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js'; import type { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types.js'; import type { StaticBuildOptions } from '../types.js';
export { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js';
export function pluginMiddleware( export function pluginMiddleware(
opts: StaticBuildOptions, opts: StaticBuildOptions,

View file

@ -7,10 +7,11 @@ import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js'; import type { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types.js'; import type { StaticBuildOptions } from '../types.js';
import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.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 { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js';
import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js';
import { getVirtualModulePageName } from './util.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 SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry';
export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID;
@ -167,6 +168,7 @@ function generateSSRCode(adapter: AstroAdapter, middlewareId: string) {
const imports = [ const imports = [
`import { renderers } from '${RENDERERS_MODULE_ID}';`, `import { renderers } from '${RENDERERS_MODULE_ID}';`,
`import * as actions from '${ASTRO_ACTIONS_INTERNAL_MODULE_ID}';`,
`import * as serverEntrypointModule from '${ADAPTER_VIRTUAL_MODULE_ID}';`, `import * as serverEntrypointModule from '${ADAPTER_VIRTUAL_MODULE_ID}';`,
`import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`, `import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`,
`import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`, `import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`,
@ -178,6 +180,7 @@ function generateSSRCode(adapter: AstroAdapter, middlewareId: string) {
` pageMap,`, ` pageMap,`,
` serverIslandMap,`, ` serverIslandMap,`,
` renderers,`, ` renderers,`,
` actions,`,
` middleware: ${edgeMiddleware ? 'undefined' : `() => import("${middlewareId}")`}`, ` middleware: ${edgeMiddleware ? 'undefined' : `() => import("${middlewareId}")`}`,
`});`, `});`,
`const _args = ${adapter.args ? JSON.stringify(adapter.args, null, 4) : 'undefined'};`, `const _args = ${adapter.args ? JSON.stringify(adapter.args, null, 4) : 'undefined'};`,

View file

@ -901,6 +901,19 @@ export const MiddlewareCantBeLoaded = {
message: 'An unknown error was thrown while loading your middleware.', message: 'An unknown error was thrown while loading your middleware.',
} satisfies ErrorData; } 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 * @docs
* @see * @see

View file

@ -32,6 +32,8 @@ import { type Pipeline, Slots, getParams, getProps } from './render/index.js';
import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js'; import { isRoute404or500, isRouteExternalRedirect, isRouteServerIsland } from './routing/match.js';
import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js'; import { copyRequest, getOriginPathname, setOriginPathname } from './routing/rewrite.js';
import { AstroSession } from './session.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'); export const apiContextRoutesSymbol = Symbol.for('context.routes');
@ -44,6 +46,7 @@ export class RenderContext {
readonly pipeline: Pipeline, readonly pipeline: Pipeline,
public locals: App.Locals, public locals: App.Locals,
readonly middleware: MiddlewareHandler, readonly middleware: MiddlewareHandler,
readonly actions: SSRActions,
// It must be a DECODED pathname // It must be a DECODED pathname
public pathname: string, public pathname: string,
public request: Request, public request: Request,
@ -80,16 +83,19 @@ export class RenderContext {
status = 200, status = 200,
props, props,
partial = undefined, partial = undefined,
actions
}: Pick<RenderContext, 'pathname' | 'pipeline' | 'request' | 'routeData' | 'clientAddress'> & }: Pick<RenderContext, 'pathname' | 'pipeline' | 'request' | 'routeData' | 'clientAddress'> &
Partial< Partial<
Pick<RenderContext, 'locals' | 'middleware' | 'status' | 'props' | 'partial'> Pick<RenderContext, 'locals' | 'middleware' | 'status' | 'props' | 'partial' | 'actions'>
>): Promise<RenderContext> { >): Promise<RenderContext> {
const pipelineMiddleware = await pipeline.getMiddleware(); const pipelineMiddleware = await pipeline.getMiddleware();
const pipelineActions = actions ?? await pipeline.getActions();
setOriginPathname(request, pathname); setOriginPathname(request, pathname);
return new RenderContext( return new RenderContext(
pipeline, pipeline,
locals, locals,
sequence(...pipeline.internalMiddleware, middleware ?? pipelineMiddleware), sequence(...pipeline.internalMiddleware, middleware ?? pipelineMiddleware),
pipelineActions,
pathname, pathname,
request, request,
routeData, routeData,
@ -132,7 +138,8 @@ export class RenderContext {
serverLike, serverLike,
base: manifest.base, base: manifest.base,
}); });
const apiContext = this.createAPIContext(props); const actionApiContext = this.createActionAPIContext();
const apiContext = this.createAPIContext(props, actionApiContext);
this.counter++; this.counter++;
if (this.counter === 4) { if (this.counter === 4) {
@ -192,6 +199,15 @@ export class RenderContext {
} }
let response: Response; 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) { switch (this.routeData.type) {
case 'endpoint': { case 'endpoint': {
response = await renderEndpoint( response = await renderEndpoint(
@ -205,7 +221,7 @@ export class RenderContext {
case 'redirect': case 'redirect':
return renderRedirect(this); return renderRedirect(this);
case 'page': { case 'page': {
const result = await this.createResult(componentInstance!); const result = await this.createResult(componentInstance!, actionApiContext);
try { try {
response = await renderPage( response = await renderPage(
result, result,
@ -263,8 +279,7 @@ export class RenderContext {
return response; return response;
} }
createAPIContext(props: APIContext['props']): APIContext { createAPIContext(props: APIContext['props'], context: ActionAPIContext): APIContext {
const context = this.createActionAPIContext();
const redirect = (path: string, status = 302) => const redirect = (path: string, status = 302) =>
new Response(null, { status, headers: { Location: path } }); new Response(null, { status, headers: { Location: path } });
Reflect.set(context, apiContextRoutesSymbol, this.pipeline); Reflect.set(context, apiContextRoutesSymbol, this.pipeline);
@ -365,7 +380,7 @@ export class RenderContext {
}; };
} }
async createResult(mod: ComponentInstance) { async createResult(mod: ComponentInstance, ctx: ActionAPIContext): Promise<SSRResult> {
const { cookies, pathname, pipeline, routeData, status } = this; const { cookies, pathname, pipeline, routeData, status } = this;
const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } = const { clientDirectives, inlinedScripts, compressHTML, manifest, renderers, resolve } =
pipeline; pipeline;
@ -403,7 +418,7 @@ export class RenderContext {
cookies, cookies,
/** This function returns the `Astro` faux-global */ /** This function returns the `Astro` faux-global */
createAstro: (astroGlobal, props, slots) => createAstro: (astroGlobal, props, slots) =>
this.createAstro(result, astroGlobal, props, slots), this.createAstro(result, astroGlobal, props, slots, ctx),
links, links,
params: this.params, params: this.params,
partial, partial,
@ -448,6 +463,7 @@ export class RenderContext {
astroStaticPartial: AstroGlobalPartial, astroStaticPartial: AstroGlobalPartial,
props: Record<string, any>, props: Record<string, any>,
slotValues: Record<string, any> | null, slotValues: Record<string, any> | null,
apiContext: ActionAPIContext,
): AstroGlobal { ): AstroGlobal {
let astroPagePartial; let astroPagePartial;
// During rewriting, we must recompute the Astro global, because we need to purge the previous params/props/etc. // 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( astroPagePartial = this.#astroPagePartial = this.createAstroPagePartial(
result, result,
astroStaticPartial, astroStaticPartial,
apiContext,
); );
} else { } else {
// Create page partial with static partial so they can be cached together. // Create page partial with static partial so they can be cached together.
astroPagePartial = this.#astroPagePartial ??= this.createAstroPagePartial( astroPagePartial = this.#astroPagePartial ??= this.createAstroPagePartial(
result, result,
astroStaticPartial, astroStaticPartial,
apiContext,
); );
} }
// Create component-level partials. `Astro.self` is added by the compiler. // Create component-level partials. `Astro.self` is added by the compiler.
@ -493,6 +511,7 @@ export class RenderContext {
createAstroPagePartial( createAstroPagePartial(
result: SSRResult, result: SSRResult,
astroStaticPartial: AstroGlobalPartial, astroStaticPartial: AstroGlobalPartial,
apiContext: ActionAPIContext,
): Omit<AstroGlobal, 'props' | 'self' | 'slots'> { ): Omit<AstroGlobal, 'props' | 'self' | 'slots'> {
const renderContext = this; const renderContext = this;
const { cookies, locals, params, pipeline, url, session } = this; const { cookies, locals, params, pipeline, url, session } = this;
@ -511,6 +530,8 @@ export class RenderContext {
return await this.#executeRewrite(reroutePayload); return await this.#executeRewrite(reroutePayload);
}; };
const callAction = createCallAction(apiContext);
return { return {
generator: astroStaticPartial.generator, generator: astroStaticPartial.generator,
glob: astroStaticPartial.glob, glob: astroStaticPartial.glob,
@ -539,7 +560,7 @@ export class RenderContext {
site: pipeline.site, site: pipeline.site,
getActionResult: createGetActionResult(locals), getActionResult: createGetActionResult(locals),
get callAction() { get callAction() {
return createCallAction(this); return callAction;
}, },
url, url,
get originPathname() { get originPathname() {

View file

@ -212,16 +212,4 @@ export class DevPipeline extends Pipeline {
setManifestData(manifestData: RoutesList) { setManifestData(manifestData: RoutesList) {
this.routesList = manifestData; 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');
}
} }

View file

@ -43,7 +43,7 @@ export default function createVitePluginAstroServer({
}: AstroPluginOptions): vite.Plugin { }: AstroPluginOptions): vite.Plugin {
return { return {
name: 'astro:server', name: 'astro:server',
configureServer(viteServer) { async configureServer(viteServer) {
const loader = createViteLoader(viteServer); const loader = createViteLoader(viteServer);
const pipeline = DevPipeline.create(routesList, { const pipeline = DevPipeline.create(routesList, {
loader, loader,

View file

@ -22,6 +22,7 @@ import type { ComponentInstance, RoutesList } from '../types/astro.js';
import type { RouteData } from '../types/public/internal.js'; import type { RouteData } from '../types/public/internal.js';
import type { DevPipeline } from './pipeline.js'; import type { DevPipeline } from './pipeline.js';
import { writeSSRResult, writeWebResponse } from './response.js'; import { writeSSRResult, writeWebResponse } from './response.js';
import {loadActions} from "../actions/loadActions.js";
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends ( type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
...args: any ...args: any
@ -159,6 +160,8 @@ export async function handleRoute({
let renderContext: RenderContext; let renderContext: RenderContext;
let mod: ComponentInstance | undefined = undefined; let mod: ComponentInstance | undefined = undefined;
let route: RouteData; let route: RouteData;
const actions = await loadActions(loader);
pipeline.setActions(actions);
const middleware = (await loadMiddleware(loader)).onRequest; 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. // 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); const locals = Reflect.get(incomingRequest, clientLocalsSymbol);
@ -192,6 +195,7 @@ export async function handleRoute({
request, request,
routeData: route, routeData: route,
clientAddress: incomingRequest.socket.remoteAddress, clientAddress: incomingRequest.socket.remoteAddress,
actions
}); });
let response; let response;

View file

@ -4,8 +4,10 @@ import {
appendForwardSlash, appendForwardSlash,
deserializeActionResult, deserializeActionResult,
getActionQueryString, getActionQueryString,
astroCalledServerError,
} from 'astro:actions'; } from 'astro:actions';
const apiContextRoutesSymbol = Symbol.for('context.routes');
const ENCODED_DOT = '%2E'; const ENCODED_DOT = '%2E';
function toActionProxy(actionCallback = {}, aggregatedPath = '') { function toActionProxy(actionCallback = {}, aggregatedPath = '') {
@ -73,11 +75,13 @@ export function getActionPath(action) {
*/ */
async function handleAction(param, path, context) { async function handleAction(param, path, context) {
// When running server-side, import the action and call it. // When running server-side, import the action and call it.
if (import.meta.env.SSR) { if (import.meta.env.SSR && context) {
const { getAction } = await import('astro/actions/runtime/virtual/get-action.js'); const pipeline = Reflect.get(context, apiContextRoutesSymbol);
const action = await getAction(path); if (!pipeline) {
throw astroCalledServerError();
}
const action = await pipeline.getAction(path);
if (!action) throw new Error(`Action not found: ${path}`); if (!action) throw new Error(`Action not found: ${path}`);
return action.bind(context)(param); return action.bind(context)(param);
} }

View file

@ -60,8 +60,8 @@ describe('Astro Actions', () => {
assert.equal(res.ok, true); assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue'); assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');
const data = devalue.parse(await res.text()); const data = devalue.parse(await res.text());
assert.equal(data.channel, 'bholmesdev'); assert.equal(data.channel, 'bholmesdev');
assert.equal(data.subscribeButtonState, 'smashed'); assert.equal(data.subscribeButtonState, 'smashed');
}); });
@ -578,7 +578,6 @@ it('Should support trailing slash', async () => {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
assert.equal(res.ok, true); assert.equal(res.ok, true);
assert.equal(res.headers.get('Content-Type'), 'application/json+devalue'); assert.equal(res.headers.get('Content-Type'), 'application/json+devalue');

View file

@ -1,6 +1,5 @@
--- ---
import { actions } from "astro:actions"; import { actions } from "astro:actions";
// this is invalid, it should fail // this is invalid, it should fail
const result = await actions.subscribe({ channel: "hey" }); const result = await actions.subscribe({ channel: "hey" });
--- ---