139 lines
5.4 KiB
TypeScript
139 lines
5.4 KiB
TypeScript
import { initSentryServer } from "./sentry.server";
|
|
import { startCacheSubscribers } from "./cache-bootstrap.server";
|
|
|
|
// IMPORTANT: Initialize Sentry as the very first action on the server
|
|
// entry so the SDK can hook into the Node runtime (HTTP, async hooks)
|
|
// before any other module runs. No-op when SENTRY_DSN_WEB is unset.
|
|
initSentryServer();
|
|
|
|
// Start cross-replica cache-invalidation subscribers. Idempotent + safe
|
|
// in single-replica deployments (no-op when REDIS_URL is unset thanks to
|
|
// SharedStore memory fallback). See docs/architecture/two-tier-cache.md.
|
|
startCacheSubscribers();
|
|
|
|
import type { AppLoadContext, EntryContext } from "react-router";
|
|
|
|
import isbot from "isbot";
|
|
|
|
import { PassThrough } from "node:stream";
|
|
import { ServerRouter } from "react-router";
|
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
|
import { renderToPipeableStream } from "react-dom/server";
|
|
|
|
import Backend from "i18next-fs-backend";
|
|
import { createInstance } from "i18next";
|
|
import { I18nextProvider, initReactI18next } from "react-i18next";
|
|
import { remixI18Next, serverLocalePath } from "./locale/i18next.server";
|
|
import { i18nConfig } from "./locale/i18n";
|
|
import { syncServerGlobalI18nLanguage } from "./locale/i18next.global.server";
|
|
import { routes as otherRoutes } from "./other-routes.server";
|
|
import { createScopedLogger } from "./utils/logger.server";
|
|
import { shouldNoindex, X_ROBOTS_TAG_NOINDEX } from "./utils/seo/noindex.server";
|
|
|
|
const ssrLogger = createScopedLogger("ssr.entry");
|
|
|
|
// const ABORT_DELAY = 5_000;
|
|
// Reject/cancel all pending promises after 5 seconds
|
|
export const streamTimeout = 5000;
|
|
|
|
export default async function handleRequest(
|
|
request: Request,
|
|
responseStatusCode: number,
|
|
responseHeaders: Headers,
|
|
reactRouterContext: EntryContext,
|
|
loadContext: AppLoadContext
|
|
) {
|
|
const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady";
|
|
|
|
for (const handler of otherRoutes) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const otherRouteResponse = await handler(request, reactRouterContext);
|
|
if (otherRouteResponse) return otherRouteResponse;
|
|
}
|
|
|
|
// Internationalization (i18n).
|
|
const i18nInstance = createInstance();
|
|
const lng = await remixI18Next.getLocale(request);
|
|
const ns = remixI18Next.getRouteNamespaces(reactRouterContext);
|
|
|
|
await i18nInstance
|
|
.use(initReactI18next) // Tell our instance to use react-i18next.
|
|
.use(Backend) // Setup backend.
|
|
.init({
|
|
...i18nConfig, // Spread configuration.
|
|
lng, // Locale detected above.
|
|
ns, // Namespaces detected above.
|
|
backend: {
|
|
localePath: serverLocalePath,
|
|
loadPath: `${serverLocalePath}/{{lng}}/{{ns}}.json`,
|
|
},
|
|
});
|
|
|
|
// Align the *global* i18next instance with the request locale so components
|
|
// that resolve strings through the module-level `t` helper (rather than the
|
|
// `useTranslation()` hook) render the same text the client hydrates with.
|
|
// See ./locale/i18next.global.server.ts for the rationale.
|
|
await syncServerGlobalI18nLanguage(lng);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let shellRendered = false;
|
|
|
|
const { pipe, abort } = renderToPipeableStream(
|
|
<I18nextProvider i18n={i18nInstance}>
|
|
<ServerRouter context={reactRouterContext} url={request.url} />
|
|
</I18nextProvider>,
|
|
{
|
|
[callbackName]: () => {
|
|
shellRendered = true;
|
|
const body = new PassThrough();
|
|
const stream = createReadableStreamFromReadable(body);
|
|
|
|
responseHeaders.set("Content-Type", "text/html");
|
|
// Default HTML responses to `no-store` so CDNs / browsers do not
|
|
// cache rendered documents that may contain per-user data (CSRF
|
|
// tokens, session-derived markup, feature flags). Loaders that
|
|
// serve cacheable HTML (marketing pages, public-tenant landing)
|
|
// can override by calling `headers.set("Cache-Control", ...)`
|
|
// themselves — `set()` here only runs when no value is present.
|
|
// See docs/ops/asset-cdn.md "Cache strategy".
|
|
if (!responseHeaders.has("Cache-Control")) {
|
|
responseHeaders.set("Cache-Control", "no-store");
|
|
}
|
|
|
|
// AK-INFRA1 — Staging-Härtung: jede HTML-Antwort als noindex markieren, wenn WEB_NOINDEX
|
|
// gesetzt ist (Staging). Greift zusätzlich zur robots.txt, auch ohne deren Abruf.
|
|
if (shouldNoindex() && !responseHeaders.has("X-Robots-Tag")) {
|
|
responseHeaders.set("X-Robots-Tag", X_ROBOTS_TAG_NOINDEX);
|
|
}
|
|
|
|
resolve(
|
|
new Response(stream, {
|
|
headers: responseHeaders,
|
|
status: responseStatusCode,
|
|
})
|
|
);
|
|
|
|
pipe(body);
|
|
},
|
|
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) {
|
|
ssrLogger.error({ err: error }, "SSR streaming error");
|
|
}
|
|
},
|
|
}
|
|
);
|
|
|
|
// setTimeout(abort, ABORT_DELAY);
|
|
// Automatically timeout the React renderer after 6 seconds, which ensures
|
|
// React has enough time to flush down the rejected boundary contents
|
|
setTimeout(abort, streamTimeout + 1000);
|
|
});
|
|
}
|