Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: pipeline lifetime #9795

Merged
merged 20 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hungry-rings-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Refactors internals relating to middleware, endpoints, and page rendering.
5 changes: 2 additions & 3 deletions packages/astro/src/assets/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs, { readFileSync } from 'node:fs';
import { basename, join } from 'node:path/posix';
import type PQueue from 'p-queue';
import type { AstroConfig } from '../../@types/astro.js';
import type { BuildPipeline } from '../../core/build/buildPipeline.js';
import type { BuildPipeline } from '../../core/build/pipeline.js';
import { getOutDirWithinCwd } from '../../core/build/common.js';
import { getTimeStat } from '../../core/build/util.js';
import { AstroError } from '../../core/errors/errors.js';
Expand Down Expand Up @@ -50,8 +50,7 @@ export async function prepareAssetsGenerationEnv(
pipeline: BuildPipeline,
totalCount: number
): Promise<AssetEnv> {
const config = pipeline.getConfig();
const logger = pipeline.getLogger();
const { config, logger } = pipeline;
let useCache = true;
const assetsCacheDir = new URL('assets/', config.cacheDir);
const count = { total: totalCount, current: 1 };
Expand Down
176 changes: 29 additions & 147 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import type {
EndpointHandler,
ManifestData,
RouteData,
SSRElement,
SSRManifest,
} from '../../@types/astro.js';
import { createI18nMiddleware, i18nPipelineHook } from '../../i18n/middleware.js';
import { REROUTE_DIRECTIVE_HEADER } from '../../runtime/server/consts.js';
import type { SinglePageBuiltModule } from '../build/types.js';
import { getSetCookiesFromResponse } from '../cookies/index.js';
import { consoleLogDestination } from '../logger/console.js';
import { AstroIntegrationLogger, Logger } from '../logger/core.js';
import { sequence } from '../middleware/index.js';
import {
appendForwardSlash,
collapseDuplicateSlashes,
Expand All @@ -20,29 +15,15 @@ import {
removeTrailingForwardSlash,
} from '../path.js';
import { RedirectSinglePageBuiltModule } from '../redirects/index.js';
import { createEnvironment, createRenderContext, type RenderContext } from '../render/index.js';
import { RouteCache } from '../render/route-cache.js';
import {
createAssetLink,
createModuleScriptElement,
createStylesheetElementSet,
} from '../render/ssr-element.js';
import { createAssetLink } from '../render/ssr-element.js';
import { matchRoute } from '../routing/match.js';
import { SSRRoutePipeline } from './ssrPipeline.js';
import type { RouteInfo } from './types.js';
import { AppPipeline } from './pipeline.js';
import { normalizeTheLocale } from '../../i18n/index.js';
import { RenderContext } from '../render-context.js';
import { clientAddressSymbol, clientLocalsSymbol, responseSentSymbol, REROUTABLE_STATUS_CODES, REROUTE_DIRECTIVE_HEADER } from '../constants.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
export { deserializeManifest } from './common.js';

const localsSymbol = Symbol.for('astro.locals');
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const responseSentSymbol = Symbol.for('astro.responseSent');

/**
* A response with one of these status codes will be rewritten
* with the result of rendering the respective error page.
*/
const REROUTABLE_STATUS_CODES = new Set([404, 500]);

export interface RenderOptions {
/**
* Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers.
Expand Down Expand Up @@ -86,18 +67,14 @@ export interface RenderErrorOptions {
}

export class App {
/**
* The current environment of the application
*/
#manifest: SSRManifest;
#manifestData: ManifestData;
#routeDataToRouteInfo: Map<RouteData, RouteInfo>;
#logger = new Logger({
dest: consoleLogDestination,
level: 'info',
});
#baseWithoutTrailingSlash: string;
#pipeline: SSRRoutePipeline;
#pipeline: AppPipeline;
#adapterLogger: AstroIntegrationLogger;
#renderOptionsDeprecationWarningShown = false;

Expand All @@ -106,9 +83,8 @@ export class App {
this.#manifestData = {
routes: manifest.routes.map((route) => route.routeData),
};
this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route]));
this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base);
this.#pipeline = new SSRRoutePipeline(this.#createEnvironment(streaming));
this.#pipeline = this.#createPipeline(streaming);
this.#adapterLogger = new AstroIntegrationLogger(
this.#logger.options,
this.#manifest.adapterName
Expand All @@ -120,19 +96,17 @@ export class App {
}

