feat(astro): experimental middleware (#6721)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Emanuele Stoppa 2023-05-03 17:40:47 +01:00 committed by GitHub
parent ad907196cb
commit 831b67cdb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 3727 additions and 2168 deletions

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
New middleware API

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
experimental: {
middleware: true,
},
});

View file

@ -0,0 +1,23 @@
{
"name": "@example/middleware",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"server": "node dist/server/entry.mjs"
},
"dependencies": {
"astro": "workspace:*",
"svelte": "^3.48.0",
"@astrojs/node": "workspace:*",
"concurrently": "^7.2.1",
"unocss": "^0.15.6",
"vite-imagetools": "^4.0.4",
"html-minifier": "^4.0.0"
}
}

View file

@ -0,0 +1,63 @@
---
export interface Props {
title: string;
body: string;
href: string;
}
const { href, title, body } = Astro.props;
---
<li class="link-card">
<a href={href}>
<h2>
{title}
<span>&rarr;</span>
</h2>
<p>
{body}
</p>
</a>
</li>
<style>
.link-card {
list-style: none;
display: flex;
padding: 0.25rem;
background-color: white;
background-image: none;
background-size: 400%;
border-radius: 0.6rem;
background-position: 100%;
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.link-card > a {
width: 100%;
text-decoration: none;
line-height: 1.4;
padding: 1rem 1.3rem;
border-radius: 0.35rem;
color: #111;
background-color: white;
opacity: 0.8;
}
h2 {
margin: 0;
font-size: 1.25rem;
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
p {
margin-top: 0.5rem;
margin-bottom: 0;
color: #444;
}
.link-card:is(:hover, :focus-within) {
background-position: 0;
background-image: var(--accent-gradient);
}
.link-card:is(:hover, :focus-within) h2 {
color: rgb(var(--accent));
}
</style>

13
examples/middleware/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
/// <reference types="astro/client" />
declare global {
namespace AstroMiddleware {
interface Locals {
user: {
name: string;
surname: string;
};
}
}
}
export {};

View file

@ -0,0 +1,35 @@
---
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
:root {
--accent: 124, 58, 237;
--accent-gradient: linear-gradient(45deg, rgb(var(--accent)), #da62c4 30%, white 60%);
}
html {
font-family: system-ui, sans-serif;
background-color: #f6f6f6;
}
code {
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
</style>

View file

@ -0,0 +1,71 @@
import { defineMiddleware, sequence } from 'astro/middleware';
import htmlMinifier from 'html-minifier';
const limit = 50;
const loginInfo = {
token: undefined,
currentTime: undefined,
};
export const minifier = defineMiddleware(async (context, next) => {
const response = await next();
// check if the response is returning some HTML
if (response.headers.get('content-type') === 'text/html') {
let headers = response.headers;
let html = await response.text();
let newHtml = htmlMinifier.minify(html, {
removeAttributeQuotes: true,
collapseWhitespace: true,
});
return new Response(newHtml, {
status: 200,
headers,
});
}
return response;
});
const validation = defineMiddleware(async (context, next) => {
if (context.request.url.endsWith('/admin')) {
if (loginInfo.currentTime) {
const difference = new Date().getTime() - loginInfo.currentTime;
if (difference > limit) {
console.log('hit threshold');
loginInfo.token = undefined;
loginInfo.currentTime = undefined;
return context.redirect('/login');
}
}
// we naively check if we have a token
if (loginInfo.token && loginInfo.token === 'loggedIn') {
// we fill the locals with user-facing information
context.locals.user = {
name: 'AstroUser',
surname: 'AstroSurname',
};
return await next();
} else {
loginInfo.token = undefined;
loginInfo.currentTime = undefined;
return context.redirect('/login');
}
} else if (context.request.url.endsWith('/api/login')) {
const response = await next();
// the login endpoint will return to us a JSON with username and password
const data = await response.json();
// we naively check if username and password are equals to some string
if (data.username === 'astro' && data.password === 'astro') {
// we store the token somewhere outside of locals because the `locals` object is attached to the request
// and when doing a redirect, we lose that information
loginInfo.token = 'loggedIn';
loginInfo.currentTime = new Date().getTime();
return context.redirect('/admin');
}
}
// we don't really care about awaiting the response in this case
next();
return;
});
export const onRequest = sequence(validation, minifier);

View file

@ -0,0 +1,55 @@
---
import Layout from '../layouts/Layout.astro';
const user = Astro.locals.user;
---
<Layout title="Welcome back!!">
<main>
<h1>Welcome back <span class="text-gradient">{user.name} {user.surname}</span></h1>
</main>
</Layout>
<style>
main {
margin: auto;
padding: 1.5rem;
max-width: 60ch;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin: 0;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
line-height: 1.6;
margin: 1rem 0;
border: 1px solid rgba(var(--accent), 25%);
background-color: white;
padding: 1rem;
border-radius: 0.4rem;
}
.instructions code {
font-size: 0.875em;
font-weight: bold;
background: rgba(var(--accent), 12%);
color: rgb(var(--accent));
border-radius: 4px;
padding: 0.3em 0.45em;
}
.instructions strong {
color: rgb(var(--accent));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 1rem;
padding: 0;
}
</style>

View file

@ -0,0 +1,18 @@
import { APIRoute } from 'astro';
export const post: APIRoute = async ({ request }) => {
const data = await request.formData();
const username = data.get('username');
const password = data.get('password');
return new Response(
JSON.stringify({
username,
password,
}),
{
headers: {
'content-type': 'application/json',
},
}
);
};

View file

@ -0,0 +1,63 @@
---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
---
<Layout title="Welcome to Astro.">
<main>
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
<p class="instructions">
To get started, open the directory <code>src/pages</code> in your project.<br />
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
</p>
{}
<ul role="list" class="link-card-grid">
<Card href="/login" title="Login" body="Try the login" />
</ul>
</main>
</Layout>
<style>
main {
margin: auto;
padding: 1.5rem;
max-width: 60ch;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin: 0;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
line-height: 1.6;
margin: 1rem 0;
border: 1px solid rgba(var(--accent), 25%);
background-color: white;
padding: 1rem;
border-radius: 0.4rem;
}
.instructions code {
font-size: 0.875em;
font-weight: bold;
background: rgba(var(--accent), 12%);
color: rgb(var(--accent));
border-radius: 4px;
padding: 0.3em 0.45em;
}
.instructions strong {
color: rgb(var(--accent));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 1rem;
padding: 0;
}
</style>

View file

@ -0,0 +1,75 @@
---
import Layout from '../layouts/Layout.astro';
const status = Astro.response.status;
let redirectMessage;
if (status === 301) {
redirectMessage = 'Your session is finished, please login again';
}
---
<Layout title="Welcome to Astro.">
<main>
<h1>Welcome to <span class="text-gradient">Astro</span></h1>
<p class="instructions">
To get started, open the directory <code>src/pages</code> in your project.<br />
<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
</p>
{redirectMessage}
<form action="/api/login" method="POST">
<label>
Username: <input type="text" minlength="1" id="username" name="username" />
</label>
<label>
Password: <input type="password" minlength="1" id="password" name="password" />
</label>
<button>Submit</button>
</form>
</main>
</Layout>
<style>
main {
margin: auto;
padding: 1.5rem;
max-width: 60ch;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin: 0;
}
.text-gradient {
background-image: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 400%;
background-position: 0%;
}
.instructions {
line-height: 1.6;
margin: 1rem 0;
border: 1px solid rgba(var(--accent), 25%);
background-color: white;
padding: 1rem;
border-radius: 0.4rem;
}
.instructions code {
font-size: 0.875em;
font-weight: bold;
background: rgba(var(--accent), 12%);
color: rgb(var(--accent));
border-radius: 4px;
padding: 0.3em 0.45em;
}
.instructions strong {
color: rgb(var(--accent));
}
.link-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(24ch, 1fr));
gap: 1rem;
padding: 0;
}
</style>

View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}

View file

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

View file

@ -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": {

9
packages/astro/src/@types/app.d.ts vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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[];
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
});
return await coreRenderPage(mod, ctx, options.env); // NOTE: without "await", errors wont get caught below
const onRequest = options.middleware.onRequest as MiddlewareResponseHandler;
const response = await callMiddleware<Response>(onRequest, apiContext, () => {
return coreRenderPage({ mod, renderContext, env: options.env, apiContext });
});
return response;
}
}
return await coreRenderPage({ mod, renderContext, env: options.env }); // NOTE: without "await", errors wont get caught below
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
experimental: {
middleware: true
}
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/middleware-dev",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

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

View file

@ -0,0 +1,9 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<p>Not interested</p>
</body>
</html>

View file

@ -0,0 +1,14 @@
---
const data = Astro.locals;
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<span>Index</span>
<p>{data?.name}</p>
</body>
</html>

View file

@ -0,0 +1,13 @@
---
const data = Astro.locals;
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<p>{data?.name}</p>
</body>
</html>

View file

@ -0,0 +1,9 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<p>Not interested</p>
</body>
</html>

View file

@ -0,0 +1,9 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<p>Rewrite</p>
</body>
</html>

View file

@ -0,0 +1,13 @@
---
const data = Astro.locals;
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<p>{data?.name}</p>
</body>
</html>

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
output: "static",
experimental: {
middleware: true
}
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/middleware-ssg",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

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

View file

@ -0,0 +1,14 @@
---
const data = Astro.locals;
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<span>Index</span>
<p>{data?.name}</p>
</body>
</html>

View file

@ -0,0 +1,13 @@
---
const data = Astro.locals;
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<p>{data?.name}</p>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff