perf(proxy): isolate settings database reads

This commit is contained in:
ajnart committed 2026-07-29 06:47:07 +02:00
1 parent add6ee56ba
commit f8d8a09c56
17 files changed
+604 -18

No files matched your search

+84
View File
@@ -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");
});
});
+10 -7
View File
@@ -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
+1
View File
@@ -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",
@@ -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<string, unknown>,
}));
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: mocks.dbEnv }));
const configureDriver = (values: Record<string, unknown>) => {
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);
});
+111
View File
@@ -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<string, unknown>,
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 }));
});
});
+168
View File
@@ -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<string, unknown>;
type ProxySchema = {
onboarding: { step: SQLWrapper };
serverSettings: { settingKey: SQLWrapper; value: SQLWrapper };
};
type ProxyQuery = {
where(condition: unknown): ProxyQuery;
limit(count: number): Promise<ProxyRow[]>;
};
type ProxyDatabase = {
select(selection: Record<string, unknown>): { from(table: unknown): ProxyQuery };
};
type ProxyReader = {
closeAsync: () => Promise<void>;
getOnboardingStepAsync: () => Promise<string>;
getDefaultLocaleAsync: () => Promise<string>;
};
// 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<void>): 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<ProxyReader> => {
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<void>((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<ProxyReader> | 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();
};
+11
View File
@@ -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(),
}),
};
+11
View File
@@ -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(),
}),
};
+11
View File
@@ -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(),
}),
};
+1
View File
@@ -13,6 +13,7 @@
},
"exports": {
".": "./index.ts",
"./cookie": "./src/cookie.ts",
"./docs/*": "./src/docs/*.ts"
},
"scripts": {
+1
View File
@@ -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"
},
+5 -5
View File
@@ -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;
+2 -2
View File
@@ -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";
@@ -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]);
});
});
+46
View File
@@ -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;
+1 -1
View File
@@ -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) =>
+3 -3
View File
@@ -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({