/**
* Creates an environment by reading the stored manifest
* Creates a pipeline by reading the stored manifest
*
* @param streaming
* @private
*/
#createEnvironment(streaming = false) {
return createEnvironment({
adapterName: this.#manifest.adapterName,
#createPipeline(streaming = false) {
return AppPipeline.create({
logger: this.#logger,
manifest: this.#manifest,
mode: 'production',
compressHTML: this.#manifest.compressHTML,
renderers: this.#manifest.renderers,
clientDirectives: this.#manifest.clientDirectives,
resolve: async (specifier: string) => {
if (!(specifier in this.#manifest.entryModules)) {
throw new Error(`Unable to resolve [${specifier}]`);
Expand All @@ -148,11 +122,9 @@ export class App {
}
}
},
routeCache: new RouteCache(this.#logger),
site: this.#manifest.site,
ssr: true,
serverLike: true,
streaming,
});
})
}

set setManifestData(newManifestData: ManifestData) {
Expand Down Expand Up @@ -297,7 +269,11 @@ export class App {
}
}
if (locals) {
Reflect.set(request, localsSymbol, locals);
if (typeof locals !== 'object') {
this.#logger.error(null, new AstroError(AstroErrorData.LocalsNotAnObject).stack!);
return this.#renderError(request, { status: 500 });
}
Reflect.set(request, clientLocalsSymbol, locals);
}
if (clientAddress) {
Reflect.set(request, clientAddressSymbol, clientAddress);
Expand All @@ -316,38 +292,17 @@ export class App {
const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
const mod = await this.#getModuleForRoute(routeData);

const pageModule = (await mod.page()) as any;
const url = new URL(request.url);

const renderContext = await this.#createRenderContext(
url,
request,
routeData,
mod,
defaultStatus
);
let response;
try {
const i18nMiddleware = createI18nMiddleware(
this.#manifest.i18n,
this.#manifest.base,
this.#manifest.trailingSlash,
this.#manifest.buildFormat
);
if (i18nMiddleware) {
this.#pipeline.setMiddlewareFunction(sequence(i18nMiddleware, this.#manifest.middleware));
this.#pipeline.onBeforeRenderRoute(i18nPipelineHook);
} else {
this.#pipeline.setMiddlewareFunction(this.#manifest.middleware);
}
response = await this.#pipeline.renderRoute(renderContext, pageModule);
const renderContext = RenderContext.create({ pipeline: this.#pipeline, locals, pathname, request, routeData, status: defaultStatus })
response = await renderContext.render(await mod.page());
} catch (err: any) {
this.#logger.error(null, err.stack || err.message || String(err));
return this.#renderError(request, { status: 500 });
}

if (
REROUTABLE_STATUS_CODES.has(response.status) &&
REROUTABLE_STATUS_CODES.includes(response.status) &&
response.headers.get(REROUTE_DIRECTIVE_HEADER) !== 'no'
) {
return this.#renderError(request, {
Expand Down Expand Up @@ -396,72 +351,6 @@ export class App {
*/
static getSetCookieFromResponse = getSetCookiesFromResponse;

/**
* Creates the render context of the current route
*/
async #createRenderContext(
url: URL,
request: Request,
routeData: RouteData,
page: SinglePageBuiltModule,
status = 200
): Promise<RenderContext> {
if (routeData.type === 'endpoint') {
const pathname = '/' + this.removeBase(url.pathname);
const mod = await page.page();
const handler = mod as unknown as EndpointHandler;

return await createRenderContext({
request,
pathname,
route: routeData,
status,
env: this.#pipeline.env,
mod: handler as any,
locales: this.#manifest.i18n?.locales,
routing: this.#manifest.i18n?.routing,
defaultLocale: this.#manifest.i18n?.defaultLocale,
});
} else {
const pathname = prependForwardSlash(this.removeBase(url.pathname));
const info = this.#routeDataToRouteInfo.get(routeData)!;
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const links = new Set<never>();
const styles = createStylesheetElementSet(info.styles);

let scripts = new Set<SSRElement>();
for (const script of info.scripts) {
if ('stage' in script) {
if (script.stage === 'head-inline') {
scripts.add({
props: {},
children: script.children,
});
}
} else {
scripts.add(createModuleScriptElement(script));
}
}
const mod = await page.page();

return await createRenderContext({
request,
pathname,
componentMetadata: this.#manifest.componentMetadata,
scripts,
styles,
links,
route: routeData,
status,
mod,
env: this.#pipeline.env,
locales: this.#manifest.i18n?.locales,
routing: this.#manifest.i18n?.routing,
defaultLocale: this.#manifest.i18n?.defaultLocale,
});
}
}

/**
* If it is a known error code, try sending the according page (e.g. 404.astro / 500.astro).
* This also handles pre-rendered /404 or /500 routes
Expand Down Expand Up @@ -490,22 +379,15 @@ export class App {
}
const mod = await this.#getModuleForRoute(errorRouteData);
try {
const newRenderContext = await this.#createRenderContext(
url,
const renderContext = RenderContext.create({
pipeline: this.#pipeline,
middleware: skipMiddleware ? (_, next) => next() : undefined,
pathname: this.#getPathnameFromRequest(request),
request,
errorRouteData,
mod,
status
);
const page = (await mod.page()) as any;
if (skipMiddleware === false) {
this.#pipeline.setMiddlewareFunction(this.#manifest.middleware);
}
if (skipMiddleware) {
// make sure middleware set by other requests is cleared out
this.#pipeline.unsetMiddlewareFunction();
}
const response = await this.#pipeline.renderRoute(newRenderContext, page);
routeData: errorRouteData,
status,
})
const response = await renderContext.render(await mod.page());
return this.#mergeResponses(response, originalResponse);
} catch {
// Middleware may be the cause of the error, so we try rendering 404/500.astro without it.
Expand Down
33 changes: 33 additions & 0 deletions packages/astro/src/core/app/pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { RouteData, SSRElement, SSRResult } from "../../@types/astro.js";
import { Pipeline } from "../base-pipeline.js";
import { createModuleScriptElement, createStylesheetElementSet } from "../render/ssr-element.js";

export class AppPipeline extends Pipeline {
static create({ logger, manifest, mode, renderers, resolve, serverLike, streaming }: Pick<AppPipeline, 'logger' | 'manifest' | 'mode' | 'renderers' | 'resolve' | 'serverLike' | 'streaming'>) {
return new AppPipeline(logger, manifest, mode, renderers, resolve, serverLike, streaming);
}

headElements(routeData: RouteData): Pick<SSRResult, 'scripts' | 'styles' | 'links'> {
const routeInfo = this.manifest.routes.find(route => route.routeData === routeData);
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const links = new Set<never>();
const scripts = new Set<SSRElement>();
const styles = createStylesheetElementSet(routeInfo?.styles ?? []);

for (const script of routeInfo?.scripts ?? []) {
if ('stage' in script) {
if (script.stage === 'head-inline') {
scripts.add({
props: {},
children: script.children,
});
}
} else {
scripts.add(createModuleScriptElement(script));
}
}
return { links, styles, scripts }
}

componentMetadata() {}
}
3 changes: 0 additions & 3 deletions packages/astro/src/core/app/ssrPipeline.ts

This file was deleted.

51 changes: 51 additions & 0 deletions packages/astro/src/core/base-pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { MiddlewareHandler, RouteData, RuntimeMode, SSRLoadedRenderer, SSRManifest, SSRResult } from '../@types/astro.js';
import type { Logger } from './logger/core.js';
import { RouteCache } from './render/route-cache.js';
import { createI18nMiddleware } from '../i18n/middleware.js';

/**
* The `Pipeline` represents the static parts of rendering that do not change between requests.
* These are mostly known when the server first starts up and do not change.
*
* Thus, a `Pipeline` is created once at process start and then used by every `RenderContext`.
*/
export abstract class Pipeline {
readonly internalMiddleware: MiddlewareHandler[];

constructor(
readonly logger: Logger,
readonly manifest: SSRManifest,
/**
* "development" or "production"
*/
readonly mode: RuntimeMode,
readonly renderers: SSRLoadedRenderer[],
readonly resolve: (s: string) => Promise<string>,
/**
* Based on Astro config's `output` option, `true` if "server" or "hybrid".
*/
readonly serverLike: boolean,
readonly streaming: boolean,
/**
* Used to provide better error messages for `Astro.clientAddress`
*/
readonly adapterName = manifest.adapterName,
readonly clientDirectives = manifest.clientDirectives,
readonly compressHTML = manifest.compressHTML,
readonly i18n = manifest.i18n,
readonly middleware = manifest.middleware,
readonly routeCache = new RouteCache(logger, mode),
/**
* Used for `Astro.site`.
*/
readonly site = manifest.site,
) {
this.internalMiddleware = [ createI18nMiddleware(i18n, manifest.base, manifest.trailingSlash, manifest.buildFormat) ];
}

abstract headElements(routeData: RouteData): Promise<HeadElements> | HeadElements
abstract componentMetadata(routeData: RouteData): Promise<SSRResult['componentMetadata']> | void
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface HeadElements extends Pick<SSRResult, 'scripts' | 'styles' | 'links'> {}