astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
201 lines (200 loc) • 9.87 kB
JavaScript
import { removeTrailingForwardSlash } from "@astrojs/internal-helpers/path";
import { getEnvironment } from "../environment/index.js";
import { FetchState } from "../fetch/fetch-state.js";
import { prepareResponse } from "../app/prepare-response.js";
import { attachCookiesToResponse } from "../cookies/index.js";
import { getCookiesFromResponse } from "../cookies/response.js";
import { handleMiddleware } from "../middleware/astro-middleware.js";
import { handlePages } from "../pages/handler.js";
import { matchRoute } from "../routing/match.js";
import { getRouteTable } from "../routing/route-table.js";
import { provideCache } from "../cache/handler.js";
import { provideSession } from "../session/provider.js";
import { validateHost } from "../app/validate-headers.js";
import { getErrorRoutePath } from "../../i18n/error-routes.js";
import { getOutputFilename } from "../output-filename.js";
import { rewroteToEmptyErrorResponse } from "./handler.js";
/**
* The default error strategy used in production SSR. Attempts to render the
* matching error route (404.astro / 500.astro), falling back to a plain
* response with the given status. Handles prerendered error pages via
* `prerenderedErrorPageFetch`.
*/
export async function renderDefaultError(manifest, request, { status, response: originalResponse, skipMiddleware = false, error, pathname, ...resolvedRenderOptions }) {
const resolvedPathname = pathname ?? new FetchState(manifest, request).pathname;
const routeTable = getRouteTable(manifest);
const errorRoutePath = getErrorRoutePath(resolvedPathname, status, routeTable.routes, manifest.i18n?.locales, manifest.trailingSlash === "always");
const errorRouteData = matchRoute(errorRoutePath, routeTable);
const url = new URL(request.url);
if (errorRouteData) {
if (errorRouteData.prerender) {
// Validate the request URL origin before using it for the error page fetch.
// Without this, an attacker-controlled Host header flows into statusURL,
// causing the server to fetch from an arbitrary origin (SSRF).
const allowedDomains = manifest.allowedDomains;
const validatedHost = validateHost(url.host, url.protocol.replace(":", ""), allowedDomains);
const safeOrigin = validatedHost ? url.origin : `${url.protocol}//localhost`;
const statusURL = new URL(`${removeTrailingForwardSlash(manifest.base)}${getOutputFilename(manifest.buildFormat, errorRouteData.route, errorRouteData)}`, safeOrigin);
if (statusURL.toString() !== request.url && resolvedRenderOptions.prerenderedErrorPageFetch) {
try {
const response = await resolvedRenderOptions.prerenderedErrorPageFetch(statusURL.toString());
// In order for the response of the remote to be usable as a response
// for this request, it needs to have our status code in the response
// instead of the likely successful 200 code it returned when fetching
// the error page.
//
// Furthermore, remote may have returned a compressed page
// (the Content-Encoding header was set to e.g. `gzip`). The fetch
// implementation in the `mergeResponses` method will make a decoded
// response available, so Content-Length and Content-Encoding will
// not match the body we provide and need to be removed.
const override = {
status,
removeContentEncodingHeaders: true
};
const newResponse = mergeResponses(response, originalResponse, override);
prepareResponse(newResponse, resolvedRenderOptions);
return newResponse;
} catch {
// If the error page fetch fails (e.g. connection refused), fall
// through to the plain error response below.
const response = mergeResponses(new Response(null, { status }), originalResponse);
prepareResponse(response, resolvedRenderOptions);
return response;
}
}
}
const mod = await getEnvironment(manifest).getComponentByRoute(manifest, errorRouteData);
const errorState = new FetchState(manifest, request);
errorState.skipMiddleware = skipMiddleware;
errorState.clientAddress = resolvedRenderOptions.clientAddress;
errorState.routeData = errorRouteData;
errorState.pathname = resolvedPathname;
errorState.status = status;
errorState.componentInstance = mod;
errorState.locals = resolvedRenderOptions.locals ?? {};
errorState.initialProps = { error };
try {
await provideSession(errorState);
await provideCache(errorState);
const response = await handleMiddleware(errorState, handlePages);
// A middleware rewrite (`ctx.rewrite()` / `next(payload)`) issued while
// rendering the error page swaps the state's routeData away from the
// error route, so the rewrite target renders instead of 404/500.astro.
// If that hijacked render produced another empty reroutable error
// response, we'd return a blank page — retry rendering the error page
// without middleware instead (same fallback used when middleware throws).
// A rewrite that produced a real body is left untouched, so middleware
// that intentionally rewrites error renders keeps working.
if (rewroteToEmptyErrorResponse(skipMiddleware, errorRouteData, errorState.routeData, response)) {
return renderDefaultError(manifest, request, {
...resolvedRenderOptions,
status,
error,
response: originalResponse,
skipMiddleware: true,
pathname: resolvedPathname
});
}
const newResponse = mergeResponses(response, originalResponse);
prepareResponse(newResponse, resolvedRenderOptions);
return newResponse;
} catch {
// Middleware may be the cause of the error, so we try rendering 404/500.astro without it.
if (skipMiddleware === false) {
return renderDefaultError(manifest, request, {
...resolvedRenderOptions,
status,
error,
response: originalResponse,
skipMiddleware: true,
pathname: resolvedPathname
});
}
} finally {
await errorState.finalizeAll();
}
}
const response = mergeResponses(new Response(null, { status }), originalResponse);
prepareResponse(response, resolvedRenderOptions);
return response;
}
function mergeResponses(newResponse, originalResponse, override) {
let newResponseHeaders = newResponse.headers;
// In order to set the body of a remote response as the new response body, we need to remove
// headers about encoding in transit, as Node's standard fetch implementation `undici`
// currently does not do so.
//
// Also see https://github.com/nodejs/undici/issues/2514
if (override?.removeContentEncodingHeaders) {
// The original headers are immutable, so we need to clone them here.
newResponseHeaders = new Headers(newResponseHeaders);
newResponseHeaders.delete("Content-Encoding");
newResponseHeaders.delete("Content-Length");
}
if (!originalResponse) {
if (override !== undefined) {
return new Response(newResponse.body, {
status: override.status,
statusText: newResponse.statusText,
headers: newResponseHeaders
});
}
return newResponse;
}
// If the new response did not have a meaningful status, an override may have been provided
// If the original status was 200 (default), override it with the new status (probably 404 or 500)
// Otherwise, the user set a specific status while rendering and we should respect that one
const status = override?.status ? override.status : originalResponse.status === 200 ? newResponse.status : originalResponse.status;
try {
// this function could throw an error if the headers are immutable...
originalResponse.headers.delete("Content-type");
// Framing headers describe the original response's body encoding/size and must
// not carry over to the error page response which has a different body.
originalResponse.headers.delete("Content-Length");
originalResponse.headers.delete("Transfer-Encoding");
} catch {}
// Build merged headers using append() to preserve multi-value headers (e.g. Set-Cookie).
// Headers from the original response take priority over new response headers for
// single-value headers, but we use append to avoid collapsing multi-value entries.
const newHeaders = new Headers();
const seen = new Set();
// Add original response headers first (they take priority)
for (const [name, value] of originalResponse.headers) {
newHeaders.append(name, value);
seen.add(name.toLowerCase());
}
// Add new response headers that weren't already set by the original response,
// but skip content-type since the error page must return text/html.
// set-cookie is special: it's a multi-value header, so we always append.
for (const [name, value] of newResponseHeaders) {
const lower = name.toLowerCase();
if (!seen.has(lower) || lower === "set-cookie") {
newHeaders.append(name, value);
}
}
const mergedResponse = 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
});
// Transfer AstroCookies from the original or new response so that
// prepareResponse can read them when addCookieHeader is true.
const originalCookies = getCookiesFromResponse(originalResponse);
const newCookies = getCookiesFromResponse(newResponse);
if (originalCookies) {
// If both responses have cookies, merge new response cookies into original
if (newCookies) {
originalCookies.merge(newCookies);
}
attachCookiesToResponse(mergedResponse, originalCookies);
} else if (newCookies) {
attachCookiesToResponse(mergedResponse, newCookies);
}
return mergedResponse;
}