astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
442 lines (441 loc) • 15.4 kB
JavaScript
import { collapseDuplicateTrailingSlashes, hasFileExtension } from "@astrojs/internal-helpers/path";
import { normalizeTheLocale } from "../../i18n/index.js";
import {
DEFAULT_404_COMPONENT,
REROUTABLE_STATUS_CODES,
REROUTE_DIRECTIVE_HEADER,
clientAddressSymbol,
responseSentSymbol
} from "../constants.js";
import { getSetCookiesFromResponse } from "../cookies/index.js";
import { AstroError, AstroErrorData } from "../errors/index.js";
import { consoleLogDestination } from "../logger/console.js";
import { AstroIntegrationLogger, Logger } from "../logger/core.js";
import { NOOP_MIDDLEWARE_FN } from "../middleware/noop-middleware.js";
import {
appendForwardSlash,
joinPaths,
prependForwardSlash,
removeTrailingForwardSlash
} from "../path.js";
import { RenderContext } from "../render-context.js";
import { createAssetLink } from "../render/ssr-element.js";
import { redirectTemplate } from "../routing/3xx.js";
import { ensure404Route } from "../routing/astro-designed-error-pages.js";
import { createDefaultRoutes } from "../routing/default.js";
import { matchRoute } from "../routing/match.js";
import { PERSIST_SYMBOL } from "../session.js";
import { AppPipeline } from "./pipeline.js";
import { deserializeManifest } from "./common.js";
class App {
#manifest;
#manifestData;
#logger = new Logger({
dest: consoleLogDestination,
level: "info"
});
#baseWithoutTrailingSlash;
#pipeline;
#adapterLogger;
constructor(manifest, streaming = true) {
this.#manifest = manifest;
this.#manifestData = {
routes: manifest.routes.map((route) => route.routeData)
};
ensure404Route(this.#manifestData);
this.#baseWithoutTrailingSlash = removeTrailingForwardSlash(this.#manifest.base);
this.#pipeline = this.#createPipeline(streaming);
this.#adapterLogger = new AstroIntegrationLogger(
this.#logger.options,
this.#manifest.adapterName
);
}
getAdapterLogger() {
return this.#adapterLogger;
}
/**
* Creates a pipeline by reading the stored manifest
*
* @param streaming
* @private
*/
#createPipeline(streaming = false) {
return AppPipeline.create({
logger: this.#logger,
manifest: this.#manifest,
runtimeMode: "production",
renderers: this.#manifest.renderers,
defaultRoutes: createDefaultRoutes(this.#manifest),
resolve: async (specifier) => {
if (!(specifier in this.#manifest.entryModules)) {
throw new Error(`Unable to resolve [${specifier}]`);
}
const bundlePath = this.#manifest.entryModules[specifier];
if (bundlePath.startsWith("data:") || bundlePath.length === 0) {
return bundlePath;
} else {
return createAssetLink(bundlePath, this.#manifest.base, this.#manifest.assetsPrefix);
}
},
serverLike: true,
streaming
});
}
set setManifestData(newManifestData) {
this.#manifestData = newManifestData;
}
removeBase(pathname) {
if (pathname.startsWith(this.#manifest.base)) {
return pathname.slice(this.#baseWithoutTrailingSlash.length + 1);
}
return pathname;
}
/**
* It removes the base from the request URL, prepends it with a forward slash and attempts to decoded it.
*
* If the decoding fails, it logs the error and return the pathname as is.
* @param request
* @private
*/
#getPathnameFromRequest(request) {
const url = new URL(request.url);
const pathname = prependForwardSlash(this.removeBase(url.pathname));
try {
return decodeURI(pathname);
} catch (e) {
this.getAdapterLogger().error(e.toString());
return pathname;
}
}
match(request) {
const url = new URL(request.url);
if (this.#manifest.assets.has(url.pathname)) return void 0;
let pathname = this.#computePathnameFromDomain(request);
if (!pathname) {
pathname = prependForwardSlash(this.removeBase(url.pathname));
}
let routeData = matchRoute(decodeURI(pathname), this.#manifestData);
if (!routeData || routeData.prerender) return void 0;
return routeData;
}
#computePathnameFromDomain(request) {
let pathname = void 0;
const url = new URL(request.url);
if (this.#manifest.i18n && (this.#manifest.i18n.strategy === "domains-prefix-always" || this.#manifest.i18n.strategy === "domains-prefix-other-locales" || this.#manifest.i18n.strategy === "domains-prefix-always-no-redirect")) {
let host = request.headers.get("X-Forwarded-Host");
let protocol = request.headers.get("X-Forwarded-Proto");
if (protocol) {
protocol = protocol + ":";
} else {
protocol = url.protocol;
}
if (!host) {
host = request.headers.get("Host");
}
if (host && protocol) {
host = host.split(":")[0];
try {
let locale;
const hostAsUrl = new URL(`${protocol}//${host}`);
for (const [domainKey, localeValue] of Object.entries(
this.#manifest.i18n.domainLookupTable
)) {
const domainKeyAsUrl = new URL(domainKey);
if (hostAsUrl.host === domainKeyAsUrl.host && hostAsUrl.protocol === domainKeyAsUrl.protocol) {
locale = localeValue;
break;
}
}
if (locale) {
pathname = prependForwardSlash(
joinPaths(normalizeTheLocale(locale), this.removeBase(url.pathname))
);
if (url.pathname.endsWith("/")) {
pathname = appendForwardSlash(pathname);
}
}
} catch (e) {
this.#logger.error(
"router",
`Astro tried to parse ${protocol}//${host} as an URL, but it threw a parsing error. Check the X-Forwarded-Host and X-Forwarded-Proto headers.`
);
this.#logger.error("router", `Error: ${e}`);
}
}
}
return pathname;
}
#redirectTrailingSlash(pathname) {
const { trailingSlash } = this.#manifest;
if (pathname === "/" || pathname.startsWith("/_")) {
return pathname;
}
const path = collapseDuplicateTrailingSlashes(pathname, trailingSlash !== "never");
if (path !== pathname) {
return path;
}
if (trailingSlash === "ignore") {
return pathname;
}
if (trailingSlash === "always" && !hasFileExtension(pathname)) {
return appendForwardSlash(pathname);
}
if (trailingSlash === "never") {
return removeTrailingForwardSlash(pathname);
}
return pathname;
}
async render(request, renderOptions) {
let routeData;
let locals;
let clientAddress;
let addCookieHeader;
const url = new URL(request.url);
const redirect = this.#redirectTrailingSlash(url.pathname);
const prerenderedErrorPageFetch = renderOptions?.prerenderedErrorPageFetch ?? fetch;
if (redirect !== url.pathname) {
const status = request.method === "GET" ? 301 : 308;
return new Response(
redirectTemplate({
status,
relativeLocation: url.pathname,
absoluteLocation: redirect,
from: request.url
}),
{
status,
headers: {
location: redirect + url.search
}
}
);
}
addCookieHeader = renderOptions?.addCookieHeader;
clientAddress = renderOptions?.clientAddress ?? Reflect.get(request, clientAddressSymbol);
routeData = renderOptions?.routeData;
locals = renderOptions?.locals;
if (routeData) {
this.#logger.debug(
"router",
"The adapter " + this.#manifest.adapterName + " provided a custom RouteData for ",
request.url
);
this.#logger.debug("router", "RouteData:\n" + routeData);
}
if (locals) {
if (typeof locals !== "object") {
const error = new AstroError(AstroErrorData.LocalsNotAnObject);
this.#logger.error(null, error.stack);
return this.#renderError(request, {
status: 500,
error,
clientAddress,
prerenderedErrorPageFetch
});
}
}
if (!routeData) {
routeData = this.match(request);
this.#logger.debug("router", "Astro matched the following route for " + request.url);
this.#logger.debug("router", "RouteData:\n" + routeData);
}
if (!routeData) {
routeData = this.#manifestData.routes.find(
(route) => route.component === "404.astro" || route.component === DEFAULT_404_COMPONENT
);
}
if (!routeData) {
this.#logger.debug("router", "Astro hasn't found routes that match " + request.url);
this.#logger.debug("router", "Here's the available routes:\n", this.#manifestData);
return this.#renderError(request, {
locals,
status: 404,
clientAddress,
prerenderedErrorPageFetch
});
}
const pathname = this.#getPathnameFromRequest(request);
const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
let response;
let session;
try {
const mod = await this.#pipeline.getModuleForRoute(routeData);
const renderContext = await RenderContext.create({
pipeline: this.#pipeline,
locals,
pathname,
request,
routeData,
status: defaultStatus,
clientAddress
});
session = renderContext.session;
response = await renderContext.render(await mod.page());
} catch (err) {
this.#logger.error(null, err.stack || err.message || String(err));
return this.#renderError(request, {
locals,
status: 500,
error: err,
clientAddress,
prerenderedErrorPageFetch
});
} finally {
await session?.[PERSIST_SYMBOL]();
}
if (REROUTABLE_STATUS_CODES.includes(response.status) && response.headers.get(REROUTE_DIRECTIVE_HEADER) !== "no") {
return this.#renderError(request, {
locals,
response,
status: response.status,
// We don't have an error to report here. Passing null means we pass nothing intentionally
// while undefined means there's no error
error: response.status === 500 ? null : void 0,
clientAddress,
prerenderedErrorPageFetch
});
}
if (response.headers.has(REROUTE_DIRECTIVE_HEADER)) {
response.headers.delete(REROUTE_DIRECTIVE_HEADER);
}
if (addCookieHeader) {
for (const setCookieHeaderValue of App.getSetCookieFromResponse(response)) {
response.headers.append("set-cookie", setCookieHeaderValue);
}
}
Reflect.set(response, responseSentSymbol, true);
return response;
}
setCookieHeaders(response) {
return getSetCookiesFromResponse(response);
}
/**
* Reads all the cookies written by `Astro.cookie.set()` onto the passed response.
* For example,
* ```ts
* for (const cookie_ of App.getSetCookieFromResponse(response)) {
* const cookie: string = cookie_
* }
* ```
* @param response The response to read cookies from.
* @returns An iterator that yields key-value pairs as equal-sign-separated strings.
*/
static getSetCookieFromResponse = getSetCookiesFromResponse;
/**
* 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
*/
async #renderError(request, {
locals,
status,
response: originalResponse,
skipMiddleware = false,
error,
clientAddress,
prerenderedErrorPageFetch
}) {
const errorRoutePath = `/${status}${this.#manifest.trailingSlash === "always" ? "/" : ""}`;
const errorRouteData = matchRoute(errorRoutePath, this.#manifestData);
const url = new URL(request.url);
if (errorRouteData) {
if (errorRouteData.prerender) {
const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? ".html" : "";
const statusURL = new URL(
`${this.#baseWithoutTrailingSlash}/${status}${maybeDotHtml}`,
url
);
if (statusURL.toString() !== request.url) {
const response2 = await prerenderedErrorPageFetch(statusURL.toString());
const override = { status };
return this.#mergeResponses(response2, originalResponse, override);
}
}
const mod = await this.#pipeline.getModuleForRoute(errorRouteData);
let session;
try {
const renderContext = await RenderContext.create({
locals,
pipeline: this.#pipeline,
middleware: skipMiddleware ? NOOP_MIDDLEWARE_FN : void 0,
pathname: this.#getPathnameFromRequest(request),
request,
routeData: errorRouteData,
status,
props: { error },
clientAddress
});
session = renderContext.session;
const response2 = await renderContext.render(await mod.page());
return this.#mergeResponses(response2, originalResponse);
} catch {
if (skipMiddleware === false) {
return this.#renderError(request, {
locals,
status,
response: originalResponse,
skipMiddleware: true,
clientAddress,
prerenderedErrorPageFetch
});
}
} finally {
await session?.[PERSIST_SYMBOL]();
}
}
const response = this.#mergeResponses(new Response(null, { status }), originalResponse);
Reflect.set(response, responseSentSymbol, true);
return response;
}
#mergeResponses(newResponse, originalResponse, override) {
if (!originalResponse) {
if (override !== void 0) {
return new Response(newResponse.body, {
status: override.status,
statusText: newResponse.statusText,
headers: newResponse.headers
});
}
return newResponse;
}
const status = override?.status ? override.status : originalResponse.status === 200 ? newResponse.status : originalResponse.status;
try {
originalResponse.headers.delete("Content-type");
} catch {
}
const mergedHeaders = new Map([
...Array.from(newResponse.headers),
...Array.from(originalResponse.headers)
]);
const newHeaders = new Headers();
for (const [name, value] of mergedHeaders) {
newHeaders.set(name, value);
}
return new Response(newResponse.body, {
status,
statusText: status === 200 ? newResponse.statusText : originalResponse.statusText,
// If you're looking at here for possible bugs, it means that it's not a bug.
// With the middleware, users can meddle with headers, and we should pass to the 404/500.
// If users see something weird, it's because they are setting some headers they should not.
//
// Although, we don't want it to replace the content-type, because the error page must return `text/html`
headers: newHeaders
});
}
#getDefaultStatusCode(routeData, pathname) {
if (!routeData.pattern.test(pathname)) {
for (const fallbackRoute of routeData.fallbackRoutes) {
if (fallbackRoute.pattern.test(pathname)) {
return 302;
}
}
}
const route = removeTrailingForwardSlash(routeData.route);
if (route.endsWith("/404")) return 404;
if (route.endsWith("/500")) return 500;
return 200;
}
}
export {
App,
deserializeManifest
};