0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-24 23:21:57 -05:00

refactor: pipeline lifetime (#9795)

* 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
This commit is contained in:
Arsh 2024-02-20 07:40:13 -07:00 committed by GitHub
parent ea990a5614
commit 5acc3135ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 831 additions and 1356 deletions

View file

@ -0,0 +1,5 @@
---
"astro": patch
---
Refactors internals relating to middleware, endpoints, and page rendering.

View file

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

View file

@ -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,
@ -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.
@ -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;
@ -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
@ -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}]`);
@ -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) {
@ -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);
@ -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, {
@ -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
@ -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.

View file

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

View file

@ -1,3 +0,0 @@
import { Pipeline } from '../pipeline.js';
export class SSRRoutePipeline extends Pipeline {}

View file

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

View file

@ -29,30 +29,21 @@ import {
removeLeadingForwardSlash,
removeTrailingForwardSlash,
} from '../../core/path.js';
import { createI18nMiddleware, i18nPipelineHook } from '../../i18n/middleware.js';
import { runHookBuildGenerated } from '../../integrations/index.js';
import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js';
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import type { SSRManifestI18n } from '../app/types.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { sequence } from '../middleware/index.js';
import { routeIsFallback } from '../redirects/helpers.js';
import {
RedirectSinglePageBuiltModule,
getRedirectLocationOrThrow,
routeIsRedirect,
} from '../redirects/index.js';
import { createRenderContext } from '../render/index.js';
import { callGetStaticPaths } from '../render/route-cache.js';
import {
createAssetLink,
createModuleScriptsSet,
createStylesheetElementSet,
} from '../render/ssr-element.js';
import { createRequest } from '../request.js';
import { matchRoute } from '../routing/match.js';
import { getOutputFilename } from '../util.js';
import { BuildPipeline } from './buildPipeline.js';
import { BuildPipeline } from './pipeline.js';
import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js';
import {
cssOrder,
@ -68,6 +59,7 @@ import type {
} from './types.js';
import { getTimeStat, shouldAppendForwardSlash } from './util.js';
import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js';
import { RenderContext } from '../render-context.js';
function createEntryURL(filePath: string, outFolder: URL) {
return new URL('./' + filePath + `?time=${Date.now()}`, outFolder);
@ -138,14 +130,14 @@ export function chunkIsPage(
return false;
}
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) {
const generatePagesTimer = performance.now();
const ssr = isServerLikeOutput(opts.settings.config);
const ssr = isServerLikeOutput(options.settings.config);
let manifest: SSRManifest;
if (ssr) {
manifest = await BuildPipeline.retrieveManifest(opts, internals);
manifest = await BuildPipeline.retrieveManifest(options, internals);
} else {
const baseDirectory = getOutputDirectory(opts.settings.config);
const baseDirectory = getOutputDirectory(options.settings.config);
const renderersEntryUrl = new URL('renderers.mjs', baseDirectory);
const renderers = await import(renderersEntryUrl.toString());
let middleware: MiddlewareHandler = (_, next) => next();
@ -157,19 +149,19 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
);
} catch {}
manifest = createBuildManifest(
opts.settings,
options.settings,
internals,
renderers.renderers as SSRLoadedRenderer[],
middleware
);
}
const pipeline = new BuildPipeline(opts, internals, manifest);
const pipeline = BuildPipeline.create({ internals, manifest, options });
const { config, logger } = pipeline;
const outFolder = ssr
? opts.settings.config.build.server
: getOutDirWithinCwd(opts.settings.config.outDir);
? options.settings.config.build.server
: getOutDirWithinCwd(options.settings.config.outDir);
const logger = pipeline.getLogger();
// HACK! `astro:assets` relies on a global to know if its running in dev, prod, ssr, ssg, full moon
// If we don't delete it here, it's technically not impossible (albeit improbable) for it to leak
if (ssr && !hasPrerenderedPages(internals)) {
@ -181,7 +173,6 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`);
const builtPaths = new Set<string>();
const pagesToGenerate = pipeline.retrieveRoutesToGenerate();
const config = pipeline.getConfig();
if (ssr) {
for (const [pageData, filePath] of pagesToGenerate) {
if (pageData.route.prerender) {
@ -195,7 +186,7 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
const ssrEntryURLPage = createEntryURL(filePath, outFolder);
const ssrEntryPage = await import(ssrEntryURLPage.toString());
if (opts.settings.adapter?.adapterFeatures?.functionPerRoute) {
if (options.settings.adapter?.adapterFeatures?.functionPerRoute) {
// forcing to use undefined, so we fail in an expected way if the module is not even there.
const ssrEntry = ssrEntryPage?.pageModule;
if (ssrEntry) {
@ -240,12 +231,12 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
.map((x) => x.transforms.size)
.reduce((a, b) => a + b, 0);
const cpuCount = os.cpus().length;
const assetsCreationEnvironment = await prepareAssetsGenerationEnv(pipeline, totalCount);
const assetsCreationpipeline = await prepareAssetsGenerationEnv(pipeline, totalCount);
const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) });
const assetsTimer = performance.now();
for (const [originalPath, transforms] of staticImageList) {
await generateImagesForPath(originalPath, transforms, assetsCreationEnvironment, queue);
await generateImagesForPath(originalPath, transforms, assetsCreationpipeline, queue);
}
await queue.onIdle();
@ -255,10 +246,7 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn
delete globalThis?.astroAsset?.addStaticImage;
}
await runHookBuildGenerated({
config: opts.settings.config,
logger: pipeline.getLogger(),
});
await runHookBuildGenerated({ config, logger });
}
async function generatePage(
@ -268,12 +256,9 @@ async function generatePage(
pipeline: BuildPipeline
) {
// prepare information we need
const logger = pipeline.getLogger();
const config = pipeline.getConfig();
const manifest = pipeline.getManifest();
const { config, internals, logger } = pipeline;
const pageModulePromise = ssrEntry.page;
const onRequest = manifest.middleware;
const pageInfo = getPageDataByComponent(pipeline.getInternals(), pageData.route.component);
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
// Calculate information of the page, like scripts, links and styles
const styles = pageData.styles
@ -283,19 +268,6 @@ async function generatePage(
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const linkIds: [] = [];
const scripts = pageInfo?.hoistedScript ?? null;
// prepare the middleware
const i18nMiddleware = createI18nMiddleware(
manifest.i18n,
manifest.base,
manifest.trailingSlash,
manifest.buildFormat
);
if (config.i18n && i18nMiddleware) {
pipeline.setMiddlewareFunction(sequence(i18nMiddleware, onRequest));
pipeline.onBeforeRenderRoute(i18nPipelineHook);
} else {
pipeline.setMiddlewareFunction(onRequest);
}
if (!pageModulePromise) {
throw new Error(
`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`
@ -322,8 +294,8 @@ async function generatePage(
let prevTimeEnd = timeStart;
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
pipeline.getEnvironment().logger.debug('build', `Generating: ${path}`);
const filePath = getOutputFilename(pipeline.getConfig(), path, pageData.route.type);
pipeline.logger.debug('build', `Generating: ${path}`);
const filePath = getOutputFilename(config, path, pageData.route.type);
const lineIcon = i === paths.length - 1 ? '└─' : '├─';
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
await generatePath(path, pipeline, generationOptions, route);
@ -349,8 +321,7 @@ async function getPathsForRoute(
pipeline: BuildPipeline,
builtPaths: Set<string>
): Promise<Array<string>> {
const opts = pipeline.getStaticBuildOptions();
const logger = pipeline.getLogger();
const { logger, options, routeCache, serverLike } = pipeline;
let paths: Array<string> = [];
if (route.pathname) {
paths.push(route.pathname);
@ -365,9 +336,9 @@ async function getPathsForRoute(
const staticPaths = await callGetStaticPaths({
mod,
route,
routeCache: opts.routeCache,
routeCache,
logger,
ssr: isServerLikeOutput(opts.settings.config),
ssr: serverLike,
}).catch((err) => {
logger.debug('build', `├── ${bold(red('✗'))} ${route.component}`);
throw err;
@ -401,7 +372,7 @@ async function getPathsForRoute(
// NOTE: The same URL may match multiple routes in the manifest.
// Routing priority needs to be verified here for any duplicate
// paths to ensure routing priority rules are enforced in the final build.
const matchedRoute = matchRoute(staticPath, opts.manifest);
const matchedRoute = matchRoute(staticPath, options.manifest);
return matchedRoute === route;
});
@ -504,84 +475,36 @@ async function generatePath(
gopts: GeneratePathOptions,
route: RouteData
) {
const { mod, scripts: hoistedScripts, styles: _styles } = gopts;
const manifest = pipeline.getManifest();
const logger = pipeline.getLogger();
const { mod } = gopts;
const { config, logger, options, serverLike } = pipeline;
logger.debug('build', `Generating: ${pathname}`);
const links = new Set<never>();
const scripts = createModuleScriptsSet(
hoistedScripts ? [hoistedScripts] : [],
manifest.base,
manifest.assetsPrefix
);
const styles = createStylesheetElementSet(_styles, manifest.base, manifest.assetsPrefix);
if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) {
const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID);
if (typeof hashedFilePath !== 'string') {
throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`);
}
const src = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
scripts.add({
props: { type: 'module', src },
children: '',
});
}
// Add all injected scripts to the page.
for (const script of pipeline.getSettings().scripts) {
if (script.stage === 'head-inline') {
scripts.add({
props: {},
children: script.content,
});
}
}
// This adds the page name to the array so it can be shown as part of stats.
if (route.type === 'page') {
addPageName(pathname, pipeline.getStaticBuildOptions());
addPageName(pathname, options);
}
const ssr = isServerLikeOutput(pipeline.getConfig());
const url = getUrlForPath(
pathname,
pipeline.getConfig().base,
pipeline.getStaticBuildOptions().origin,
pipeline.getConfig().build.format,
pipeline.getConfig().trailingSlash,
config.base,
options.origin,
config.build.format,
config.trailingSlash,
route.type
);
const request = createRequest({
url,
headers: new Headers(),
logger: pipeline.getLogger(),
ssr,
});
const i18n = pipeline.getConfig().i18n;
const renderContext = await createRenderContext({
pathname,
request,
componentMetadata: manifest.componentMetadata,
scripts,
styles,
links,
route,
env: pipeline.getEnvironment(),
mod,
locales: i18n?.locales,
routing: i18n?.routing,
defaultLocale: i18n?.defaultLocale,
logger,
ssr: serverLike,
});
const renderContext = RenderContext.create({ pipeline, pathname, request, routeData: route })
let body: string | Uint8Array;
let response: Response;
try {
response = await pipeline.renderRoute(renderContext, mod);
response = await renderContext.render(mod);
} catch (err) {
if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') {
(err as SSRError).id = route.component;
@ -591,13 +514,13 @@ async function generatePath(
if (response.status >= 300 && response.status < 400) {
// If redirects is set to false, don't output the HTML
if (!pipeline.getConfig().build.redirects) {
if (!config.build.redirects) {
return;
}
const locationSite = getRedirectLocationOrThrow(response.headers);
const siteURL = pipeline.getConfig().site;
const siteURL = config.site;
const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
const fromPath = new URL(renderContext.request.url).pathname;
const fromPath = new URL(request.url).pathname;
// A short delay causes Google to interpret the redirect as temporary.
// https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh
const delay = response.status === 302 ? 2 : 0;
@ -609,7 +532,7 @@ async function generatePath(
<body>
<a href="${location}">Redirecting from <code>${fromPath}</code> to <code>${location}</code></a>
</body>`;
if (pipeline.getConfig().compressHTML === true) {
if (config.compressHTML === true) {
body = body.replaceAll('\n', '');
}
// A dynamic redirect, set the location so that integrations know about it.
@ -622,8 +545,8 @@ async function generatePath(
body = Buffer.from(await response.arrayBuffer());
}
const outFolder = getOutFolder(pipeline.getConfig(), pathname, route);
const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route);
const outFolder = getOutFolder(config, pathname, route);
const outFile = getOutFile(config, outFolder, pathname, route);
route.distURL = outFile;
await fs.promises.mkdir(outFolder, { recursive: true });

View file

@ -27,7 +27,6 @@ import { createVite } from '../create-vite.js';
import type { Logger } from '../logger/core.js';
import { levels, timerMessage } from '../logger/core.js';
import { apply as applyPolyfill } from '../polyfill.js';
import { RouteCache } from '../render/route-cache.js';
import { createRouteManifest } from '../routing/index.js';
import { collectPagesData } from './page-data.js';
import { staticBuild, viteBuild } from './static-build.js';
@ -98,7 +97,6 @@ class AstroBuilder {
private logger: Logger;
private mode: RuntimeMode = 'production';
private origin: string;
private routeCache: RouteCache;
private manifest: ManifestData;
private timer: Record<string, number>;
private teardownCompiler: boolean;
@ -110,7 +108,6 @@ class AstroBuilder {
this.settings = settings;
this.logger = options.logger;
this.teardownCompiler = options.teardownCompiler ?? true;
this.routeCache = new RouteCache(this.logger);
this.origin = settings.config.site
? new URL(settings.config.site).origin
: `http://localhost:${settings.config.server.port}`;
@ -195,7 +192,6 @@ class AstroBuilder {
mode: this.mode,
origin: this.origin,
pageNames,
routeCache: this.routeCache,
teardownCompiler: this.teardownCompiler,
viteConfig,
};

View file

@ -1,13 +1,11 @@
import type { AstroConfig, AstroSettings, SSRLoadedRenderer } from '../../@types/astro.js';
import type { RouteData, SSRLoadedRenderer, SSRResult } from '../../@types/astro.js';
import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js';
import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import type { SSRManifest } from '../app/types.js';
import type { Logger } from '../logger/core.js';
import { Pipeline } from '../pipeline.js';
import { routeIsFallback, routeIsRedirect } from '../redirects/helpers.js';
import { createEnvironment } from '../render/index.js';
import { createAssetLink } from '../render/ssr-element.js';
import type { BuildInternals } from './internal.js';
import { Pipeline } from '../render/index.js';
import { createAssetLink, createModuleScriptsSet, createStylesheetElementSet } from '../render/ssr-element.js';
import { getPageDataByComponent, type BuildInternals, cssOrder, mergeInlineCss } from './internal.js';
import {
ASTRO_PAGE_RESOLVED_MODULE_ID,
getVirtualModulePageNameFromPath,
@ -18,79 +16,42 @@ import type { PageBuildData, StaticBuildOptions } from './types.js';
import { i18nHasFallback } from './util.js';
/**
* This pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files.
* The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files.
*/
export class BuildPipeline extends Pipeline {
#internals: BuildInternals;
#staticBuildOptions: StaticBuildOptions;
#manifest: SSRManifest;
constructor(
staticBuildOptions: StaticBuildOptions,
internals: BuildInternals,
manifest: SSRManifest
private constructor(
readonly internals: BuildInternals,
readonly manifest: SSRManifest,
readonly options: StaticBuildOptions,
readonly config = options.settings.config,
readonly settings = options.settings
) {
const ssr = isServerLikeOutput(staticBuildOptions.settings.config);
const resolveCache = new Map<string, string>();
super(
createEnvironment({
adapterName: manifest.adapterName,
logger: staticBuildOptions.logger,
mode: staticBuildOptions.mode,
renderers: manifest.renderers,
clientDirectives: manifest.clientDirectives,
compressHTML: manifest.compressHTML,
async resolve(specifier: string) {
if (resolveCache.has(specifier)) {
return resolveCache.get(specifier)!;
}
const hashedFilePath = manifest.entryModules[specifier];
if (typeof hashedFilePath !== 'string' || hashedFilePath === '') {
// If no "astro:scripts/before-hydration.js" script exists in the build,
// then we can assume that no before-hydration scripts are needed.
if (specifier === BEFORE_HYDRATION_SCRIPT_ID) {
resolveCache.set(specifier, '');
return '';
}
throw new Error(`Cannot find the built path for ${specifier}`);
}
const assetLink = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
resolveCache.set(specifier, assetLink);
return assetLink;
},
routeCache: staticBuildOptions.routeCache,
site: manifest.site,
ssr,
streaming: true,
})
);
this.#internals = internals;
this.#staticBuildOptions = staticBuildOptions;
this.#manifest = manifest;
async function resolve(specifier: string) {
if (resolveCache.has(specifier)) {
return resolveCache.get(specifier)!;
}
const hashedFilePath = manifest.entryModules[specifier];
if (typeof hashedFilePath !== 'string' || hashedFilePath === '') {
// If no "astro:scripts/before-hydration.js" script exists in the build,
// then we can assume that no before-hydration scripts are needed.
if (specifier === BEFORE_HYDRATION_SCRIPT_ID) {
resolveCache.set(specifier, '');
return '';
}
throw new Error(`Cannot find the built path for ${specifier}`);
}
const assetLink = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix);
resolveCache.set(specifier, assetLink);
return assetLink;
}
const serverLike = isServerLikeOutput(config);
const streaming = true;
super(options.logger, manifest, options.mode, manifest.renderers, resolve, serverLike, streaming)
}
getInternals(): Readonly<BuildInternals> {
return this.#internals;
}
getSettings(): Readonly<AstroSettings> {
return this.#staticBuildOptions.settings;
}
getStaticBuildOptions(): Readonly<StaticBuildOptions> {
return this.#staticBuildOptions;
}
getConfig(): AstroConfig {
return this.#staticBuildOptions.settings.config;
}
getManifest(): SSRManifest {
return this.#manifest;
}
getLogger(): Logger {
return this.getEnvironment().logger;
static create({ internals, manifest, options }: Pick<BuildPipeline, 'internals' | 'manifest' | 'options'>) {
return new BuildPipeline(internals, manifest, options);
}
/**
@ -144,6 +105,44 @@ export class BuildPipeline extends Pipeline {
};
}
headElements(routeData: RouteData): Pick<SSRResult, 'scripts' | 'styles' | 'links'> {
const { internals, manifest: { assetsPrefix, base }, settings } = this
const links = new Set<never>();
const pageBuildData = getPageDataByComponent(internals, routeData.component)
const scripts = createModuleScriptsSet(
pageBuildData?.hoistedScript ? [pageBuildData.hoistedScript] : [],
base,
assetsPrefix
);
const sortedCssAssets = pageBuildData?.styles.sort(cssOrder).map(({ sheet }) => sheet).reduce(mergeInlineCss, []);
const styles = createStylesheetElementSet(sortedCssAssets ?? [], base, assetsPrefix);
if (settings.scripts.some((script) => script.stage === 'page')) {
const hashedFilePath = internals.entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID);
if (typeof hashedFilePath !== 'string') {
throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`);
}
const src = createAssetLink(hashedFilePath, base, assetsPrefix);
scripts.add({
props: { type: 'module', src },
children: '',
});
}
// Add all injected scripts to the page.
for (const script of settings.scripts) {
if (script.stage === 'head-inline') {
scripts.add({
props: {},
children: script.content,
});
}
}
return { scripts, styles, links }
}
componentMetadata() {}
/**
* It collects the routes to generate during the build.
*
@ -152,7 +151,7 @@ export class BuildPipeline extends Pipeline {
retrieveRoutesToGenerate(): Map<PageBuildData, string> {
const pages = new Map<PageBuildData, string>();
for (const [entrypoint, filePath] of this.#internals.entrySpecifierToBundleMap) {
for (const [entrypoint, filePath] of this.internals.entrySpecifierToBundleMap) {
// virtual pages can be emitted with different prefixes:
// - the classic way are pages emitted with prefix ASTRO_PAGE_RESOLVED_MODULE_ID -> plugin-pages
// - pages emitted using `build.split`, in this case pages are emitted with prefix RESOLVED_SPLIT_MODULE_ID
@ -161,7 +160,7 @@ export class BuildPipeline extends Pipeline {
entrypoint.includes(RESOLVED_SPLIT_MODULE_ID)
) {
const [, pageName] = entrypoint.split(':');
const pageData = this.#internals.pagesByComponent.get(
const pageData = this.internals.pagesByComponent.get(
`${pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}`
);
if (!pageData) {
@ -174,12 +173,12 @@ export class BuildPipeline extends Pipeline {
}
}
for (const [path, pageData] of this.#internals.pagesByComponent.entries()) {
for (const [path, pageData] of this.internals.pagesByComponent.entries()) {
if (routeIsRedirect(pageData.route)) {
pages.set(pageData, path);
} else if (
routeIsFallback(pageData.route) &&
(i18nHasFallback(this.getConfig()) ||
(i18nHasFallback(this.config) ||
(routeIsFallback(pageData.route) && pageData.route.route === '/'))
) {
// The original component is transformed during the first build, so we have to retrieve
@ -190,7 +189,7 @@ export class BuildPipeline extends Pipeline {
// Here, we take the component path and transform it in the virtual module name
const moduleSpecifier = getVirtualModulePageNameFromPath(path);
// We retrieve the original JS module
const filePath = this.#internals.entrySpecifierToBundleMap.get(moduleSpecifier);
const filePath = this.internals.entrySpecifierToBundleMap.get(moduleSpecifier);
if (filePath) {
// it exists, added it to pages to render, using the file path that we jus retrieved
pages.set(pageData, filePath);

View file

@ -11,7 +11,6 @@ import type {
SSRLoadedRenderer,
} from '../../@types/astro.js';
import type { Logger } from '../logger/core.js';
import type { RouteCache } from '../render/route-cache.js';
export type ComponentPath = string;
export type ViteID = string;
@ -43,7 +42,6 @@ export interface StaticBuildOptions {
mode: RuntimeMode;
origin: string;
pageNames: string[];
routeCache: RouteCache;
viteConfig: InlineConfig;
teardownCompiler: boolean;
}

View file

@ -1,6 +1,19 @@
// process.env.PACKAGE_VERSION is injected when we build and publish the astro package.
export const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development';
export const REROUTE_DIRECTIVE_HEADER = 'X-Astro-Reroute';
export const ROUTE_TYPE_HEADER = 'X-Astro-Route-Type';
/**
* A response with one of these status codes will be rewritten
* with the result of rendering the respective error page.
*/
export const REROUTABLE_STATUS_CODES = [404, 500];
export const clientAddressSymbol = Symbol.for('astro.clientAddress');
export const clientLocalsSymbol = Symbol.for('astro.locals');
export const responseSentSymbol = Symbol.for('astro.responseSent');
// possible extensions for markdown files
export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
'.markdown',
@ -13,5 +26,3 @@ export const SUPPORTED_MARKDOWN_FILE_EXTENSIONS = [
// The folder name where to find the middleware
export const MIDDLEWARE_PATH_SEGMENT_NAME = 'middleware';
export const ROUTE_DATA_SYMBOL = 'astro.routeData';

View file

@ -1,26 +1,18 @@
import type {
APIContext,
EndpointHandler,
Locales,
MiddlewareHandler,
Params,
} from '../../@types/astro.js';
import { renderEndpoint } from '../../runtime/server/index.js';
import { ASTRO_VERSION } from '../constants.js';
import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js';
import { ASTRO_VERSION, clientAddressSymbol, clientLocalsSymbol } from '../constants.js';
import type { AstroCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { callMiddleware } from '../middleware/callMiddleware.js';
import {
computeCurrentLocale,
computePreferredLocale,
computePreferredLocaleList,
} from '../render/context.js';
import { type Environment, type RenderContext } from '../render/index.js';
} from '../../i18n/utils.js';
import type { RoutingStrategies } from '../config/schema.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
type CreateAPIContext = {
request: Request;
params: Params;
@ -30,6 +22,8 @@ type CreateAPIContext = {
locales: Locales | undefined;
routingStrategy: RoutingStrategies | undefined;
defaultLocale: string | undefined;
route: string;
cookies: AstroCookies
};
/**
@ -46,13 +40,15 @@ export function createAPIContext({
locales,
routingStrategy,
defaultLocale,
route,
cookies
}: CreateAPIContext): APIContext {
let preferredLocale: string | undefined = undefined;
let preferredLocaleList: string[] | undefined = undefined;
let currentLocale: string | undefined = undefined;
const context = {
cookies: new AstroCookies(request),
cookies,
request,
params,
site: site ? new URL(site) : undefined,
@ -93,7 +89,7 @@ export function createAPIContext({
return currentLocale;
}
if (locales) {
currentLocale = computeCurrentLocale(request, locales, routingStrategy, defaultLocale);
currentLocale = computeCurrentLocale(route, locales, routingStrategy, defaultLocale);
}
return currentLocale;
@ -138,33 +134,3 @@ export function createAPIContext({
return context;
}
export async function callEndpoint(
mod: EndpointHandler,
env: Environment,
ctx: RenderContext,
onRequest: MiddlewareHandler | undefined
): Promise<Response> {
const context = createAPIContext({
request: ctx.request,
params: ctx.params,
props: ctx.props,
site: env.site,
adapterName: env.adapterName,
routingStrategy: ctx.routing,
defaultLocale: ctx.defaultLocale,
locales: ctx.locales,
});
let response;
if (onRequest) {
response = await callMiddleware(onRequest, context, async () => {
return await renderEndpoint(mod, context, env.ssr, env.logger);
});
} else {
response = await renderEndpoint(mod, context, env.ssr, env.logger);
}
attachCookiesToResponse(response, context.cookies);
return response;
}

View file

@ -1,5 +1,4 @@
import type { APIContext, MiddlewareHandler, MiddlewareNext } from '../../@types/astro.js';
import { attachCookiesToResponse, responseHasCookies } from '../cookies/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
/**
@ -39,10 +38,10 @@ import { AstroError, AstroErrorData } from '../errors/index.js';
export async function callMiddleware(
onRequest: MiddlewareHandler,
apiContext: APIContext,
responseFunction: () => Promise<Response>
responseFunction: () => Promise<Response> | Response
): Promise<Response> {
let nextCalled = false;
let responseFunctionPromise: Promise<Response> | undefined = undefined;
let responseFunctionPromise: Promise<Response> | Response | undefined = undefined;
const next: MiddlewareNext = async () => {
nextCalled = true;
responseFunctionPromise = responseFunction();
@ -67,7 +66,7 @@ export async function callMiddleware(
if (value instanceof Response === false) {
throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
}
return ensureCookiesAttached(apiContext, value);
return value;
} else {
/**
* Here we handle the case where `next` was called and returned nothing.
@ -90,14 +89,7 @@ export async function callMiddleware(
throw new AstroError(AstroErrorData.MiddlewareNotAResponse);
} else {
// Middleware did not call resolve and returned a value
return ensureCookiesAttached(apiContext, value);
return value;
}
});
}
function ensureCookiesAttached(apiContext: APIContext, response: Response): Response {
if (apiContext.cookies !== undefined && !responseHasCookies(response)) {
attachCookiesToResponse(response, apiContext.cookies);
}
return response;
}

View file

@ -1,6 +1,16 @@
import type { MiddlewareHandler, Params } from '../../@types/astro.js';
import { createAPIContext } from '../endpoint/index.js';
import type { APIContext, MiddlewareHandler, Params } from '../../@types/astro.js';
import { AstroCookies } from '../cookies/index.js';
import { sequence } from './sequence.js';
import { ASTRO_VERSION } from '../constants.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import {
computeCurrentLocale,
computePreferredLocale,
computePreferredLocaleList,
} from '../../i18n/utils.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
function defineMiddleware(fn: MiddlewareHandler) {
return fn;
@ -28,16 +38,64 @@ export type CreateContext = {
/**
* Creates a context to be passed to Astro middleware `onRequest` function.
*/
function createContext({ request, params, userDefinedLocales = [] }: CreateContext) {
return createAPIContext({
function createContext({ request, params = {}, userDefinedLocales = [] }: CreateContext): APIContext {
let preferredLocale: string | undefined = undefined;
let preferredLocaleList: string[] | undefined = undefined;
let currentLocale: string | undefined = undefined;
const url = new URL(request.url);
const route = url.pathname
return {
cookies: new AstroCookies(request),
request,
params: params ?? {},
props: {},
params,
site: undefined,
locales: userDefinedLocales,
defaultLocale: undefined,
routingStrategy: undefined,
});
generator: `Astro v${ASTRO_VERSION}`,
props: {},
redirect(path, status) {
return new Response(null, {
status: status || 302,
headers: {
Location: path,
},
});
},
get preferredLocale(): string | undefined {
return preferredLocale ??= computePreferredLocale(request, userDefinedLocales);
},
get preferredLocaleList(): string[] | undefined {
return preferredLocaleList ??= computePreferredLocaleList(request, userDefinedLocales);
},
get currentLocale(): string | undefined {
return currentLocale ??= computeCurrentLocale(route, userDefinedLocales, undefined, undefined);
},
url,
get clientAddress() {
if (clientAddressSymbol in request) {
return Reflect.get(request, clientAddressSymbol) as string;
}
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
},
get locals() {
let locals = Reflect.get(request, clientLocalsSymbol);
if (locals === undefined) {
locals = {};
Reflect.set(request, clientLocalsSymbol, locals);
}
if (typeof locals !== 'object') {
throw new AstroError(AstroErrorData.LocalsNotAnObject);
}
return locals;
},
// We define a custom property, so we can check the value passed to locals
set locals(val) {
if (typeof val !== 'object') {
throw new AstroError(AstroErrorData.LocalsNotAnObject);
} else {
Reflect.set(request, clientLocalsSymbol, val);
}
},
};
}
/**

View file

@ -1,132 +0,0 @@
import type { ComponentInstance, EndpointHandler, MiddlewareHandler } from '../@types/astro.js';
import { callEndpoint, createAPIContext } from './endpoint/index.js';
import { callMiddleware } from './middleware/callMiddleware.js';
import { renderPage } from './render/core.js';
import { type Environment, type RenderContext } from './render/index.js';
type PipelineHooks = {
before: PipelineHookFunction[];
};
export type PipelineHookFunction = (ctx: RenderContext, mod: ComponentInstance | undefined) => void;
/**
* 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?: MiddlewareHandler;
#hooks: PipelineHooks = {
before: [],
};
/**
* 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;
}
setEnvironment() {}
/**
* A middleware function that will be called before each request.
*/
setMiddlewareFunction(onRequest: MiddlewareHandler) {
this.#onRequest = onRequest;
}
/**
* Removes the current middleware function. Subsequent requests won't trigger any middleware.
*/
unsetMiddlewareFunction() {
this.#onRequest = undefined;
}
/**
* Returns the current environment
*/
getEnvironment(): Readonly<Environment> {
return this.env;
}
/**
* The main function of the pipeline. Use this function to render any route known to Astro;
*/
async renderRoute(
renderContext: RenderContext,
componentInstance: ComponentInstance | undefined
): Promise<Response> {
for (const hook of this.#hooks.before) {
hook(renderContext, componentInstance);
}
return await this.#tryRenderRoute(renderContext, this.env, componentInstance, this.#onRequest);
}
/**
* 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(
renderContext: Readonly<RenderContext>,
env: Readonly<Environment>,
mod: Readonly<ComponentInstance> | undefined,
onRequest?: MiddlewareHandler
): Promise<Response> {
const apiContext = createAPIContext({
request: renderContext.request,
params: renderContext.params,
props: renderContext.props,
site: env.site,
adapterName: env.adapterName,
locales: renderContext.locales,
routingStrategy: renderContext.routing,
defaultLocale: renderContext.defaultLocale,
});
switch (renderContext.route.type) {
case 'page':
case 'fallback':
case 'redirect': {
if (onRequest) {
return await callMiddleware(onRequest, apiContext, () => {
return renderPage({
mod,
renderContext,
env,
cookies: apiContext.cookies,
});
});
} else {
return await renderPage({
mod,
renderContext,
env,
cookies: apiContext.cookies,
});
}
}
case 'endpoint': {
return await callEndpoint(mod as any as EndpointHandler, env, renderContext, onRequest);
}
default:
throw new Error(`Couldn't find route of type [${renderContext.route.type}]`);
}
}
/**
* Store a function that will be called before starting the rendering phase.
* @param fn
*/
onBeforeRenderRoute(fn: PipelineHookFunction) {
this.#hooks.before.push(fn);
}
}

View file

@ -1,9 +1,4 @@
import type {
Params,
RedirectRouteData,
RouteData,
ValidRedirectStatus,
} from '../../@types/astro.js';
import type { RedirectRouteData, RouteData } from '../../@types/astro.js';
export function routeIsRedirect(route: RouteData | undefined): route is RedirectRouteData {
return route?.type === 'redirect';
@ -13,33 +8,3 @@ export function routeIsFallback(route: RouteData | undefined): route is Redirect
return route?.type === 'fallback';
}
export function redirectRouteGenerate(redirectRoute: RouteData, data: Params): string {
const routeData = redirectRoute.redirectRoute;
const route = redirectRoute.redirect;
if (typeof routeData !== 'undefined') {
return routeData?.generate(data) || routeData?.pathname || '/';
} else if (typeof route === 'string') {
// TODO: this logic is duplicated between here and manifest/create.ts
let target = route;
for (const param of Object.keys(data)) {
const paramValue = data[param]!;
target = target.replace(`[${param}]`, paramValue);
target = target.replace(`[...${param}]`, paramValue);
}
return target;
} else if (typeof route === 'undefined') {
return '/';
}
return route.destination;
}
export function redirectRouteStatus(redirectRoute: RouteData, method = 'GET'): ValidRedirectStatus {
const routeData = redirectRoute.redirectRoute;
if (routeData && typeof redirectRoute.redirect === 'object') {
return redirectRoute.redirect.status;
} else if (method !== 'GET') {
return 308;
}
return 301;
}

View file

@ -1,3 +1,4 @@
export { RedirectComponentInstance, RedirectSinglePageBuiltModule } from './component.js';
export { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from './helpers.js';
export { routeIsRedirect } from './helpers.js';
export { getRedirectLocationOrThrow } from './validate.js';
export { renderRedirect } from './render.js';

View file

@ -0,0 +1,32 @@
import type { RenderContext } from '../render-context.js';
export async function renderRedirect(renderContext: RenderContext) {
const { request: { method }, routeData } = renderContext;
const { redirect, redirectRoute } = routeData;
const status =
redirectRoute && typeof redirect === "object" ? redirect.status
: method === "GET" ? 301
: 308
const headers = { location: redirectRouteGenerate(renderContext) };
return new Response(null, { status, headers });
}
function redirectRouteGenerate(renderContext: RenderContext): string {
const { params, routeData: { redirect, redirectRoute } } = renderContext;
if (typeof redirectRoute !== 'undefined') {
return redirectRoute?.generate(params) || redirectRoute?.pathname || '/';
} else if (typeof redirect === 'string') {
// TODO: this logic is duplicated between here and manifest/create.ts
let target = redirect;
for (const param of Object.keys(params)) {
const paramValue = params[param]!;
target = target.replace(`[${param}]`, paramValue);
target = target.replace(`[...${param}]`, paramValue);
}
return target;
} else if (typeof redirect === 'undefined') {
return '/';
}
return redirect.destination;
}

View file

@ -0,0 +1,148 @@
import type { APIContext, ComponentInstance, MiddlewareHandler, RouteData } from '../@types/astro.js';
import { renderEndpoint } from '../runtime/server/endpoint.js';
import { attachCookiesToResponse } from './cookies/index.js';
import { callMiddleware } from './middleware/callMiddleware.js';
import { sequence } from './middleware/index.js';
import { AstroCookies } from './cookies/index.js';
import { createResult } from './render/index.js';
import { renderPage } from '../runtime/server/index.js';
import { ASTRO_VERSION, ROUTE_TYPE_HEADER, clientAddressSymbol, clientLocalsSymbol } from './constants.js';
import { getParams, getProps, type Pipeline } from './render/index.js';
import { AstroError, AstroErrorData } from './errors/index.js';
import {
computeCurrentLocale,
computePreferredLocale,
computePreferredLocaleList,
} from '../i18n/utils.js';
import { renderRedirect } from './redirects/render.js';
export class RenderContext {
private constructor(
readonly pipeline: Pipeline,
public locals: App.Locals,
readonly middleware: MiddlewareHandler,
readonly pathname: string,
readonly request: Request,
readonly routeData: RouteData,
public status: number,
readonly cookies = new AstroCookies(request),
readonly params = getParams(routeData, pathname),
) {}
static create({ locals = {}, middleware, pathname, pipeline, request, routeData, status = 200 }: Pick<RenderContext, 'pathname' |'pipeline' | 'request' | 'routeData'> & Partial<Pick<RenderContext, 'locals' | 'middleware' | 'status'>>) {
return new RenderContext(pipeline, locals, sequence(...pipeline.internalMiddleware, middleware ?? pipeline.middleware), pathname, request, routeData, status);
}
/**
* The main function of the RenderContext.
*
* Use this function to render any route known to Astro.
* It attempts to render a route. A route can be a:
*
* - page
* - redirect
* - endpoint
* - fallback
*/
async render(componentInstance: ComponentInstance | undefined): Promise<Response> {
const { cookies, middleware, pathname, pipeline, routeData } = this;
const { logger, routeCache, serverLike, streaming } = pipeline;
const props = await getProps({ mod: componentInstance, routeData, routeCache, pathname, logger, serverLike });
const apiContext = this.createAPIContext(props);
const { type } = routeData;
const lastNext =
type === 'endpoint' ? () => renderEndpoint(componentInstance as any, apiContext, serverLike, logger) :
type === 'redirect' ? () => renderRedirect(this) :
type === 'page' ? async () => {
const result = await this.createResult(componentInstance!);
const response = await renderPage(result, componentInstance?.default as any, props, {}, streaming, routeData);
response.headers.set(ROUTE_TYPE_HEADER, "page");
return response;
} :
type === 'fallback' ? () => new Response(null, { status: 500, headers: { [ROUTE_TYPE_HEADER]: "fallback" } }) :
() => { throw new Error("Unknown type of route: " + type) }
const response = await callMiddleware(middleware, apiContext, lastNext);
if (response.headers.get(ROUTE_TYPE_HEADER)) {
response.headers.delete(ROUTE_TYPE_HEADER)
}
// LEGACY: we put cookies on the response object,
// where the adapter might be expecting to read it.
// New code should be using `app.render({ addCookieHeader: true })` instead.
attachCookiesToResponse(response, cookies);
return response;
}
createAPIContext(props: APIContext['props']): APIContext {
const renderContext = this;
const { cookies, i18nData, params, pipeline, request } = this;
const { currentLocale, preferredLocale, preferredLocaleList } = i18nData;
const generator = `Astro v${ASTRO_VERSION}`;
const redirect = (path: string, status = 302) => new Response(null, { status, headers: { Location: path } });
const site = pipeline.site ? new URL(pipeline.site) : undefined;
const url = new URL(request.url);
return {
cookies, currentLocale, generator, params, preferredLocale, preferredLocaleList, props, redirect, request, site, url,
get clientAddress() {
if (clientAddressSymbol in request) {
return Reflect.get(request, clientAddressSymbol) as string;
}
if (pipeline.adapterName) {
throw new AstroError({
...AstroErrorData.ClientAddressNotAvailable,
message: AstroErrorData.ClientAddressNotAvailable.message(pipeline.adapterName),
});
} else {
throw new AstroError(AstroErrorData.StaticClientAddressNotAvailable);
}
},
get locals() {
return renderContext.locals;
},
// TODO(breaking): disallow replacing the locals object
set locals(val) {
if (typeof val !== 'object') {
throw new AstroError(AstroErrorData.LocalsNotAnObject);
} else {
renderContext.locals = val;
// we also put it on the original Request object,
// where the adapter might be expecting to read it after the response.
Reflect.set(request, clientLocalsSymbol, val);
}
}
}
}
async createResult(mod: ComponentInstance) {
const { cookies, locals, params, pathname, pipeline, request, routeData, status } = this;
const { adapterName, clientDirectives, compressHTML, i18n, manifest, logger, renderers, resolve, site, serverLike } = pipeline;
const { links, scripts, styles } = await pipeline.headElements(routeData);
const componentMetadata = await pipeline.componentMetadata(routeData) ?? manifest.componentMetadata;
const { defaultLocale, locales, routing: routingStrategy } = i18n ?? {};
const partial = Boolean(mod.partial);
return createResult({ adapterName, clientDirectives, componentMetadata, compressHTML, cookies, defaultLocale, locales, locals, logger, links, params, partial, pathname, renderers, resolve, request, route: routeData.route, routingStrategy, site, scripts, ssr: serverLike, status, styles });
}
/**
* API Context may be created multiple times per request, i18n data needs to be computed only once.
* So, it is computed and saved here on creation of the first APIContext and reused for later ones.
*/
#i18nData?: Pick<APIContext, "currentLocale" | "preferredLocale" | "preferredLocaleList">
get i18nData() {
if (this.#i18nData) return this.#i18nData
const { pipeline: { i18n }, request, routeData } = this;
if (!i18n) return {
currentLocale: undefined,
preferredLocale: undefined,
preferredLocaleList: undefined
}
const { defaultLocale, locales, routing } = i18n
return this.#i18nData = {
currentLocale: computeCurrentLocale(routeData.route, locales, routing, defaultLocale),
preferredLocale: computePreferredLocale(request, locales),
preferredLocaleList: computePreferredLocaleList(request, locales)
}
}
}

View file

@ -1,83 +0,0 @@
import type { AstroCookies, ComponentInstance } from '../../@types/astro.js';
import { renderPage as runtimeRenderPage } from '../../runtime/server/index.js';
import { attachCookiesToResponse } from '../cookies/index.js';
import { CantRenderPage } from '../errors/errors-data.js';
import { AstroError } from '../errors/index.js';
import { routeIsFallback } from '../redirects/helpers.js';
import { redirectRouteGenerate, redirectRouteStatus, routeIsRedirect } from '../redirects/index.js';
import type { RenderContext } from './context.js';
import type { Environment } from './environment.js';
import { createResult } from './result.js';
export type RenderPage = {
mod: ComponentInstance | undefined;
renderContext: RenderContext;
env: Environment;
cookies: AstroCookies;
};
export async function renderPage({ mod, renderContext, env, cookies }: RenderPage) {
if (routeIsRedirect(renderContext.route)) {
return new Response(null, {
status: redirectRouteStatus(renderContext.route, renderContext.request.method),
headers: {
location: redirectRouteGenerate(renderContext.route, renderContext.params),
},
});
} else if (routeIsFallback(renderContext.route)) {
// We return a 404 because fallback routes don't exist.
// It's responsibility of the middleware to catch them and re-route the requests
return new Response(null, {
status: 404,
});
} else if (!mod) {
throw new AstroError(CantRenderPage);
}
// Validate the page component before rendering the page
const Component = mod.default;
if (!Component)
throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
const result = createResult({
adapterName: env.adapterName,
links: renderContext.links,
styles: renderContext.styles,
logger: env.logger,
params: renderContext.params,
pathname: renderContext.pathname,
componentMetadata: renderContext.componentMetadata,
resolve: env.resolve,
renderers: env.renderers,
clientDirectives: env.clientDirectives,
compressHTML: env.compressHTML,
request: renderContext.request,
partial: !!mod.partial,
site: env.site,
scripts: renderContext.scripts,
ssr: env.ssr,
status: renderContext.status ?? 200,
cookies,
locals: renderContext.locals ?? {},
locales: renderContext.locales,
defaultLocale: renderContext.defaultLocale,
routingStrategy: renderContext.routing,
});
const response = await runtimeRenderPage(
result,
Component,
renderContext.props,
{},
env.streaming,
renderContext.route
);
// If there is an Astro.cookies instance, attach it to the response so that
// adapters can grab the Set-Cookie headers.
if (result.cookies) {
attachCookiesToResponse(response, result.cookies);
}
return response;
}

View file

@ -1,39 +0,0 @@
import type { RuntimeMode, SSRLoadedRenderer } from '../../@types/astro.js';
import type { Logger } from '../logger/core.js';
import type { RouteCache } from './route-cache.js';
/**
* An environment 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, they can be created once and passed through to renderPage on each request.
*/
export interface Environment {
/**
* Used to provide better error messages for `Astro.clientAddress`
*/
adapterName?: string;
/** logging options */
logger: Logger;
/** "development" or "production" */
mode: RuntimeMode;
compressHTML: boolean;
renderers: SSRLoadedRenderer[];
clientDirectives: Map<string, string>;
resolve: (s: string) => Promise<string>;
routeCache: RouteCache;
/**
* Used for `Astro.site`
*/
site?: string;
/**
* Value of Astro config's `output` option, true if "server" or "hybrid"
*/
ssr: boolean;
streaming: boolean;
}
export type CreateEnvironmentArgs = Environment;
export function createEnvironment(options: CreateEnvironmentArgs): Environment {
return options;
}

View file

@ -1,16 +1,13 @@
import type { AstroMiddlewareInstance, ComponentInstance, RouteData } from '../../@types/astro.js';
import type { Environment } from './environment.js';
export { computePreferredLocale, createRenderContext } from './context.js';
export type { RenderContext } from './context.js';
export { createEnvironment } from './environment.js';
export { getParamsAndProps } from './params-and-props.js';
import type { ComponentInstance, RouteData } from '../../@types/astro.js';
import type { Pipeline } from '../base-pipeline.js';
export { Pipeline } from '../base-pipeline.js';
export { getParams, getProps } from './params-and-props.js';
export { loadRenderer } from './renderer.js';
export type { Environment };
export { createResult } from './result.js';
export interface SSROptions {
/** The environment instance */
env: Environment;
/** The pipeline instance */
pipeline: Pipeline;
/** location of file on disk */
filePath: URL;
/** the web request (needed for dynamic routes) */
@ -21,8 +18,4 @@ export interface SSROptions {
request: Request;
/** optional, in case we need to render something outside a dev server */
route: RouteData;
/**
* Optional middlewares
*/
middleware?: AstroMiddlewareInstance;
}

View file

@ -3,35 +3,34 @@ import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Logger } from '../logger/core.js';
import { routeIsFallback } from '../redirects/helpers.js';
import { routeIsRedirect } from '../redirects/index.js';
import { getParams } from '../routing/params.js';
import type { RouteCache } from './route-cache.js';
import { callGetStaticPaths, findPathItemByKey } from './route-cache.js';
interface GetParamsAndPropsOptions {
mod: ComponentInstance | undefined;
route?: RouteData | undefined;
routeData?: RouteData | undefined;
routeCache: RouteCache;
pathname: string;
logger: Logger;
ssr: boolean;
serverLike: boolean;
}
export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise<[Params, Props]> {
const { logger, mod, route, routeCache, pathname, ssr } = opts;
export async function getProps(opts: GetParamsAndPropsOptions): Promise<Props> {
const { logger, mod, routeData: route, routeCache, pathname, serverLike } = opts;
// If there's no route, or if there's a pathname (e.g. a static `src/pages/normal.astro` file),
// then we know for sure they don't have params and props, return a fallback value.
if (!route || route.pathname) {
return [{}, {}];
return {};
}
// This is a dynamic route, start getting the params
const params = getRouteParams(route, pathname) ?? {};
if (routeIsRedirect(route) || routeIsFallback(route)) {
return [params, {}];
return {};
}
// This is a dynamic route, start getting the params
const params = getParams(route, pathname);
if (mod) {
validatePrerenderEndpointCollision(route, mod, params);
}
@ -43,11 +42,11 @@ export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise
route,
routeCache,
logger,
ssr,
ssr: serverLike,
});
const matchedStaticPath = findPathItemByKey(staticPaths, params, route, logger);
if (!matchedStaticPath && (ssr ? route.prerender : true)) {
if (!matchedStaticPath && (serverLike ? route.prerender : true)) {
throw new AstroError({
...AstroErrorData.NoMatchingStaticPathFound,
message: AstroErrorData.NoMatchingStaticPathFound.message(pathname),
@ -57,18 +56,28 @@ export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise
const props: Props = matchedStaticPath?.props ? { ...matchedStaticPath.props } : {};
return [params, props];
return props;
}
function getRouteParams(route: RouteData, pathname: string): Params | undefined {
if (route.params.length) {
// The RegExp pattern expects a decoded string, but the pathname is encoded
// when the URL contains non-English characters.
const paramsMatch = route.pattern.exec(decodeURIComponent(pathname));
if (paramsMatch) {
return getParams(route.params)(paramsMatch);
/**
* When given a route with the pattern `/[x]/[y]/[z]/svelte`, and a pathname `/a/b/c/svelte`,
* returns the params object: { x: "a", y: "b", z: "c" }.
*/
export function getParams(route: RouteData, pathname: string): Params {
if (!route.params.length) return {};
// The RegExp pattern expects a decoded string, but the pathname is encoded
// when the URL contains non-English characters.
const paramsMatch = route.pattern.exec(decodeURIComponent(pathname));
if (!paramsMatch) return {};
const params: Params = {};
route.params.forEach((key, i) => {
if (key.startsWith('...')) {
params[key.slice(3)] = paramsMatch[i + 1] ? paramsMatch[i + 1] : undefined;
} else {
params[key] = paramsMatch[i + 1];
}
}
});
return params;
}
/**

View file

@ -17,11 +17,9 @@ import {
computeCurrentLocale,
computePreferredLocale,
computePreferredLocaleList,
} from './context.js';
} from '../../i18n/utils.js';
import type { RoutingStrategies } from '../config/schema.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const responseSentSymbol = Symbol.for('astro.responseSent');
import { clientAddressSymbol, responseSentSymbol } from '../constants.js';
export interface CreateResultArgs {
/**
@ -44,16 +42,17 @@ export interface CreateResultArgs {
* Used for `Astro.site`
*/
site: string | undefined;
links?: Set<SSRElement>;
scripts?: Set<SSRElement>;
styles?: Set<SSRElement>;
componentMetadata?: SSRResult['componentMetadata'];
links: Set<SSRElement>;
scripts: Set<SSRElement>;
styles: Set<SSRElement>;
componentMetadata: SSRResult['componentMetadata'];
request: Request;
status: number;
locals: App.Locals;
cookies?: AstroCookies;
cookies: AstroCookies;
locales: Locales | undefined;
defaultLocale: string | undefined;
route: string;
routingStrategy: RoutingStrategies | undefined;
}
@ -233,7 +232,7 @@ export function createResult(args: CreateResultArgs): SSRResult {
}
if (args.locales) {
currentLocale = computeCurrentLocale(
request,
url.pathname,
args.locales,
args.routingStrategy,
args.defaultLocale

View file

@ -1,5 +1,4 @@
export { createRouteManifest } from './manifest/create.js';
export { deserializeRouteData, serializeRouteData } from './manifest/serialization.js';
export { matchAllRoutes, matchRoute } from './match.js';
export { getParams } from './params.js';
export { validateDynamicRouteModule, validateGetStaticPathsResult } from './validation.js';

View file

@ -2,27 +2,6 @@ import type { GetStaticPathsItem, Params, RouteData } from '../../@types/astro.j
import { trimSlashes } from '../path.js';
import { validateGetStaticPathsParameter } from './validation.js';
/**
* given an array of params like `['x', 'y', 'z']` for
* src/routes/[x]/[y]/[z]/svelte, create a function
* that turns a RegExpExecArray into ({ x, y, z })
*/
export function getParams(array: string[]) {
const fn = (match: RegExpExecArray) => {
const params: Params = {};
array.forEach((key, i) => {
if (key.startsWith('...')) {
params[key.slice(3)] = match[i + 1] ? match[i + 1] : undefined;
} else {
params[key] = match[i + 1];
}
});
return params;
};
return fn;
}
/**
* given a route's Params object, validate parameter
* values and create a stringified key for the route

View file

@ -1,18 +1,9 @@
import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path';
import type {
APIContext,
Locales,
MiddlewareHandler,
RouteData,
SSRManifest,
} from '../@types/astro.js';
import type { PipelineHookFunction } from '../core/pipeline.js';
import type { APIContext, Locales, MiddlewareHandler, SSRManifest } from '../@types/astro.js';
import { getPathByLocale, normalizeTheLocale } from './index.js';
import { shouldAppendForwardSlash } from '../core/build/util.js';
import { ROUTE_DATA_SYMBOL } from '../core/constants.js';
import type { SSRManifestI18n } from '../core/app/types.js';
const routeDataSymbol = Symbol.for(ROUTE_DATA_SYMBOL);
import type { SSRManifestI18n } from '../core/app/types.js'
import { ROUTE_TYPE_HEADER } from '../core/constants.js';
// Checks if the pathname has any locale, exception for the defaultLocale, which is ignored on purpose.
function pathnameHasLocale(pathname: string, locales: Locales): boolean {
@ -107,18 +98,16 @@ export function createI18nMiddleware(
};
return async (context, next) => {
const routeData: RouteData | undefined = Reflect.get(context.request, routeDataSymbol);
// If the route we're processing is not a page, then we ignore it
if (routeData?.type !== 'page' && routeData?.type !== 'fallback') {
return await next();
}
const currentLocale = context.currentLocale;
const url = context.url;
const { locales, defaultLocale, fallback, routing } = i18n;
const response = await next();
const type = response.headers.get(ROUTE_TYPE_HEADER);
// If the route we're processing is not a page, then we ignore it
if (type !== 'page' && type !== 'fallback') {
return response
}
const { url, currentLocale } = context;
const { locales, defaultLocale, fallback, routing } = i18n;
if (response instanceof Response) {
switch (i18n.routing) {
case 'domains-prefix-other-locales': {
if (localeHasntDomain(i18n, currentLocale)) {
@ -207,19 +196,11 @@ export function createI18nMiddleware(
return context.redirect(newPathname);
}
}
}
return response;
};
}
/**
* This pipeline hook attaches a `RouteData` object to the `Request`
*/
export const i18nPipelineHook: PipelineHookFunction = (ctx) => {
Reflect.set(ctx.request, routeDataSymbol, ctx.route);
};
/**
* Checks if the current locale doesn't belong to a configured domain
* @param i18n

View file

@ -1,92 +1,6 @@
import type {
ComponentInstance,
Locales,
Params,
Props,
RouteData,
SSRElement,
SSRResult,
} from '../../@types/astro.js';
import { normalizeTheLocale, toCodes } from '../../i18n/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import type { Environment } from './environment.js';
import { getParamsAndProps } from './params-and-props.js';
import type { RoutingStrategies } from '../config/schema.js';
import { ROUTE_DATA_SYMBOL } from '../constants.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
const routeDataSymbol = Symbol.for(ROUTE_DATA_SYMBOL);
/**
* The RenderContext represents the parts of rendering that are specific to one request.
*/
export interface RenderContext {
request: Request;
pathname: string;
scripts?: Set<SSRElement>;
links?: Set<SSRElement>;
styles?: Set<SSRElement>;
componentMetadata?: SSRResult['componentMetadata'];
route: RouteData;
status?: number;
params: Params;
props: Props;
locals?: object;
locales: Locales | undefined;
defaultLocale: string | undefined;
routing: RoutingStrategies | undefined;
}
export type CreateRenderContextArgs = Partial<
Omit<RenderContext, 'params' | 'props' | 'locals'>
> & {
route: RouteData;
request: RenderContext['request'];
mod: ComponentInstance | undefined;
env: Environment;
};
export async function createRenderContext(
options: CreateRenderContextArgs
): Promise<RenderContext> {
const request = options.request;
const pathname = options.pathname ?? new URL(request.url).pathname;
const [params, props] = await getParamsAndProps({
mod: options.mod as any,
route: options.route,
routeCache: options.env.routeCache,
pathname: pathname,
logger: options.env.logger,
ssr: options.env.ssr,
});
const context: RenderContext = {
...options,
pathname,
params,
props,
locales: options.locales,
routing: options.routing,
defaultLocale: options.defaultLocale,
};
// We define a custom property, so we can check the value passed to locals
Object.defineProperty(context, 'locals', {
enumerable: true,
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;
}
import type { Locales } from '../@types/astro.js';
import { normalizeTheLocale, toCodes } from './index.js';
import type { RoutingStrategies } from '../core/config/schema.js';
type BrowserLocale = {
locale: string;
@ -240,19 +154,12 @@ export function computePreferredLocaleList(request: Request, locales: Locales):
}
export function computeCurrentLocale(
request: Request,
pathname: string,
locales: Locales,
routingStrategy: RoutingStrategies | undefined,
defaultLocale: string | undefined
): undefined | string {
const routeData: RouteData | undefined = Reflect.get(request, routeDataSymbol);
if (!routeData) {
return defaultLocale;
}
// Typically, RouteData::pathname has the correct information in SSR, but it's not available in SSG, so we fall back
// to use the pathname from the Request
const pathname = routeData.pathname ?? new URL(request.url).pathname;
for (const segment of pathname.split('/').filter(Boolean)) {
for (const segment of pathname.split('/')) {
for (const locale of locales) {
if (typeof locale === 'string') {
// we skip ta locale that isn't present in the current segment

View file

@ -1,7 +1,6 @@
import type { AstroSettings, ComponentInstance, RouteData } from '../@types/astro.js';
import { RedirectComponentInstance, routeIsRedirect } from '../core/redirects/index.js';
import type DevPipeline from '../vite-plugin-astro-server/devPipeline.js';
import { preload } from '../vite-plugin-astro-server/index.js';
import type { DevPipeline } from '../vite-plugin-astro-server/pipeline.js';
import { getPrerenderStatus } from './metadata.js';
type GetSortedPreloadedMatchesParams = {
@ -52,12 +51,12 @@ async function preloadAndSetPrerenderStatus({
continue;
}
const preloadedComponent = await preload({ pipeline, filePath });
const preloadedComponent = await pipeline.preload(filePath);
// gets the prerender metadata set by the `astro:scanner` vite plugin
const prerenderStatus = getPrerenderStatus({
filePath,
loader: pipeline.getModuleLoader(),
loader: pipeline.loader,
});
if (prerenderStatus !== undefined) {

View file

@ -1 +0,0 @@
export const REROUTE_DIRECTIVE_HEADER = 'X-Astro-Reroute';

View file

@ -1,5 +1,5 @@
import { bold } from 'kleur/colors';
import { REROUTE_DIRECTIVE_HEADER } from './consts.js';
import { REROUTABLE_STATUS_CODES, REROUTE_DIRECTIVE_HEADER } from '../../core/constants.js';;
import type { APIContext, EndpointHandler } from '../../@types/astro.js';
import type { Logger } from '../../core/logger/core.js';
@ -51,7 +51,7 @@ export async function renderEndpoint(
const response = await handler.call(mod, context);
// Endpoints explicitly returning 404 or 500 response status should
// NOT be subject to rerouting to 404.astro or 500.astro.
if (response.status === 404 || response.status === 500) {
if (REROUTABLE_STATUS_CODES.includes(response.status)) {
// Only `Response.redirect` headers are immutable, therefore a `try..catch` is not necessary.
// Note: `Response.redirect` can only be called with HTTP status codes: 301, 302, 303, 307, 308.
// Source: https://developer.mozilla.org/en-US/docs/Web/API/Response/redirect_static#parameters

View file

@ -1,91 +0,0 @@
import type {
AstroConfig,
AstroSettings,
RuntimeMode,
SSRLoadedRenderer,
SSRManifest,
} from '../@types/astro.js';
import type { Logger } from '../core/logger/core.js';
import type { ModuleLoader } from '../core/module-loader/index.js';
import { Pipeline } from '../core/pipeline.js';
import type { Environment } from '../core/render/index.js';
import { createEnvironment, loadRenderer } from '../core/render/index.js';
import { RouteCache } from '../core/render/route-cache.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import { createResolve } from './resolve.js';
export default class DevPipeline extends Pipeline {
#settings: AstroSettings;
#loader: ModuleLoader;
#devLogger: Logger;
constructor({
manifest,
logger,
settings,
loader,
}: {
manifest: SSRManifest;
logger: Logger;
settings: AstroSettings;
loader: ModuleLoader;
}) {
const env = DevPipeline.createDevelopmentEnvironment(manifest, settings, logger, loader);
super(env);
this.#devLogger = logger;
this.#settings = settings;
this.#loader = loader;
}
clearRouteCache() {
this.env.routeCache.clearAll();
}
getSettings(): Readonly<AstroSettings> {
return this.#settings;
}
getConfig(): Readonly<AstroConfig> {
return this.#settings.config;
}
getModuleLoader(): Readonly<ModuleLoader> {
return this.#loader;
}
get logger(): Readonly<Logger> {
return this.#devLogger;
}
async loadRenderers() {
const renderers = await Promise.all(
this.#settings.renderers.map((r) => loadRenderer(r, this.#loader))
);
this.env.renderers = renderers.filter(Boolean) as SSRLoadedRenderer[];
}
static createDevelopmentEnvironment(
manifest: SSRManifest,
settings: AstroSettings,
logger: Logger,
loader: ModuleLoader
): Environment {
const mode: RuntimeMode = 'development';
return createEnvironment({
adapterName: manifest.adapterName,
logger,
mode,
// This will be overridden in the dev server
renderers: [],
clientDirectives: manifest.clientDirectives,
compressHTML: manifest.compressHTML,
resolve: createResolve(loader, settings.config.root),
routeCache: new RouteCache(logger, mode),
site: manifest.site,
ssr: isServerLikeOutput(settings.config),
streaming: true,
});
}
async handleFallback() {}
}

View file

@ -1,6 +1,6 @@
import type { ModuleLoader } from '../core/module-loader/index.js';
import type { AstroConfig } from '../@types/astro.js';
import type DevPipeline from './devPipeline.js';
import type { DevPipeline } from './pipeline.js';
import { collectErrorMetadata } from '../core/errors/dev/index.js';
import { createSafeError, AstroErrorData } from '../core/errors/index.js';
@ -10,7 +10,7 @@ import { eventError, telemetry } from '../events/index.js';
export function recordServerError(
loader: ModuleLoader,
config: AstroConfig,
pipeline: DevPipeline,
{ logger }: DevPipeline,
_err: unknown
) {
const err = createSafeError(_err);
@ -29,9 +29,9 @@ export function recordServerError(
telemetry.record(eventError({ cmd: 'dev', err: errorWithMetadata, isFatal: false }));
}
pipeline.logger.error(
logger.error(
null,
formatErrorMessage(errorWithMetadata, pipeline.logger.level() === 'debug')
formatErrorMessage(errorWithMetadata, logger.level() === 'debug')
);
return {

View file

@ -1,34 +1,3 @@
import type { ComponentInstance } from '../@types/astro.js';
import { enhanceViteSSRError } from '../core/errors/dev/index.js';
import { AggregateError, CSSError, MarkdownError } from '../core/errors/index.js';
import { viteID } from '../core/util.js';
import type DevPipeline from './devPipeline.js';
export async function preload({
pipeline,
filePath,
}: {
pipeline: DevPipeline;
filePath: URL;
}): Promise<ComponentInstance> {
// Important: This needs to happen first, in case a renderer provides polyfills.
await pipeline.loadRenderers();
try {
// Load the module from the Vite SSR Runtime.
const mod = (await pipeline.getModuleLoader().import(viteID(filePath))) as ComponentInstance;
return mod;
} catch (error) {
// If the error came from Markdown or CSS, we already handled it and there's no need to enhance it
if (MarkdownError.is(error) || CSSError.is(error) || AggregateError.is(error)) {
throw error;
}
throw enhanceViteSSRError({ error, filePath, loader: pipeline.getModuleLoader() });
}
}
export { createController, runWithErrorHandling } from './controller.js';
export { default as vitePluginAstroServer } from './plugin.js';
export { handleRequest } from './request.js';

View file

@ -0,0 +1,137 @@
import url from 'node:url'
import type { AstroSettings, ComponentInstance, DevToolbarMetadata, RouteData, SSRElement, SSRLoadedRenderer, SSRManifest } from '../@types/astro.js';
import type { Logger } from '../core/logger/core.js';
import type { ModuleLoader } from '../core/module-loader/index.js';
import { Pipeline, loadRenderer } from '../core/render/index.js';
import { isPage, resolveIdToUrl, viteID } from '../core/util.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import { createResolve } from './resolve.js';
import { AggregateError, CSSError, MarkdownError } from '../core/errors/index.js';
import { enhanceViteSSRError } from '../core/errors/dev/index.js';
import type { HeadElements } from '../core/base-pipeline.js';
import { getScriptsForURL } from './scripts.js';
import { ASTRO_VERSION } from '../core/constants.js';
import { getInfoOutput } from '../cli/info/index.js';
import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
import { getStylesForURL } from './css.js';
import { getComponentMetadata } from './metadata.js';
export class DevPipeline extends Pipeline {
// renderers are loaded on every request,
// so it needs to be mutable here unlike in other environments
override renderers = new Array<SSRLoadedRenderer>
private constructor(
readonly loader: ModuleLoader,
readonly logger: Logger,
readonly manifest: SSRManifest,
readonly settings: AstroSettings,
readonly config = settings.config,
) {
const mode = 'development'
const resolve = createResolve(loader, config.root);
const serverLike = isServerLikeOutput(config);
const streaming = true;
super(logger, manifest, mode, [], resolve, serverLike, streaming);
}
static create({ loader, logger, manifest, settings }: Pick<DevPipeline, 'loader' | 'logger' | 'manifest' | 'settings'>) {
return new DevPipeline(loader, logger, manifest, settings)
}
async headElements(routeData: RouteData): Promise<HeadElements> {
const { config: { root }, loader, mode, settings } = this;
const filePath = new URL(`./${routeData.component}`, root);
const { scripts } = await getScriptsForURL(filePath, root, loader);
// Inject HMR scripts
if (isPage(filePath, settings) && mode === 'development') {
scripts.add({
props: { type: 'module', src: '/@vite/client' },
children: '',
});
if (
settings.config.devToolbar.enabled &&
(await settings.preferences.get('devToolbar.enabled'))
) {
const src = await resolveIdToUrl(loader, 'astro/runtime/client/dev-toolbar/entrypoint.js')
scripts.add({ props: { type: 'module', src }, children: '' });
const additionalMetadata: DevToolbarMetadata['__astro_dev_toolbar__'] = {
root: url.fileURLToPath(settings.config.root),
version: ASTRO_VERSION,
debugInfo: await getInfoOutput({ userConfig: settings.config, print: false }),
};
// Additional data for the dev overlay
const children = `window.__astro_dev_toolbar__ = ${JSON.stringify(additionalMetadata)}`;
scripts.add({ props: {}, children });
}
}
// TODO: We should allow adding generic HTML elements to the head, not just scripts
for (const script of settings.scripts) {
if (script.stage === 'head-inline') {
scripts.add({
props: {},
children: script.content,
});
} else if (script.stage === 'page' && isPage(filePath, settings)) {
scripts.add({
props: { type: 'module', src: `/@id/${PAGE_SCRIPT_ID}` },
children: '',
});
}
}
// Pass framework CSS in as style tags to be appended to the page.
const links = new Set<SSRElement>();
const { urls, styles: _styles } = await getStylesForURL(filePath, loader);
for (const href of urls) {
links.add({ props: { rel: 'stylesheet', href }, children: '' });
}
const styles = new Set<SSRElement>();
for (const { id, url: src, content } of _styles) {
// Vite handles HMR for styles injected as scripts
scripts.add({ props: { type: 'module', src }, children: '' });
// But we still want to inject the styles to avoid FOUC. The style tags
// should emulate what Vite injects so further HMR works as expected.
styles.add({ props: { 'data-vite-dev-id': id }, children: content });
};
return { scripts, styles, links }
}
componentMetadata(routeData: RouteData) {
const { config: { root }, loader } = this;
const filePath = new URL(`./${routeData.component}`, root);
return getComponentMetadata(filePath, loader)
}
async preload(filePath: URL) {
const { loader } = this;
// Important: This needs to happen first, in case a renderer provides polyfills.
const renderers__ = this.settings.renderers.map((r) => loadRenderer(r, loader));
const renderers_ = await Promise.all(renderers__);
this.renderers = renderers_.filter((r): r is SSRLoadedRenderer => Boolean(r));
try {
// Load the module from the Vite SSR Runtime.
return await loader.import(viteID(filePath)) as ComponentInstance;
} catch (error) {
// If the error came from Markdown or CSS, we already handled it and there's no need to enhance it
if (MarkdownError.is(error) || CSSError.is(error) || AggregateError.is(error)) {
throw error;
}
throw enhanceViteSSRError({ error, filePath, loader });
}
}
clearRouteCache() {
this.routeCache.clearAll();
}
}

View file

@ -8,7 +8,7 @@ import { createViteLoader } from '../core/module-loader/index.js';
import { createRouteManifest } from '../core/routing/index.js';
import { baseMiddleware } from './base.js';
import { createController } from './controller.js';
import DevPipeline from './devPipeline.js';
import { DevPipeline } from './pipeline.js';
import { handleRequest } from './request.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { getViteErrorPayload } from '../core/errors/dev/index.js';
@ -33,7 +33,7 @@ export default function createVitePluginAstroServer({
configureServer(viteServer) {
const loader = createViteLoader(viteServer);
const manifest = createDevelopmentManifest(settings);
const pipeline = new DevPipeline({ logger, manifest, settings, loader });
const pipeline = DevPipeline.create({ loader, logger, manifest, settings });
let manifestData: ManifestData = createRouteManifest({ settings, fsMod }, logger);
const controller = createController({ loader });
const localStorage = new AsyncLocalStorage();
@ -90,7 +90,6 @@ export default function createVitePluginAstroServer({
controller,
incomingRequest: request,
incomingResponse: response,
manifest,
});
});
});

View file

@ -1,10 +1,10 @@
import type http from 'node:http';
import type { ManifestData, SSRManifest } from '../@types/astro.js';
import type { ManifestData } from '../@types/astro.js';
import { collapseDuplicateSlashes, removeTrailingForwardSlash } from '../core/path.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import type { DevServerController } from './controller.js';
import { runWithErrorHandling } from './controller.js';
import type DevPipeline from './devPipeline.js';
import type { DevPipeline } from './pipeline.js';
import { handle500Response } from './response.js';
import { handleRoute, matchRoute } from './route.js';
import { recordServerError } from './error.js';
@ -15,7 +15,6 @@ type HandleRequest = {
controller: DevServerController;
incomingRequest: http.IncomingMessage;
incomingResponse: http.ServerResponse;
manifest: SSRManifest;
};
/** The main logic to route dev server requests to pages in Astro. */
@ -25,11 +24,9 @@ export async function handleRequest({
controller,
incomingRequest,
incomingResponse,
manifest,
}: HandleRequest) {
const config = pipeline.getConfig();
const moduleLoader = pipeline.getModuleLoader();
const origin = `${moduleLoader.isHttps() ? 'https' : 'http'}://${incomingRequest.headers.host}`;
const { config, loader } = pipeline;
const origin = `${loader.isHttps() ? 'https' : 'http'}://${incomingRequest.headers.host}`;
const buildingToSSR = isServerLikeOutput(config);
const url = new URL(collapseDuplicateSlashes(origin + incomingRequest.url));
@ -82,12 +79,11 @@ export async function handleRequest({
manifestData,
incomingRequest: incomingRequest,
incomingResponse: incomingResponse,
manifest,
});
},
onError(_err) {
const { error, errorWithMetadata } = recordServerError(moduleLoader, config, pipeline, _err);
handle500Response(moduleLoader, incomingResponse, errorWithMetadata);
const { error, errorWithMetadata } = recordServerError(loader, config, pipeline, _err);
handle500Response(loader, incomingResponse, errorWithMetadata);
return error;
},
});

View file

@ -1,43 +1,22 @@
import type http from 'node:http';
import { fileURLToPath } from 'node:url';
import type {
ComponentInstance,
DevToolbarMetadata,
ManifestData,
MiddlewareHandler,
RouteData,
SSRElement,
SSRManifest,
} from '../@types/astro.js';
import { getInfoOutput } from '../cli/info/index.js';
import { ASTRO_VERSION } from '../core/constants.js';
import { AstroErrorData, isAstroError } from '../core/errors/index.js';
import { req } from '../core/messages.js';
import { sequence } from '../core/middleware/index.js';
import { loadMiddleware } from '../core/middleware/loadMiddleware.js';
import {
createRenderContext,
getParamsAndProps,
type RenderContext,
type SSROptions,
} from '../core/render/index.js';
import { getProps, type SSROptions } from '../core/render/index.js';
import { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.js';
import { isPage, resolveIdToUrl } from '../core/util.js';
import { normalizeTheLocale } from '../i18n/index.js';
import { createI18nMiddleware, i18nPipelineHook } from '../i18n/middleware.js';
import { getSortedPreloadedMatches } from '../prerender/routing.js';
import { isServerLikeOutput } from '../prerender/utils.js';
import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
import { getStylesForURL } from './css.js';
import type DevPipeline from './devPipeline.js';
import { preload } from './index.js';
import { getComponentMetadata } from './metadata.js';
import type { DevPipeline } from './pipeline.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
import { getScriptsForURL } from './scripts.js';
import { REROUTE_DIRECTIVE_HEADER } from '../runtime/server/consts.js';
const clientLocalsSymbol = Symbol.for('astro.locals');
import { REROUTE_DIRECTIVE_HEADER, clientLocalsSymbol } from '../core/constants.js';
import { RenderContext } from '../core/render-context.js';
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
...args: any
@ -67,27 +46,22 @@ export async function matchRoute(
manifestData: ManifestData,
pipeline: DevPipeline
): Promise<MatchedRoute | undefined> {
const env = pipeline.getEnvironment();
const { routeCache, logger } = env;
let matches = matchAllRoutes(pathname, manifestData);
const { config, logger, routeCache, serverLike, settings } = pipeline;
const matches = matchAllRoutes(pathname, manifestData);
const preloadedMatches = await getSortedPreloadedMatches({
pipeline,
matches,
settings: pipeline.getSettings(),
});
const preloadedMatches = await getSortedPreloadedMatches({ pipeline, matches, settings });
for await (const { preloadedComponent, route: maybeRoute, filePath } of preloadedMatches) {
// attempt to get static paths
// if this fails, we have a bad URL match!
try {
await getParamsAndProps({
await getProps({
mod: preloadedComponent,
route: maybeRoute,
routeData: maybeRoute,
routeCache,
pathname: pathname,
logger,
ssr: isServerLikeOutput(pipeline.getConfig()),
serverLike,
});
return {
route: maybeRoute,
@ -116,7 +90,7 @@ export async function matchRoute(
if (matches.length) {
const possibleRoutes = matches.flatMap((route) => route.component);
pipeline.logger.warn(
logger.warn(
'router',
`${AstroErrorData.NoMatchingStaticPathFound.message(
pathname
@ -127,8 +101,8 @@ export async function matchRoute(
const custom404 = getCustom404Route(manifestData);
if (custom404) {
const filePath = new URL(`./${custom404.component}`, pipeline.getConfig().root);
const preloadedComponent = await preload({ pipeline, filePath });
const filePath = new URL(`./${custom404.component}`, config.root);
const preloadedComponent = await pipeline.preload(filePath);
return {
route: custom404,
@ -151,7 +125,6 @@ type HandleRoute = {
manifestData: ManifestData;
incomingRequest: http.IncomingMessage;
incomingResponse: http.ServerResponse;
manifest: SSRManifest;
status?: 404 | 500;
pipeline: DevPipeline;
};
@ -167,13 +140,9 @@ export async function handleRoute({
manifestData,
incomingRequest,
incomingResponse,
manifest,
}: HandleRoute): Promise<void> {
const timeStart = performance.now();
const env = pipeline.getEnvironment();
const config = pipeline.getConfig();
const moduleLoader = pipeline.getModuleLoader();
const { logger } = env;
const { config, loader, logger } = pipeline;
if (!matchedRoute && !config.i18n) {
if (isLoggedRequest(pathname)) {
logger.info(null, req({ url: pathname, method: incomingRequest.method, statusCode: 404 }));
@ -188,8 +157,8 @@ export async function handleRoute({
let mod: ComponentInstance | undefined = undefined;
let options: SSROptions | undefined = undefined;
let route: RouteData;
const middleware = await loadMiddleware(moduleLoader);
const middleware = (await loadMiddleware(loader)).onRequest;
if (!matchedRoute) {
if (config.i18n) {
const locales = config.i18n.locales;
@ -239,16 +208,7 @@ export async function handleRoute({
fallbackRoutes: [],
isIndex: false,
};
renderContext = await createRenderContext({
request,
pathname,
env,
mod,
route,
locales: manifest.i18n?.locales,
routing: manifest.i18n?.routing,
defaultLocale: manifest.i18n?.defaultLocale,
});
renderContext = RenderContext.create({ pipeline: pipeline, pathname, middleware, request, routeData: route });
} else {
return handle404Response(origin, incomingRequest, incomingResponse);
}
@ -256,16 +216,17 @@ export async function handleRoute({
const filePath: URL | undefined = matchedRoute.filePath;
const { preloadedComponent } = matchedRoute;
route = matchedRoute.route;
// Headers are only available when using SSR.
// Allows adapters to pass in locals in dev mode.
const locals = Reflect.get(incomingRequest, clientLocalsSymbol)
request = createRequest({
url,
// Headers are only available when using SSR.
headers: buildingToSSR ? incomingRequest.headers : new Headers(),
method: incomingRequest.method,
body,
logger,
ssr: buildingToSSR,
clientAddress: buildingToSSR ? incomingRequest.socket.remoteAddress : undefined,
locals: Reflect.get(incomingRequest, clientLocalsSymbol), // Allows adapters to pass in locals in dev mode.
});
// Set user specified headers to response object.
@ -274,60 +235,19 @@ export async function handleRoute({
}
options = {
env,
pipeline,
filePath,
preload: preloadedComponent,
pathname,
request,
route,
middleware,
};
mod = options.preload;
const { scripts, links, styles, metadata } = await getScriptsAndStyles({
pipeline,
filePath: options.filePath,
});
const i18n = pipeline.getConfig().i18n;
renderContext = await createRenderContext({
request: options.request,
pathname: options.pathname,
scripts,
links,
styles,
componentMetadata: metadata,
route: options.route,
mod,
env,
locales: i18n?.locales,
routing: i18n?.routing,
defaultLocale: i18n?.defaultLocale,
});
mod = preloadedComponent;
renderContext = RenderContext.create({ locals, pipeline, pathname, middleware, request, routeData: route });
}
const onRequest: MiddlewareHandler = middleware.onRequest;
if (config.i18n) {
const i18Middleware = createI18nMiddleware(
manifest.i18n,
config.base,
config.trailingSlash,
config.build.format
);
if (i18Middleware) {
pipeline.setMiddlewareFunction(sequence(i18Middleware, onRequest));
pipeline.onBeforeRenderRoute(i18nPipelineHook);
} else {
pipeline.setMiddlewareFunction(onRequest);
}
} else {
pipeline.setMiddlewareFunction(onRequest);
}
let response = await pipeline.renderRoute(renderContext, mod);
let response = await renderContext.render(mod);
if (isLoggedRequest(pathname)) {
const timeEnd = performance.now();
logger.info(
@ -358,7 +278,6 @@ export async function handleRoute({
manifestData,
incomingRequest,
incomingResponse,
manifest,
});
}
if (route.type === 'endpoint') {
@ -385,104 +304,6 @@ export async function handleRoute({
await writeSSRResult(request, response, incomingResponse);
}
interface GetScriptsAndStylesParams {
pipeline: DevPipeline;
filePath: URL;
}
async function getScriptsAndStyles({ pipeline, filePath }: GetScriptsAndStylesParams) {
const moduleLoader = pipeline.getModuleLoader();
const settings = pipeline.getSettings();
const mode = pipeline.getEnvironment().mode;
// Add hoisted script tags
const { scripts } = await getScriptsForURL(filePath, settings.config.root, moduleLoader);
// Inject HMR scripts
if (isPage(filePath, settings) && mode === 'development') {
scripts.add({
props: { type: 'module', src: '/@vite/client' },
children: '',
});
if (
settings.config.devToolbar.enabled &&
(await settings.preferences.get('devToolbar.enabled'))
) {
scripts.add({
props: {
type: 'module',
src: await resolveIdToUrl(moduleLoader, 'astro/runtime/client/dev-toolbar/entrypoint.js'),
},
children: '',
});
const additionalMetadata: DevToolbarMetadata['__astro_dev_toolbar__'] = {
root: fileURLToPath(settings.config.root),
version: ASTRO_VERSION,
debugInfo: await getInfoOutput({ userConfig: settings.config, print: false }),
};
// Additional data for the dev overlay
scripts.add({
props: {},
children: `window.__astro_dev_toolbar__ = ${JSON.stringify(additionalMetadata)}`,
});
}
}
// TODO: We should allow adding generic HTML elements to the head, not just scripts
for (const script of settings.scripts) {
if (script.stage === 'head-inline') {
scripts.add({
props: {},
children: script.content,
});
} else if (script.stage === 'page' && isPage(filePath, settings)) {
scripts.add({
props: { type: 'module', src: `/@id/${PAGE_SCRIPT_ID}` },
children: '',
});
}
}
// Pass framework CSS in as style tags to be appended to the page.
const { urls: styleUrls, styles: importedStyles } = await getStylesForURL(filePath, moduleLoader);
let links = new Set<SSRElement>();
[...styleUrls].forEach((href) => {
links.add({
props: {
rel: 'stylesheet',
href,
},
children: '',
});
});
let styles = new Set<SSRElement>();
importedStyles.forEach(({ id, url, content }) => {
// Vite handles HMR for styles injected as scripts
scripts.add({
props: {
type: 'module',
src: url,
},
children: '',
});
// But we still want to inject the styles to avoid FOUC. The style tags
// should emulate what Vite injects so further HMR works as expected.
styles.add({
props: {
'data-vite-dev-id': id,
},
children: content,
});
});
const metadata = await getComponentMetadata(filePath, moduleLoader);
return { scripts, styles, links, metadata };
}
function getStatus(matchedRoute?: MatchedRoute): 404 | 500 | undefined {
if (!matchedRoute) return 404;
if (matchedRoute.route.route === '/404') return 404;

View file

@ -1,4 +1,4 @@
import { defineConfig} from "astro/config";
import { defineConfig } from "astro/config";
export default defineConfig({
i18n: {

View file

@ -4,7 +4,7 @@ import {
getLocaleAbsoluteUrl,
getLocaleAbsoluteUrlList,
} from '../../../dist/i18n/index.js';
import { parseLocale } from '../../../dist/core/render/context.js';
import { parseLocale } from '../../../dist/i18n/utils.js';
import { describe, it } from 'node:test';
import * as assert from 'node:assert/strict';
import { validateConfig } from '../../../dist/core/config/config.js';

View file

@ -9,18 +9,22 @@ import {
renderHead,
Fragment,
} from '../../../dist/runtime/server/index.js';
import { createRenderContext } from '../../../dist/core/render/index.js';
import { createBasicEnvironment } from '../test-utils.js';
import { RenderContext } from '../../../dist/core/render-context.js';
import { createBasicPipeline } from '../test-utils.js';
import * as cheerio from 'cheerio';
import { Pipeline } from '../../../dist/core/pipeline.js';
const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
describe('core/render', () => {
describe('Injected head contents', () => {
let env;
let pipeline;
before(async () => {
env = createBasicEnvironment();
pipeline = createBasicPipeline();
pipeline.headElements = () => ({
links: new Set([{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }]),
scripts: new Set,
styles: new Set
});
});
it('Multi-level layouts and head injection, with explicit head', async () => {
@ -90,16 +94,10 @@ describe('core/render', () => {
});
const PageModule = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index', component: 'src/pages/index.astro' },
request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }],
mod: PageModule,
env,
});
const pipeline = new Pipeline(env);
const response = await pipeline.renderRoute(ctx, PageModule);
const request = new Request('http://example.com/');
const routeData = { type: 'page', pathname: '/index', component: 'src/pages/index.astro', params: {} };
const renderContext = RenderContext.create({ pipeline, request, routeData });
const response = await renderContext.render(PageModule);
const html = await response.text();
const $ = cheerio.load(html);
@ -172,17 +170,11 @@ describe('core/render', () => {
});
const PageModule = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index', component: 'src/pages/index.astro' },
request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }],
env,
mod: PageModule,
});
const request = new Request('http://example.com/');
const routeData = { type: 'page', pathname: '/index', component: 'src/pages/index.astro', params: {} };
const renderContext = RenderContext.create({ pipeline, request, routeData });
const response = await renderContext.render(PageModule);
const pipeline = new Pipeline(env);
const response = await pipeline.renderRoute(ctx, PageModule);
const html = await response.text();
const $ = cheerio.load(html);
@ -221,16 +213,11 @@ describe('core/render', () => {
});
const PageModule = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index', component: 'src/pages/index.astro' },
request: new Request('http://example.com/'),
links: [{ name: 'link', props: { rel: 'stylesheet', href: '/main.css' }, children: '' }],
env,
mod: PageModule,
});
const request = new Request('http://example.com/');
const routeData = { type: 'page', pathname: '/index', component: 'src/pages/index.astro', params: {} };
const renderContext = RenderContext.create({ pipeline, request, routeData });
const response = await renderContext.render(PageModule);
const pipeline = new Pipeline(env);
const response = await pipeline.renderRoute(ctx, PageModule);
const html = await response.text();
const $ = cheerio.load(html);

View file

@ -7,19 +7,19 @@ import {
renderSlot,
} from '../../../dist/runtime/server/index.js';
import { jsx } from '../../../dist/jsx-runtime/index.js';
import { createRenderContext, loadRenderer } from '../../../dist/core/render/index.js';
import { loadRenderer } from '../../../dist/core/render/index.js';
import { RenderContext } from '../../../dist/core/render-context.js';
import { createAstroJSXComponent, renderer as jsxRenderer } from '../../../dist/jsx/index.js';
import { createBasicEnvironment } from '../test-utils.js';
import { Pipeline } from '../../../dist/core/pipeline.js';
import { createBasicPipeline } from '../test-utils.js';
const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
const loadJSXRenderer = () => loadRenderer(jsxRenderer, { import: (s) => import(s) });
describe('core/render', () => {
describe('Astro JSX components', () => {
let env;
let pipeline;
before(async () => {
env = createBasicEnvironment({
pipeline = createBasicPipeline({
renderers: [await loadJSXRenderer()],
});
});
@ -42,15 +42,10 @@ describe('core/render', () => {
});
const mod = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index', component: 'src/pages/index.mdx' },
request: new Request('http://example.com/'),
env,
mod,
});
const pipeline = new Pipeline(env);
const response = await pipeline.renderRoute(ctx, mod);
const request = new Request('http://example.com/');
const routeData = { type: 'page', pathname: '/index', component: 'src/pages/index.mdx', params: {} };
const renderContext = RenderContext.create({ pipeline, request, routeData });
const response = await renderContext.render(mod);
assert.equal(response.status, 200);
@ -89,14 +84,10 @@ describe('core/render', () => {
});
const mod = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index', component: 'src/pages/index.mdx' },
request: new Request('http://example.com/'),
env,
mod,
});
const pipeline = new Pipeline(env);
const response = await pipeline.renderRoute(ctx, mod);
const request = new Request('http://example.com/');
const routeData = { type: 'page', pathname: '/index', component: 'src/pages/index.mdx', params: {} };
const renderContext = RenderContext.create({ pipeline, request, routeData });
const response = await renderContext.render(mod);
assert.equal(response.status, 200);
@ -119,15 +110,10 @@ describe('core/render', () => {
});
const mod = createAstroModule(Page);
const ctx = await createRenderContext({
route: { type: 'page', pathname: '/index', component: 'src/pages/index.mdx' },
request: new Request('http://example.com/'),
env,
mod,
});
const pipeline = new Pipeline(env);
const response = await pipeline.renderRoute(ctx, mod);
const request = new Request('http://example.com/');
const routeData = { type: 'page', pathname: '/index', component: 'src/pages/index.mdx', params: {} };
const renderContext = RenderContext.create({ pipeline, request, routeData });
const response = await renderContext.render(mod);
try {
await response.text();

View file

@ -14,7 +14,7 @@ import * as cheerio from 'cheerio';
import testAdapter from '../../test-adapter.js';
import { getSortedPreloadedMatches } from '../../../dist/prerender/routing.js';
import { createDevelopmentManifest } from '../../../dist/vite-plugin-astro-server/plugin.js';
import DevPipeline from '../../../dist/vite-plugin-astro-server/devPipeline.js';
import { DevPipeline } from '../../../dist/vite-plugin-astro-server/pipeline.js';
const root = new URL('../../fixtures/alias/', import.meta.url);
const fileSystem = {
@ -146,7 +146,7 @@ describe('Route matching', () => {
const loader = createViteLoader(container.viteServer);
const manifest = createDevelopmentManifest(container.settings);
pipeline = new DevPipeline({ manifest, logger: defaultLogger, settings, loader });
pipeline = DevPipeline.create({ loader, logger: defaultLogger, manifest, settings });
manifestData = createRouteManifest(
{
cwd: fileURLToPath(root),

View file

@ -6,7 +6,7 @@ import npath from 'node:path';
import { fileURLToPath } from 'node:url';
import { getDefaultClientDirectives } from '../../dist/core/client-directive/index.js';
import { nodeLogDestination } from '../../dist/core/logger/node.js';
import { createEnvironment } from '../../dist/core/render/index.js';
import { Pipeline } from '../../dist/core/render/index.js';
import { RouteCache } from '../../dist/core/render/route-cache.js';
import { resolveConfig } from '../../dist/core/config/index.js';
import { createBaseSettings } from '../../dist/core/config/settings.js';
@ -181,25 +181,30 @@ export function buffersToString(buffers) {
export const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
/**
* @param {Partial<import('../../src/core/render/environment.js').CreateEnvironmentArgs>} options
* @returns {import('../../src/core/render/environment.js').Environment}
* @param {Partial<Pipeline>} options
* @returns {Pipeline}
*/
export function createBasicEnvironment(options = {}) {
export function createBasicPipeline(options = {}) {
const mode = options.mode ?? 'development';
return createEnvironment({
...options,
markdown: {
...(options.markdown ?? {}),
},
mode,
renderers: options.renderers ?? [],
clientDirectives: getDefaultClientDirectives(),
resolve: options.resolve ?? ((s) => Promise.resolve(s)),
routeCache: new RouteCache(options.logging, mode),
logger: options.logger ?? defaultLogger,
ssr: options.ssr ?? true,
streaming: options.streaming ?? true,
});
const pipeline = new Pipeline(
options.logger ?? defaultLogger,
options.manifest ?? {},
options.mode ?? 'development',
options.renderers ?? [],
options.resolve ?? (s => Promise.resolve(s)),
options.serverLike ?? true,
options.streaming ?? true,
options.adapterName,
options.clientDirectives ?? getDefaultClientDirectives(),
options.compressHTML,
options.i18n,
options.middleware,
options.routeCache ?? new RouteCache(options.logging, mode),
options.site
);
pipeline.headElements = () => ({ scripts: new Set, styles: new Set, links: new Set });
pipeline.componentMetadata = () => {};
return pipeline
}
/**

View file

@ -12,19 +12,14 @@ import {
defaultLogger,
} from '../test-utils.js';
import { createDevelopmentManifest } from '../../../dist/vite-plugin-astro-server/plugin.js';
import DevPipeline from '../../../dist/vite-plugin-astro-server/devPipeline.js';
import { DevPipeline } from '../../../dist/vite-plugin-astro-server/pipeline.js';
async function createDevPipeline(overrides = {}) {
const settings = overrides.settings ?? (await createBasicSettings({ root: '/' }));
const loader = overrides.loader ?? createLoader();
const manifest = createDevelopmentManifest(settings);
return new DevPipeline({
manifest,
settings,
logger: defaultLogger,
loader,
});
return DevPipeline.create({ loader, logger: defaultLogger, manifest, settings });
}
describe('vite-plugin-astro-server', () => {
@ -32,7 +27,10 @@ describe('vite-plugin-astro-server', () => {
it('renders a request', async () => {
const pipeline = await createDevPipeline({
loader: createLoader({
import() {
import(id) {
if (id === '\0astro-internal:middleware') {
return { onRequest: (_, next) => next() }
}
const Page = createComponent(() => {
return render`<div id="test">testing</div>`;
});
@ -40,7 +38,7 @@ describe('vite-plugin-astro-server', () => {
},
}),
});
const controller = createController({ loader: pipeline.getModuleLoader() });
const controller = createController({ loader: pipeline.loader });
const { req, res, text } = createRequestAndResponse();
const fs = createFs(
{
@ -52,7 +50,7 @@ describe('vite-plugin-astro-server', () => {
const manifestData = createRouteManifest(
{
fsMod: fs,
settings: pipeline.getSettings(),
settings: pipeline.settings,
},
defaultLogger
);
@ -64,6 +62,7 @@ describe('vite-plugin-astro-server', () => {
controller,
incomingRequest: req,
incomingResponse: res,
manifest: {}
});
} catch (err) {
assert.equal(err.message, undefined);

View file

@ -84,7 +84,7 @@ describe('endpoints', () => {
});
});
it('Headers with multiple values (set-cookie special case)', async () => {
it('Can bail on streaming', async () => {
const { req, res, done } = createRequestAndResponse({
method: 'GET',
url: '/streaming',