sendou.ink/app/modules/i18n/i18next.server.ts
Kalle bce7523ed1 Fall back to default i18next instance when context missing
To avoid these logs presumably from bots: Error: No value found for context
2026-07-19 14:16:49 +03:00

150 lines
4.3 KiB
TypeScript

import { AsyncLocalStorage } from "node:async_hooks";
import {
createInstance,
type DefaultNamespace,
type FlatNamespace,
type i18n,
type Namespace,
type TFunction,
} from "i18next";
import { type FallbackNs, initReactI18next } from "react-i18next";
import {
createCookie,
type MiddlewareFunction,
type RouterContextProvider,
} from "react-router";
import { createI18nextMiddleware } from "remix-i18next";
import invariant from "~/utils/invariant";
import { config } from "./config";
import { resources } from "./resources.server";
const TEN_YEARS_IN_SECONDS = 31_536_000 * 10;
export const i18nCookie = createCookie("i18n", {
sameSite: "lax",
path: "/",
maxAge: TEN_YEARS_IN_SECONDS,
});
const [remixI18nextMiddleware, getLocaleFromContext, getInstanceFromContext] =
createI18nextMiddleware({
detection: {
cookie: i18nCookie,
supportedLanguages: config.supportedLngs,
fallbackLanguage: config.fallbackLng,
},
i18next: {
...config,
resources,
},
plugins: [initReactI18next],
});
interface I18nStore {
locale: string;
instance: i18n;
}
const i18nAsyncLocalStorage = new AsyncLocalStorage<I18nStore>();
/**
* Detects the request locale, initializes a request-scoped i18next instance and
* makes both available to server helpers ({@link getLocale},
* {@link getServerTFunction}) for the duration of the request.
*/
export const i18nMiddleware: MiddlewareFunction<Response> = (args, next) =>
remixI18nextMiddleware(args, () =>
i18nAsyncLocalStorage.run(
{
locale: getLocaleFromContext(args.context),
instance: getInstanceFromContext(args.context),
},
() => next(),
),
);
/** The locale detected for the current request. */
export function getLocale(): string {
return currentStore().locale;
}
/**
* A `TFunction` bound to the current request's locale and the given namespaces.
* Namespaces are already available in-memory so no async loading is needed.
*/
export function getServerTFunction<
N extends
| FlatNamespace
| readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace,
>(namespaces?: N): TFunction<FallbackNs<N>> {
const { instance, locale } = currentStore();
return instance.getFixedT(
locale,
namespaces as Namespace,
) as unknown as TFunction<FallbackNs<N>>;
}
/**
* The request-scoped i18next instance, used to provide translations while
* server-side rendering. Reads from the React Router context so it can be called
* from `entry.server` where the request context is passed explicitly.
*
* Falls back to a shared default-language instance when the context is missing.
* This happens when the render is an error boundary for a request whose
* middleware chain threw before {@link i18nMiddleware} ran: without the fallback
* the missing context would throw a second error that masks the original one.
*/
export function getI18nInstance(
context: Readonly<RouterContextProvider>,
): i18n {
try {
return getInstanceFromContext(context);
} catch {
return fallbackI18nInstance();
}
}
/**
* A `TFunction` bound to a fixed language, independent of the current request.
* Use this when translating outside of a request (e.g. notifications) or when a
* specific language is required regardless of the user's locale.
*/
export async function getFixedTForLanguage<
N extends
| FlatNamespace
| readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace,
>(language: string, namespaces?: N): Promise<TFunction<FallbackNs<N>>> {
const instance = createInstance();
await instance.init({ ...config, resources, lng: language });
return instance.getFixedT(
language,
namespaces as Namespace,
) as unknown as TFunction<FallbackNs<N>>;
}
function currentStore(): I18nStore {
const store = i18nAsyncLocalStorage.getStore();
invariant(store, "i18n store not found, is i18nMiddleware registered?");
return store;
}
let sharedFallbackInstance: i18n | undefined;
function fallbackI18nInstance(): i18n {
if (!sharedFallbackInstance) {
const instance = createInstance();
instance.use(initReactI18next);
// initAsync: false makes init synchronous, which is safe here since
// resources are already in-memory and lets this be called from the sync
// getI18nInstance without awaiting.
instance.init({
...config,
resources,
lng: config.fallbackLng,
initAsync: false,
});
sharedFallbackInstance = instance;
}
return sharedFallbackInstance;
}