refactor: add pipeline concept (#8020)

This commit is contained in:
Emanuele Stoppa 2023-08-10 14:56:13 +01:00 committed by GitHub
parent 924bef998e
commit a39ff7ed6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 279 additions and 78 deletions

View file

@ -3,3 +3,16 @@
Code that executes within the top-level Node context. Contains the main Astro logic for the `build` and `dev` commands, and also manages the Vite server and SSR.
[See CONTRIBUTING.md](../../../../CONTRIBUTING.md) for a code overview.
## Pipeline
The pipeline is an internal concept that describes how Astro pages are eventually created and rendered to the user.
Each pipeline has different requirements, criteria and quirks. Although, each pipeline must use the same underline functions, because
the core of the pipeline is the same.
The core of the pipeline is rendering a generic route (page, endpoint or redirect) and returning a `Response`.
When rendering a route, a pipeline must pass a `RenderContext` and `ComponentInstance`. The way these two information are
computed doesn't concern the core of a pipeline. In fact, these types will be computed in different manner based on the type of pipeline.
Each consumer will decide how to handle a `Response`.

View file

@ -1,13 +1,13 @@
import mime from 'mime';
import type {
EndpointHandler,
ManifestData,
MiddlewareEndpointHandler,
RouteData,
SSRElement,
SSRManifest,
} from '../../@types/astro';
import type { SinglePageBuiltModule } from '../build/types';
import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js';
import { getSetCookiesFromResponse } from '../cookies/index.js';
import { consoleLogDestination } from '../logger/console.js';
import { error, type LogOptions } from '../logger/core.js';
import {
@ -16,12 +16,10 @@ import {
removeTrailingForwardSlash,
} from '../path.js';
import { RedirectSinglePageBuiltModule } from '../redirects/index.js';
import { isResponse } from '../render/core.js';
import {
createEnvironment,
createRenderContext,
tryRenderRoute,
type Environment,
type RenderContext,
} from '../render/index.js';
import { RouteCache } from '../render/route-cache.js';
@ -32,6 +30,7 @@ import {
} from '../render/ssr-element.js';
import { matchRoute } from '../routing/match.js';
import type { RouteInfo } from './types';
import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js';
export { deserializeManifest } from './common.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
@ -53,16 +52,15 @@ export class App {
/**
* The current environment of the application
*/
#env: Environment;
#manifest: SSRManifest;
#manifestData: ManifestData;
#routeDataToRouteInfo: Map<RouteData, RouteInfo>;
#encoder = new TextEncoder();
#logging: LogOptions = {
dest: consoleLogDestination,
level: 'info',
};
#baseWithoutTrailingSlash: string;
#pipeline: SSRRoutePipeline;
constructor(manifest: SSRManifest, streaming = true) {
this.#manifest = manifest;
@ -71,7 +69,7 @@ export class App {
};
this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route]));
this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base);
this.#env = this.#createEnvironment(streaming);
this.#pipeline = new SSRRoutePipeline(this.#createEnvironment(streaming));
}
set setManifest(newManifest: SSRManifest) {
@ -163,19 +161,21 @@ export class App {
);
let response;
try {
response = await tryRenderRoute(
routeData.type,
renderContext,
this.#env,
pageModule,
mod.onRequest
);
// NOTE: ideally we could set the middleware function just once, but we don't have the infrastructure to that yet
if (mod.onRequest) {
this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler);
}
response = await this.#pipeline.renderRoute(renderContext, pageModule);
} catch (err: any) {
error(this.#logging, 'ssr', err.stack || err.message || String(err));
return this.#renderError(request, { status: 500 });
if (err instanceof EndpointNotFoundError) {
return this.#renderError(request, { status: 404, response: err.originalResponse });
} else {
error(this.#logging, 'ssr', err.stack || err.message || String(err));
return this.#renderError(request, { status: 500 });
}
}
if (isResponse(response, routeData.type)) {
if (SSRRoutePipeline.isResponse(response, routeData.type)) {
if (STATUS_CODES.has(response.status)) {
return this.#renderError(request, {
response,
@ -184,35 +184,8 @@ export class App {
}
Reflect.set(response, responseSentSymbol, true);
return response;
} else {
if (response.type === 'response') {
if (response.response.headers.get('X-Astro-Response') === 'Not-Found') {
return this.#renderError(request, {
response: response.response,
status: 404,
});
}
return response.response;
} else {
const headers = new Headers();
const mimeType = mime.getType(url.pathname);
if (mimeType) {
headers.set('Content-Type', `${mimeType};charset=utf-8`);
} else {
headers.set('Content-Type', 'text/plain;charset=utf-8');
}
const bytes =
response.encoding !== 'binary' ? this.#encoder.encode(response.body) : response.body;
headers.set('Content-Length', bytes.byteLength.toString());
const newResponse = new Response(bytes, {
status: 200,
headers,
});
attachToResponse(newResponse, response.cookies);
return newResponse;
}
}
return response;
}
setCookieHeaders(response: Response) {
@ -238,7 +211,7 @@ export class App {
pathname,
route: routeData,
status,
env: this.#env,
env: this.#pipeline.env,
mod: handler as any,
});
} else {
@ -272,7 +245,7 @@ export class App {
route: routeData,
status,
mod,
env: this.#env,
env: this.#pipeline.env,
});
}
}
@ -301,9 +274,8 @@ export class App {
);
const page = (await mod.page()) as any;
const response = (await tryRenderRoute(
'page', // this is hardcoded to ensure proper behavior for missing endpoints
newRenderContext,
this.#env,
this.#pipeline.env,
page
)) as Response;
return this.#mergeResponses(response, originalResponse);

