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; const src: string;
export default src; 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": { "./zod": {
"types": "./zod.d.ts", "types": "./zod.d.ts",
"default": "./zod.mjs" "default": "./zod.mjs"
},
"./middleware": {
"types": "./dist/core/middleware/index.d.ts",
"default": "./dist/core/middleware/index.js"
} }
}, },
"imports": { "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; drafts?: boolean;
open?: boolean; open?: boolean;
experimentalAssets?: boolean; experimentalAssets?: boolean;
experimentalMiddleware?: boolean;
} }
export interface BuildConfig { export interface BuildConfig {
@ -1034,6 +1035,26 @@ export interface AstroUserConfig {
* } * }
*/ */
assets?: boolean; 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 // 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 to another page (**SSR Only**).
*/ */
redirect(path: string, status?: 301 | 302 | 303 | 307 | 308): Response; 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>> 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']; 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) * [context reference](https://docs.astro.build/en/guides/api-reference/#contextredirect)
*/ */
redirect: AstroSharedContext['redirect']; 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>; 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 { export interface AstroPluginOptions {
settings: AstroSettings; settings: AstroSettings;
logging: LogOptions; logging: LogOptions;

View file

@ -2,6 +2,7 @@ import type {
ComponentInstance, ComponentInstance,
EndpointHandler, EndpointHandler,
ManifestData, ManifestData,
MiddlewareResponseHandler,
RouteData, RouteData,
SSRElement, SSRElement,
} from '../../@types/astro'; } from '../../@types/astro';
@ -9,9 +10,10 @@ import type { RouteInfo, SSRManifest as Manifest } from './types';
import mime from 'mime'; import mime from 'mime';
import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js'; 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 { consoleLogDestination } from '../logger/console.js';
import { error, type LogOptions } from '../logger/core.js'; import { error, type LogOptions } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import { removeTrailingForwardSlash } from '../path.js'; import { removeTrailingForwardSlash } from '../path.js';
import { import {
createEnvironment, createEnvironment,
@ -28,6 +30,8 @@ import {
import { matchRoute } from '../routing/match.js'; import { matchRoute } from '../routing/match.js';
export { deserializeManifest } from './common.js'; export { deserializeManifest } from './common.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry'; export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry';
export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId; export const resolvedPagesVirtualModuleId = '\0' + pagesVirtualModuleId;
const responseSentSymbol = Symbol.for('astro.responseSent'); 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 // Use the 404 status code for 404.astro components
if (routeData.route === '/404') { if (routeData.route === '/404') {
defaultStatus = 404; defaultStatus = 404;
@ -191,7 +197,7 @@ export class App {
} }
try { try {
const ctx = createRenderContext({ const renderContext = await createRenderContext({
request, request,
origin: url.origin, origin: url.origin,
pathname, pathname,
@ -200,9 +206,35 @@ export class App {
links, links,
route: routeData, route: routeData,
status, 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); Reflect.set(request, responseSentSymbol, true);
return response; return response;
} catch (err: any) { } catch (err: any) {
@ -224,15 +256,23 @@ export class App {
const pathname = '/' + this.removeBase(url.pathname); const pathname = '/' + this.removeBase(url.pathname);
const handler = mod as unknown as EndpointHandler; const handler = mod as unknown as EndpointHandler;
const ctx = createRenderContext({ const ctx = await createRenderContext({
request, request,
origin: url.origin, origin: url.origin,
pathname, pathname,
route: routeData, route: routeData,
status, 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.type === 'response') {
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') { 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 { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type { import type {
AstroMiddlewareInstance,
ComponentInstance, ComponentInstance,
RouteData, RouteData,
SerializedRouteData, SerializedRouteData,
@ -38,6 +39,7 @@ export interface SSRManifest {
entryModules: Record<string, string>; entryModules: Record<string, string>;
assets: Set<string>; assets: Set<string>;
componentMetadata: SSRResult['componentMetadata']; componentMetadata: SSRResult['componentMetadata'];
middleware?: AstroMiddlewareInstance<unknown>;
} }
export type SerializedSSRManifest = Omit<SSRManifest, 'routes' | 'assets' | 'componentMetadata'> & { 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 { fileURLToPath } from 'url';
import type { import type {
AstroConfig, AstroConfig,
AstroMiddlewareInstance,
AstroSettings, AstroSettings,
ComponentInstance, ComponentInstance,
EndpointHandler, EndpointHandler,
EndpointOutput,
ImageTransform, ImageTransform,
MiddlewareResponseHandler,
RouteType, RouteType,
SSRError, SSRError,
SSRLoadedRenderer, SSRLoadedRenderer,
@ -25,9 +28,14 @@ import {
} from '../../core/path.js'; } from '../../core/path.js';
import { runHookBuildGenerated } from '../../integrations/index.js'; import { runHookBuildGenerated } from '../../integrations/index.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/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 { AstroError } from '../errors/index.js';
import { debug, info } from '../logger/core.js'; import { debug, info } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import { createEnvironment, createRenderContext, renderPage } from '../render/index.js'; import { createEnvironment, createRenderContext, renderPage } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js'; import { callGetStaticPaths } from '../render/route-cache.js';
import { import {
@ -157,6 +165,7 @@ async function generatePage(
const scripts = pageInfo?.hoistedScript ?? null; const scripts = pageInfo?.hoistedScript ?? null;
const pageModule = ssrEntry.pageMap?.get(pageData.component); const pageModule = ssrEntry.pageMap?.get(pageData.component);
const middleware = ssrEntry.middleware;
if (!pageModule) { if (!pageModule) {
throw new Error( throw new Error(
@ -186,7 +195,7 @@ async function generatePage(
for (let i = 0; i < paths.length; i++) { for (let i = 0; i < paths.length; i++) {
const path = paths[i]; const path = paths[i];
await generatePath(path, opts, generationOptions); await generatePath(path, opts, generationOptions, middleware);
const timeEnd = performance.now(); const timeEnd = performance.now();
const timeChange = getTimeStat(timeStart, timeEnd); const timeChange = getTimeStat(timeStart, timeEnd);
const timeIncrease = `(+${timeChange})`; const timeIncrease = `(+${timeChange})`;
@ -328,7 +337,8 @@ function getUrlForPath(
async function generatePath( async function generatePath(
pathname: string, pathname: string,
opts: StaticBuildOptions, opts: StaticBuildOptions,
gopts: GeneratePathOptions gopts: GeneratePathOptions,
middleware?: AstroMiddlewareInstance<unknown>
) { ) {
const { settings, logging, origin, routeCache } = opts; const { settings, logging, origin, routeCache } = opts;
const { mod, internals, linkIds, scripts: hoistedScripts, pageData, renderers } = gopts; const { mod, internals, linkIds, scripts: hoistedScripts, pageData, renderers } = gopts;
@ -414,7 +424,8 @@ async function generatePath(
ssr, ssr,
streaming: true, streaming: true,
}); });
const ctx = createRenderContext({
const renderContext = await createRenderContext({
origin, origin,
pathname, pathname,
request: createRequest({ url, headers: new Headers(), logging, ssr }), request: createRequest({ url, headers: new Headers(), logging, ssr }),
@ -422,13 +433,22 @@ async function generatePath(
scripts, scripts,
links, links,
route: pageData.route, route: pageData.route,
env,
mod,
}); });
let body: string | Uint8Array; let body: string | Uint8Array;
let encoding: BufferEncoding | undefined; let encoding: BufferEncoding | undefined;
if (pageData.route.type === 'endpoint') { if (pageData.route.type === 'endpoint') {
const endpointHandler = mod as unknown as EndpointHandler; 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') { if (result.type === 'response') {
throwIfRedirectNotAllowed(result.response, opts.settings.config); throwIfRedirectNotAllowed(result.response, opts.settings.config);
@ -443,7 +463,26 @@ async function generatePath(
} else { } else {
let response: Response; let response: Response;
try { 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) { } catch (err) {
if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
(err as SSRError).id = pageData.component; (err as SSRError).id = pageData.component;

View file

@ -1,10 +1,10 @@
import type { Plugin as VitePlugin } from 'vite'; 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 { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../../app/index.js';
import { MIDDLEWARE_PATH_SEGMENT_NAME } from '../../constants.js';
import { addRollupInput } from '../add-rollup-input.js'; import { addRollupInput } from '../add-rollup-input.js';
import { eachPageData, hasPrerenderedPages, type BuildInternals } from '../internal.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 { export function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return { return {
@ -22,8 +22,15 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern
} }
}, },
load(id) { async load(id) {
if (id === resolvedPagesVirtualModuleId) { 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 importMap = '';
let imports = []; let imports = [];
let i = 0; let i = 0;
@ -47,8 +54,12 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern
const def = `${imports.join('\n')} const def = `${imports.join('\n')}
${middlewareId ? `import * as _middleware from "${middlewareId.id}";` : ''}
export const pageMap = new Map([${importMap}]); export const pageMap = new Map([${importMap}]);
export const renderers = [${rendererItems}];`; export const renderers = [${rendererItems}];
${middlewareId ? `export const middleware = _middleware;` : ''}
`;
return def; return def;
} }

View file

@ -1,5 +1,5 @@
import type { Plugin as VitePlugin } from 'vite'; 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 { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types';
import type { BuildInternals } from '../internal.js'; import type { BuildInternals } from '../internal.js';
import type { StaticBuildOptions } from '../types'; import type { StaticBuildOptions } from '../types';
@ -21,7 +21,11 @@ const resolvedVirtualModuleId = '\0' + virtualModuleId;
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); 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 { return {
name: '@astrojs/vite-plugin-astro-ssr', name: '@astrojs/vite-plugin-astro-ssr',
enforce: 'post', enforce: 'post',
@ -35,13 +39,18 @@ export function vitePluginSSR(internals: BuildInternals, adapter: AstroAdapter):
}, },
load(id) { load(id) {
if (id === resolvedVirtualModuleId) { if (id === resolvedVirtualModuleId) {
let middleware = '';
if (config.experimental?.middleware === true) {
middleware = 'middleware: _main.middleware';
}
return `import * as adapter from '${adapter.serverEntrypoint}'; return `import * as adapter from '${adapter.serverEntrypoint}';
import * as _main from '${pagesVirtualModuleId}'; import * as _main from '${pagesVirtualModuleId}';
import { deserializeManifest as _deserializeManifest } from 'astro/app'; import { deserializeManifest as _deserializeManifest } from 'astro/app';
import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'; import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest';
const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), {
pageMap: _main.pageMap, pageMap: _main.pageMap,
renderers: _main.renderers renderers: _main.renderers,
${middleware}
}); });
_privateSetManifestDontUseThis(_manifest); _privateSetManifestDontUseThis(_manifest);
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
@ -235,7 +244,9 @@ export function pluginSSR(
build: 'ssr', build: 'ssr',
hooks: { hooks: {
'build:before': () => { 'build:before': () => {
let vitePlugin = ssr ? vitePluginSSR(internals, options.settings.adapter!) : undefined; let vitePlugin = ssr
? vitePluginSSR(internals, options.settings.adapter!, options.settings.config)
: undefined;
return { return {
enforce: 'after-user-plugins', enforce: 'after-user-plugins',

View file

@ -1,6 +1,7 @@
import type { default as vite, InlineConfig } from 'vite'; import type { default as vite, InlineConfig } from 'vite';
import type { import type {
AstroConfig, AstroConfig,
AstroMiddlewareInstance,
AstroSettings, AstroSettings,
BuildConfig, BuildConfig,
ComponentInstance, ComponentInstance,
@ -44,6 +45,7 @@ export interface StaticBuildOptions {
export interface SingleFileBuiltModule { export interface SingleFileBuiltModule {
pageMap: Map<ComponentPath, ComponentInstance>; pageMap: Map<ComponentPath, ComponentInstance>;
middleware: AstroMiddlewareInstance<unknown>;
renderers: SSRLoadedRenderer[]; renderers: SSRLoadedRenderer[];
} }

View file

@ -103,6 +103,8 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags {
drafts: typeof flags.drafts === 'boolean' ? flags.drafts : undefined, drafts: typeof flags.drafts === 'boolean' ? flags.drafts : undefined,
experimentalAssets: experimentalAssets:
typeof flags.experimentalAssets === 'boolean' ? flags.experimentalAssets : undefined, 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. // TODO: Come back here and refactor to remove this expected error.
astroConfig.server.open = flags.open; astroConfig.server.open = flags.open;
} }
if (typeof flags.experimentalMiddleware === 'boolean') {
astroConfig.experimental.middleware = true;
}
return astroConfig; return astroConfig;
} }

View file

@ -37,6 +37,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
legacy: {}, legacy: {},
experimental: { experimental: {
assets: false, assets: false,
middleware: false,
}, },
}; };
@ -187,6 +188,7 @@ export const AstroConfigSchema = z.object({
experimental: z experimental: z
.object({ .object({
assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets), assets: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.assets),
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
}) })
.optional() .optional()
.default({}), .default({}),

View file

@ -10,3 +10,6 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
'.mdwn', '.mdwn',
'.md', '.md',
] as const; ] 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 { const {
env, env,
preload: [, mod], preload: [, mod],
middleware,
} = options; } = options;
const endpointHandler = mod as unknown as EndpointHandler; const endpointHandler = mod as unknown as EndpointHandler;
const ctx = createRenderContext({ const ctx = await createRenderContext({
request: options.request, request: options.request,
origin: options.origin, origin: options.origin,
pathname: options.pathname, pathname: options.pathname,
route: options.route, 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 type { Environment, RenderContext } from '../render/index';
import { renderEndpoint } from '../../runtime/server/index.js'; 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 { AstroCookies, attachToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js';
import { warn, type LogOptions } from '../logger/core.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 clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
type EndpointCallResult = type EndpointCallResult =
| { | {
@ -22,7 +32,7 @@ type EndpointCallResult =
response: Response; response: Response;
}; };
function createAPIContext({ export function createAPIContext({
request, request,
params, params,
site, site,
@ -35,7 +45,7 @@ function createAPIContext({
props: Record<string, any>; props: Record<string, any>;
adapterName?: string; adapterName?: string;
}): APIContext { }): APIContext {
return { const context = {
cookies: new AstroCookies(request), cookies: new AstroCookies(request),
request, request,
params, params,
@ -51,7 +61,6 @@ function createAPIContext({
}); });
}, },
url: new URL(request.url), url: new URL(request.url),
// @ts-expect-error
get clientAddress() { get clientAddress() {
if (!(clientAddressSymbol in request)) { if (!(clientAddressSymbol in request)) {
if (adapterName) { if (adapterName) {
@ -66,44 +75,60 @@ function createAPIContext({
return Reflect.get(request, clientAddressSymbol); 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, mod: EndpointHandler,
env: Environment, env: Environment,
ctx: RenderContext, ctx: RenderContext,
logging: LogOptions logging: LogOptions,
middleware?: AstroMiddlewareInstance<MiddlewareResult> | undefined
): Promise<EndpointCallResult> { ): 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({ const context = createAPIContext({
request: ctx.request, request: ctx.request,
params, params: ctx.params,
props, props: ctx.props,
site: env.site, site: env.site,
adapterName: env.adapterName, 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) { if (response instanceof Response) {
attachToResponse(response, context.cookies); 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, code: 3030,
message: 'The response has already been sent to the browser and cannot be altered.', 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. // No headings here, that way Vite errors are merged with Astro ones in the docs, which makes more sense to users.
// Vite Errors - 4xxx // 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. * The RenderContext represents the parts of rendering that are specific to one request.
@ -14,22 +23,38 @@ export interface RenderContext {
componentMetadata?: SSRResult['componentMetadata']; componentMetadata?: SSRResult['componentMetadata'];
route?: RouteData; route?: RouteData;
status?: number; status?: number;
params: Params;
props: Props;
} }
export type CreateRenderContextArgs = Partial<RenderContext> & { export type CreateRenderContextArgs = Partial<RenderContext> & {
origin?: string; origin?: string;
request: RenderContext['request']; 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 request = options.request;
const url = new URL(request.url); const url = new URL(request.url);
const origin = options.origin ?? url.origin; const origin = options.origin ?? url.origin;
const pathname = options.pathname ?? url.pathname; 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 { return {
...options, ...options,
origin, origin,
pathname, pathname,
url, url,
params,
props,
}; };
} }

View file

@ -1,12 +1,11 @@
import type { ComponentInstance, Params, Props, RouteData } from '../../@types/astro'; import type { APIContext, 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 { renderPage as runtimeRenderPage } from '../../runtime/server/index.js'; import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js';
import { attachToResponse } from '../cookies/index.js'; import { attachToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js';
import type { LogOptions } from '../logger/core.js';
import { getParams } from '../routing/params.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 { createResult } from './result.js';
import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js'; import { callGetStaticPaths, findPathItemByKey, RouteCache } from './route-cache.js';
@ -23,6 +22,26 @@ export const enum GetParamsAndPropsError {
NoMatchingStaticPath, 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( export async function getParamsAndProps(
opts: GetParamsAndPropsOptions opts: GetParamsAndPropsOptions
): Promise<[Params, Props] | GetParamsAndPropsError> { ): Promise<[Params, Props] | GetParamsAndPropsError> {
@ -84,65 +103,63 @@ export async function getParamsAndProps(
return [params, pageProps]; return [params, pageProps];
} }
export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env: Environment) { export type RenderPage = {
const paramsAndPropsRes = await getParamsAndProps({ mod: ComponentInstance;
logging: env.logging, renderContext: RenderContext;
mod, env: Environment;
route: ctx.route, apiContext?: APIContext;
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 async function renderPage({ mod, renderContext, env, apiContext }: RenderPage) {
// Validate the page component before rendering the page // Validate the page component before rendering the page
const Component = mod.default; const Component = mod.default;
if (!Component) if (!Component)
throw new Error(`Expected an exported Astro component but received typeof ${typeof 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({ const result = createResult({
adapterName: env.adapterName, adapterName: env.adapterName,
links: ctx.links, links: renderContext.links,
styles: ctx.styles, styles: renderContext.styles,
logging: env.logging, logging: env.logging,
markdown: env.markdown, markdown: env.markdown,
mode: env.mode, mode: env.mode,
origin: ctx.origin, origin: renderContext.origin,
params, params: renderContext.params,
props: pageProps, props: renderContext.props,
pathname: ctx.pathname, pathname: renderContext.pathname,
componentMetadata: ctx.componentMetadata, componentMetadata: renderContext.componentMetadata,
resolve: env.resolve, resolve: env.resolve,
renderers: env.renderers, renderers: env.renderers,
request: ctx.request, request: renderContext.request,
site: env.site, site: env.site,
scripts: ctx.scripts, scripts: renderContext.scripts,
ssr: env.ssr, ssr: env.ssr,
status: ctx.status ?? 200, status: renderContext.status ?? 200,
locals,
}); });
// Support `export const components` for `MDX` pages // Support `export const components` for `MDX` pages
if (typeof (mod as any).components === 'object') { 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, result,
Component, Component,
pageProps, renderContext.props,
null, null,
env.streaming, env.streaming,
ctx.route renderContext.route
); );
// If there is an Astro.cookies instance, attach it to the response so that // 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; 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 { fileURLToPath } from 'url';
import type { import type {
AstroMiddlewareInstance,
AstroSettings, AstroSettings,
ComponentInstance, ComponentInstance,
MiddlewareResponseHandler,
RouteData, RouteData,
SSRElement, SSRElement,
SSRLoadedRenderer, SSRLoadedRenderer,
} from '../../../@types/astro'; } from '../../../@types/astro';
import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import { createAPIContext } from '../../endpoint/index.js';
import { enhanceViteSSRError } from '../../errors/dev/index.js'; import { enhanceViteSSRError } from '../../errors/dev/index.js';
import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js'; import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js';
import { callMiddleware } from '../../middleware/callMiddleware.js';
import type { ModuleLoader } from '../../module-loader/index'; import type { ModuleLoader } from '../../module-loader/index';
import { isPage, resolveIdToUrl, viteID } from '../../util.js'; import { isPage, resolveIdToUrl, viteID } from '../../util.js';
import { createRenderContext, renderPage as coreRenderPage } from '../index.js'; import { createRenderContext, renderPage as coreRenderPage } from '../index.js';
@ -35,6 +39,10 @@ export interface SSROptions {
request: Request; request: Request;
/** optional, in case we need to render something outside of a dev server */ /** optional, in case we need to render something outside of a dev server */
route?: RouteData; route?: RouteData;
/**
* Optional middlewares
*/
middleware?: AstroMiddlewareInstance<unknown>;
} }
export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance]; export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance];
@ -158,8 +166,9 @@ export async function renderPage(options: SSROptions): Promise<Response> {
env: options.env, env: options.env,
filePath: options.filePath, filePath: options.filePath,
}); });
const { env } = options;
const ctx = createRenderContext({ const renderContext = await createRenderContext({
request: options.request, request: options.request,
origin: options.origin, origin: options.origin,
pathname: options.pathname, pathname: options.pathname,
@ -168,7 +177,25 @@ export async function renderPage(options: SSROptions): Promise<Response> {
styles, styles,
componentMetadata: metadata, componentMetadata: metadata,
route: options.route, 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 { createRenderContext } from './context.js';
export type { RenderContext } 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 type { Environment } from './environment';
export { createBasicEnvironment, createEnvironment } from './environment.js'; export { createBasicEnvironment, createEnvironment } from './environment.js';
export { loadRenderer } from './renderer.js'; export { loadRenderer } from './renderer.js';

View file

@ -50,6 +50,7 @@ export interface CreateResultArgs {
componentMetadata?: SSRResult['componentMetadata']; componentMetadata?: SSRResult['componentMetadata'];
request: Request; request: Request;
status: number; status: number;
locals: App.Locals;
} }
function getFunctionExpression(slot: any) { function getFunctionExpression(slot: any) {
@ -131,7 +132,7 @@ class Slots {
let renderMarkdown: any = null; let renderMarkdown: any = null;
export function createResult(args: CreateResultArgs): SSRResult { 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 url = new URL(request.url);
const headers = new Headers(); const headers = new Headers();
@ -200,6 +201,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
}, },
params, params,
props, props,
locals,
request, request,
url, url,
redirect: args.ssr redirect: args.ssr

View file

@ -16,6 +16,7 @@ export interface CreateRequestOptions {
} }
const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
export function createRequest({ export function createRequest({
url, url,
@ -65,5 +66,7 @@ export function createRequest({
Reflect.set(request, clientAddressSymbol, clientAddress); Reflect.set(request, clientAddressSymbol, clientAddress);
} }
Reflect.set(request, clientLocalsSymbol, {});
return request; return request;
} }

View file

@ -55,6 +55,7 @@ export async function runHookConfigSetup({
let updatedConfig: AstroConfig = { ...settings.config }; let updatedConfig: AstroConfig = { ...settings.config };
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig }; let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
for (const integration of settings.config.integrations) { for (const integration of settings.config.integrations) {
/** /**
* By making integration hooks optional, Astro can now ignore null or undefined 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'> = { const hooks: HookParameters<'astro:config:setup'> = {
config: updatedConfig, config: updatedConfig,
command, command,

View file

@ -19,7 +19,7 @@ function getHandlerFromModule(mod: EndpointHandler, method: string) {
/** Renders an endpoint request to completion, returning the body. */ /** Renders an endpoint request to completion, returning the body. */
export async function renderEndpoint(mod: EndpointHandler, context: APIContext, ssr: boolean) { 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 chosenMethod = request.method?.toLowerCase();
const handler = getHandlerFromModule(mod, chosenMethod); const handler = getHandlerFromModule(mod, chosenMethod);
if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') { if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') {

View file

@ -1,17 +1,17 @@
import type http from 'http'; import type http from 'http';
import mime from 'mime'; import mime from 'mime';
import type { ComponentInstance, ManifestData, RouteData } from '../@types/astro'; 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 { attachToResponse } from '../core/cookies/index.js';
import { call as callEndpoint } from '../core/endpoint/dev/index.js'; import { call as callEndpoint } from '../core/endpoint/dev/index.js';
import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js'; import { throwIfRedirectNotAllowed } from '../core/endpoint/index.js';
import { AstroErrorData } from '../core/errors/index.js'; import { AstroErrorData } from '../core/errors/index.js';
import { warn } from '../core/logger/core.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 { preload, renderPage } from '../core/render/dev/index.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/index.js';
import { createRequest } from '../core/request.js'; import { createRequest } from '../core/request.js';
@ -169,7 +169,12 @@ export async function handleRoute(
request, request,
route, 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. // Route successfully matched! Render it.
if (route.type === 'endpoint') { if (route.type === 'endpoint') {
const result = await callEndpoint(options, logging); 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); return new Response(data);
} }
Reflect.set(request, Symbol.for('astro.locals'), {});
${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''} ${provideAddress ? `request[Symbol.for('astro.clientAddress')] = '0.0.0.0';` : ''}
return super.render(request, routeData); return super.render(request, routeData);
} }
@ -51,6 +52,7 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd
return { return {
manifest, manifest,
createApp: (streaming) => new MyApp(manifest, streaming) createApp: (streaming) => new MyApp(manifest, streaming)
}; };
} }
`; `;

View file

@ -231,7 +231,7 @@ export async function loadFixture(inlineConfig) {
}, },
loadTestAdapterApp: async (streaming) => { loadTestAdapterApp: async (streaming) => {
const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir); 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); const app = createApp(streaming);
app.manifest = manifest; app.manifest = manifest;
return app; 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/'), request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }], 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 html = await response.text();
const $ = cheerio.load(html); 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/'), request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }], 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 html = await response.text();
const $ = cheerio.load(html); 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/'), request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }], 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 html = await response.text();
const $ = cheerio.load(html); const $ = cheerio.load(html);

View file

@ -1,5 +1,4 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { import {
createComponent, createComponent,
render, render,
@ -46,8 +45,18 @@ describe('core/render', () => {
}); });
}); });
const ctx = createRenderContext({ request: new Request('http://example.com/') }); const mod = createAstroModule(Page);
const response = await renderPage(createAstroModule(Page), ctx, env); 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); expect(response.status).to.equal(200);
@ -85,8 +94,17 @@ describe('core/render', () => {
}); });
}); });
const ctx = createRenderContext({ request: new Request('http://example.com/') }); const mod = createAstroModule(Page);
const response = await renderPage(createAstroModule(Page), ctx, env); 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); expect(response.status).to.equal(200);
@ -105,8 +123,18 @@ describe('core/render', () => {
return render`<div>${renderComponent(result, 'Component', Component, {})}</div>`; return render`<div>${renderComponent(result, 'Component', Component, {})}</div>`;
}); });
const ctx = createRenderContext({ request: new Request('http://example.com/') }); const mod = createAstroModule(Page);
const response = await renderPage(createAstroModule(Page), ctx, env); const ctx = await createRenderContext({
request: new Request('http://example.com/'),
env,
mod,
});
const response = await renderPage({
mod,
renderContext: ctx,
env,
});
try { try {
await response.text(); await response.text();

View file

@ -1,7 +1,7 @@
import { polyfill } from '@astrojs/webapi'; import { polyfill } from '@astrojs/webapi';
import type { SSRManifest } from 'astro'; import type { SSRManifest } from 'astro';
import { NodeApp } from 'astro/app/node'; import { NodeApp } from 'astro/app/node';
import middleware from './middleware.js'; import middleware from './nodeMiddleware.js';
import startServer from './standalone.js'; import startServer from './standalone.js';
import type { Options } from './types'; import type { Options } from './types';

View file

@ -3,7 +3,7 @@ import https from 'https';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { createServer } from './http-server.js'; import { createServer } from './http-server.js';
import middleware from './middleware.js'; import middleware from './nodeMiddleware.js';
import type { Options } from './types'; import type { Options } from './types';
function resolvePaths(options: Options) { function resolvePaths(options: Options) {

File diff suppressed because it is too large Load diff