diff options
Diffstat (limited to 'packages')
54 files changed, 1183 insertions, 129 deletions
diff --git a/packages/astro/client-base.d.ts b/packages/astro/client-base.d.ts index 15c1fb905..6e37b60c7 100644 --- a/packages/astro/client-base.d.ts +++ b/packages/astro/client-base.d.ts @@ -387,3 +387,9 @@ declare module '*?inline' { const src: string; export default src; } + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace App { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface Locals {} +} diff --git a/packages/astro/package.json b/packages/astro/package.json index 1dabc7d78..08ee274a6 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -64,6 +64,10 @@ "./zod": { "types": "./zod.d.ts", "default": "./zod.mjs" + }, + "./middleware": { + "types": "./dist/core/middleware/index.d.ts", + "default": "./dist/core/middleware/index.js" } }, "imports": { diff --git a/packages/astro/src/@types/app.d.ts b/packages/astro/src/@types/app.d.ts new file mode 100644 index 000000000..1c0908bb8 --- /dev/null +++ b/packages/astro/src/@types/app.d.ts @@ -0,0 +1,9 @@ +/** + * Shared interfaces throughout the application that can be overridden by the user. + */ +declare namespace App { + /** + * Used by middlewares to store information, that can be read by the user via the global `Astro.locals` + */ + interface Locals {} +} diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index d68f6c75f..b8d7338f6 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -103,6 +103,7 @@ export interface CLIFlags { drafts?: boolean; open?: boolean; experimentalAssets?: boolean; + experimentalMiddleware?: boolean; } export interface BuildConfig { @@ -1034,6 +1035,26 @@ export interface AstroUserConfig { * } */ assets?: boolean; + + /** + * @docs + * @name experimental.middleware + * @type {boolean} + * @default `false` + * @version 2.4.0 + * @description + * Enable experimental support for Astro middleware. + * + * To enable this feature, set `experimental.middleware` to `true` in your Astro config: + * + * ```js + * { + * experimental: { + * middleware: true, + * }, + * } + */ + middleware?: boolean; }; // Legacy options to be removed @@ -1431,6 +1452,11 @@ interface AstroSharedContext<Props extends Record<string, any> = Record<string, * Redirect to another page (**SSR Only**). */ redirect(path: string, status?: 301 | 302 | 303 | 307 | 308): Response; + + /** + * Object accessed via Astro middleware + */ + locals: App.Locals; } export interface APIContext<Props extends Record<string, any> = Record<string, any>> @@ -1464,7 +1490,7 @@ export interface APIContext<Props extends Record<string, any> = Record<string, a * } * ``` * - * [context reference](https://docs.astro.build/en/guides/api-reference/#contextparams) + * [context reference](https://docs.astro.build/en/reference/api-reference/#contextparams) */ params: AstroSharedContext['params']; /** @@ -1504,6 +1530,31 @@ export interface APIContext<Props extends Record<string, any> = Record<string, a * [context reference](https://docs.astro.build/en/guides/api-reference/#contextredirect) */ redirect: AstroSharedContext['redirect']; + + /** + * Object accessed via Astro middleware. + * + * Example usage: + * + * ```ts + * // src/middleware.ts + * import {defineMiddleware} from "astro/middleware"; + * + * export const onRequest = defineMiddleware((context, next) => { + * context.locals.greeting = "Hello!"; + * next(); + * }); + * ``` + * Inside a `.astro` file: + * ```astro + * --- + * // src/pages/index.astro + * const greeting = Astro.locals.greeting; + * --- + * <h1>{greeting}</h1> + * ``` + */ + locals: App.Locals; } export type Props = Record<string, unknown>; @@ -1592,6 +1643,22 @@ export interface AstroIntegration { }; } +export type MiddlewareNext<R> = () => Promise<R>; +export type MiddlewareHandler<R> = ( + context: APIContext, + next: MiddlewareNext<R> +) => Promise<R> | Promise<void> | void; + +export type MiddlewareResponseHandler = MiddlewareHandler<Response>; +export type MiddlewareEndpointHandler = MiddlewareHandler<Response | EndpointOutput>; +export type MiddlewareNextResponse = MiddlewareNext<Response>; + +// NOTE: when updating this file with other functions, +// remember to update `plugin-page.ts` too, to add that function as a no-op function. +export type AstroMiddlewareInstance<R> = { + onRequest?: MiddlewareHandler<R>; +}; + export interface AstroPluginOptions { settings: AstroSettings; logging: LogOptions; diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index a1d19ee92..b499a875b 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -2,6 +2,7 @@ import type { ComponentInstance, EndpointHandler, ManifestData, + MiddlewareResponseHandler, RouteData, SSRElement, } from '../../@types/astro'; @@ -9,9 +10,10 @@ import type { RouteInfo, SSRManifest as Manifest } from './types'; import mime from 'mime'; import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js'; -import { call as callEndpoint } from '../endpoint/index.js'; +import { call as callEndpoint, createAPIContext } from '../endpoint/index.js'; import { consoleLogDestination } from '../logger/console.js'; import { error, type LogOptions } from '../logger/core.js'; +import { callMiddleware } from '../middleware/callMiddleware.js'; import { removeTrailingForwardSlash } from '../path.js'; import { createEnvironment, @@ -28,6 +30,8 @@ import { import { matchRoute } from '../routing/match.js'; export { deserializeManifest } from './common.js'; +const clientLocalsSymbol = Symbol.for('astro.locals'); + export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry'; export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId; const responseSentSymbol = Symbol.for('astro.responseSent'); @@ -127,6 +131,8 @@ export class App { } } + Reflect.set(request, clientLocalsSymbol, {}); + // Use the 404 status code for 404.astro components if (routeData.route === '/404') { defaultStatus = 404; @@ -191,7 +197,7 @@ export class App { } try { - const ctx = createRenderContext({ + const renderContext = await createRenderContext({ request, origin: url.origin, pathname, @@ -200,9 +206,35 @@ export class App { links, route: routeData, status, + mod: mod as any, + env: this.#env, }); - const response = await renderPage(mod, ctx, this.#env); + const apiContext = createAPIContext({ + request: renderContext.request, + params: renderContext.params, + props: renderContext.props, + site: this.#env.site, + adapterName: this.#env.adapterName, + }); + const onRequest = this.#manifest.middleware?.onRequest; + let response; + if (onRequest) { + response = await callMiddleware<Response>( + onRequest as MiddlewareResponseHandler, + apiContext, + () => { + return renderPage({ mod, renderContext, env: this.#env, apiContext }); + } + ); + } else { + response = await renderPage({ + mod, + renderContext, + env: this.#env, + apiContext, + }); + } Reflect.set(request, responseSentSymbol, true); return response; } catch (err: any) { @@ -224,15 +256,23 @@ export class App { const pathname = '/' + this.removeBase(url.pathname); const handler = mod as unknown as EndpointHandler; - const ctx = createRenderContext({ + const ctx = await createRenderContext({ request, origin: url.origin, pathname, route: routeData, status, + env: this.#env, + mod: handler as any, }); - const result = await callEndpoint(handler, this.#env, ctx, this.#logging); + const result = await callEndpoint( + handler, + this.#env, + ctx, + this.#logging, + this.#manifest.middleware + ); if (result.type === 'response') { if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index ab91c13ca..79503161d 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -1,5 +1,6 @@ import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark'; import type { + AstroMiddlewareInstance, ComponentInstance, RouteData, SerializedRouteData, @@ -38,6 +39,7 @@ export interface SSRManifest { entryModules: Record<string, string>; assets: Set<string>; componentMetadata: SSRResult['componentMetadata']; + middleware?: AstroMiddlewareInstance<unknown>; } export type SerializedSSRManifest = Omit<SSRManifest, 'routes' | 'assets' | 'componentMetadata'> & { diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 6e50d687f..d89575bd4 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -5,10 +5,13 @@ import type { OutputAsset, OutputChunk } from 'rollup'; import { fileURLToPath } from 'url'; import type { AstroConfig, + AstroMiddlewareInstance, AstroSettings, ComponentInstance, EndpointHandler, + EndpointOutput, ImageTransform, + MiddlewareResponseHandler, RouteType, SSRError, SSRLoadedRenderer, @@ -25,9 +28,14 @@ import { } from '../../core/path.js'; import { runHookBuildGenerated } from '../../integrations/index.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; -import { call as callEndpoint, throwIfRedirectNotAllowed } from '../endpoint/index.js'; +import { + call as callEndpoint, + createAPIContext, + throwIfRedirectNotAllowed, +} from '../endpoint/index.js'; import { AstroError } from '../errors/index.js'; import { debug, info } from '../logger/core.js'; +import { callMiddleware } from '../middleware/callMiddleware.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { @@ -157,6 +165,7 @@ async function generatePage( const scripts = pageInfo?.hoistedScript ?? null; const pageModule = ssrEntry.pageMap?.get(pageData.component); + const middleware = ssrEntry.middleware; if (!pageModule) { throw new Error( @@ -186,7 +195,7 @@ async function generatePage( for (let i = 0; i < paths.length; i++) { const path = paths[i]; - await generatePath(path, opts, generationOptions); + await generatePath(path, opts, generationOptions, middleware); const timeEnd = performance.now(); const timeChange = getTimeStat(timeStart, timeEnd); const timeIncrease = `(+${timeChange})`; @@ -328,7 +337,8 @@ function getUrlForPath( async function generatePath( pathname: string, opts: StaticBuildOptions, - gopts: GeneratePathOptions + gopts: GeneratePathOptions, + middleware?: AstroMiddlewareInstance<unknown> ) { const { settings, logging, origin, routeCache } = opts; const { mod, internals, linkIds, scripts: hoistedScripts, pageData, renderers } = gopts; @@ -414,7 +424,8 @@ async function generatePath( ssr, streaming: true, }); - const ctx = createRenderContext({ + + const renderContext = await createRenderContext({ origin, pathname, request: createRequest({ url, headers: new Headers(), logging, ssr }), @@ -422,13 +433,22 @@ async function generatePath( scripts, links, route: pageData.route, + env, + mod, }); let body: string | Uint8Array; let encoding: BufferEncoding | undefined; if (pageData.route.type === 'endpoint') { const endpointHandler = mod as unknown as EndpointHandler; - const result = await callEndpoint(endpointHandler, env, ctx, logging); + + const result = await callEndpoint( + endpointHandler, + env, + renderContext, + logging, + middleware as AstroMiddlewareInstance<Response | EndpointOutput> + ); if (result.type === 'response') { throwIfRedirectNotAllowed(result.response, opts.settings.config); @@ -443,7 +463,26 @@ async function generatePath( } else { let response: Response; try { - response = await renderPage(mod, ctx, env); + const apiContext = createAPIContext({ + request: renderContext.request, + params: renderContext.params, + props: renderContext.props, + site: env.site, + adapterName: env.adapterName, + }); + + const onRequest = middleware?.onRequest; + if (onRequest) { + response = await callMiddleware<Response>( + onRequest as MiddlewareResponseHandler, + apiContext, + () => { + return renderPage({ mod, renderContext, env, apiContext }); + } + ); + } else { + response = await renderPage({ mod, renderContext, env, apiContext }); + } } catch (err) { if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { (err as SSRError).id = pageData.component; diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index 5bb070978..132d03cf8 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -1,10 +1,10 @@ import type { Plugin as VitePlugin } from 'vite'; -import type { AstroBuildPlugin } from '../plugin'; -import type { StaticBuildOptions } from '../types'; - import { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../../app/index.js'; +import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../../constants.js'; import { addRollupInput } from '../add-rollup-input.js'; import { eachPageData, hasPrerenderedPages, type BuildInternals } from '../internal.js'; +import type { AstroBuildPlugin } from '../plugin'; +import type { StaticBuildOptions } from '../types'; export function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { return { @@ -22,8 +22,15 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern } }, - load(id) { + async load(id) { if (id === resolvedPagesVirtualModuleId) { + let middlewareId = null; + if (opts.settings.config.experimental.middleware) { + middlewareId = await this.resolve( + `${opts.settings.config.srcDir.pathname}/${MIDDLEWARE_PATH_SEGMENT_NAME}` + ); + } + let importMap = ''; let imports = []; let i = 0; @@ -47,8 +54,12 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern const def = `${imports.join('\n')} +${middlewareId ? `import * as _middleware from "${middlewareId.id}";` : ''} + export const pageMap = new Map([${importMap}]); -export const renderers = [${rendererItems}];`; +export const renderers = [${rendererItems}]; +${middlewareId ? `export const middleware = _middleware;` : ''} +`; return def; } diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index e5bca2ad0..65e104425 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -1,5 +1,5 @@ import type { Plugin as VitePlugin } from 'vite'; -import type { AstroAdapter } from '../../../@types/astro'; +import type { AstroAdapter, AstroConfig } from '../../../@types/astro'; import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; import type { BuildInternals } from '../internal.js'; import type { StaticBuildOptions } from '../types'; @@ -21,7 +21,11 @@ const resolvedVirtualModuleId = '\0' + virtualModuleId; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); -export function vitePluginSSR(internals: BuildInternals, adapter: AstroAdapter): VitePlugin { +export function vitePluginSSR( + internals: BuildInternals, + adapter: AstroAdapter, + config: AstroConfig +): VitePlugin { return { name: '@astrojs/vite-plugin-astro-ssr', enforce: 'post', @@ -35,13 +39,18 @@ export function vitePluginSSR(internals: BuildInternals, adapter: AstroAdapter): }, load(id) { if (id === resolvedVirtualModuleId) { + let middleware = ''; + if (config.experimental?.middleware === true) { + middleware = 'middleware: _main.middleware'; + } return `import * as adapter from '${adapter.serverEntrypoint}'; import * as _main from '${pagesVirtualModuleId}'; import { deserializeManifest as _deserializeManifest } from 'astro/app'; import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'; const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { pageMap: _main.pageMap, - renderers: _main.renderers + renderers: _main.renderers, + ${middleware} }); _privateSetManifestDontUseThis(_manifest); const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; @@ -235,7 +244,9 @@ export function pluginSSR( build: 'ssr', hooks: { 'build:before': () => { - let vitePlugin = ssr ? vitePluginSSR(internals, options.settings.adapter!) : undefined; + let vitePlugin = ssr + ? vitePluginSSR(internals, options.settings.adapter!, options.settings.config) + : undefined; return { enforce: 'after-user-plugins', diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts index 02f5618d8..fc7839390 100644 --- a/packages/astro/src/core/build/types.ts +++ b/packages/astro/src/core/build/types.ts @@ -1,6 +1,7 @@ import type { default as vite, InlineConfig } from 'vite'; import type { AstroConfig, + AstroMiddlewareInstance, AstroSettings, BuildConfig, ComponentInstance, @@ -44,6 +45,7 @@ export interface StaticBuildOptions { export interface SingleFileBuiltModule { pageMap: Map<ComponentPath, ComponentInstance>; + middleware: AstroMiddlewareInstance<unknown>; renderers: SSRLoadedRenderer[]; } diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 9c09a934f..9915ed162 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -103,6 +103,8 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags { drafts: typeof flags.drafts === 'boolean' ? flags.drafts : undefined, experimentalAssets: typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined, + experimentalMiddleware: + typeof flags.experimentalMiddleware === 'boolean' ? flags.experimentalMiddleware : undefined, }; } @@ -136,6 +138,9 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags) { // TODO: Come back here and refactor to remove this expected error. astroConfig.server.open = flags.open; } + if (typeof flags.experimentalMiddleware === 'boolean') { + astroConfig.experimental.middleware = true; + } return astroConfig; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 424972cba..6d081d126 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -37,6 +37,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { legacy: {}, experimental: { assets: false, + middleware: false, }, }; @@ -187,6 +188,7 @@ export const AstroConfigSchema = z.object({ experimental: z .object({ assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets), + middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware), }) .optional() .default({}), diff --git a/packages/astro/src/core/constants.ts b/packages/astro/src/core/constants.ts index 16dde7550..471614ce3 100644 --- a/packages/astro/src/core/constants.ts +++ b/packages/astro/src/core/constants.ts @@ -10,3 +10,6 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [ '.mdwn', '.md', ] as const; + +// The folder name where to find the middleware +export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware'; diff --git a/packages/astro/src/core/endpoint/dev/index.ts b/packages/astro/src/core/endpoint/dev/index.ts index 515b7aa41..24d2aa3bd 100644 --- a/packages/astro/src/core/endpoint/dev/index.ts +++ b/packages/astro/src/core/endpoint/dev/index.ts @@ -8,15 +8,18 @@ export async function call(options: SSROptions, logging: LogOptions) { const { env, preload: [, mod], + middleware, } = options; const endpointHandler = mod as unknown as EndpointHandler; - const ctx = createRenderContext({ + const ctx = await createRenderContext({ request: options.request, origin: options.origin, pathname: options.pathname, route: options.route, + env, + mod: endpointHandler as any, }); - return await callEndpoint(endpointHandler, env, ctx, logging); + return await callEndpoint(endpointHandler, env, ctx, logging, middleware); } diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 876c21aa5..e49ce8a43 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -1,4 +1,12 @@ -import type { APIContext, AstroConfig, EndpointHandler, Params } from '../../@types/astro'; +import type { + APIContext, + AstroConfig, + AstroMiddlewareInstance, + EndpointHandler, + EndpointOutput, + MiddlewareEndpointHandler, + Params, +} from '../../@types/astro'; import type { Environment, RenderContext } from '../render/index'; import { renderEndpoint } from '../../runtime/server/index.js'; @@ -6,9 +14,11 @@ import { ASTRO_VERSION } from '../constants.js'; import { AstroCookies, attachToResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { warn, type LogOptions } from '../logger/core.js'; -import { getParamsAndProps, GetParamsAndPropsError } from '../render/core.js'; +import { callMiddleware } from '../middleware/callMiddleware.js'; +import { isValueSerializable } from '../render/core.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); +const clientLocalsSymbol = Symbol.for('astro.locals'); type EndpointCallResult = | { @@ -22,7 +32,7 @@ type EndpointCallResult = response: Response; }; -function createAPIContext({ +export function createAPIContext({ request, params, site, @@ -35,7 +45,7 @@ function createAPIContext({ props: Record<string, any>; adapterName?: string; }): APIContext { - return { + const context = { cookies: new AstroCookies(request), request, params, @@ -51,7 +61,6 @@ function createAPIContext({ }); }, url: new URL(request.url), - // @ts-expect-error get clientAddress() { if (!(clientAddressSymbol in request)) { if (adapterName) { @@ -66,44 +75,60 @@ function createAPIContext({ return Reflect.get(request, clientAddressSymbol); }, - }; + } as APIContext; + + // We define a custom property, so we can check the value passed to locals + Object.defineProperty(context, 'locals', { + get() { + return Reflect.get(request, clientLocalsSymbol); + }, + set(val) { + if (typeof val !== 'object') { + throw new AstroError(AstroErrorData.LocalsNotAnObject); + } else { + Reflect.set(request, clientLocalsSymbol, val); + } + }, + }); + return context; } -export async function call( +export async function call<MiddlewareResult = Response | EndpointOutput>( mod: EndpointHandler, env: Environment, ctx: RenderContext, - logging: LogOptions + logging: LogOptions, + middleware?: AstroMiddlewareInstance<MiddlewareResult> | undefined ): Promise<EndpointCallResult> { - const paramsAndPropsResp = await getParamsAndProps({ - mod: mod as any, - route: ctx.route, - routeCache: env.routeCache, - pathname: ctx.pathname, - logging: env.logging, - ssr: env.ssr, - }); - - if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) { - throw new AstroError({ - ...AstroErrorData.NoMatchingStaticPathFound, - message: AstroErrorData.NoMatchingStaticPathFound.message(ctx.pathname), - hint: ctx.route?.component - ? AstroErrorData.NoMatchingStaticPathFound.hint([ctx.route?.component]) - : '', - }); - } - const [params, props] = paramsAndPropsResp; - const context = createAPIContext({ request: ctx.request, - params, - props, + params: ctx.params, + props: ctx.props, site: env.site, adapterName: env.adapterName, }); - const response = await renderEndpoint(mod, context, env.ssr); + let response = await renderEndpoint(mod, context, env.ssr); + if (middleware && middleware.onRequest) { + if (response.body === null) { + const onRequest = middleware.onRequest as MiddlewareEndpointHandler; + response = await callMiddleware<Response | EndpointOutput>(onRequest, context, async () => { + if (env.mode === 'development' && !isValueSerializable(context.locals)) { + throw new AstroError({ + ...AstroErrorData.LocalsNotSerializable, + message: AstroErrorData.LocalsNotSerializable.message(ctx.pathname), + }); + } + return response; + }); + } else { + warn( + env.logging, + 'middleware', + "Middleware doesn't work for endpoints that return a simple body. The middleware will be disabled for this page." + ); + } + } if (response instanceof Response) { attachToResponse(response, context.cookies); diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index bb9a86506..27425aee5 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -628,6 +628,95 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati code: 3030, message: 'The response has already been sent to the browser and cannot be altered.', }, + + /** + * @docs + * @description + * Thrown when the middleware does not return any data or call the `next` function. + * + * For example: + * ```ts + * import {defineMiddleware} from "astro/middleware"; + * export const onRequest = defineMiddleware((context, _) => { + * // doesn't return anything or call `next` + * context.locals.someData = false; + * }); + * ``` + */ + MiddlewareNoDataOrNextCalled: { + title: "The middleware didn't return a response or call `next`", + code: 3031, + message: + 'The middleware needs to either return a `Response` object or call the `next` function.', + }, + + /** + * @docs + * @description + * Thrown in development mode when middleware returns something that is not a `Response` object. + * + * For example: + * ```ts + * import {defineMiddleware} from "astro/middleware"; + * export const onRequest = defineMiddleware(() => { + * return "string" + * }); + * ``` + */ + MiddlewareNotAResponse: { + title: 'The middleware returned something that is not a `Response` object', + code: 3032, + message: 'Any data returned from middleware must be a valid `Response` object.', + }, + + /** + * @docs + * @description + * + * Thrown in development mode when `locals` is overwritten with something that is not an object + * + * For example: + * ```ts + * import {defineMiddleware} from "astro/middleware"; + * export const onRequest = defineMiddleware((context, next) => { + * context.locals = 1541; + * return next(); + * }); + * ``` + */ + LocalsNotAnObject: { + title: 'Value assigned to `locals` is not accepted', + code: 3033, + message: + '`locals` can only be assigned to an object. Other values like numbers, strings, etc. are not accepted.', + hint: 'If you tried to remove some information from the `locals` object, try to use `delete` or set the property to `undefined`.', + }, + + /** + * @docs + * @description + * Thrown in development mode when a user attempts to store something that is not serializable in `locals`. + * + * For example: + * ```ts + * import {defineMiddleware} from "astro/middleware"; + * export const onRequest = defineMiddleware((context, next) => { + * context.locals = { + * foo() { + * alert("Hello world!") + * } + * }; + * return next(); + * }); + * ``` + */ + LocalsNotSerializable: { + title: '`Astro.locals` is not serializable', + code: 3034, + message: (href: string) => { + return `The information stored in \`Astro.locals\` for the path "${href}" is not serializable.\nMake sure you store only serializable data.`; + }, + }, // No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users. // Vite Errors - 4xxx /** diff --git a/packages/astro/src/core/middleware/callMiddleware.ts b/packages/astro/src/core/middleware/callMiddleware.ts new file mode 100644 index 000000000..5836786e6 --- /dev/null +++ b/packages/astro/src/core/middleware/callMiddleware.ts @@ -0,0 +1,99 @@ +import type { APIContext, MiddlewareHandler, MiddlewareNext } from '../../@types/astro'; +import { AstroError, AstroErrorData } from '../errors/index.js'; + +/** + * Utility function that is in charge of calling the middleware. + * + * It accepts a `R` generic, which usually is the `Response` returned. + * It is a generic because endpoints can return a different payload. + * + * When calling a middleware, we provide a `next` function, this function might or + * might not be called. + * + * A middleware, to behave correctly, can: + * - return a `Response`; + * - call `next`; + * + * Failing doing so will result an error. A middleware can call `next` and do not return a + * response. A middleware can not call `next` and return a new `Response` from scratch (maybe with a redirect). + * + * ```js + * const onRequest = async (context, next) => { + * const response = await next(context); + * return response; + * } + * ``` + * + * ```js + * const onRequest = async (context, next) => { + * context.locals = "foo"; + * next(); + * } + * ``` + * + * @param onRequest The function called which accepts a `context` and a `resolve` function + * @param apiContext The API context + * @param responseFunction A callback function that should return a promise with the response + */ +export async function callMiddleware<R>( + onRequest: MiddlewareHandler<R>, + apiContext: APIContext, + responseFunction: () => Promise<R> +): Promise<Response | R> { + let resolveResolve: any; + new Promise((resolve) => { + resolveResolve = resolve; + }); + + let nextCalled = false; + const next: MiddlewareNext<R> = async () => { + nextCalled = true; + return await responseFunction(); + }; + + let middlewarePromise = onRequest(apiContext, next); + + return await Promise.resolve(middlewarePromise).then(async (value) => { + // first we check if `next` was called + if (nextCalled) { + /** + * Then we check if a value is returned. If so, we need to return the value returned by the + * middleware. + * e.g. + * ```js + * const response = await next(); + * const new Response(null, { status: 500, headers: response.headers }); + * ``` + */ + if (typeof value !== 'undefined') { + if (value instanceof Response === false) { + throw new AstroError(AstroErrorData.MiddlewareNotAResponse); + } + return value as R; + } else { + /** + * Here we handle the case where `next` was called and returned nothing. + */ + const responseResult = await responseFunction(); + return responseResult; + } + } else if (typeof value === 'undefined') { + /** + * There might be cases where `next` isn't called and the middleware **must** return + * something. + * + * If not thing is returned, then we raise an Astro error. + */ + throw new AstroError(AstroErrorData.MiddlewareNoDataOrNextCalled); + } else if (value instanceof Response === false) { + throw new AstroError(AstroErrorData.MiddlewareNotAResponse); + } else { + // Middleware did not call resolve and returned a value + return value as R; + } + }); +} + +function isEndpointResult(response: any): boolean { + return response && typeof response.body !== 'undefined'; +} diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts new file mode 100644 index 000000000..f9fb07bd4 --- /dev/null +++ b/packages/astro/src/core/middleware/index.ts @@ -0,0 +1,9 @@ +import type { MiddlewareResponseHandler } from '../../@types/astro'; +import { sequence } from './sequence.js'; + +function defineMiddleware(fn: MiddlewareResponseHandler) { + return fn; +} + +// NOTE: this export must export only the functions that will be exposed to user-land as officials APIs +export { sequence, defineMiddleware }; diff --git a/packages/astro/src/core/middleware/loadMiddleware.ts b/packages/astro/src/core/middleware/loadMiddleware.ts new file mode 100644 index 000000000..5c64565af --- /dev/null +++ b/packages/astro/src/core/middleware/loadMiddleware.ts @@ -0,0 +1,22 @@ +import type { AstroSettings } from '../../@types/astro'; +import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../constants.js'; +import type { ModuleLoader } from '../module-loader'; + +/** + * 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 loadMiddleware( + moduleLoader: ModuleLoader, + srcDir: AstroSettings['config']['srcDir'] +) { + // can't use node Node.js builtins + let middlewarePath = srcDir.pathname + '/' + MIDDLEWARE_PATH_SEGMENT_NAME; + try { + const module = await moduleLoader.import(middlewarePath); + return module; + } catch { + return void 0; + } +} diff --git a/packages/astro/src/core/middleware/sequence.ts b/packages/astro/src/core/middleware/sequence.ts new file mode 100644 index 000000000..0358f3719 --- /dev/null +++ b/packages/astro/src/core/middleware/sequence.ts @@ -0,0 +1,36 @@ +import type { APIContext, MiddlewareResponseHandler } from '../../@types/astro'; +import { defineMiddleware } from './index.js'; + +// From SvelteKit: https://github.com/sveltejs/kit/blob/master/packages/kit/src/exports/hooks/sequence.js +/** + * + * It accepts one or more middleware handlers and makes sure that they are run in sequence. + */ +export function sequence(...handlers: MiddlewareResponseHandler[]): MiddlewareResponseHandler { + const length = handlers.length; + if (!length) { + const handler: MiddlewareResponseHandler = defineMiddleware((context, next) => { + return next(); + }); + return handler; + } + + return defineMiddleware((context, next) => { + return applyHandle(0, context); + + function applyHandle(i: number, handleContext: APIContext) { + const handle = handlers[i]; + // @ts-expect-error + // SAFETY: Usually `next` always returns something in user land, but in `sequence` we are actually + // doing a loop over all the `next` functions, and eventually we call the last `next` that returns the `Response`. + const result = handle(handleContext, async () => { + if (i < length - 1) { + return applyHandle(i + 1, handleContext); + } else { + return next(); + } + }); + return result; + } + }); +} diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index f6a82e9ca..d4efe35df 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -1,4 +1,13 @@ -import type { RouteData, SSRElement, SSRResult } from '../../@types/astro'; +import type { + ComponentInstance, + Params, + Props, + RouteData, + SSRElement, + SSRResult, +} from '../../@types/astro'; +import { getParamsAndPropsOrThrow } from './core.js'; +import type { Environment } from './environment'; /** * The RenderContext represents the parts of rendering that are specific to one request. @@ -14,22 +23,38 @@ export interface RenderContext { componentMetadata?: SSRResult['componentMetadata']; route?: RouteData; status?: number; + params: Params; + props: Props; } export type CreateRenderContextArgs = Partial<RenderContext> & { origin?: string; request: RenderContext['request']; + mod: ComponentInstance; + env: Environment; }; -export function createRenderContext(options: CreateRenderContextArgs): RenderContext { +export async function createRenderContext( + options: CreateRenderContextArgs +): Promise<RenderContext> { const request = options.request; const url = new URL(request.url); const origin = options.origin ?? url.origin; const pathname = options.pathname ?? url.pathname; + const [params, props] = await getParamsAndPropsOrThrow({ + mod: options.mod as any, + route: options.route, + routeCache: options.env.routeCache, + pathname: pathname, + logging: options.env.logging, + ssr: options.env.ssr, + }); return { ...options, origin, pathname, url, + params, + props, }; } diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index 8687e9006..fd57ad8bc 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -1,12 +1,11 @@ -import type { ComponentInstance, Params, Props, RouteData } from '../../@types/astro'; -import type { LogOptions } from '../logger/core.js'; -import type { RenderContext } from './context.js'; -import type { Environment } from './environment.js'; - +import type { APIContext, ComponentInstance, Params, Props, RouteData } from '../../@types/astro'; import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; import { attachToResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; +import type { LogOptions } from '../logger/core.js'; import { getParams } from '../routing/params.js'; +import type { RenderContext } from './context.js'; +import type { Environment } from './environment.js'; import { createResult } from './result.js'; import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js'; @@ -23,6 +22,26 @@ export const enum GetParamsAndPropsError { NoMatchingStaticPath, } +/** + * It retrieves `Params` and `Props`, or throws an error + * if they are not correctly retrieved. + */ +export async function getParamsAndPropsOrThrow( + options: GetParamsAndPropsOptions +): Promise<[Params, Props]> { + let paramsAndPropsResp = await getParamsAndProps(options); + if (paramsAndPropsResp === GetParamsAndPropsError.NoMatchingStaticPath) { + throw new AstroError({ + ...AstroErrorData.NoMatchingStaticPathFound, + message: AstroErrorData.NoMatchingStaticPathFound.message(options.pathname), + hint: options.route?.component + ? AstroErrorData.NoMatchingStaticPathFound.hint([options.route?.component]) + : '', + }); + } + return paramsAndPropsResp; +} + export async function getParamsAndProps( opts: GetParamsAndPropsOptions ): Promise<[Params, Props] | GetParamsAndPropsError> { @@ -84,65 +103,63 @@ export async function getParamsAndProps( return [params, pageProps]; } -export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env: Environment) { - const paramsAndPropsRes = await getParamsAndProps({ - logging: env.logging, - mod, - route: ctx.route, - routeCache: env.routeCache, - pathname: ctx.pathname, - ssr: env.ssr, - }); - - if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) { - throw new AstroError({ - ...AstroErrorData.NoMatchingStaticPathFound, - message: AstroErrorData.NoMatchingStaticPathFound.message(ctx.pathname), - hint: ctx.route?.component - ? AstroErrorData.NoMatchingStaticPathFound.hint([ctx.route?.component]) - : '', - }); - } - const [params, pageProps] = paramsAndPropsRes; +export type RenderPage = { + mod: ComponentInstance; + renderContext: RenderContext; + env: Environment; + apiContext?: APIContext; +}; +export async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) { // Validate the page component before rendering the page const Component = mod.default; if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); + let locals = {}; + if (apiContext) { + if (env.mode === 'development' && !isValueSerializable(apiContext.locals)) { + throw new AstroError({ + ...AstroErrorData.LocalsNotSerializable, + message: AstroErrorData.LocalsNotSerializable.message(renderContext.pathname), + }); + } + locals = apiContext.locals; + } const result = createResult({ adapterName: env.adapterName, - links: ctx.links, - styles: ctx.styles, + links: renderContext.links, + styles: renderContext.styles, logging: env.logging, markdown: env.markdown, mode: env.mode, - origin: ctx.origin, - params, - props: pageProps, - pathname: ctx.pathname, - componentMetadata: ctx.componentMetadata, + origin: renderContext.origin, + params: renderContext.params, + props: renderContext.props, + pathname: renderContext.pathname, + componentMetadata: renderContext.componentMetadata, resolve: env.resolve, renderers: env.renderers, - request: ctx.request, + request: renderContext.request, site: env.site, - scripts: ctx.scripts, + scripts: renderContext.scripts, ssr: env.ssr, - status: ctx.status ?? 200, + status: renderContext.status ?? 200, + locals, }); // Support `export const components` for `MDX` pages if (typeof (mod as any).components === 'object') { - Object.assign(pageProps, { components: (mod as any).components }); + Object.assign(renderContext.props, { components: (mod as any).components }); } - const response = await runtimeRenderPage( + let response = await runtimeRenderPage( result, Component, - pageProps, + renderContext.props, null, env.streaming, - ctx.route + renderContext.route ); // If there is an Astro.cookies instance, attach it to the response so that @@ -153,3 +170,57 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env return response; } + +/** + * Checks whether any value can is serializable. + * + * A serializable value contains plain values. For example, `Proxy`, `Set`, `Map`, functions, etc. + * are not serializable objects. + * + * @param object + */ +export function isValueSerializable(value: unknown): boolean { + let type = typeof value; + let plainObject = true; + if (type === 'object' && isPlainObject(value)) { + for (const [, nestedValue] of Object.entries(value)) { + if (!isValueSerializable(nestedValue)) { + plainObject = false; + break; + } + } + } else { + plainObject = false; + } + let result = + value === null || + type === 'string' || + type === 'number' || + type === 'boolean' || + Array.isArray(value) || + plainObject; + + return result; +} + +/** + * + * From [redux-toolkit](https://github.com/reduxjs/redux-toolkit/blob/master/packages/toolkit/src/isPlainObject.ts) + * + * Returns true if the passed value is "plain" object, i.e. an object whose + * prototype is the root `Object.prototype`. This includes objects created + * using object literals, but not for instance for class instances. + */ +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false; + + let proto = Object.getPrototypeOf(value); + if (proto === null) return true; + + let baseProto = proto; + while (Object.getPrototypeOf(baseProto) !== null) { + baseProto = Object.getPrototypeOf(baseProto); + } + + return proto === baseProto; +} diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 51920e800..fbbe0d48d 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -1,14 +1,18 @@ import { fileURLToPath } from 'url'; import type { + AstroMiddlewareInstance, AstroSettings, ComponentInstance, + MiddlewareResponseHandler, RouteData, SSRElement, SSRLoadedRenderer, } from '../../../@types/astro'; import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; +import { createAPIContext } from '../../endpoint/index.js'; import { enhanceViteSSRError } from '../../errors/dev/index.js'; import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js'; +import { callMiddleware } from '../../middleware/callMiddleware.js'; import type { ModuleLoader } from '../../module-loader/index'; import { isPage, resolveIdToUrl, viteID } from '../../util.js'; import { createRenderContext, renderPage as coreRenderPage } from '../index.js'; @@ -35,6 +39,10 @@ export interface SSROptions { request: Request; /** optional, in case we need to render something outside of a dev server */ route?: RouteData; + /** + * Optional middlewares + */ + middleware?: AstroMiddlewareInstance<unknown>; } export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance]; @@ -158,8 +166,9 @@ export async function renderPage(options: SSROptions): Promise<Response> { env: options.env, filePath: options.filePath, }); + const { env } = options; - const ctx = createRenderContext({ + const renderContext = await createRenderContext({ request: options.request, origin: options.origin, pathname: options.pathname, @@ -168,7 +177,25 @@ export async function renderPage(options: SSROptions): Promise<Response> { styles, componentMetadata: metadata, route: options.route, + mod, + env, }); + if (options.middleware) { + if (options.middleware && options.middleware.onRequest) { + const apiContext = createAPIContext({ + request: options.request, + params: renderContext.params, + props: renderContext.props, + adapterName: options.env.adapterName, + }); + + const onRequest = options.middleware.onRequest as MiddlewareResponseHandler; + const response = await callMiddleware<Response>(onRequest, apiContext, () => { + return coreRenderPage({ mod, renderContext, env: options.env, apiContext }); + }); - return await coreRenderPage(mod, ctx, options.env); // NOTE: without "await", errors won’t get caught below + return response; + } + } + return await coreRenderPage({ mod, renderContext, env: options.env }); // NOTE: without "await", errors won’t get caught below } diff --git a/packages/astro/src/core/render/index.ts b/packages/astro/src/core/render/index.ts index 99d680549..4e4df8239 100644 --- a/packages/astro/src/core/render/index.ts +++ b/packages/astro/src/core/render/index.ts @@ -1,6 +1,11 @@ export { createRenderContext } from './context.js'; export type { RenderContext } from './context.js'; -export { getParamsAndProps, GetParamsAndPropsError, renderPage } from './core.js'; +export { + getParamsAndProps, + GetParamsAndPropsError, + getParamsAndPropsOrThrow, + renderPage, +} from './core.js'; export type { Environment } from './environment'; export { createBasicEnvironment, createEnvironment } from './environment.js'; export { loadRenderer } from './renderer.js'; diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 26ea22eee..598ec116f 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -50,6 +50,7 @@ export interface CreateResultArgs { componentMetadata?: SSRResult['componentMetadata']; request: Request; status: number; + locals: App.Locals; } function getFunctionExpression(slot: any) { @@ -131,7 +132,7 @@ class Slots { let renderMarkdown: any = null; export function createResult(args: CreateResultArgs): SSRResult { - const { markdown, params, pathname, renderers, request, resolve } = args; + const { markdown, params, pathname, renderers, request, resolve, locals } = args; const url = new URL(request.url); const headers = new Headers(); @@ -200,6 +201,7 @@ export function createResult(args: CreateResultArgs): SSRResult { }, params, props, + locals, request, url, redirect: args.ssr diff --git a/packages/astro/src/core/request.ts b/packages/astro/src/core/request.ts index 24356983f..d8ac9033d 100644 --- a/packages/astro/src/core/request.ts +++ b/packages/astro/src/core/request.ts @@ -16,6 +16,7 @@ export interface CreateRequestOptions { } const clientAddressSymbol = Symbol.for('astro.clientAddress'); +const clientLocalsSymbol = Symbol.for('astro.locals'); export function createRequest({ url, @@ -65,5 +66,7 @@ export function createRequest({ Reflect.set(request, clientAddressSymbol, clientAddress); } + Reflect.set(request, clientLocalsSymbol, {}); + return request; } diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 9b86259ae..d306e7be3 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -55,6 +55,7 @@ export async function runHookConfigSetup({ let updatedConfig: AstroConfig = { ...settings.config }; let updatedSettings: AstroSettings = { ...settings, config: updatedConfig }; + for (const integration of settings.config.integrations) { /** * By making integration hooks optional, Astro can now ignore null or undefined Integrations @@ -68,7 +69,7 @@ export async function runHookConfigSetup({ * ] * ``` */ - if (integration?.hooks?.['astro:config:setup']) { + if (integration.hooks?.['astro:config:setup']) { const hooks: HookParameters<'astro:config:setup'> = { config: updatedConfig, command, diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts index 33ce8f6f9..9780d6599 100644 --- a/packages/astro/src/runtime/server/endpoint.ts +++ b/packages/astro/src/runtime/server/endpoint.ts @@ -19,7 +19,7 @@ function getHandlerFromModule(mod: EndpointHandler, method: string) { /** Renders an endpoint request to completion, returning the body. */ export async function renderEndpoint(mod: EndpointHandler, context: APIContext, ssr: boolean) { - const { request, params } = context; + const { request, params, locals } = context; const chosenMethod = request.method?.toLowerCase(); const handler = getHandlerFromModule(mod, chosenMethod); if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') { diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index da280f7e1..cb2e76178 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -1,17 +1,17 @@ import type http from 'http'; import mime from 'mime'; import type { ComponentInstance, ManifestData, RouteData } from '../@types/astro'; -import type { - ComponentPreload, - DevelopmentEnvironment, - SSROptions, -} from '../core/render/dev/index'; - import { attachToResponse } from '../core/cookies/index.js'; import { call as callEndpoint } from '../core/endpoint/dev/index.js'; import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js'; import { AstroErrorData } from '../core/errors/index.js'; import { warn } from '../core/logger/core.js'; +import { loadMiddleware } from '../core/middleware/loadMiddleware.js'; +import type { + ComponentPreload, + DevelopmentEnvironment, + SSROptions, +} from '../core/render/dev/index'; import { preload, renderPage } from '../core/render/dev/index.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js'; import { createRequest } from '../core/request.js'; @@ -169,7 +169,12 @@ export async function handleRoute( request, route, }; - + if (env.settings.config.experimental.middleware) { + const middleware = await loadMiddleware(env.loader, env.settings.config.srcDir); + if (middleware) { + options.middleware = middleware; + } + } // Route successfully matched! Render it. if (route.type === 'endpoint') { const result = await callEndpoint(options, logging); diff --git a/packages/astro/test/fixtures/middleware-dev/astro.config.mjs b/packages/astro/test/fixtures/middleware-dev/astro.config.mjs new file mode 100644 index 000000000..4379be246 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + experimental: { + middleware: true + } +}); diff --git a/packages/astro/test/fixtures/middleware-dev/package.json b/packages/astro/test/fixtures/middleware-dev/package.json new file mode 100644 index 000000000..bc889aa63 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/middleware-dev", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/middleware-dev/src/middleware.js b/packages/astro/test/fixtures/middleware-dev/src/middleware.js new file mode 100644 index 000000000..2a09552e7 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/middleware.js @@ -0,0 +1,40 @@ +import { sequence, defineMiddleware } from 'astro/middleware'; + +const first = defineMiddleware(async (context, next) => { + if (context.request.url.includes('/lorem')) { + context.locals.name = 'ipsum'; + } else if (context.request.url.includes('/rewrite')) { + return new Response('<span>New content!!</span>', { + status: 200, + }); + } else if (context.request.url.includes('/broken-500')) { + return new Response(null, { + status: 500, + }); + } else { + context.locals.name = 'bar'; + } + return await next(); +}); + +const second = defineMiddleware(async (context, next) => { + if (context.request.url.includes('/second')) { + context.locals.name = 'second'; + } else if (context.request.url.includes('/redirect')) { + return context.redirect('/', 302); + } + return await next(); +}); + +const third = defineMiddleware(async (context, next) => { + if (context.request.url.includes('/broken-locals')) { + context.locals = { + fn() {}, + }; + } else if (context.request.url.includes('/does-nothing')) { + return undefined; + } + next(); +}); + +export const onRequest = sequence(first, second, third); diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/broken-500.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/broken-500.astro new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/broken-500.astro diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/broken-locals.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/broken-locals.astro new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/broken-locals.astro diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/does-nothing.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/does-nothing.astro new file mode 100644 index 000000000..344b3797b --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/does-nothing.astro @@ -0,0 +1,9 @@ +<html> +<head> + <title>Testing</title> +</head> +<body> + +<p>Not interested</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/index.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/index.astro new file mode 100644 index 000000000..395a4d695 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +const data = Astro.locals; +--- + +<html> +<head> + <title>Testing</title> +</head> +<body> + + <span>Index</span> + <p>{data?.name}</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/lorem.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/lorem.astro new file mode 100644 index 000000000..c6edf9cd7 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/lorem.astro @@ -0,0 +1,13 @@ +--- +const data = Astro.locals; +--- + +<html> +<head> + <title>Testing</title> +</head> +<body> + +<p>{data?.name}</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/not-interested.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/not-interested.astro new file mode 100644 index 000000000..344b3797b --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/not-interested.astro @@ -0,0 +1,9 @@ +<html> +<head> + <title>Testing</title> +</head> +<body> + +<p>Not interested</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/redirect.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/redirect.astro new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/redirect.astro diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/rewrite.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/rewrite.astro new file mode 100644 index 000000000..f7f70dc88 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/rewrite.astro @@ -0,0 +1,9 @@ +<html> +<head> + <title>Testing</title> +</head> +<body> + + <p>Rewrite</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-dev/src/pages/second.astro b/packages/astro/test/fixtures/middleware-dev/src/pages/second.astro new file mode 100644 index 000000000..c6edf9cd7 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-dev/src/pages/second.astro @@ -0,0 +1,13 @@ +--- +const data = Astro.locals; +--- + +<html> +<head> + <title>Testing</title> +</head> +<body> + +<p>{data?.name}</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-ssg/astro.config.mjs b/packages/astro/test/fixtures/middleware-ssg/astro.config.mjs new file mode 100644 index 000000000..2f2e911a8 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-ssg/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + output: "static", + experimental: { + middleware: true + } +}); diff --git a/packages/astro/test/fixtures/middleware-ssg/package.json b/packages/astro/test/fixtures/middleware-ssg/package.json new file mode 100644 index 000000000..2ac442454 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-ssg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/middleware-ssg", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/middleware-ssg/src/middleware.js b/packages/astro/test/fixtures/middleware-ssg/src/middleware.js new file mode 100644 index 000000000..f28d89f67 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-ssg/src/middleware.js @@ -0,0 +1,12 @@ +import { sequence, defineMiddleware } from 'astro/middleware'; + +const first = defineMiddleware(async (context, next) => { + if (context.request.url.includes('/second')) { + context.locals.name = 'second'; + } else { + context.locals.name = 'bar'; + } + return await next(); +}); + +export const onRequest = sequence(first); diff --git a/packages/astro/test/fixtures/middleware-ssg/src/pages/index.astro b/packages/astro/test/fixtures/middleware-ssg/src/pages/index.astro new file mode 100644 index 000000000..395a4d695 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-ssg/src/pages/index.astro @@ -0,0 +1,14 @@ +--- +const data = Astro.locals; +--- + +<html> +<head> + <title>Testing</title> +</head> +<body> + + <span>Index</span> + <p>{data?.name}</p> +</body> +</html> diff --git a/packages/astro/test/fixtures/middleware-ssg/src/pages/second.astro b/packages/astro/test/fixtures/middleware-ssg/src/pages/second.astro new file mode 100644 index 000000000..c6edf9cd7 --- /dev/null +++ b/packages/astro/test/fixtures/middleware-ssg/src/pages/second.astro @@ -0,0 +1,13 @@ +--- +const data = Astro.locals; +--- + +<html> +<head> + <title>Testing</title> +</head> +<body> + +<p>{data?.name}</p> +</body> +</html> diff --git a/packages/astro/test/middleware.test.js b/packages/astro/test/middleware.test.js new file mode 100644 index 000000000..784a32c30 --- /dev/null +++ b/packages/astro/test/middleware.test.js @@ -0,0 +1,202 @@ +import { loadFixture } from './test-utils.js'; +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; + +describe('Middleware in DEV mode', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/middleware-dev/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render locals data', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerio.load(html); + expect($('p').html()).to.equal('bar'); + }); + + it('should change locals data based on URL', async () => { + let html = await fixture.fetch('/').then((res) => res.text()); + let $ = cheerio.load(html); + expect($('p').html()).to.equal('bar'); + + html = await fixture.fetch('/lorem').then((res) => res.text()); + $ = cheerio.load(html); + expect($('p').html()).to.equal('ipsum'); + }); + + it('should call a second middleware', async () => { + let html = await fixture.fetch('/second').then((res) => res.text()); + let $ = cheerio.load(html); + expect($('p').html()).to.equal('second'); + }); + + it('should successfully create a new response', async () => { + let html = await fixture.fetch('/rewrite').then((res) => res.text()); + let $ = cheerio.load(html); + expect($('p').html()).to.be.null; + expect($('span').html()).to.equal('New content!!'); + }); + + it('should return a new response that is a 500', async () => { + await fixture.fetch('/broken-500').then((res) => { + expect(res.status).to.equal(500); + return res.text(); + }); + }); + + it('should successfully render a page if the middleware calls only next() and returns nothing', async () => { + let html = await fixture.fetch('/not-interested').then((res) => res.text()); + let $ = cheerio.load(html); + expect($('p').html()).to.equal('Not interested'); + }); + + it('should throw an error when locals are not serializable', async () => { + let html = await fixture.fetch('/broken-locals').then((res) => res.text()); + let $ = cheerio.load(html); + expect($('title').html()).to.equal('LocalsNotSerializable'); + }); + + it("should throw an error when the middleware doesn't call next or doesn't return a response", async () => { + let html = await fixture.fetch('/does-nothing').then((res) => res.text()); + let $ = cheerio.load(html); + expect($('title').html()).to.equal('MiddlewareNoDataOrNextCalled'); + }); +}); + +describe('Middleware in PROD mode, SSG', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').PreviewServer} */ + let previewServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/middleware-ssg/', + }); + await fixture.build(); + }); + + it('should render locals data', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + expect($('p').html()).to.equal('bar'); + }); + + it('should change locals data based on URL', async () => { + let html = await fixture.readFile('/index.html'); + let $ = cheerio.load(html); + expect($('p').html()).to.equal('bar'); + + html = await fixture.readFile('/second/index.html'); + $ = cheerio.load(html); + expect($('p').html()).to.equal('second'); + }); +}); + +describe('Middleware API in PROD mode, SSR', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/middleware-dev/', + output: 'server', + adapter: testAdapter({ + // exports: ['manifest', 'createApp', 'middleware'], + }), + }); + await fixture.build(); + }); + + it('should render locals data', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('p').html()).to.equal('bar'); + }); + + it('should change locals data based on URL', async () => { + const app = await fixture.loadTestAdapterApp(); + let response = await app.render(new Request('http://example.com/')); + let html = await response.text(); + let $ = cheerio.load(html); + expect($('p').html()).to.equal('bar'); + + response = await app.render(new Request('http://example.com/lorem')); + html = await response.text(); + $ = cheerio.load(html); + expect($('p').html()).to.equal('ipsum'); + }); + + it('should successfully redirect to another page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/redirect'); + const response = await app.render(request); + expect(response.status).to.equal(302); + }); + + it('should call a second middleware', async () => { + const app = await fixture.loadTestAdapterApp(); + const response = await app.render(new Request('http://example.com/second')); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('p').html()).to.equal('second'); + }); + + it('should successfully create a new response', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/rewrite'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('p').html()).to.be.null; + expect($('span').html()).to.equal('New content!!'); + }); + + it('should return a new response that is a 500', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/broken-500'); + const response = await app.render(request); + expect(response.status).to.equal(500); + }); + + it('should successfully render a page if the middleware calls only next() and returns nothing', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/not-interested'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('p').html()).to.equal('Not interested'); + }); + + it('should NOT throw an error when locals are not serializable', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/broken-locals'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('title').html()).to.not.equal('LocalsNotSerializable'); + }); + + it("should throws an error when the middleware doesn't call next or doesn't return a response", async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/does-nothing'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('title').html()).to.not.equal('MiddlewareNoDataReturned'); + }); +}); diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index 4faa9a7c6..cc34e3c33 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -42,6 +42,7 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd return new Response(data); } + Reflect.set(request, Symbol.for('astro.locals'), {}); ${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''} return super.render(request, routeData); } @@ -51,6 +52,7 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd return { manifest, createApp: (streaming) => new MyApp(manifest, streaming) + }; } `; diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index 6e3113978..f933a13ad 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -231,7 +231,7 @@ export async function loadFixture(inlineConfig) { }, loadTestAdapterApp: async (streaming) => { const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir); - const { createApp, manifest } = await import(url); + const { createApp, manifest, middleware } = await import(url); const app = createApp(streaming); app.manifest = manifest; return app; diff --git a/packages/astro/test/units/render/head.test.js b/packages/astro/test/units/render/head.test.js index 103c84fda..83fbc8b11 100644 --- a/packages/astro/test/units/render/head.test.js +++ b/packages/astro/test/units/render/head.test.js @@ -95,13 +95,21 @@ describe('core/render', () => { )}`; }); - const ctx = createRenderContext({ + const PageModule = createAstroModule(Page); + const ctx = await createRenderContext({ request: new Request('http://example.com/'), links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }], + mod: PageModule, + env, }); - const PageModule = createAstroModule(Page); - const response = await renderPage(PageModule, ctx, env); + const response = await renderPage({ + mod: PageModule, + renderContext: ctx, + env, + params: ctx.params, + props: ctx.props, + }); const html = await response.text(); const $ = cheerio.load(html); @@ -173,14 +181,21 @@ describe('core/render', () => { )}`; }); - const ctx = createRenderContext({ + const PageModule = createAstroModule(Page); + const ctx = await createRenderContext({ request: new Request('http://example.com/'), links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }], + env, + mod: PageModule, }); - const PageModule = createAstroModule(Page); - - const response = await renderPage(PageModule, ctx, env); + const response = await renderPage({ + mod: PageModule, + renderContext: ctx, + env, + params: ctx.params, + props: ctx.props, + }); const html = await response.text(); const $ = cheerio.load(html); @@ -218,14 +233,21 @@ describe('core/render', () => { )}`; }); - const ctx = createRenderContext({ + const PageModule = createAstroModule(Page); + const ctx = await createRenderContext({ request: new Request('http://example.com/'), links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }], + env, + mod: PageModule, }); - const PageModule = createAstroModule(Page); - - const response = await renderPage(PageModule, ctx, env); + const response = await renderPage({ + mod: PageModule, + renderContext: ctx, + env, + params: ctx.params, + props: ctx.props, + }); const html = await response.text(); const $ = cheerio.load(html); diff --git a/packages/astro/test/units/render/jsx.test.js b/packages/astro/test/units/render/jsx.test.js index a34b6b53b..c249bcbd5 100644 --- a/packages/astro/test/units/render/jsx.test.js +++ b/packages/astro/test/units/render/jsx.test.js @@ -1,5 +1,4 @@ import { expect } from 'chai'; - import { createComponent, render, @@ -46,8 +45,18 @@ describe('core/render', () => { }); }); - const ctx = createRenderContext({ request: new Request('http://example.com/') }); - const response = await renderPage(createAstroModule(Page), ctx, env); + const mod = createAstroModule(Page); + const ctx = await createRenderContext({ + request: new Request('http://example.com/'), + env, + mod, + }); + + const response = await renderPage({ + mod, + renderContext: ctx, + env, + }); expect(response.status).to.equal(200); @@ -85,8 +94,17 @@ describe('core/render', () => { }); }); - const ctx = createRenderContext({ request: new Request('http://example.com/') }); - const response = await renderPage(createAstroModule(Page), ctx, env); + const mod = createAstroModule(Page); + const ctx = await createRenderContext({ + request: new Request('http://example.com/'), + env, + mod, + }); + const response = await renderPage({ + mod, + renderContext: ctx, + env, + }); expect(response.status).to.equal(200); @@ -105,8 +123,18 @@ describe('core/render', () => { return render`<div>${renderComponent(result, 'Component', Component, {})}</div>`; }); - const ctx = createRenderContext({ request: new Request('http://example.com/') }); - const response = await renderPage(createAstroModule(Page), ctx, env); + const mod = createAstroModule(Page); + const ctx = await createRenderContext({ + request: new Request('http://example.com/'), + env, + mod, + }); + + const response = await renderPage({ + mod, + renderContext: ctx, + env, + }); try { await response.text(); diff --git a/packages/integrations/node/src/middleware.ts b/packages/integrations/node/src/nodeMiddleware.ts index c23cdb89c..c23cdb89c 100644 --- a/packages/integrations/node/src/middleware.ts +++ b/packages/integrations/node/src/nodeMiddleware.ts diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index ed03b68a6..98f5cd14b 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -1,7 +1,7 @@ import { polyfill } from '@astrojs/webapi'; import type { SSRManifest } from 'astro'; import { NodeApp } from 'astro/app/node'; -import middleware from './middleware.js'; +import middleware from './nodeMiddleware.js'; import startServer from './standalone.js'; import type { Options } from './types'; diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts index 813174252..85eb3822a 100644 --- a/packages/integrations/node/src/standalone.ts +++ b/packages/integrations/node/src/standalone.ts @@ -3,7 +3,7 @@ import https from 'https'; import path from 'path'; import { fileURLToPath } from 'url'; import { createServer } from './http-server.js'; -import middleware from './middleware.js'; +import middleware from './nodeMiddleware.js'; import type { Options } from './types'; function resolvePaths(options: Options) { |