View file

@ -0,0 +1,54 @@
import type { Environment } from '../render';
import type { EndpointCallResult } from '../endpoint/index.js';
import mime from 'mime';
import { attachCookiesToResponse } from '../cookies/index.js';
import { Pipeline } from '../pipeline.js';
/**
* Thrown when an endpoint contains a response with the header "X-Astro-Response" === 'Not-Found'
*/
export class EndpointNotFoundError extends Error {
originalResponse: Response;
constructor(originalResponse: Response) {
super();
this.originalResponse = originalResponse;
}
}
export class SSRRoutePipeline extends Pipeline {
encoder = new TextEncoder();
constructor(env: Environment) {
super(env);
this.setEndpointHandler(this.#ssrEndpointHandler);
}
// This function is responsible for handling the result coming from an endpoint.
async #ssrEndpointHandler(request: Request, response: EndpointCallResult): Promise<Response> {
if (response.type === 'response') {
if (response.response.headers.get('X-Astro-Response') === 'Not-Found') {
throw new EndpointNotFoundError(response.response);
}
return response.response;
} else {
const url = new URL(request.url);
const headers = new Headers();
const mimeType = mime.getType(url.pathname);
if (mimeType) {
headers.set('Content-Type', `${mimeType};charset=utf-8`);
} else {
headers.set('Content-Type', 'text/plain;charset=utf-8');
}
const bytes =
response.encoding !== 'binary' ? this.encoder.encode(response.body) : response.body;
headers.set('Content-Length', bytes.byteLength.toString());
const newResponse = new Response(bytes, {
status: 200,
headers,
});
attachCookiesToResponse(newResponse, response.cookies);
return newResponse;
}
}
}

View file

