perf(proxy): isolate settings database reads
This commit is contained in:
1 parent
add6ee56ba
commit
f8d8a09c56
17 files changed
+604
-18
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./cookie": "./src/cookie.ts",
|
||||
"./docs/*": "./src/docs/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user