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

View file

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

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

View file

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

View file

@ -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') {

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 {
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,

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

View file

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

View file

@ -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<CryptoKey>;
i18n: SSRManifestI18n | undefined;
middleware?: () => Promise<AstroMiddlewareInstance> | AstroMiddlewareInstance;
actions?: SSRActions;
checkOrigin: boolean;
sessionConfig?: ResolvedSessionConfig<any>;
cacheDir: string | URL;
@ -85,6 +88,10 @@ export type SSRManifest = {
buildServerDir: string | URL;
};
export type SSRActions = {
server: Record<string, ActionClient<unknown, ActionAccept, ZodType>>;
};
export type SSRManifestI18n = {
fallback: Record<string, string> | undefined;
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 { 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<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

View file

@ -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<CryptoKey>,
): 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,

View file

@ -87,7 +87,8 @@ export interface BuildInternals {
manifestFileName?: string;
entryPoints: Map<RouteData, URL>;
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,
};
}

View file

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

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 { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types.js';
export { MIDDLEWARE_MODULE_ID } from '../../middleware/vite-plugin.js';
export function pluginMiddleware(
opts: StaticBuildOptions,

View file

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

View file

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

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 { 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<RenderContext, 'pathname' | 'pipeline' | 'request' | 'routeData' | 'clientAddress'> &
Partial<
Pick<RenderContext, 'locals' | 'middleware' | 'status' | 'props' | 'partial'>
Pick<RenderContext, 'locals' | 'middleware' | 'status' | 'props' | 'partial' | 'actions'>
>): Promise<RenderContext> {
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<SSRResult> {
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<string, any>,
slotValues: Record<string, any> | 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<AstroGlobal, 'props' | 'self' | 'slots'> {
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() {

View file

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

View file

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

View file

@ -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<T extends (...args: any) => Promise<any>> = 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;

View file

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

View file

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

View file

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