UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

400 lines (399 loc) 13.7 kB
import { appendForwardSlash, collapseDuplicateLeadingSlashes, joinPaths, prependForwardSlash, removeTrailingForwardSlash } from "@astrojs/internal-helpers/path"; import { matchPattern } from "@astrojs/internal-helpers/remote"; import { normalizeTheLocale } from "../../i18n/index.js"; import { PipelineFeatures } from "../base-pipeline.js"; import { ASTRO_ERROR_HEADER, clientAddressSymbol } from "../constants.js"; import { getSetCookiesFromResponse } from "../cookies/index.js"; import { AstroError, AstroErrorData } from "../errors/index.js"; import { AstroIntegrationLogger } from "../logger/core.js"; import { DefaultFetchHandler } from "../fetch/default-handler.js"; import { appSymbol } from "../constants.js"; import { DefaultErrorHandler } from "../errors/default-handler.js"; import { setRenderOptions } from "./render-options.js"; import { MultiLevelEncodingError } from "../util/pathname.js"; class BaseApp { manifest; manifestData; pipeline; #adapterLogger; baseWithoutTrailingSlash; /** * The handler that turns incoming `Request` objects into `Response`s. * Defaults to a `DefaultFetchHandler` pinned to this app and can be * overridden via `setFetchHandler` — typically by the bundled * entrypoint after importing `virtual:astro:fetchable`. */ #fetchHandler; #errorHandler; /** * Whether a custom fetch handler (from `src/app.ts`) has been set * via `setFetchHandler`. When false, the `DefaultFetchHandler` is * in use and all features are implicitly active. */ #hasCustomFetchHandler = false; /** * Whether the missing-feature check has already run. We only want * to warn once — after the first request in dev, or at build end. */ #featureCheckDone = false; get logger() { return this.pipeline.logger; } get adapterLogger() { if (!this.#adapterLogger) { this.#adapterLogger = new AstroIntegrationLogger( this.logger.options, this.manifest.adapterName ); } return this.#adapterLogger; } constructor(manifest, streaming = true, ...args) { this.manifest = manifest; this.baseWithoutTrailingSlash = removeTrailingForwardSlash(manifest.base); this.pipeline = this.createPipeline(streaming, manifest, ...args); this.manifestData = this.pipeline.manifestData; this.#fetchHandler = new DefaultFetchHandler(this); this.#errorHandler = this.createErrorHandler(); } /** * Override the fetch handler used to dispatch requests. Entrypoints * call this with the default export of `virtual:astro:fetchable` to * plug in a user-authored handler from `src/app.ts`. */ setFetchHandler(handler) { this.#fetchHandler = handler; this.#hasCustomFetchHandler = !(handler instanceof DefaultFetchHandler); } /** * Returns the error handler strategy used by this app. Override to * provide environment-specific behavior (dev overlay, build-time throws, etc.). */ createErrorHandler() { return new DefaultErrorHandler(this); } /** * Resets the cached adapter logger so it picks up a new logger instance. * Used by BuildApp when the logger is replaced via setOptions(). */ resetAdapterLogger() { this.#adapterLogger = void 0; } getAllowedDomains() { return this.manifest.allowedDomains; } matchesAllowedDomains(forwardedHost, protocol) { return BaseApp.validateForwardedHost(forwardedHost, this.manifest.allowedDomains, protocol); } static validateForwardedHost(forwardedHost, allowedDomains, protocol) { if (!allowedDomains || allowedDomains.length === 0) { return false; } try { const testUrl = new URL(`${protocol || "https"}://${forwardedHost}`); return allowedDomains.some((pattern) => { return matchPattern(testUrl, pattern); }); } catch { return false; } } set setManifestData(newManifestData) { this.manifestData = newManifestData; this.pipeline.manifestData = newManifestData; this.pipeline.rebuildRouter(); } removeBase(pathname) { pathname = collapseDuplicateLeadingSlashes(pathname); if (pathname.startsWith(this.manifest.base)) { return pathname.slice(this.baseWithoutTrailingSlash.length + 1); } return pathname; } /** * Extracts the base-stripped, decoded pathname from a request. * Used by adapters to compute the pathname for dev-mode route matching. */ getPathnameFromRequest(request) { const url = new URL(request.url); const pathname = prependForwardSlash(this.removeBase(url.pathname)); try { return decodeURI(pathname); } catch (e) { this.adapterLogger.error(e.toString()); return pathname; } } /** * Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered * routes aren't returned, even if they are matched. * * When `allowPrerenderedRoutes` is `true`, the function returns matched prerendered routes too. * @param request * @param allowPrerenderedRoutes */ match(request, allowPrerenderedRoutes = false) { 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)); } const routeData = this.pipeline.matchRoute(decodeURI(pathname)); if (!routeData) return void 0; if (allowPrerenderedRoutes) { return routeData; } if (routeData.prerender) { return void 0; } return routeData; } /** * A matching route function to use in the development server. * Contrary to the `.match` function, this function resolves props and params, returning the correct * route based on the priority, segments. It also returns the correct, resolved pathname. * @param pathname */ devMatch(pathname) { pathname; return void 0; } 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 (this.manifest.trailingSlash === "always") { pathname = appendForwardSlash(pathname); } else if (this.manifest.trailingSlash === "never") { pathname = removeTrailingForwardSlash(pathname); } else 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; } async render(request, { addCookieHeader = false, clientAddress = Reflect.get(request, clientAddressSymbol), locals, prerenderedErrorPageFetch = fetch, routeData, waitUntil } = {}) { await this.pipeline.getLogger(); if (routeData) { this.logger.debug( "router", "The adapter " + this.manifest.adapterName + " provided a custom RouteData for ", request.url ); this.logger.debug("router", "RouteData"); this.logger.debug("router", routeData); } if (locals) { if (typeof locals !== "object") { const error = new AstroError(AstroErrorData.LocalsNotAnObject); this.logger.error(null, error.stack); return this.renderError(request, { addCookieHeader, clientAddress, prerenderedErrorPageFetch, // If locals are invalid, we don't want to include them when // rendering the error page locals: void 0, routeData, waitUntil, status: 500, error }); } } if (!routeData) { const domainPathname = this.computePathnameFromDomain(request); if (domainPathname) { routeData = this.pipeline.matchRoute(decodeURI(domainPathname)); } } const resolvedOptions = { addCookieHeader, clientAddress, prerenderedErrorPageFetch, locals, routeData, waitUntil }; let response; try { if (this.#fetchHandler instanceof DefaultFetchHandler) { Reflect.set(request, appSymbol, this); response = await this.#fetchHandler.renderWithOptions(request, resolvedOptions); } else { setRenderOptions(request, resolvedOptions); Reflect.set(request, appSymbol, this); response = await this.#fetchHandler.fetch(request); } } catch (err) { if (err instanceof MultiLevelEncodingError) { return new Response("Bad Request", { status: 400 }); } throw err; } this.#warnMissingFeatures(); if (response.headers.get(ASTRO_ERROR_HEADER)) { response.headers.delete(ASTRO_ERROR_HEADER); return this.renderError(request, { addCookieHeader, clientAddress, prerenderedErrorPageFetch, locals, routeData, waitUntil, response, status: response.status, error: response.status === 500 ? null : void 0 }); } 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. * * Delegates to the app's configured `ErrorHandler`. To customize behavior * for a specific environment, override `createErrorHandler()` rather than * this method. */ async renderError(request, options) { return this.#errorHandler.renderError(request, options); } /** * One-shot check: after the first request with a custom `src/app.ts`, * compare `usedFeatures` against the manifest and warn about any * configured features the user's pipeline doesn't call. */ #warnMissingFeatures() { if (this.#featureCheckDone || !this.#hasCustomFetchHandler) return; this.#featureCheckDone = true; const manifest = this.manifest; const missing = []; const used = this.pipeline.usedFeatures; if (manifest.routes.some((r) => r.routeData.type === "redirect") && !(used & PipelineFeatures.redirects)) { missing.push("redirects"); } if (manifest.sessionConfig && !(used & PipelineFeatures.sessions)) { missing.push("sessions"); } if (manifest.actions && !(used & PipelineFeatures.actions)) { missing.push("actions"); } if (manifest.middleware && !(used & PipelineFeatures.middleware)) { missing.push("middleware"); } if (manifest.i18n && manifest.i18n.strategy !== "manual" && !(used & PipelineFeatures.i18n)) { missing.push("i18n"); } if (manifest.cacheConfig && !(used & PipelineFeatures.cache)) { missing.push("cache"); } for (const feature of missing) { this.logger.warn( "router", `Your project uses ${feature}, but your custom src/app.ts does not call the ${feature}() handler. This feature will not work unless you add it to your app.ts pipeline.` ); } } 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; } getManifest() { return this.pipeline.manifest; } logThisRequest({ pathname, method, statusCode, isRewrite, timeStart }) { const timeEnd = performance.now(); this.logRequest({ pathname, method, statusCode, isRewrite, reqTime: timeEnd - timeStart }); } } export { BaseApp };