From f8d8a09c5619e1b6228d91bf13e9d92baf422a7a Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 17:42:48 +0200 Subject: [PATCH] perf(proxy): isolate settings database reads --- apps/nextjs/src/proxy.spec.ts | 84 ++++++++++ apps/nextjs/src/proxy.ts | 17 +- packages/db/package.json | 1 + packages/db/proxy-reader.integration.spec.ts | 128 ++++++++++++++ packages/db/proxy-reader.spec.ts | 111 ++++++++++++ packages/db/proxy-reader.ts | 168 +++++++++++++++++++ packages/db/proxy/mysql.ts | 11 ++ packages/db/proxy/postgresql.ts | 11 ++ packages/db/proxy/sqlite.ts | 11 ++ packages/definitions/package.json | 1 + packages/translation/package.json | 1 + packages/translation/src/config.ts | 10 +- packages/translation/src/index.ts | 4 +- packages/translation/src/languages.spec.ts | 10 ++ packages/translation/src/languages.ts | 46 +++++ packages/translation/src/middleware.ts | 2 +- packages/translation/src/routing.ts | 6 +- 17 files changed, 604 insertions(+), 18 deletions(-) create mode 100644 apps/nextjs/src/proxy.spec.ts create mode 100644 packages/db/proxy-reader.integration.spec.ts create mode 100644 packages/db/proxy-reader.spec.ts create mode 100644 packages/db/proxy-reader.ts create mode 100644 packages/db/proxy/mysql.ts create mode 100644 packages/db/proxy/postgresql.ts create mode 100644 packages/db/proxy/sqlite.ts create mode 100644 packages/translation/src/languages.spec.ts create mode 100644 packages/translation/src/languages.ts diff --git a/apps/nextjs/src/proxy.spec.ts b/apps/nextjs/src/proxy.spec.ts new file mode 100644 index 000000000..d6f1d306f --- /dev/null +++ b/apps/nextjs/src/proxy.spec.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { localeCookieKey } from "@homarr/definitions"; + +const mocks = vi.hoisted(() => ({ + getOnboardingStep: vi.fn(), + getDefaultLocale: vi.fn(), + createI18nMiddleware: vi.fn(), +})); + +vi.mock("@homarr/db/proxy-reader", () => ({ + getDefaultLocaleForProxyAsync: mocks.getDefaultLocale, + getOnboardingStepForProxyAsync: mocks.getOnboardingStep, +})); + +vi.mock("@homarr/translation/middleware", () => ({ + createI18nMiddleware: mocks.createI18nMiddleware, +})); + +const loadProxy = async () => { + vi.resetModules(); + return import("./proxy"); +}; + +const createRequest = (pathname: string, locale?: string) => { + const request = new NextRequest(`http://localhost${pathname}`); + if (locale) request.cookies.set(localeCookieKey, locale); + return request; +}; + +describe("locale and onboarding proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getOnboardingStep.mockResolvedValue("finish"); + mocks.getDefaultLocale.mockResolvedValue("en"); + mocks.createI18nMiddleware.mockImplementation((defaultLocale: string) => (request: NextRequest) => { + request.headers.set("x-test-default-locale", defaultLocale); + return NextResponse.next(); + }); + }); + + it("redirects unfinished onboarding to /init", async () => { + mocks.getOnboardingStep.mockResolvedValue("integrations"); + const { proxy } = await loadProxy(); + + const response = await proxy(createRequest("/en/dashboard")); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("http://localhost/init"); + expect(mocks.getOnboardingStep).toHaveBeenCalledOnce(); + }); + + it("allows /init while onboarding is unfinished", async () => { + mocks.getOnboardingStep.mockResolvedValue("start"); + const { proxy } = await loadProxy(); + + const response = await proxy(createRequest("/en/init")); + + expect(response.status).toBe(200); + expect(mocks.getOnboardingStep).not.toHaveBeenCalled(); + expect(mocks.getDefaultLocale).toHaveBeenCalledOnce(); + }); + + it("uses the configured default locale for a missing or invalid locale cookie", async () => { + mocks.getDefaultLocale.mockResolvedValue("de"); + const { proxy } = await loadProxy(); + + await proxy(createRequest("/en/dashboard", "not-a-locale")); + + expect(mocks.getDefaultLocale).toHaveBeenCalledOnce(); + expect(mocks.createI18nMiddleware).toHaveBeenCalledWith("de"); + }); + + it("keeps a valid locale cookie and does not query the configured default", async () => { + const { proxy } = await loadProxy(); + + const response = await proxy(createRequest("/fr/dashboard", "fr")); + + expect(response.status).toBe(200); + expect(mocks.getDefaultLocale).not.toHaveBeenCalled(); + expect(mocks.createI18nMiddleware).toHaveBeenCalledWith("en"); + }); +}); diff --git a/apps/nextjs/src/proxy.ts b/apps/nextjs/src/proxy.ts index 42553bc2c..3178f158a 100644 --- a/apps/nextjs/src/proxy.ts +++ b/apps/nextjs/src/proxy.ts @@ -1,10 +1,10 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { api } from "@homarr/api/server"; -import { localeCookieKey } from "@homarr/definitions"; -import type { SupportedLanguage } from "@homarr/translation"; -import { supportedLanguages } from "@homarr/translation"; +import { getDefaultLocaleForProxyAsync, getOnboardingStepForProxyAsync } from "@homarr/db/proxy-reader"; +import { localeCookieKey } from "@homarr/definitions/cookie"; +import type { SupportedLanguage } from "@homarr/translation/languages"; +import { supportedLanguages } from "@homarr/translation/languages"; import { createI18nMiddleware } from "@homarr/translation/middleware"; let isOnboardingFinished = false; @@ -14,8 +14,8 @@ export async function proxy(request: NextRequest) { const pathname = request.nextUrl.pathname; if (!isOnboardingFinished && !pathname.endsWith("/init")) { - const currentOnboardingStep = await api.onboard.currentStep(); - if (currentOnboardingStep.current !== "finish") { + const currentOnboardingStep = await getOnboardingStepForProxyAsync(); + if (currentOnboardingStep !== "finish") { return NextResponse.redirect(new URL("/init", request.url)); } @@ -26,7 +26,10 @@ export async function proxy(request: NextRequest) { const currentLocale = request.cookies.get(localeCookieKey)?.value; let defaultLocale: SupportedLanguage = "en"; if (!currentLocale || !supportedLanguages.includes(currentLocale as SupportedLanguage)) { - defaultLocale = await api.serverSettings.getCulture().then((culture) => culture.defaultLocale); + const configuredLocale = await getDefaultLocaleForProxyAsync(); + if (supportedLanguages.includes(configuredLocale as SupportedLanguage)) { + defaultLocale = configuredLocale as SupportedLanguage; + } } // We don't want to fallback to accept-language header so we clear it diff --git a/packages/db/package.json b/packages/db/package.json index 970fbab94..f8bdfacc5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,6 +9,7 @@ "exports": { ".": "./index.ts", "./collection": "./collection.ts", + "./proxy-reader": "./proxy-reader.ts", "./schema": "./schema/index.ts", "./test": "./test/index.ts", "./queries": "./queries/index.ts", diff --git a/packages/db/proxy-reader.integration.spec.ts b/packages/db/proxy-reader.integration.spec.ts new file mode 100644 index 000000000..2d85495b5 --- /dev/null +++ b/packages/db/proxy-reader.integration.spec.ts @@ -0,0 +1,128 @@ +// @vitest-environment node + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { MySqlContainer } from "@testcontainers/mysql"; +import { PostgreSqlContainer } from "@testcontainers/postgresql"; +import mysql from "mysql2/promise"; +import { Client } from "pg"; +import { stringify } from "superjson"; +import { describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + dbEnv: {} as Record, +})); + +vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv })); + +const configureDriver = (values: Record) => { + vi.resetModules(); + for (const key of Object.keys(mocks.dbEnv)) delete mocks.dbEnv[key]; + Object.assign(mocks.dbEnv, values); +}; + +const readSettingsAsync = async () => { + const reader = await import("./proxy-reader"); + const result = { + locale: await reader.getDefaultLocaleForProxyAsync(), + onboardingStep: await reader.getOnboardingStepForProxyAsync(), + }; + await reader.closeProxySettingsReaderAsync(); + return result; +}; + +describe("proxy settings reader database compatibility", () => { + test("reads SQLite settings and falls back for malformed culture data", async () => { + const databasePath = path.join(os.tmpdir(), `homarr-proxy-reader-${process.pid}-${Date.now()}.sqlite`); + const database = new Database(databasePath); + database.exec("CREATE TABLE onboarding (step TEXT NOT NULL)"); + database.exec('CREATE TABLE "serverSetting" (setting_key TEXT NOT NULL, value TEXT NOT NULL)'); + database.prepare("INSERT INTO onboarding (step) VALUES (?)").run("finish"); + database + .prepare('INSERT INTO "serverSetting" (setting_key, value) VALUES (?, ?)') + .run("culture", stringify({ defaultLocale: "de" })); + database.close(); + + try { + configureDriver({ DRIVER: "better-sqlite3", URL: databasePath }); + await expect(readSettingsAsync()).resolves.toEqual({ locale: "de", onboardingStep: "finish" }); + + const malformedDatabase = new Database(databasePath); + malformedDatabase.prepare('UPDATE "serverSetting" SET value = ? WHERE setting_key = ?').run("{", "culture"); + malformedDatabase.close(); + vi.resetModules(); + await expect(readSettingsAsync()).resolves.toEqual({ locale: "en", onboardingStep: "finish" }); + } finally { + await fs.rm(databasePath, { force: true }); + } + }); + + test("reads MySQL settings through the bounded host configuration", async () => { + const container = await new MySqlContainer("mysql:latest").start(); + try { + const connection = await mysql.createConnection({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getUserPassword(), + }); + await connection.execute("CREATE TABLE onboarding (step VARCHAR(32) NOT NULL)"); + await connection.execute("CREATE TABLE serverSetting (setting_key VARCHAR(64) NOT NULL, value TEXT NOT NULL)"); + await connection.execute("INSERT INTO onboarding (step) VALUES (?)", ["finish"]); + await connection.execute("INSERT INTO serverSetting (setting_key, value) VALUES (?, ?)", [ + "culture", + stringify({ defaultLocale: "fr" }), + ]); + await connection.end(); + + configureDriver({ + DRIVER: "mysql2", + HOST: container.getHost(), + PORT: container.getPort(), + NAME: container.getDatabase(), + USER: container.getUsername(), + PASSWORD: container.getUserPassword(), + }); + await expect(readSettingsAsync()).resolves.toEqual({ locale: "fr", onboardingStep: "finish" }); + } finally { + await container.stop(); + } + }, 120_000); + + test("reads PostgreSQL settings through the bounded host configuration", async () => { + const container = await new PostgreSqlContainer("postgres:latest").start(); + try { + const connection = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + }); + await connection.connect(); + await connection.query("CREATE TABLE onboarding (step TEXT NOT NULL)"); + await connection.query('CREATE TABLE "serverSetting" (setting_key TEXT NOT NULL, value TEXT NOT NULL)'); + await connection.query("INSERT INTO onboarding (step) VALUES ($1)", ["finish"]); + await connection.query('INSERT INTO "serverSetting" (setting_key, value) VALUES ($1, $2)', [ + "culture", + stringify({ defaultLocale: "es" }), + ]); + await connection.end(); + + configureDriver({ + DRIVER: "node-postgres", + HOST: container.getHost(), + PORT: container.getPort(), + NAME: container.getDatabase(), + USER: container.getUsername(), + PASSWORD: container.getPassword(), + }); + await expect(readSettingsAsync()).resolves.toEqual({ locale: "es", onboardingStep: "finish" }); + } finally { + await container.stop(); + } + }, 120_000); +}); diff --git a/packages/db/proxy-reader.spec.ts b/packages/db/proxy-reader.spec.ts new file mode 100644 index 000000000..16fa37d9a --- /dev/null +++ b/packages/db/proxy-reader.spec.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const query = { + where: vi.fn(), + limit: vi.fn().mockResolvedValue([{ step: "finish" }]), + }; + query.where.mockReturnValue(query); + + return { + dbEnv: { + DRIVER: "mysql2", + URL: "mysql://homarr:test@localhost:3306/homarr", + } as Record, + mysqlCreatePool: vi.fn(() => ({})), + postgresPool: vi.fn(function PostgresPool() { + return {}; + }), + database: { + select: vi.fn(() => ({ from: vi.fn(() => query) })), + }, + }; +}); + +vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv })); +vi.mock("mysql2", () => ({ default: { createPool: mocks.mysqlCreatePool } })); +vi.mock("pg", () => ({ Pool: mocks.postgresPool })); +vi.mock("drizzle-orm/mysql2", () => ({ drizzle: vi.fn(() => mocks.database) })); +vi.mock("drizzle-orm/node-postgres", () => ({ drizzle: vi.fn(() => mocks.database) })); +vi.mock("./proxy/mysql", () => ({ + proxySchema: { onboarding: { step: {} }, serverSettings: { settingKey: {}, value: {} } }, +})); +vi.mock("./proxy/postgresql", () => ({ + proxySchema: { onboarding: { step: {} }, serverSettings: { settingKey: {}, value: {} } }, +})); + +const initializeProxyReader = async () => { + const { getOnboardingStepForProxyAsync } = await import("./proxy-reader"); + await getOnboardingStepForProxyAsync(); +}; + +describe("proxy database pools", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + for (const key of Object.keys(mocks.dbEnv)) delete mocks.dbEnv[key]; + }); + + it("bounds MySQL URL pools instead of falling back to the driver defaults", async () => { + Object.assign(mocks.dbEnv, { + DRIVER: "mysql2", + URL: "mysql://homarr:test@localhost:3306/homarr", + }); + + await initializeProxyReader(); + + expect(mocks.mysqlCreatePool).toHaveBeenCalledWith({ + uri: "mysql://homarr:test@localhost:3306/homarr", + connectionLimit: 1, + maxIdle: 1, + idleTimeout: 60_000, + enableKeepAlive: true, + }); + }); + + it("bounds MySQL host pools instead of falling back to the driver defaults", async () => { + Object.assign(mocks.dbEnv, { + DRIVER: "mysql2", + HOST: "localhost", + PORT: 3306, + NAME: "homarr", + USER: "homarr", + PASSWORD: "test", + }); + + await initializeProxyReader(); + + expect(mocks.mysqlCreatePool).toHaveBeenCalledWith(expect.objectContaining({ connectionLimit: 1, maxIdle: 1 })); + }); + + it("bounds PostgreSQL URL pools instead of falling back to the driver defaults", async () => { + Object.assign(mocks.dbEnv, { + DRIVER: "node-postgres", + URL: "postgresql://homarr:test@localhost:5432/homarr", + }); + + await initializeProxyReader(); + + expect(mocks.postgresPool).toHaveBeenCalledWith({ + connectionString: "postgresql://homarr:test@localhost:5432/homarr", + max: 1, + idleTimeoutMillis: 60_000, + allowExitOnIdle: false, + }); + }); + + it("bounds PostgreSQL host pools instead of falling back to the driver defaults", async () => { + Object.assign(mocks.dbEnv, { + DRIVER: "node-postgres", + HOST: "localhost", + PORT: 5432, + NAME: "homarr", + USER: "homarr", + PASSWORD: "test", + }); + + await initializeProxyReader(); + + expect(mocks.postgresPool).toHaveBeenCalledWith(expect.objectContaining({ max: 1 })); + }); +}); diff --git a/packages/db/proxy-reader.ts b/packages/db/proxy-reader.ts new file mode 100644 index 000000000..d0bb98319 --- /dev/null +++ b/packages/db/proxy-reader.ts @@ -0,0 +1,168 @@ +import { parse as parseSuperJson } from "superjson"; + +import { eq } from "drizzle-orm"; +import type { SQLWrapper } from "drizzle-orm"; +import { defaultServerSettings } from "@homarr/server-settings"; +import { dbEnv } from "@homarr/core/infrastructure/db/env"; + +type ProxyRow = Record; +type ProxySchema = { + onboarding: { step: SQLWrapper }; + serverSettings: { settingKey: SQLWrapper; value: SQLWrapper }; +}; +type ProxyQuery = { + where(condition: unknown): ProxyQuery; + limit(count: number): Promise; +}; +type ProxyDatabase = { + select(selection: Record): { from(table: unknown): ProxyQuery }; +}; + +type ProxyReader = { + closeAsync: () => Promise; + getOnboardingStepAsync: () => Promise; + getDefaultLocaleAsync: () => Promise; +}; + +// The proxy is long-lived and only performs two single-row reads. Keep its +// independent pool deliberately smaller than the application pool. +const proxyPoolConnectionLimit = 1; + +const createProxyReader = (database: unknown, schema: ProxySchema, closeAsync: () => Promise): ProxyReader => { + const queryDatabase = database as ProxyDatabase; + + return { + closeAsync, + async getOnboardingStepAsync() { + const rows = await queryDatabase.select({ step: schema.onboarding.step }).from(schema.onboarding).limit(1); + const step = rows[0]?.step; + return typeof step === "string" ? step : "start"; + }, + async getDefaultLocaleAsync() { + const rows = await queryDatabase + .select({ value: schema.serverSettings.value }) + .from(schema.serverSettings) + .where(eq(schema.serverSettings.settingKey, "culture")) + .limit(1); + const value = rows[0]?.value; + if (typeof value !== "string") return defaultServerSettings.culture.defaultLocale; + + try { + const culture: unknown = parseSuperJson(value); + if ( + typeof culture === "object" && + culture !== null && + "defaultLocale" in culture && + typeof culture.defaultLocale === "string" + ) { + return culture.defaultLocale; + } + } catch { + // Fall through to the default when a legacy or malformed value cannot be decoded. + } + + return defaultServerSettings.culture.defaultLocale; + }, + }; +}; + +const createProxyReaderAsync = async (): Promise => { + switch (dbEnv.DRIVER) { + case "better-sqlite3": { + const [{ default: Database }, { drizzle }, { proxySchema }] = await Promise.all([ + import("better-sqlite3"), + import("drizzle-orm/better-sqlite3"), + import("./proxy/sqlite"), + ]); + const connection = new Database(dbEnv.URL); + const database = drizzle(connection, { schema: proxySchema }); + return createProxyReader(database, proxySchema, async () => { + connection.close(); + }); + } + case "mysql2": { + const [{ default: mysql }, { drizzle }, { proxySchema }] = await Promise.all([ + import("mysql2"), + import("drizzle-orm/mysql2"), + import("./proxy/mysql"), + ]); + const connection = dbEnv.HOST + ? mysql.createPool({ + host: dbEnv.HOST, + port: dbEnv.PORT, + database: dbEnv.NAME, + user: dbEnv.USER, + password: dbEnv.PASSWORD, + connectionLimit: proxyPoolConnectionLimit, + maxIdle: 1, + idleTimeout: 60_000, + enableKeepAlive: true, + }) + : mysql.createPool({ + uri: dbEnv.URL, + connectionLimit: proxyPoolConnectionLimit, + maxIdle: 1, + idleTimeout: 60_000, + enableKeepAlive: true, + }); + const database = drizzle(connection, { schema: proxySchema, mode: "default" }); + return createProxyReader( + database, + proxySchema, + async () => + await new Promise((resolve, reject) => { + connection.end((error) => { + if (error) reject(error); + else resolve(); + }); + }), + ); + } + case "node-postgres": { + const [{ Pool }, { drizzle }, { proxySchema }] = await Promise.all([ + import("pg"), + import("drizzle-orm/node-postgres"), + import("./proxy/postgresql"), + ]); + const connection = dbEnv.HOST + ? new Pool({ + host: dbEnv.HOST, + port: dbEnv.PORT, + database: dbEnv.NAME, + user: dbEnv.USER, + password: dbEnv.PASSWORD, + max: proxyPoolConnectionLimit, + idleTimeoutMillis: 60_000, + allowExitOnIdle: false, + }) + : new Pool({ + connectionString: dbEnv.URL, + max: proxyPoolConnectionLimit, + idleTimeoutMillis: 60_000, + allowExitOnIdle: false, + }); + const database = drizzle({ client: connection, schema: proxySchema }); + return createProxyReader(database, proxySchema, async () => { + await connection.end(); + }); + } + } +}; + +let proxyReaderPromise: Promise | null = null; + +const getProxyReaderAsync = () => { + proxyReaderPromise ??= createProxyReaderAsync(); + return proxyReaderPromise; +}; + +export const getOnboardingStepForProxyAsync = async () => (await getProxyReaderAsync()).getOnboardingStepAsync(); + +export const getDefaultLocaleForProxyAsync = async () => (await getProxyReaderAsync()).getDefaultLocaleAsync(); + +export const closeProxySettingsReaderAsync = async () => { + if (!proxyReaderPromise) return; + const reader = await proxyReaderPromise; + proxyReaderPromise = null; + await reader.closeAsync(); +}; diff --git a/packages/db/proxy/mysql.ts b/packages/db/proxy/mysql.ts new file mode 100644 index 000000000..266cc4610 --- /dev/null +++ b/packages/db/proxy/mysql.ts @@ -0,0 +1,11 @@ +import { mysqlTable, text, varchar } from "drizzle-orm/mysql-core"; + +export const proxySchema = { + onboarding: mysqlTable("onboarding", { + step: varchar("step", { length: 32 }).notNull(), + }), + serverSettings: mysqlTable("serverSetting", { + settingKey: varchar("setting_key", { length: 64 }).notNull(), + value: text("value").notNull(), + }), +}; diff --git a/packages/db/proxy/postgresql.ts b/packages/db/proxy/postgresql.ts new file mode 100644 index 000000000..832f0f559 --- /dev/null +++ b/packages/db/proxy/postgresql.ts @@ -0,0 +1,11 @@ +import { pgTable, text } from "drizzle-orm/pg-core"; + +export const proxySchema = { + onboarding: pgTable("onboarding", { + step: text("step").notNull(), + }), + serverSettings: pgTable("serverSetting", { + settingKey: text("setting_key").notNull(), + value: text("value").notNull(), + }), +}; diff --git a/packages/db/proxy/sqlite.ts b/packages/db/proxy/sqlite.ts new file mode 100644 index 000000000..c95491d7f --- /dev/null +++ b/packages/db/proxy/sqlite.ts @@ -0,0 +1,11 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const proxySchema = { + onboarding: sqliteTable("onboarding", { + step: text("step").notNull(), + }), + serverSettings: sqliteTable("serverSetting", { + settingKey: text("setting_key").notNull(), + value: text("value").notNull(), + }), +}; diff --git a/packages/definitions/package.json b/packages/definitions/package.json index 9059e3c92..dff0deb5e 100644 --- a/packages/definitions/package.json +++ b/packages/definitions/package.json @@ -13,6 +13,7 @@ }, "exports": { ".": "./index.ts", + "./cookie": "./src/cookie.ts", "./docs/*": "./src/docs/*.ts" }, "scripts": { diff --git a/packages/translation/package.json b/packages/translation/package.json index 4dc9d8cd6..411d2dd07 100644 --- a/packages/translation/package.json +++ b/packages/translation/package.json @@ -16,6 +16,7 @@ "./client": "./src/client/index.ts", "./server": "./src/server.ts", "./middleware": "./src/middleware.ts", + "./languages": "./src/languages.ts", "./request": "./src/request.ts", "./dayjs": "./src/dayjs.ts" }, diff --git a/packages/translation/src/config.ts b/packages/translation/src/config.ts index 6acf807d4..ea3bc7dca 100644 --- a/packages/translation/src/config.ts +++ b/packages/translation/src/config.ts @@ -1,6 +1,9 @@ import type { MRT_Localization } from "mantine-react-table"; -import { objectKeys } from "@homarr/common"; +import type { SupportedLanguage } from "./languages"; + +export { supportedLanguages } from "./languages"; +export type { SupportedLanguage } from "./languages"; export const localeConfigurations = { ca: { @@ -433,10 +436,7 @@ export type LanguageIconDefinition = url: string; }; -export const supportedLanguages = objectKeys(localeConfigurations); -export type SupportedLanguage = (typeof supportedLanguages)[number]; - -export const fallbackLocale = "en" satisfies SupportedLanguage; +export { fallbackLocale } from "./languages"; export const isLocaleRTL = (locale: SupportedLanguage) => "isRTL" in localeConfigurations[locale] && localeConfigurations[locale].isRTL; diff --git a/packages/translation/src/index.ts b/packages/translation/src/index.ts index fff1e95aa..4a234d3d5 100644 --- a/packages/translation/src/index.ts +++ b/packages/translation/src/index.ts @@ -1,5 +1,5 @@ -import type { SupportedLanguage } from "./config"; -import { supportedLanguages } from "./config"; +import type { SupportedLanguage } from "./languages"; +import { supportedLanguages } from "./languages"; import type { stringOrTranslation, TranslationFunction } from "./type"; export * from "./type"; diff --git a/packages/translation/src/languages.spec.ts b/packages/translation/src/languages.spec.ts new file mode 100644 index 000000000..7775119d0 --- /dev/null +++ b/packages/translation/src/languages.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { localeConfigurations } from "./config"; +import { supportedLanguages } from "./languages"; + +describe("supported translation languages", () => { + it("matches the locale configuration registry", () => { + expect(Object.keys(localeConfigurations)).toEqual([...supportedLanguages]); + }); +}); diff --git a/packages/translation/src/languages.ts b/packages/translation/src/languages.ts new file mode 100644 index 000000000..bffc197c9 --- /dev/null +++ b/packages/translation/src/languages.ts @@ -0,0 +1,46 @@ +/** + * Keep the locale list dependency-free. This module is used by Next's proxy, + * where importing the full translation barrel would widen every route graph. + */ +export const supportedLanguages = [ + "ca", + "cn", + "cr", + "cs", + "da", + "de", + "de-CH", + "en-gb", + "en", + "el", + "es", + "et", + "fi", + "fr", + "he", + "hr", + "hu", + "it", + "ja", + "ko", + "lt", + "lv", + "nl", + "no", + "pl", + "pt", + "pt-br", + "ro", + "ru", + "sk", + "sl", + "sv", + "tr", + "zh", + "uk", + "vi", +] as const; + +export type SupportedLanguage = (typeof supportedLanguages)[number]; + +export const fallbackLocale = "en" satisfies SupportedLanguage; diff --git a/packages/translation/src/middleware.ts b/packages/translation/src/middleware.ts index 4c41325ee..14f587959 100644 --- a/packages/translation/src/middleware.ts +++ b/packages/translation/src/middleware.ts @@ -1,7 +1,7 @@ import createMiddleware from "next-intl/middleware"; import type { SupportedLanguage } from "."; -import { supportedLanguages } from "./config"; +import { supportedLanguages } from "./languages"; import { createRouting } from "./routing"; export const createI18nMiddleware = (defaultLocale: SupportedLanguage) => diff --git a/packages/translation/src/routing.ts b/packages/translation/src/routing.ts index e6f268690..b579ae131 100644 --- a/packages/translation/src/routing.ts +++ b/packages/translation/src/routing.ts @@ -1,9 +1,9 @@ import { defineRouting } from "next-intl/routing"; -import { localeCookieKey } from "@homarr/definitions"; +import { localeCookieKey } from "@homarr/definitions/cookie"; -import type { SupportedLanguage } from "./config"; -import { supportedLanguages } from "./config"; +import type { SupportedLanguage } from "./languages"; +import { supportedLanguages } from "./languages"; export const createRouting = (defaultLocale: SupportedLanguage) => defineRouting({