Skip to content

Commit

Permalink
refactor: pipeline lifetime (#9795)
Browse files Browse the repository at this point in the history
* Base Environment

* SSRRoutePipeline -> AppEnvironment

* BuildPipeline -> BuildEnvironment

* DevPipeline -> DevEnvironment

* per-request pipeline

* internal middleware: i18n

* delete callEndpoint

* pipeline access for internal middleware

* Address review comments

`environment.ts` is now lives directly in `src/core`, rather than `src/core/render`.

`environment.createPipeline` is removed. `Pipeline.create` is used instead.

Constructors with positional arguments are replaced by `Environment.create` with named arguments.

Clarifies the use of `HiddenPipeline`.

* migrate some of `RenderContext`'s responsibilities to `Pipeline`

* delete renderPage

* RenderContext.params -> Pipeline.params

* delete `RenderContext`

* `Pipeline` -> `RenderContext`

* `Environment` -> `Pipeline`

* `AppEnvironment` -> `AppPipeline`

* `BuildEnvironment` -> `BuildPipeline`

* `DevEnvironment` -> `DevPipeline`

* provide locals directly to renderContext

* add changeset
  • Loading branch information
lilnasy committed Feb 20, 2024
1 parent ea990a5 commit 5acc313
Show file tree
Hide file tree
Showing 46 changed files with 828 additions and 1,353 deletions.
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'> {}

0 comments on commit 5acc313

Please sign in to comment.