UNPKG

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