158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
/* ============================================================================
|
|
SERVER ENTRY
|
|
----------------------------------------------------------------------------
|
|
The stock React Router streaming entry plus two SHARP-ER additions:
|
|
|
|
- `Content-Language` is stamped from the URL's locale prefix, so the
|
|
header agrees with <html lang> that root.tsx renders. `Vary` is NOT set
|
|
globally: every page URL carries its own locale, so only the bare "/"
|
|
redirect varies on Accept-Language and it sets the header itself.
|
|
- `handleError` logs server errors without the noise of client aborts.
|
|
========================================================================= */
|
|
|
|
import { PassThrough } from "node:stream";
|
|
|
|
import type { EntryContext, RouterContextProvider } from "react-router";
|
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
|
import { ServerRouter } from "react-router";
|
|
import { isbot } from "isbot";
|
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
|
import { renderToPipeableStream } from "react-dom/server";
|
|
|
|
import { DEFAULT_LOCALE, getLocaleFromPathname } from "~/i18n/config";
|
|
import { getServerEnv } from "~/lib/env.server";
|
|
|
|
export const streamTimeout = 5_000;
|
|
|
|
/** The locale a URL addresses, for the `Content-Language` response header. */
|
|
function contentLanguage(request: Request): string {
|
|
try {
|
|
return getLocaleFromPathname(new URL(request.url).pathname) ?? DEFAULT_LOCALE;
|
|
} catch {
|
|
return DEFAULT_LOCALE;
|
|
}
|
|
}
|
|
|
|
function readCspNonce(routerContext: EntryContext): string | undefined {
|
|
const rootData = routerContext.staticHandlerContext.loaderData["root"] as
|
|
| { nonce?: unknown }
|
|
| undefined;
|
|
return typeof rootData?.nonce === "string" ? rootData.nonce : undefined;
|
|
}
|
|
|
|
function contentSecurityPolicy(nonce: string): string {
|
|
const { STRAPI_URL, isProduction } = getServerEnv();
|
|
const mediaOrigin = new URL(STRAPI_URL).origin;
|
|
const directives = [
|
|
"default-src 'self'",
|
|
`script-src 'self' 'nonce-${nonce}' https://www.googletagmanager.com`,
|
|
"script-src-attr 'none'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
`img-src 'self' ${mediaOrigin} https://www.google-analytics.com data: blob:`,
|
|
`media-src 'self' ${mediaOrigin} data: blob:`,
|
|
"font-src 'self' data:",
|
|
"connect-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://region1.google-analytics.com",
|
|
"object-src 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
"frame-ancestors 'none'",
|
|
...(isProduction ? ["upgrade-insecure-requests"] : []),
|
|
];
|
|
return directives.join("; ");
|
|
}
|
|
|
|
export default function handleRequest(
|
|
request: Request,
|
|
responseStatusCode: number,
|
|
responseHeaders: Headers,
|
|
routerContext: EntryContext,
|
|
loadContext: RouterContextProvider,
|
|
) {
|
|
const nonce = readCspNonce(routerContext);
|
|
if (nonce) {
|
|
responseHeaders.set("Content-Security-Policy", contentSecurityPolicy(nonce));
|
|
}
|
|
|
|
// https://httpwg.org/specs/rfc9110.html#HEAD
|
|
if (request.method.toUpperCase() === "HEAD") {
|
|
return new Response(null, {
|
|
status: responseStatusCode,
|
|
headers: responseHeaders,
|
|
});
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let shellRendered = false;
|
|
let userAgent = request.headers.get("user-agent");
|
|
|
|
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
|
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
|
let readyOption: keyof RenderToPipeableStreamOptions =
|
|
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
|
? "onAllReady"
|
|
: "onShellReady";
|
|
|
|
// Abort the rendering stream after the `streamTimeout` so it has time to
|
|
// flush down the rejected boundaries
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
|
|
() => abort(),
|
|
streamTimeout + 1000,
|
|
);
|
|
|
|
const { pipe, abort } = renderToPipeableStream(
|
|
<ServerRouter context={routerContext} url={request.url} nonce={nonce} />,
|
|
{
|
|
nonce,
|
|
[readyOption]() {
|
|
shellRendered = true;
|
|
const body = new PassThrough({
|
|
final(callback) {
|
|
// Clear the timeout to prevent retaining the closure and memory leak
|
|
clearTimeout(timeoutId);
|
|
timeoutId = undefined;
|
|
callback();
|
|
},
|
|
});
|
|
const stream = createReadableStreamFromReadable(body);
|
|
|
|
responseHeaders.set("Content-Type", "text/html");
|
|
responseHeaders.set("Content-Language", contentLanguage(request));
|
|
|
|
pipe(body);
|
|
|
|
resolve(
|
|
new Response(stream, {
|
|
headers: responseHeaders,
|
|
status: responseStatusCode,
|
|
}),
|
|
);
|
|
},
|
|
onShellError(error: unknown) {
|
|
reject(error);
|
|
},
|
|
onError(error: unknown) {
|
|
responseStatusCode = 500;
|
|
// Log streaming rendering errors from inside the shell. Don't log
|
|
// errors encountered during initial shell rendering since they'll
|
|
// reject and get logged in handleDocumentRequest.
|
|
if (shellRendered) {
|
|
console.error(error);
|
|
}
|
|
},
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Central server-side error logging. A request the client walked away from is
|
|
* not an application error, so it is not logged as one.
|
|
*/
|
|
export function handleError(
|
|
error: unknown,
|
|
{ request }: { request: Request },
|
|
): void {
|
|
if (request.signal.aborted) return;
|
|
console.error(`[sharp-er] ${request.method} ${request.url}`, error);
|
|
}
|