@ -547,7 +547,7 @@ async function generatePath(
let response;
try {
response = await tryRenderRoute(pageData.route.type, renderContext, env, mod, onRequest);
response = await tryRenderRoute(renderContext, env, mod, onRequest);
} catch (err) {
if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
(err as SSRError).id = pageData.component;

View file

@ -1,4 +1,3 @@
import type { Node as ESTreeNode } from 'estree-walker';
import type { ModuleInfo, PluginContext } from 'rollup';
import type { Plugin as VitePlugin } from 'vite';
import type { PluginMetadata as AstroPluginMetadata } from '../../../vite-plugin-astro/types';

View file

@ -1,2 +1,2 @@
export { AstroCookies } from './cookies.js';
export { attachToResponse, getSetCookiesFromResponse } from './response.js';
export { attachCookiesToResponse, getSetCookiesFromResponse } from './response.js';

View file

@ -2,7 +2,7 @@ import type { AstroCookies } from './cookies';
const astroCookiesSymbol = Symbol.for('astro.cookies');
export function attachToResponse(response: Response, cookies: AstroCookies) {
export function attachCookiesToResponse(response: Response, cookies: AstroCookies) {
Reflect.set(response, astroCookiesSymbol, cookies);
}

View file

@ -9,7 +9,7 @@ import type {
import type { Environment, RenderContext } from '../render/index';
import { renderEndpoint } from '../../runtime/server/index.js';
import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachToResponse } from '../cookies/index.js';
import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { warn } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
@ -125,7 +125,7 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput>
}
if (response instanceof Response) {
attachToResponse(response, context.cookies);
attachCookiesToResponse(response, context.cookies);
return {
type: 'response',
response,

View file

@ -0,0 +1,156 @@
import { type RenderContext, type Environment } from './render/index.js';
import { type EndpointCallResult, callEndpoint, createAPIContext } from './endpoint/index.js';
import type {
MiddlewareHandler,
MiddlewareResponseHandler,
ComponentInstance,
MiddlewareEndpointHandler,
RouteType,
EndpointHandler,
} from '../@types/astro';
import { callMiddleware } from './middleware/callMiddleware.js';
import { renderPage } from './render/core.js';
type EndpointResultHandler = (
originalRequest: Request,
result: EndpointCallResult
) => Promise<Response> | Response;
/**
* This is the basic class of a pipeline.
*
* Check the {@link ./README.md|README} for more information about the pipeline.
*/
export class Pipeline {
env: Environment;
onRequest?: MiddlewareEndpointHandler;
/**
* The handler accepts the *original* `Request` and result returned by the endpoint.
* It must return a `Response`.
*/
endpointHandler?: EndpointResultHandler;
/**
* When creating a pipeline, an environment is mandatory.
* The environment won't change for the whole lifetime of the pipeline.
*/
constructor(env: Environment) {
this.env = env;
}
/**
* When rendering a route, an "endpoint" will a type that needs to be handled and transformed into a `Response`.
*
* Each consumer might have different needs; use this function to set up the handler.
*/
setEndpointHandler(handler: EndpointResultHandler) {
this.endpointHandler = handler;
}
/**
* A middleware function that will be called before each request.
*/
setMiddlewareFunction(onRequest: MiddlewareEndpointHandler) {
this.onRequest = onRequest;
}
/**
* The main function of the pipeline. Use this function to render any route known to Astro;
*/
async renderRoute(
renderContext: RenderContext,
componentInstance: ComponentInstance
): Promise<Response> {
const result = await this.#tryRenderRoute(
renderContext,
this.env,
componentInstance,
this.onRequest
);
if (Pipeline.isEndpointResult(result, renderContext.route.type)) {
if (!this.endpointHandler) {
throw new Error(
'You created a pipeline that does not know how to handle the result coming from an endpoint.'
);
}
return this.endpointHandler(renderContext.request, result);
} else {
return result;
}
}
/**
* It attempts to render a route. A route can be a:
* - page
* - redirect
* - endpoint
*
* ## Errors
*
* It throws an error if the page can't be rendered.
*/
async #tryRenderRoute<MiddlewareReturnType = Response>(
renderContext: Readonly<RenderContext>,
env: Readonly<Environment>,
mod: Readonly<ComponentInstance>,
onRequest?: MiddlewareHandler<MiddlewareReturnType>
): Promise<Response | EndpointCallResult> {
const apiContext = createAPIContext({
request: renderContext.request,
params: renderContext.params,
props: renderContext.props,
site: env.site,
adapterName: env.adapterName,
});
switch (renderContext.route.type) {
case 'page':
case 'redirect': {
if (onRequest) {
return await callMiddleware<Response>(
env.logging,
onRequest as MiddlewareResponseHandler,
apiContext,
() => {
return renderPage({
mod,
renderContext,
env,
cookies: apiContext.cookies,
});
}
);
} else {
return await renderPage({
mod,
renderContext,
env,
cookies: apiContext.cookies,
});
}
}
case 'endpoint': {
const result = await callEndpoint(
mod as any as EndpointHandler,
env,
renderContext,
onRequest
);
return result;
}
default:
throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);
}
}
/**
* Use this function
*/
static isEndpointResult(result: any, routeType: RouteType): result is EndpointCallResult {
return !(result instanceof Response) && routeType === 'endpoint';
}
static isResponse(result: any, routeType: RouteType): result is Response {
return result instanceof Response && (routeType === 'page' || routeType === 'redirect');
}
}

View file

@ -22,7 +22,7 @@ export interface RenderContext {
links?: Set<SSRElement>;
styles?: Set<SSRElement>;
componentMetadata?: SSRResult['componentMetadata'];
route?: RouteData;
route: RouteData;
status?: number;
params: Params;
props: Props;
@ -32,6 +32,7 @@ export interface RenderContext {
export type CreateRenderContextArgs = Partial<
Omit<RenderContext, 'params' | 'props' | 'locals'>
> & {
route: RouteData;
request: RenderContext['request'];
mod: ComponentInstance;
env: Environment;

View file

@ -7,7 +7,7 @@ import type {
RouteType,
} from '../../@types/astro';
import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js';
import { attachToResponse } from '../cookies/index.js';
import { attachCookiesToResponse } from '../cookies/index.js';
import { callEndpoint, createAPIContext, type EndpointCallResult } from '../endpoint/index.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../redirects/index.js';
@ -22,7 +22,7 @@ export type RenderPage = {
cookies: AstroCookies;
};
async function renderPage({ mod, renderContext, env, cookies }: RenderPage) {
export async function renderPage({ mod, renderContext, env, cookies }: RenderPage) {
if (routeIsRedirect(renderContext.route)) {
return new Response(null, {
status: redirectRouteStatus(renderContext.route, renderContext.request.method),
@ -70,7 +70,7 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) {
// If there is an Astro.cookies instance, attach it to the response so that
// adapters can grab the Set-Cookie headers.
if (result.cookies) {
attachToResponse(response, result.cookies);
attachCookiesToResponse(response, result.cookies);
}
return response;
@ -85,9 +85,9 @@ async function renderPage({ mod, renderContext, env, cookies }: RenderPage) {
* ## Errors
*
* It throws an error if the page can't be rendered.
* @deprecated Use the pipeline instead
*/
export async function tryRenderRoute<MiddlewareReturnType = Response>(
routeType: RouteType,
renderContext: Readonly<RenderContext>,
env: Readonly<Environment>,
mod: Readonly<ComponentInstance>,
@ -101,7 +101,7 @@ export async function tryRenderRoute<MiddlewareReturnType = Response>(
adapterName: env.adapterName,
});
switch (routeType) {
switch (renderContext.route.type) {
case 'page':
case 'redirect': {
if (onRequest) {
@ -137,7 +137,7 @@ export async function tryRenderRoute<MiddlewareReturnType = Response>(
return result;
}
default:
throw new Error(`Couldn't find route of type [${routeType}]`);
throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);
}
}

View file

@ -22,7 +22,7 @@ export interface SSROptions {
/** Request */
request: Request;
/** optional, in case we need to render something outside of a dev server */
route?: RouteData;
route: RouteData;
/**
* Optional middlewares
*/

View file

@ -8,7 +8,7 @@ import type {
SSRElement,
SSRManifest,
} from '../@types/astro';
import { attachToResponse } from '../core/cookies/index.js';
import { attachCookiesToResponse } from '../core/cookies/index.js';
import { AstroErrorData, isAstroError } from '../core/errors/index.js';
import { warn } from '../core/logger/core.js';
import { loadMiddleware } from '../core/middleware/loadMiddleware.js';
@ -49,18 +49,18 @@ export interface MatchedRoute {
mod: ComponentInstance;
}
function getCustom404Route(manifest: ManifestData): RouteData | undefined {
function getCustom404Route(manifestData: ManifestData): RouteData | undefined {
const route404 = /^\/404\/?$/;
return manifest.routes.find((r) => route404.test(r.route));
return manifestData.routes.find((r) => route404.test(r.route));
}
export async function matchRoute(
pathname: string,
env: DevelopmentEnvironment,
manifest: ManifestData
manifestData: ManifestData
): Promise<MatchedRoute | undefined> {
const { logging, settings, routeCache } = env;
const matches = matchAllRoutes(pathname, manifest);
const matches = matchAllRoutes(pathname, manifestData);
const preloadedMatches = await getSortedPreloadedMatches({ env, matches, settings });
for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) {
@ -96,7 +96,7 @@ export async function matchRoute(
// build formats, and is necessary based on how the manifest tracks build targets.
const altPathname = pathname.replace(/(index)?\.html$/, '');
if (altPathname !== pathname) {
return await matchRoute(altPathname, env, manifest);
return await matchRoute(altPathname, env, manifestData);
}
if (matches.length) {
@ -112,7 +112,7 @@ export async function matchRoute(
}
log404(logging, pathname);
const custom404 = getCustom404Route(manifest);
const custom404 = getCustom404Route(manifestData);
if (custom404) {
const filePath = new URL(`./${custom404.component}`, settings.config.root);
@ -216,7 +216,7 @@ export async function handleRoute({
});
const onRequest = options.middleware?.onRequest as MiddlewareResponseHandler | undefined;
const result = await tryRenderRoute(route.type, renderContext, env, mod, onRequest);
const result = await tryRenderRoute(renderContext, env, mod, onRequest);
if (isEndpointResult(result, route.type)) {
if (result.type === 'response') {
if (result.response.headers.get('X-Astro-Response') === 'Not-Found') {
@ -255,7 +255,7 @@ export async function handleRoute({
},
}
);
attachToResponse(response, result.cookies);
attachCookiesToResponse(response, result.cookies);
await writeWebResponse(incomingResponse, response);
}
} else {

View file

@ -90,13 +90,14 @@ describe('core/render', () => {
const PageModule = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index' },
request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }],
mod: PageModule,
env,
});
const response = await tryRenderRoute('page', ctx, env, PageModule);
const response = await tryRenderRoute(ctx, env, PageModule);
const html = await response.text();
const $ = cheerio.load(html);
@ -170,13 +171,14 @@ describe('core/render', () => {
const PageModule = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index' },
request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }],
env,
mod: PageModule,
});
const response = await tryRenderRoute('page', ctx, env, PageModule);
const response = await tryRenderRoute(ctx, env, PageModule);
const html = await response.text();
const $ = cheerio.load(html);
@ -216,13 +218,14 @@ describe('core/render', () => {
const PageModule = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index' },
request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }],
env,
mod: PageModule,
});
const response = await tryRenderRoute('page', ctx, env, PageModule);
const response = await tryRenderRoute(ctx, env, PageModule);
const html = await response.text();
const $ = cheerio.load(html);

View file

@ -45,12 +45,13 @@ describe('core/render', () => {
const mod = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index' },
request: new Request('http://example.com/'),
env,
mod,
});
const response = await tryRenderRoute('page', ctx, env, mod);
const response = await tryRenderRoute(ctx, env, mod);
expect(response.status).to.equal(200);
@ -90,11 +91,12 @@ describe('core/render', () => {
const mod = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index' },
request: new Request('http://example.com/'),
env,
mod,
});
const response = await tryRenderRoute('page', ctx, env, mod);
const response = await tryRenderRoute(ctx, env, mod);
expect(response.status).to.equal(200);
@ -115,12 +117,13 @@ describe('core/render', () => {
const mod = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index' },
request: new Request('http://example.com/'),
env,
mod,
});
const response = await tryRenderRoute('page', ctx, env, mod);
const response = await tryRenderRoute(ctx, env, mod);
try {
await response.text();