UNPKG

astro

Version:

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

218 lines (217 loc) • 8.01 kB
import { collapseDuplicateSlashes } from "@astrojs/internal-helpers/path"; import { shouldAppendForwardSlash } from "../build/util.js"; import { originPathnameSymbol } from "../constants.js"; import { AstroError, AstroErrorData } from "../errors/index.js"; import { appendForwardSlash, joinPaths, prependForwardSlash, removeTrailingForwardSlash, trimSlashes } from "../path.js"; import { createRequest } from "../request.js"; import { validateAndDecodePathname } from "../util/pathname.js"; import { DEFAULT_404_ROUTE } from "./internal/astro-designed-error-pages.js"; import { isRoute404, isRoute500 } from "./internal/route-errors.js"; /** * Shared logic to retrieve the rewritten route. It returns a tuple that represents: * 1. The new `Request` object. It contains `base` * 2. */ export async function findRouteToRewrite({ payload, routes, request, trailingSlash, buildFormat, base, outDir, validate }) { let newUrl = undefined; if (payload instanceof URL) { newUrl = payload; } else if (payload instanceof Request) { newUrl = new URL(payload.url); } else { newUrl = new URL(collapseDuplicateSlashes(payload), new URL(request.url).origin); } const { pathname, resolvedUrlPathname } = normalizeRewritePathname(newUrl.pathname, base, trailingSlash, buildFormat); newUrl.pathname = resolvedUrlPathname; // Decode the path the same way the first route match did (see // `validateAndDecodePathname`) instead of calling `decodeURI` once here. // Routing has to use the exact same path that middleware checked; decoding // one extra time here is what let an encoded path slip past middleware and // still reach a protected route. For an already-decoded path this changes // nothing. const decodedPathname = validateAndDecodePathname(pathname); // Error pages (404/500) take precedence over dynamic routes that might // capture the same path (e.g. [locale] matching /404). See #15098. if (isRoute404(decodedPathname)) { const errorRoute = routes.find((route) => isRoute404(route.route)); if (errorRoute) { return { routeData: errorRoute, newUrl, pathname: decodedPathname }; } } if (isRoute500(decodedPathname)) { const errorRoute = routes.find((route) => isRoute500(route.route)); if (errorRoute) { return { routeData: errorRoute, newUrl, pathname: decodedPathname }; } } let foundRoute; // A candidate whose `getStaticPaths()` throws is skipped, and the error is // surfaced only when no candidate owns the path. Same rules as `matchRoute`. let firstValidationError = null; for (const route of routes) { if (route.pattern.test(decodedPathname)) { // If it's a dynamic route, make sure it actually generates the pathname // Checking for params to make sure it's a dynamic route if (route.params && route.params.length !== 0) { if (route.distURL && route.distURL.length !== 0) { // Remove outDir from beginning of distURL // Remove /index.html or .html from end of distURL and compare with pathname // Use pathname (encoded) instead of decodedPathname because url.href is encoded if (!route.distURL.find((url) => url.href.replace(outDir.toString(), "").replace(/(?:\/index\.html|\.html)$/, "") === trimSlashes(pathname))) { continue; } } else { // `distURL` is empty outside a build, so ask the route whether // `getStaticPaths()` produces this pathname, as `matchRoute` does. let ownsPathname; try { ownsPathname = await validate(route, decodedPathname); } catch (error) { firstValidationError ??= error; continue; } if (!ownsPathname) continue; } } foundRoute = route; break; } } if (foundRoute) { return { routeData: foundRoute, newUrl, pathname: decodedPathname }; } else if (firstValidationError) { throw firstValidationError; } else { const custom404 = routes.find((route) => isRoute404(route.route)); if (custom404) { return { routeData: custom404, newUrl, pathname }; } else { return { routeData: DEFAULT_404_ROUTE, newUrl, pathname }; } } } /** * Utility function that creates a new `Request` with a new URL from an old `Request`. * * @param newUrl The new `URL` * @param oldRequest The old `Request` * @param isPrerendered It needs to be the flag of the previous routeData, before the rewrite * @param logger * @param routePattern */ export function copyRequest(newUrl, oldRequest, isPrerendered, logger, routePattern) { const canHaveBody = oldRequest.method !== "GET" && oldRequest.method !== "HEAD"; if (canHaveBody && oldRequest.bodyUsed) { throw new AstroError(AstroErrorData.RewriteWithBodyUsed); } return createRequest({ url: newUrl, method: oldRequest.method, body: canHaveBody ? oldRequest.body : undefined, isPrerendered, logger, headers: isPrerendered ? {} : oldRequest.headers, routePattern, init: { referrer: oldRequest.referrer, referrerPolicy: oldRequest.referrerPolicy, mode: oldRequest.mode, credentials: oldRequest.credentials, cache: oldRequest.cache, redirect: oldRequest.redirect, integrity: oldRequest.integrity, signal: oldRequest.signal, keepalive: oldRequest.keepalive, // https://fetch.spec.whatwg.org/#dom-request-duplex // @ts-expect-error It isn't part of the types, but undici accepts it and it allows carrying over the body to a new request duplex: "half" } }); } export function setOriginPathname(request, pathname, trailingSlash, buildFormat) { // Handle undefined pathname if (!pathname) { pathname = "/"; } // Apply trailing slash logic based on configuration const shouldAppendSlash = shouldAppendForwardSlash(trailingSlash, buildFormat); let finalPathname; // Special handling for root path if (pathname === "/") { // Root path always keeps the slash finalPathname = "/"; } else if (shouldAppendSlash) { finalPathname = appendForwardSlash(pathname); } else { finalPathname = removeTrailingForwardSlash(pathname); } Reflect.set(request, originPathnameSymbol, encodeURIComponent(finalPathname)); } export function getOriginPathname(request) { const origin = Reflect.get(request, originPathnameSymbol); if (origin) { return decodeURIComponent(origin); } return new URL(request.url).pathname; } /** * Pure function that normalizes a rewrite target pathname by stripping the base, * handling trailing slashes, removing `.html` suffixes, and computing the final * full URL pathname (with base re-prepended). */ export function normalizeRewritePathname(urlPathname, base, trailingSlash, buildFormat) { let pathname = collapseDuplicateSlashes(urlPathname); const shouldAppendSlash = shouldAppendForwardSlash(trailingSlash, buildFormat); // Special handling for base path if (base !== "/") { // Check if this is a request to the base path const isBasePathRequest = urlPathname === base || urlPathname === removeTrailingForwardSlash(base); if (isBasePathRequest) { pathname = "/"; } else if (urlPathname.startsWith(base)) { // For non-root paths under the base pathname = shouldAppendSlash ? appendForwardSlash(urlPathname) : removeTrailingForwardSlash(urlPathname); pathname = pathname.slice(base.length); } } // Ensure pathname starts with '/' when needed if (!pathname.startsWith("/") && shouldAppendSlash && urlPathname.endsWith("/")) { pathname = prependForwardSlash(pathname); } // If config.build.format = 'file' then pathname includes .html, so we need to remove it if (buildFormat === "file") { pathname = pathname.replace(/\.html$/, ""); } // Compute the resolved URL pathname (with base re-prepended) let resolvedUrlPathname; if (base !== "/" && (pathname === "" || pathname === "/") && !shouldAppendSlash) { // Special case for root path at base URL with trailingSlash: 'never' resolvedUrlPathname = removeTrailingForwardSlash(base); } else { resolvedUrlPathname = joinPaths(...[base, pathname].filter(Boolean)); } return { pathname, resolvedUrlPathname }; }