- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
4.5 KiB
TypeScript
186 lines
4.5 KiB
TypeScript
import { match } from "@formatjs/intl-localematcher";
|
|
import dayjs from "dayjs";
|
|
import { createInstance } from "i18next";
|
|
import resourcesToBackend from "i18next-resources-to-backend";
|
|
import Negotiator from "negotiator";
|
|
import { initReactI18next } from "react-i18next/initReactI18next";
|
|
import * as z from "zod";
|
|
|
|
import { logger } from "@turbostarter/shared/logger";
|
|
|
|
import { config, getInitOptions } from "../config";
|
|
import { env } from "../env";
|
|
import { loadTranslation, makeZodI18nMap } from "../utils";
|
|
|
|
import type { i18n, Namespace, TFunction } from "i18next";
|
|
|
|
export const initializeServerI18n = async ({
|
|
locale,
|
|
defaultLocale,
|
|
ns,
|
|
}: {
|
|
locale?: string;
|
|
defaultLocale?: string;
|
|
ns?: Namespace;
|
|
}): Promise<i18n> => {
|
|
const i18n = createInstance();
|
|
const loadedNamespaces = new Set<string>();
|
|
|
|
await new Promise((resolve) => {
|
|
void i18n
|
|
.use(
|
|
resourcesToBackend(
|
|
async (
|
|
language: (typeof config.locales)[number],
|
|
namespace: (typeof config.namespaces)[number],
|
|
callback,
|
|
) => {
|
|
const data = await loadTranslation(language, namespace);
|
|
|
|
loadedNamespaces.add(namespace);
|
|
return callback(null, data);
|
|
},
|
|
),
|
|
)
|
|
.use({
|
|
type: "3rdParty",
|
|
init: async (i18next: typeof i18n) => {
|
|
let iterations = 0;
|
|
const maxIterations = 100;
|
|
|
|
while (i18next.isInitializing) {
|
|
iterations++;
|
|
|
|
if (iterations > maxIterations) {
|
|
logger.error(
|
|
`i18next is not initialized after ${maxIterations} iterations`,
|
|
);
|
|
|
|
break;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
}
|
|
|
|
initReactI18next.init(i18next);
|
|
resolve(i18next);
|
|
},
|
|
})
|
|
.init(getInitOptions({ locale, defaultLocale, ns }));
|
|
});
|
|
|
|
const namespaces = ns
|
|
? typeof ns === "string"
|
|
? [ns]
|
|
: ns
|
|
: config.namespaces;
|
|
|
|
// If all namespaces are already loaded, return the i18n instance
|
|
if (loadedNamespaces.size === namespaces.length) {
|
|
return i18n;
|
|
}
|
|
|
|
// Otherwise, wait for all namespaces to be loaded
|
|
|
|
const maxWaitTimeMs = 100;
|
|
const checkIntervalMs = 5;
|
|
|
|
async function waitForNamespaces() {
|
|
const startTime = Date.now();
|
|
|
|
while (Date.now() - startTime < maxWaitTimeMs) {
|
|
const allNamespacesLoaded = namespaces.every((ns) =>
|
|
loadedNamespaces.has(ns),
|
|
);
|
|
|
|
if (allNamespacesLoaded) {
|
|
return true;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, checkIntervalMs));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const success = await waitForNamespaces();
|
|
|
|
if (!success) {
|
|
logger.warn(
|
|
`Not all namespaces were loaded after ${maxWaitTimeMs}ms. Initialization may be incomplete.`,
|
|
);
|
|
}
|
|
|
|
return i18n;
|
|
};
|
|
|
|
export const getLocaleFromCookies = async () => {
|
|
try {
|
|
const { cookies } = await import("next/headers");
|
|
return (await cookies()).get(config.cookie)?.value;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
export const getLocaleFromRequest = (request?: Request) => {
|
|
if (!request) return env.DEFAULT_LOCALE ?? config.defaultLocale;
|
|
|
|
const localeCookie = request.headers
|
|
.get("cookie")
|
|
?.split(";")
|
|
.find((cookie) => cookie.trim().startsWith(`${config.cookie}=`))
|
|
?.split("=")[1]
|
|
?.trim()
|
|
.replace(/[.,]/g, "");
|
|
|
|
if (localeCookie) {
|
|
return localeCookie;
|
|
}
|
|
|
|
const negotiatorHeaders: Record<string, string> = {};
|
|
request.headers.forEach((value: string, key: string) => {
|
|
negotiatorHeaders[key] = value;
|
|
});
|
|
|
|
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
|
|
|
|
try {
|
|
return match(
|
|
languages,
|
|
config.locales,
|
|
env.DEFAULT_LOCALE ?? config.defaultLocale,
|
|
);
|
|
} catch {
|
|
return env.DEFAULT_LOCALE ?? config.defaultLocale;
|
|
}
|
|
};
|
|
|
|
export const getTranslation = async <T extends Namespace>({
|
|
locale: passedLocale,
|
|
request,
|
|
ns,
|
|
}: { locale?: string; request?: Request; ns?: T } = {}) => {
|
|
const locale =
|
|
passedLocale ??
|
|
(request ? getLocaleFromRequest(request) : null) ??
|
|
(await getLocaleFromCookies()) ??
|
|
undefined;
|
|
const i18nextInstance = await initializeServerI18n({ locale, ns });
|
|
dayjs.locale(i18nextInstance.language);
|
|
|
|
const t = i18nextInstance.getFixedT<T>(
|
|
i18nextInstance.language,
|
|
ns,
|
|
) as TFunction<T>;
|
|
|
|
z.config({
|
|
localeError: makeZodI18nMap({ t: t as TFunction }),
|
|
});
|
|
|
|
return {
|
|
t,
|
|
i18n: i18nextInstance,
|
|
};
|
|
};
|